database = $database; $this->table = Schema::tables( $database )['import_challenges']; } /** * @return array{schema_version:string,kind:string,site_ref:string,challenge:string,issued_at:string,expires_at:string,data_classes:list} */ public function issue( int $actor_id = 0 ): array { if ( $actor_id < 0 ) { throw new InvalidArgumentException( 'invalid_challenge_actor' ); } $this->cleanup(); $now = current_time( 'mysql', true ); // Table name is selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $open = (int) $this->database->get_var( $this->database->prepare( "SELECT COUNT(*) FROM {$this->table} WHERE used_at IS NULL AND expires_at >= %s", $now ) ); if ( $open >= self::MAX_OPEN ) { throw new StorageException( 'too_many_open_import_challenges' ); } $site_ref = wp_generate_uuid4(); $challenge = bin2hex( random_bytes( 32 ) ); $expires = gmdate( 'Y-m-d H:i:s', time() + self::TTL_SECONDS ); $inserted = $this->database->insert( $this->table, array( 'site_ref' => $site_ref, 'challenge_hash' => hash( 'sha256', $challenge ), 'actor_id' => $actor_id, 'created_at' => $now, 'expires_at' => $expires, ), array( '%s', '%s', '%d', '%s', '%s' ) ); if ( false === $inserted ) { throw new StorageException( 'import_challenge_create_failed' ); } return array( 'schema_version' => '1.0.0', 'kind' => 'server_import_preflight', 'site_ref' => $site_ref, 'challenge' => $challenge, 'issued_at' => $this->rfc3339( $now ), 'expires_at' => $this->rfc3339( $expires ), 'data_classes' => array( 'normalized_server_observations' ), ); } public function consume( string $site_ref, string $challenge, string $artifact_created_at ): void { if ( ! wp_is_uuid( $site_ref, 4 ) || 1 !== preg_match( '/^[a-f0-9]{64}$/D', $challenge ) ) { throw new InvalidArgumentException( 'invalid_import_challenge' ); } try { $created = new DateTimeImmutable( $artifact_created_at ); } catch ( \Exception $exception ) { throw new InvalidArgumentException( 'invalid_import_timestamp' ); } $now = current_time( 'mysql', true ); if ( abs( time() - $created->getTimestamp() ) > self::TTL_SECONDS ) { throw new InvalidArgumentException( 'stale_import_artifact' ); } // Table name is selected from the compiled Schema allowlist. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $row = $this->database->get_row( $this->database->prepare( "SELECT id, challenge_hash, expires_at, used_at FROM {$this->table} WHERE site_ref = %s LIMIT 1", $site_ref ), ARRAY_A ); if ( ! is_array( $row ) || null !== $row['used_at'] || (string) $row['expires_at'] < $now || ! hash_equals( (string) $row['challenge_hash'], hash( 'sha256', $challenge ) ) ) { throw new InvalidArgumentException( 'invalid_or_expired_import_challenge' ); } // Atomic compare-and-swap makes the challenge one-time under concurrency. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $updated = $this->database->query( $this->database->prepare( "UPDATE {$this->table} SET used_at = %s WHERE id = %d AND challenge_hash = %s AND used_at IS NULL", $now, (int) $row['id'], (string) $row['challenge_hash'] ) ); if ( 1 !== $updated ) { throw new StorageException( 'import_challenge_replay_denied' ); } } /** * Revoke one unused challenge when its private preflight cannot be emitted. */ public function revoke( string $site_ref, string $challenge ): bool { if ( ! wp_is_uuid( $site_ref, 4 ) || 1 !== preg_match( '/^[a-f0-9]{64}$/D', $challenge ) ) { throw new InvalidArgumentException( 'invalid_import_challenge' ); } // Delete only the exact, still-unused plugin-owned challenge just issued by // the failed command. A consumed challenge can never be revoked here. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $deleted = $this->database->query( $this->database->prepare( "DELETE FROM {$this->table} WHERE site_ref = %s AND challenge_hash = %s AND used_at IS NULL LIMIT 1", $site_ref, hash( 'sha256', $challenge ) ) ); if ( false === $deleted ) { throw new StorageException( 'import_challenge_revoke_failed' ); } return 1 === $deleted; } private function cleanup(): void { $cutoff = gmdate( 'Y-m-d H:i:s', time() - self::CLEANUP_RETENTION ); // This bounded delete targets only expired plugin-owned challenge rows. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $this->database->query( $this->database->prepare( "DELETE FROM {$this->table} WHERE expires_at < %s LIMIT 20", $cutoff ) ); } private function rfc3339( string $mysql ): string { return ( new DateTimeImmutable( $mysql, new DateTimeZone( 'UTC' ) ) )->format( DATE_RFC3339 ); } }