16 ) { return array(); } foreach ( $keys as $id => $encoded ) { if ( ! is_string( $id ) || ! preg_match( '/^[A-Za-z0-9._-]{1,64}$/D', $id ) || ! is_string( $encoded ) || 32 !== strlen( (string) base64_decode( $encoded, true ) ) ) { return array(); } } return $keys; } public static function resetCache(): void { self::$state = null; self::$cacheExpires = PHP_INT_MAX; } public static function summary(): array { $state = self::active(); unset( $state['indicators'] ); return $state; } private static function active(): array { if ( null !== self::$state && time() < self::$cacheExpires ) { return self::$state; } self::resetCache(); $state = array( 'status' => 'not_configured', 'sequence' => 0, 'digest' => '', 'published_at' => null, 'expires_at' => null, 'indicator_count' => 0, 'indicators' => array(), 'limitation' => 'No signed malware indicator service is configured. Original local structural rules remain available.' ); $keys = self::trustedKeys(); if ( ! function_exists( 'get_option' ) ) { return self::$state = $state; } if ( ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) { $state['status'] = 'verification_unavailable'; $state['limitation'] = 'The sodium signature verifier is unavailable; signed indicators are not used.'; return self::$state = $state; } $saved = get_option( self::OPTION, null ); if ( null === $saved || false === $saved ) { $state['status'] = $keys ? 'not_installed' : 'not_configured'; $state['limitation'] = 'A trusted key is configured, but no verified signed indicator bundle has been installed.'; return self::$state = $state; } try { if ( ! is_array( $saved ) || ! is_int( $saved['sequence'] ?? null ) || ! is_string( $saved['envelope'] ?? null ) ) { throw new \RuntimeException( 'Invalid saved bundle.' ); } $minimum = $saved['sequence']; if ( 'automatic' === ( $saved['source'] ?? '' ) ) { foreach ( array( 'RuleTrust', 'RuleUpdates' ) as $class ) { if ( ! class_exists( __NAMESPACE__ . '\\' . $class, false ) ) { require_once __DIR__ . '/' . $class . '.php'; } } $trust = RuleUpdates::verificationContext(); self::$cacheExpires = $trust['expires_at']; $keys = $trust['keys']; $minimum = max( $minimum, $trust['minimum_bundle_sequence'] ); } $bundle = self::verify( $saved['envelope'], $keys, $minimum, time(), true ); if ( $bundle['sequence'] !== $saved['sequence'] ) { throw new \RuntimeException( 'Saved bundle sequence mismatch.' ); } if ( $bundle['expires_at'] > time() ) { self::$cacheExpires = min( self::$cacheExpires, $bundle['expires_at'] ); } $state = array( 'status' => $bundle['expires_at'] <= time() ? 'stale' : 'current', 'sequence' => $bundle['sequence'], 'digest' => $bundle['digest'], 'source' => $saved['source'] ?? 'manual', 'published_at' => gmdate( 'c', $bundle['published_at'] ), 'expires_at' => gmdate( 'c', $bundle['expires_at'] ), 'indicator_count' => count( $bundle['indicators'] ), 'indicators' => $bundle['indicators'], 'limitation' => $bundle['expires_at'] <= time() ? 'Last verified indicators are expired and may miss newer threats. Local structural analysis continues.' : 'Only exact hashes in this operator-trusted bundle are recognized; coverage depends on its provenance and maintenance.', ); } catch ( \Throwable $error ) { $state['status'] = 'rejected'; $state['limitation'] = 'Saved signed indicators could not be authenticated against current, unexpired publisher trust or the required revision. They are not used; original structural analysis continues.'; } return self::$state = $state; } /** Verify exact signed bytes; minimum sequence is inclusive for persisted snapshot verification. */ public static function verify( string $envelope, array $keys, int $minimumSequence, int $now, bool $allowExpired = false ): array { if ( strlen( $envelope ) > self::MAX_ENVELOPE_BYTES || ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) { throw new \RuntimeException( 'Bundle exceeds budget or signature verification is unavailable.' ); } $outer = json_decode( $envelope, true, 8, JSON_THROW_ON_ERROR ); self::fields( $outer, array( 'schema_version', 'key_id', 'payload', 'signature' ) ); if ( 1 !== $outer['schema_version'] || ! is_string( $outer['key_id'] ) || ! is_string( $outer['payload'] ) || ! is_string( $outer['signature'] ) || ! isset( $keys[ $outer['key_id'] ] ) || ! is_string( $keys[ $outer['key_id'] ] ) ) { throw new \RuntimeException( 'Unsupported or untrusted signed bundle.' ); } $payload = base64_decode( $outer['payload'], true ); $signature = base64_decode( $outer['signature'], true ); $key = base64_decode( $keys[ $outer['key_id'] ], true ); if ( false === $payload || strlen( $payload ) > self::MAX_PAYLOAD_BYTES || false === $signature || 64 !== strlen( $signature ) || false === $key || 32 !== strlen( $key ) || ! sodium_crypto_sign_verify_detached( $signature, $payload, $key ) ) { throw new \RuntimeException( 'Bundle signature or byte budget is invalid.' ); } $data = json_decode( $payload, true, 12, JSON_THROW_ON_ERROR ); self::fields( $data, array( 'schema_version', 'engine', 'sequence', 'published_at', 'expires_at', 'indicators', 'revoked_indicator_ids' ) ); if ( 1 !== $data['schema_version'] || 1 !== $data['engine'] || ! is_int( $data['sequence'] ) || $data['sequence'] < 1 || $data['sequence'] < $minimumSequence || ! is_int( $data['published_at'] ) || ! is_int( $data['expires_at'] ) || $data['published_at'] < 1 || $data['published_at'] > $now + 300 || $data['expires_at'] <= $data['published_at'] || $data['expires_at'] - $data['published_at'] > 90 * 86400 || ( ! $allowExpired && $data['expires_at'] <= $now ) || ! is_array( $data['indicators'] ) || ! array_is_list( $data['indicators'] ) || count( $data['indicators'] ) > self::MAX_INDICATORS || ! is_array( $data['revoked_indicator_ids'] ) || ! array_is_list( $data['revoked_indicator_ids'] ) || count( $data['revoked_indicator_ids'] ) > self::MAX_INDICATORS ) { throw new \RuntimeException( 'Bundle policy, freshness, sequence or structure is invalid.' ); } $revoked = array(); foreach ( $data['revoked_indicator_ids'] as $id ) { if ( ! is_string( $id ) || ! preg_match( '/^[a-z0-9][a-z0-9.-]{0,95}$/D', $id ) || isset( $revoked[ $id ] ) ) { throw new \RuntimeException( 'Invalid or repeated indicator revocation.' ); } $revoked[ $id ] = true; } $ids = array(); $hashes = array(); foreach ( $data['indicators'] as $indicator ) { self::fields( $indicator, array( 'id', 'sha256', 'reason', 'provenance', 'license', 'reference' ) ); foreach ( array( 'id' => 96, 'sha256' => 64, 'reason' => 480, 'provenance' => 360, 'license' => 80, 'reference' => 500 ) as $field => $max ) { if ( ! is_string( $indicator[ $field ] ) || strlen( $indicator[ $field ] ) < 1 || strlen( $indicator[ $field ] ) > $max || preg_match( '/[\x00-\x1f\x7f]/', $indicator[ $field ] ) ) { throw new \RuntimeException( 'Invalid indicator metadata.' ); } } $url = wp_parse_url( $indicator['reference'] ); if ( ! preg_match( '/^[a-z0-9][a-z0-9.-]{0,95}$/D', $indicator['id'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $indicator['sha256'] ) || isset( $ids[ $indicator['id'] ] ) || isset( $hashes[ $indicator['sha256'] ] ) || isset( $revoked[ $indicator['id'] ] ) || ! is_array( $url ) || 'https' !== ( $url['scheme'] ?? null ) || empty( $url['host'] ) || isset( $url['user'] ) || isset( $url['pass'] ) || false === filter_var( $indicator['reference'], FILTER_VALIDATE_URL ) ) { throw new \RuntimeException( 'Invalid, repeated or revoked indicator.' ); } $ids[ $indicator['id'] ] = true; $hashes[ $indicator['sha256'] ] = true; } $data['digest'] = hash( 'sha256', $payload ); return $data; } private static function fields( mixed $value, array $fields ): void { if ( ! is_array( $value ) || count( $value ) !== count( $fields ) || array_diff( $fields, array_keys( $value ) ) ) { throw new \RuntimeException( 'Unsupported bundle fields; only declarative hash indicators are accepted.' ); } } /** Call only after administrator capability and nonce checks. No remote requests are made. */ public static function install( string $envelope, ?Store $store = null ): array { $keys = self::trustedKeys(); if ( ! $keys ) { throw new \RuntimeException( 'No operator-trusted public signing key is configured.' ); } $store = $store ?? new Store(); $owner = $store->lock( 'signed-rule-update', 60 ); if ( ! $owner ) { throw new \RuntimeException( 'Another signed rule update is running; retry later.' ); } try { $saved = Store::freshOption( self::OPTION, null ); if ( null !== $saved && false !== $saved && ( ! is_array( $saved ) || ! is_int( $saved['sequence'] ?? null ) || $saved['sequence'] < 1 ) ) { throw new \RuntimeException( 'Saved sequence is invalid; an operator must review the retained rule state.' ); } $sequence = is_array( $saved ) ? $saved['sequence'] : 0; if ( $sequence >= PHP_INT_MAX ) { throw new \RuntimeException( 'Signed rule sequence is exhausted.' ); } $bundle = self::verify( $envelope, $keys, $sequence + 1, time() ); // One atomic option replacement retains the previous verified snapshot on rejected input. Store::saveOption( self::OPTION, array( 'sequence' => $bundle['sequence'], 'envelope' => $envelope, 'installed_at' => time(), 'source' => 'manual' ) ); self::resetCache(); return self::summary(); } finally { $store->unlock( 'signed-rule-update', $owner ); } } /** Internal: caller holds RuleUpdates::LOCK after rechecking consent and current configuration. */ public static function installServiceLocked( string $envelope ): array { $trust = RuleUpdates::verificationContext(); $saved = Store::freshOption( self::OPTION, null ); if ( null !== $saved && false !== $saved && ( ! is_array( $saved ) || ! is_int( $saved['sequence'] ?? null ) || $saved['sequence'] < 1 || ! is_string( $saved['envelope'] ?? null ) ) ) { throw new \RuntimeException( 'Saved rule sequence requires operator recovery.' ); } $sequence = is_array( $saved ) ? $saved['sequence'] : 0; $bundle = self::verify( $envelope, $trust['keys'], max( $sequence, $trust['minimum_bundle_sequence'] ), time() ); if ( $sequence === $bundle['sequence'] ) { // Exact same signed bytes may be rechecked; a different envelope at an existing revision is not an update. if ( ! hash_equals( hash( 'sha256', $saved['envelope'] ), hash( 'sha256', $envelope ) ) || 'automatic' !== ( $saved['source'] ?? '' ) ) { throw new \RuntimeException( 'Same-revision rule equivocation rejected.' ); } } else { Store::saveOption( self::OPTION, array( 'sequence' => $bundle['sequence'], 'envelope' => $envelope, 'installed_at' => time(), 'source' => 'automatic' ) ); } self::resetCache(); return self::summary(); } public static function inspect( string $content, array $base ): array { $state = self::active(); if ( ! $state['indicators'] ) { return array(); } // Do not trust an externally supplied content_hash for exact-match detection. $hash = hash( 'sha256', $content ); foreach ( $state['indicators'] as $indicator ) { if ( hash_equals( $indicator['sha256'], $hash ) ) { return array( Finding::make( array_merge( $base, array( 'category' => 'known_malware', 'detector_id' => 'signature.exact-sha256', 'detector_version' => '1.1.0', 'severity' => 'high', 'confidence' => 'confirmed', 'reason' => 'This content exactly matches a SHA-256 indicator in the operator-trusted signed bundle: ' . $indicator['reason'], 'evidence' => array( array( 'type' => 'signed_sha256_indicator', 'indicator_id' => $indicator['id'], 'sha256' => $hash, 'bundle_sequence' => $state['sequence'] ) ), 'source' => array( 'provider' => 'Operator-trusted signed rule bundle', 'provenance' => $indicator['provenance'], 'license' => $indicator['license'], 'reference' => $indicator['reference'] ), 'rule_freshness' => array( 'version' => (string) $state['sequence'], 'published_at' => $state['published_at'], 'expires_at' => $state['expires_at'], 'status' => $state['status'] ), 'limitations' => array( 'Confidence describes the exact indicator match; its classification depends on the trusted publisher and provenance.', 'A match does not identify every affected file or prove how compromise occurred.', $state['limitation'] ), 'recommended_action' => 'Preserve evidence, verify the indicator provenance and assess related findings. Approve a trusted replacement only after recovery checks; this match does not authorize automatic deletion.', ) ) ) ); } } return array(); } }