500 || ! filter_var( $config[ $field ], FILTER_VALIDATE_URL ) ) { throw new \RuntimeException( 'A valid configured service and privacy URL are required.' ); } $url = wp_parse_url( $config[ $field ] ); if ( 'https' !== ( $url['scheme'] ?? '' ) || empty( $url['host'] ) || isset( $url['user'] ) || isset( $url['pass'] ) || isset( $url['query'] ) || isset( $url['fragment'] ) || ( isset( $url['port'] ) && 443 !== $url['port'] ) || ! str_contains( $url['host'], '.' ) || filter_var( trim( $url['host'], '[]' ), FILTER_VALIDATE_IP ) || preg_match( '/(?:^|\.)(?:localhost|local|internal|test|invalid)$/iD', $url['host'] ) ) { throw new \RuntimeException( 'The service must use a public HTTPS hostname without credentials, query, fragment or custom port.' ); } } if ( ! is_string( $config['publisher'] ) || ! preg_match( '/^[a-z0-9][a-z0-9.-]{0,95}$/D', $config['publisher'] ) ) { throw new \RuntimeException( 'A fixed publisher identity is required.' ); } $config['root_keys'] = RuleTrust::keys( $config['root_keys'] ); ksort( $config['root_keys'] ); return $config; } private static function digest( array $config ): string { return hash( 'sha256', json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) ); } private static function state(): array { $state = Store::freshOption( self::OPTION, null ); if ( null === $state || false === $state ) { return array( 'enabled' => false, 'generation' => '', 'config_digest' => '', 'last_attempt' => 0, 'last_success' => 0, 'next_attempt' => 0, 'failures' => 0, 'attempt' => '', 'result' => 'disabled' ); } if ( ! is_array( $state ) || ! is_bool( $state['enabled'] ?? null ) || ! is_string( $state['generation'] ?? null ) || ! preg_match( '/^[a-f0-9]{32}$/D', $state['generation'] ) || ! is_string( $state['config_digest'] ?? null ) || ( '' !== $state['config_digest'] && ! preg_match( '/^[a-f0-9]{64}$/D', $state['config_digest'] ) ) || ! is_string( $state['attempt'] ?? null ) || ( '' !== $state['attempt'] && ! preg_match( '/^[a-f0-9]{32}$/D', $state['attempt'] ) ) || ! in_array( $state['result'] ?? null, array( 'disabled', 'awaiting_first_check', 'checking', 'unavailable', 'rejected', 'current' ), true ) ) { throw new \RuntimeException( 'Rule-update permissions cannot be read. Re-save this separate connection preference.' ); } foreach ( array( 'last_attempt', 'last_success', 'next_attempt', 'failures' ) as $field ) { if ( ! is_int( $state[ $field ] ?? null ) || $state[ $field ] < 0 ) { throw new \RuntimeException( 'Rule-update state cannot be read.' ); } } return $state; } /** Shared with manual imports, policy changes and network-result commits; never held for HTTP. */ public static function withLock( callable $operation ): mixed { $store = new Store(); $owner = $store->lock( self::LOCK, 60 ); if ( ! $owner ) { throw new \RuntimeException( 'Indicator state is changing. Wait a moment and retry.' ); } try { return $operation(); } finally { $store->unlock( self::LOCK, $owner ); } } /** The caller must check administrator capability and nonce; enabling is always explicit. */ public static function setEnabled( bool $enabled ): void { self::withLock( static function () use ( $enabled ): void { $config = $enabled ? self::config() : null; if ( $enabled && ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) { throw new \RuntimeException( 'The sodium verifier is required before enabling downloads.' ); } // A disable operation can repair malformed permission state and always wins over in-flight work. try { $state = self::state(); } catch ( \Throwable $e ) { $state = array( 'last_attempt' => 0, 'last_success' => 0, 'next_attempt' => 0, 'failures' => 0 ); } $state = array_merge( $state, array( 'enabled' => $enabled, 'generation' => bin2hex( random_bytes( 16 ) ), 'config_digest' => $enabled ? self::digest( $config ) : '', 'attempt' => '', 'result' => $enabled ? 'awaiting_first_check' : 'disabled' ) ); // Repeated form submissions cannot bypass the existing transport cooldown. Store::saveOption( self::OPTION, $state ); } ); self::schedule(); } public static function register(): void { add_action( self::HOOK, array( self::class, 'tick' ) ); self::schedule(); } private static function schedule(): void { try { $state = self::state(); if ( ! $state['enabled'] ) { wp_clear_scheduled_hook( self::HOOK ); return; } $config = self::config(); if ( ! hash_equals( $state['config_digest'], self::digest( $config ) ) ) { wp_clear_scheduled_hook( self::HOOK ); return; } if ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_event( time() + 60, 'hourly', self::HOOK ); } } catch ( \Throwable $e ) { /* No remote action is authorized by unreadable configuration. */ } } public static function stop(): void { wp_clear_scheduled_hook( self::HOOK ); } public static function tick(): void { try { self::refresh(); } catch ( \Throwable $e ) { /* Safe status is shown locally; never log remote response bodies or operator paths. */ } } public static function status(): array { $out = array( 'configured' => false, 'enabled' => false, 'status' => 'not_configured', 'destination' => '', 'privacy_url' => '', 'last_attempt' => null, 'last_success' => null, 'next_attempt' => null, 'missed_schedule' => false, 'indicator_status' => 'not_configured', 'installed_sequence' => 0, 'limitation' => 'No maintained indicator publisher is configured. Local structural checks continue. Automatic delivery does not establish malware-detection effectiveness.' ); try { $config = self::config(); $out['configured'] = function_exists( 'sodium_crypto_sign_verify_detached' ); $out['destination'] = $config['url']; $out['privacy_url'] = $config['privacy_url']; $out['status'] = 'state_unavailable'; $state = self::state(); $out['enabled'] = $state['enabled']; $out['status'] = ! $out['configured'] ? 'verification_unavailable' : ( $state['enabled'] && ! hash_equals( $state['config_digest'], self::digest( $config ) ) ? 'consent_required' : ( $state['enabled'] ? $state['result'] : 'disabled' ) ); foreach ( array( 'last_attempt', 'last_success', 'next_attempt' ) as $field ) { $out[ $field ] = $state[ $field ] ? gmdate( 'c', $state[ $field ] ) : null; } $out['missed_schedule'] = $state['enabled'] && $state['next_attempt'] > 0 && time() > $state['next_attempt'] + 7200; $bundle = RuleBundle::summary(); $out['indicator_status'] = $bundle['status']; $out['installed_sequence'] = $bundle['sequence']; if ( 'current' === $out['status'] && in_array( $bundle['status'], array( 'stale', 'rejected' ), true ) ) { $out['status'] = 'stale' === $bundle['status'] ? 'stale' : 'trust_unavailable'; } $out['limitation'] = 'This optional connection downloads signed exact-hash indicators. The publisher receives your server IP and ordinary HTTPS request timing; no site URL, inventory, files, findings or telemetry are uploaded. Disabling stops future downloads and rejects in-flight results; previously verified local indicators remain subject to trust expiry and revocation. WordPress scheduled tasks need site traffic or a configured host cron.'; } catch ( \Throwable $e ) { /* Configurations and malformed state are not exposed to visitors or remote logs. */ } return $out; } /** Validate the current root-authorized policy before using a saved automatically delivered bundle. */ public static function verificationContext(): array { $config = self::config(); $saved = Store::freshOption( self::TRUST, null ); if ( ! is_array( $saved ) || ! is_string( $saved['envelope'] ?? null ) || ! is_string( $saved['config_digest'] ?? null ) || ! hash_equals( self::digest( $config ), $saved['config_digest'] ) ) { throw new \RuntimeException( 'Current publisher authorization is missing.' ); } $trust = RuleTrust::verify( $saved['envelope'], $config['root_keys'], $config['publisher'], time() ); if ( $trust['sequence'] !== ( $saved['sequence'] ?? null ) || $trust['digest'] !== ( $saved['digest'] ?? null ) ) { throw new \RuntimeException( 'Saved publisher authorization is inconsistent.' ); } return $trust; } private static function acceptTrust( string $envelope, array $config ): array { $trust = RuleTrust::verify( $envelope, $config['root_keys'], $config['publisher'], time() ); $old = Store::freshOption( self::TRUST, null ); if ( null !== $old && false !== $old ) { if ( ! is_array( $old ) || ! is_int( $old['sequence'] ?? null ) || ! is_string( $old['digest'] ?? null ) || ! is_int( $old['minimum_bundle_sequence'] ?? null ) ) { throw new \RuntimeException( 'Saved trust sequence requires operator recovery.' ); } if ( ! is_array( $old['revoked_key_ids'] ?? null ) || array_diff( $old['revoked_key_ids'], $trust['revoked_key_ids'] ) ) { throw new \RuntimeException( 'Previously revoked publisher keys cannot be restored.' ); } if ( $trust['sequence'] < $old['sequence'] || $trust['minimum_bundle_sequence'] < $old['minimum_bundle_sequence'] || ( $trust['sequence'] === $old['sequence'] && $trust['digest'] !== $old['digest'] ) ) { throw new \RuntimeException( 'Trust rollback or equivocation rejected.' ); } } Store::saveOption( self::TRUST, array( 'sequence' => $trust['sequence'], 'digest' => $trust['digest'], 'minimum_bundle_sequence' => $trust['minimum_bundle_sequence'], 'envelope' => $envelope, 'config_digest' => self::digest( $config ), 'accepted_at' => time(), 'revoked_key_ids' => $trust['revoked_key_ids'] ) ); // Persist root-authorized revocation even if a replacement bundle is subsequently invalid. RuleBundle::resetCache(); return $trust; } public static function refresh(): array { $start = self::withLock( static function (): ?array { $config = self::config(); $state = self::state(); if ( ! $state['enabled'] || ! hash_equals( $state['config_digest'], self::digest( $config ) ) || $state['next_attempt'] > time() ) { return null; } $state['last_attempt'] = time(); $state['next_attempt'] = time() + 300; $state['attempt'] = bin2hex( random_bytes( 16 ) ); $state['result'] = 'checking'; Store::saveOption( self::OPTION, $state ); return array( $state, $config ); } ); if ( null === $start ) { return self::status(); } list( $state, $config ) = $start; $packet = null; $error = 'unavailable'; try { $response = wp_safe_remote_get( $config['url'], array( 'timeout' => 12, 'redirection' => 0, 'sslverify' => true, 'reject_unsafe_urls' => true, 'limit_response_size' => self::RESPONSE_BYTES + 1, 'cookies' => array(), 'user-agent' => 'antivirus.ai-indicator-client/1', 'headers' => array( 'Accept' => 'application/json' ) ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { throw new \RuntimeException( 'Publisher unavailable.' ); } $body = wp_remote_retrieve_body( $response ); if ( ! is_string( $body ) || strlen( $body ) > self::RESPONSE_BYTES ) { throw new \RuntimeException( 'Response budget exceeded.' ); } $error = 'rejected'; $packet = json_decode( $body, true, 12, JSON_THROW_ON_ERROR ); RuleTrust::fields( $packet, array( 'schema_version', 'trust', 'bundle' ) ); if ( 1 !== $packet['schema_version'] || ! is_string( $packet['trust'] ) || ! is_string( $packet['bundle'] ) ) { throw new \RuntimeException( 'Unsupported publisher response.' ); } } catch ( \Throwable $e ) { $packet = null; } self::withLock( static function () use ( $state, $config, $packet, $error ): void { $current = self::state(); if ( ! $current['enabled'] || ! hash_equals( $current['generation'], $state['generation'] ) || $current['attempt'] !== $state['attempt'] || ! hash_equals( $current['config_digest'], self::digest( self::config() ) ) ) { return; } $result = $error; if ( null !== $packet ) { try { self::acceptTrust( $packet['trust'], $config ); RuleBundle::installServiceLocked( $packet['bundle'] ); $result = 'current'; } catch ( \Throwable $e ) { $result = 'rejected'; } } $current['result'] = $result; $current['attempt'] = ''; $current['failures'] = 'current' === $result ? 0 : min( 8, $current['failures'] + 1 ); $current['next_attempt'] = time() + ( 'current' === $result ? 3600 : min( 21600, 300 * ( 2 ** $current['failures'] ) ) ); if ( 'current' === $result ) { $current['last_success'] = time(); } Store::saveOption( self::OPTION, $current ); } ); return self::status(); } }