*/ private $public_keys; /** @param array|null $public_keys Pinned P-256 keys. */ public function __construct( $public_keys = null ) { $this->public_keys = null === $public_keys ? Trust_Anchors::release() : $public_keys; } /** * @param array $response Signed response. * @param int $current_sequence Last accepted sequence. * @param string $current_hash Last accepted control hash. * @param string $project_id Bound project id. * @param int|null $now Unix timestamp. * @return array|\WP_Error */ public function verify_response( $response, $current_sequence, $current_hash, $project_id, $now = null ) { $now = null === $now ? time() : $now; if ( empty( $this->public_keys ) ) { return $this->error( 'release_trust_anchor_unconfigured' ); } if ( ! $this->has_exact_keys( $response, array( 'schemaVersion', 'control', 'manifest' ) ) || self::RESPONSE_SCHEMA !== $response['schemaVersion'] ) { return $this->error( 'release_bad_response' ); } $control = $this->verify_control( $response['control'], $now ); $manifest = $this->verify_manifest( $response['manifest'], $now ); if ( is_wp_error( $control ) ) { return $control; } if ( is_wp_error( $manifest ) ) { return $manifest; } $control_hash = Crypto::sha256_base64url( Crypto::canonical_json( $response['control'] ) ); $sequence = (int) $control['sequence']; if ( $current_sequence > 0 && $sequence < $current_sequence ) { return $this->error( 'release_stale_sequence' ); } if ( $current_sequence > 0 && $sequence === $current_sequence && ! Crypto::safe_equal( $current_hash, $control_hash ) ) { return $this->error( 'release_sequence_conflict' ); } $entry = $this->select_entry( $control ); if ( ! $entry ) { return $this->error( 'release_incompatible' ); } if ( ! $this->entry_matches_manifest( $entry, $manifest ) || ! $this->selection_matches_control( $entry, $control, $manifest ) ) { return $this->error( 'release_control_manifest_mismatch' ); } $killed = ! empty( $control['killSwitch'] ) || empty( $manifest['policy']['enabled'] ) || ! empty( $manifest['policy']['killSwitch'] ); return array( 'control_sequence' => $sequence, 'control_hash' => $control_hash, 'control_generated_at' => $control['generatedAt'], 'control_expires_at' => $control['expiresAt'], 'manifest' => $manifest, 'killed' => $killed, ); } /** * @param mixed $envelope Signed manifest. * @param int|null $now Unix timestamp. * @return array|\WP_Error */ public function verify_manifest( $envelope, $now = null ) { $now = null === $now ? time() : $now; $payload = $this->verify_envelope( $envelope, 'release_manifest' ); if ( is_wp_error( $payload ) ) { return $payload; } if ( ! $this->valid_manifest( $payload ) ) { return $this->error( 'release_manifest_invalid' ); } $issued = Protocol::parse_canonical_time( $payload['issuedAt'] ); $expires = Protocol::parse_canonical_time( $payload['expiresAt'] ); if ( null === $issued || null === $expires || $issued > $now + self::CLOCK_SKEW || $expires <= $now || $expires <= $issued ) { return $this->error( 'release_manifest_expired' ); } if ( $this->compare_semver( ACHLAAI_BRIDGE_VERSION, $payload['release']['minBridgeVersion'] ) < 0 ) { return $this->error( 'release_incompatible' ); } return $payload; } /** * @param mixed $envelope Signed control. * @param int|null $now Unix timestamp. * @return array|\WP_Error */ public function verify_control( $envelope, $now = null ) { $now = null === $now ? time() : $now; $payload = $this->verify_envelope( $envelope, 'release_control' ); if ( is_wp_error( $payload ) ) { return $payload; } if ( ! $this->valid_control( $payload ) ) { return $this->error( 'release_control_invalid' ); } $generated = Protocol::parse_canonical_time( $payload['generatedAt'] ); $expires = Protocol::parse_canonical_time( $payload['expiresAt'] ); if ( null === $generated || null === $expires || $generated > $now + self::CLOCK_SKEW || $expires <= $now || $expires <= $generated || $expires - $generated > self::MAX_CONTROL_LIFETIME ) { return $this->error( 'release_control_expired' ); } return $payload; } /** * Verify a signed payload that defines and validates its own schema. * * @param mixed $envelope Signed envelope. * @param string $prefix Error prefix. * @return array|\WP_Error */ public function verify_signed_payload( $envelope, $prefix = 'release_metadata' ) { return $this->verify_envelope( $envelope, $prefix ); } /** * @param mixed $envelope Signed envelope. * @param string $prefix Error prefix. * @return array|\WP_Error */ private function verify_envelope( $envelope, $prefix ) { if ( ! $this->has_exact_keys( $envelope, array( 'payload', 'signature' ) ) || ! is_array( $envelope['payload'] ) || ! is_array( $envelope['signature'] ) ) { return $this->error( $prefix . '_bad_envelope' ); } $sig = $envelope['signature']; if ( ! $this->has_exact_keys( $sig, array( 'kid', 'alg', 'canonicalization', 'signedPayloadSha256', 'sig' ) ) || ! Protocol::is_identifier( $sig['kid'] ) || self::ALGORITHM !== $sig['alg'] || self::CANONICALIZATION !== $sig['canonicalization'] || ! Protocol::is_token( $sig['signedPayloadSha256'], 32, 128 ) || ! Protocol::is_token( $sig['sig'], 32, 256 ) ) { return $this->error( $prefix . '_bad_signature_block' ); } if ( ! isset( $this->public_keys[ $sig['kid'] ] ) ) { return $this->error( $prefix . '_unknown_key' ); } try { $canonical = Crypto::canonical_json( $envelope['payload'] ); } catch ( \Exception $error ) { return $this->error( $prefix . '_bad_payload' ); } if ( ! Crypto::safe_equal( Crypto::sha256_base64url( $canonical ), $sig['signedPayloadSha256'] ) ) { return $this->error( $prefix . '_bad_payload_hash' ); } if ( ! Crypto::verify_p256( $canonical, $sig['sig'], $this->public_keys[ $sig['kid'] ] ) ) { return $this->error( $prefix . '_bad_signature' ); } return $envelope['payload']; } /** @return bool */ private function valid_manifest( $p ) { if ( ! $this->has_exact_keys( $p, array( 'schemaVersion', 'issuedAt', 'expiresAt', 'release', 'rollout', 'policy', 'rollback' ) ) || self::MANIFEST_SCHEMA !== $p['schemaVersion'] || ! $this->has_exact_keys( $p['release'], array( 'version', 'assetUrl', 'sri', 'sha384', 'bytes', 'minBridgeVersion', 'bridgeContract' ) ) || ! $this->has_exact_keys( $p['rollout'], array( 'phase', 'percent', 'cohortSalt' ) ) || ! $this->has_exact_keys( $p['policy'], array( 'enabled', 'killSwitch' ) ) || ! $this->has_exact_keys( $p['rollback'], array( 'lastKnownGoodVersion', 'rollbackFromVersion', 'reason' ) ) ) { return false; } $r = $p['release']; if ( ! $this->valid_release_version( $r['version'] ) || ! $this->valid_asset( $r ) || ! is_int( $r['bytes'] ) || $r['bytes'] < 0 || ! $this->valid_semver( $r['minBridgeVersion'] ) || self::BRIDGE_CONTRACT !== $r['bridgeContract'] ) { return false; } $percent = array( 'internal' => 0, 'percent_5' => 5, 'percent_25' => 25, 'all' => 100, ); if ( ! isset( $percent[ $p['rollout']['phase'] ] ) || $percent[ $p['rollout']['phase'] ] !== $p['rollout']['percent'] || ! is_string( $p['rollout']['cohortSalt'] ) || strlen( $p['rollout']['cohortSalt'] ) < 8 ) { return false; } if ( ! is_bool( $p['policy']['enabled'] ) || ! is_bool( $p['policy']['killSwitch'] ) || ( $p['policy']['enabled'] && $p['policy']['killSwitch'] ) ) { return false; } return $this->valid_release_version( $p['rollback']['lastKnownGoodVersion'] ) && ( null === $p['rollback']['rollbackFromVersion'] || ( $this->valid_release_version( $p['rollback']['rollbackFromVersion'] ) && $p['rollback']['rollbackFromVersion'] !== $r['version'] && $p['rollback']['lastKnownGoodVersion'] === $r['version'] ) ) && ( null === $p['rollback']['reason'] || ( is_string( $p['rollback']['reason'] ) && strlen( $p['rollback']['reason'] ) <= 500 ) ); } /** @return bool */ private function valid_control( $p ) { if ( ! $this->has_exact_keys( $p, array( 'schemaVersion', 'generatedAt', 'expiresAt', 'sequence', 'currentVersion', 'lastKnownGoodVersion', 'releases', 'rollback', 'killSwitch' ) ) || self::CONTROL_SCHEMA !== $p['schemaVersion'] || ! is_int( $p['sequence'] ) || $p['sequence'] < 1 || ! $this->valid_release_version( $p['currentVersion'] ) || ( null !== $p['lastKnownGoodVersion'] && ! $this->valid_release_version( $p['lastKnownGoodVersion'] ) ) || ! is_array( $p['releases'] ) || empty( $p['releases'] ) || ( null !== $p['rollback'] && null !== $p['killSwitch'] ) ) { return false; } $entries = array(); foreach ( $p['releases'] as $entry ) { if ( ! $this->valid_entry( $entry ) || isset( $entries[ $entry['version'] ] ) ) { return false; } $entries[ $entry['version'] ] = $entry; } if ( ! isset( $entries[ $p['currentVersion'] ] ) || ( null !== $p['lastKnownGoodVersion'] && ! isset( $entries[ $p['lastKnownGoodVersion'] ] ) ) ) { return false; } if ( null !== $p['rollback'] ) { if ( ! $this->has_exact_keys( $p['rollback'], array( 'active', 'toVersion', 'fromVersion', 'reason' ) ) || true !== $p['rollback']['active'] || $p['rollback']['fromVersion'] !== $p['currentVersion'] || $p['rollback']['toVersion'] !== $p['lastKnownGoodVersion'] || ! isset( $entries[ $p['rollback']['fromVersion'] ], $entries[ $p['rollback']['toVersion'] ] ) || $p['rollback']['fromVersion'] === $p['rollback']['toVersion'] || ( null !== $p['rollback']['reason'] && ( ! is_string( $p['rollback']['reason'] ) || strlen( $p['rollback']['reason'] ) > 500 ) ) ) { return false; } $target = $entries[ $p['rollback']['toVersion'] ]; if ( ! $this->safe_fallback( $target ) || $target['manifestRollback']['rollbackFromVersion'] !== $p['rollback']['fromVersion'] || $target['manifestRollback']['lastKnownGoodVersion'] !== $p['rollback']['toVersion'] || $target['manifestRollback']['reason'] !== $p['rollback']['reason'] ) { return false; } } elseif ( null !== $p['killSwitch'] ) { if ( ! $this->has_exact_keys( $p['killSwitch'], array( 'active', 'version', 'reason' ) ) || true !== $p['killSwitch']['active'] || $p['killSwitch']['version'] !== $p['currentVersion'] || ! isset( $entries[ $p['killSwitch']['version'] ] ) || ( null !== $p['killSwitch']['reason'] && ( ! is_string( $p['killSwitch']['reason'] ) || strlen( $p['killSwitch']['reason'] ) > 500 ) ) ) { return false; } $target = $entries[ $p['killSwitch']['version'] ]; if ( $target['enabled'] || ! $target['killSwitch'] || ! $this->neutral_manifest_binding( $target, $p['lastKnownGoodVersion'] ) ) { return false; } if ( null !== $p['lastKnownGoodVersion'] && $p['lastKnownGoodVersion'] !== $target['version'] && ! $this->safe_fallback( $entries[ $p['lastKnownGoodVersion'] ] ) ) { return false; } } else { $current = $entries[ $p['currentVersion'] ]; if ( ! $current['enabled'] || $current['killSwitch'] || ! $this->neutral_manifest_binding( $current, $p['lastKnownGoodVersion'] ) ) { return false; } if ( null !== $p['lastKnownGoodVersion'] && ! $this->safe_fallback( $entries[ $p['lastKnownGoodVersion'] ] ) ) { return false; } foreach ( $entries as $entry ) { if ( $this->safe_fallback( $entry ) && ! $this->neutral_manifest_binding( $entry, $p['lastKnownGoodVersion'] ) ) { return false; } } } return true; } /** @return bool */ private function valid_entry( $r ) { if ( ! $this->has_exact_keys( $r, array( 'version', 'manifestPath', 'phase', 'cohortSalt', 'minBridgeVersion', 'bridgeContract', 'enabled', 'killSwitch', 'assetUrl', 'sri', 'sha384', 'bytes', 'createdAt', 'manifestIssuedAt', 'manifestRollback' ) ) ) { return false; } if ( ! $this->valid_release_version( $r['version'] ) || ! is_string( $r['manifestPath'] ) || false !== strpos( $r['manifestPath'], '..' ) || 0 !== strpos( $r['manifestPath'], $r['version'] . '/manifest.' ) || '.json' !== substr( $r['manifestPath'], -5 ) || ! in_array( $r['phase'], array( 'internal', 'percent_5', 'percent_25', 'all' ), true ) || ! is_string( $r['cohortSalt'] ) || strlen( $r['cohortSalt'] ) < 8 || strlen( $r['cohortSalt'] ) > 200 || ! $this->valid_semver( $r['minBridgeVersion'] ) || self::BRIDGE_CONTRACT !== $r['bridgeContract'] || ! is_bool( $r['enabled'] ) || ! is_bool( $r['killSwitch'] ) || ( $r['enabled'] && $r['killSwitch'] ) || ! is_int( $r['bytes'] ) || $r['bytes'] < 0 || null === Protocol::parse_canonical_time( $r['createdAt'] ) || null === Protocol::parse_canonical_time( $r['manifestIssuedAt'] ) || ! $this->valid_asset( $r ) || ! $this->has_exact_keys( $r['manifestRollback'], array( 'lastKnownGoodVersion', 'rollbackFromVersion', 'reason' ) ) ) { return false; } $rollback = $r['manifestRollback']; return $this->valid_release_version( $rollback['lastKnownGoodVersion'] ) && ( null === $rollback['rollbackFromVersion'] || $this->valid_release_version( $rollback['rollbackFromVersion'] ) ) && ( null === $rollback['reason'] || ( is_string( $rollback['reason'] ) && strlen( $rollback['reason'] ) <= 500 ) ); } /** @return bool */ private function valid_asset( $release ) { if ( ! isset( $release['assetUrl'], $release['sri'], $release['sha384'], $release['version'] ) || ! is_string( $release['assetUrl'] ) || ! is_string( $release['sha384'] ) || ! is_string( $release['sri'] ) || 'sha384-' . $release['sha384'] !== $release['sri'] ) { return false; } $digest = base64_decode( $release['sha384'], true ); if ( false === $digest || 48 !== strlen( $digest ) || base64_encode( $digest ) !== $release['sha384'] ) { return false; } $url = wp_parse_url( $release['assetUrl'] ); $expected_path = '/widget/releases/' . $release['version'] . '/w.' . Crypto::base64url_encode( $digest ) . '.js'; return is_array( $url ) && 'https' === ( isset( $url['scheme'] ) ? strtolower( $url['scheme'] ) : '' ) && ! isset( $url['user'] ) && ! isset( $url['pass'] ) && ! isset( $url['query'] ) && ! isset( $url['fragment'] ) && isset( $url['host'], $url['path'] ) && $expected_path === $url['path']; } /** * Select the control entry that applies to this response. * * The project_id binding is enforced upstream (the response is fetched per * project and the manifest/entry contents are cross-checked against the * signed control), not by recomputing rollout eligibility here. * * @return array|null */ private function select_entry( $control ) { $entries = array(); foreach ( $control['releases'] as $entry ) { $entries[ $entry['version'] ] = $entry; } if ( null !== $control['killSwitch'] ) { $entry = $entries[ $control['killSwitch']['version'] ]; return $this->compatible( $entry ) && ! $entry['enabled'] && $entry['killSwitch'] ? $entry : null; } if ( null !== $control['rollback'] ) { $entry = $entries[ $control['rollback']['toVersion'] ]; return $this->compatible( $entry ) && $this->safe_fallback( $entry ) ? $entry : null; } // Selector semantics: rollout.phase/cohort resolution (including the `internal` // phase) is server-authoritative. The backend already resolves the applicable // entry for this project_id before signing the control document, and never // discloses internal cohort membership to the CMS. The CMS therefore only // validates the resolved decision (signature, SRI, bridge compatibility, // expiry) below and must not re-derive or second-guess rollout.phase/cohort. $current = $entries[ $control['currentVersion'] ]; if ( $current['enabled'] && ! $current['killSwitch'] && $this->compatible( $current ) ) { return $current; } if ( null !== $control['lastKnownGoodVersion'] ) { $lkg = $entries[ $control['lastKnownGoodVersion'] ]; if ( $this->compatible( $lkg ) && $this->safe_fallback( $lkg ) ) { return $lkg; } $candidates = array_filter( $entries, function ( $candidate ) { return $this->compatible( $candidate ) && $this->safe_fallback( $candidate ); } ); usort( $candidates, function ( $left, $right ) { return strcmp( $right['manifestIssuedAt'], $left['manifestIssuedAt'] ); } ); if ( ! empty( $candidates ) ) { return $candidates[0]; } } return null; } /** @return bool */ private function entry_matches_manifest( $entry, $manifest ) { $r = $manifest['release']; return $r['version'] === $entry['version'] && $r['assetUrl'] === $entry['assetUrl'] && $r['sri'] === $entry['sri'] && $r['sha384'] === $entry['sha384'] && $r['bytes'] === $entry['bytes'] && $r['minBridgeVersion'] === $entry['minBridgeVersion'] && $r['bridgeContract'] === $entry['bridgeContract'] && $manifest['rollout']['phase'] === $entry['phase'] && $manifest['rollout']['cohortSalt'] === $entry['cohortSalt'] && $manifest['policy']['enabled'] === $entry['enabled'] && $manifest['policy']['killSwitch'] === $entry['killSwitch'] && $manifest['issuedAt'] === $entry['manifestIssuedAt'] && $manifest['rollback'] === $entry['manifestRollback']; } /** @return bool */ private function selection_matches_control( $entry, $control, $manifest ) { if ( null !== $control['killSwitch'] ) { $expected_lkg = null === $control['lastKnownGoodVersion'] ? $entry['version'] : $control['lastKnownGoodVersion']; return $control['killSwitch']['version'] === $entry['version'] && null === $manifest['rollback']['rollbackFromVersion'] && null === $manifest['rollback']['reason'] && $manifest['rollback']['lastKnownGoodVersion'] === $expected_lkg; } if ( null !== $control['rollback'] ) { return $control['rollback']['toVersion'] === $entry['version'] && $manifest['rollback']['lastKnownGoodVersion'] === $control['rollback']['toVersion'] && $manifest['rollback']['rollbackFromVersion'] === $control['rollback']['fromVersion'] && $manifest['rollback']['reason'] === $control['rollback']['reason']; } $expected_lkg = null === $control['lastKnownGoodVersion'] ? $entry['version'] : $control['lastKnownGoodVersion']; return null === $manifest['rollback']['rollbackFromVersion'] && null === $manifest['rollback']['reason'] && Crypto::safe_equal( $manifest['rollback']['lastKnownGoodVersion'], $expected_lkg ); } /** @return bool */ private function compatible( $entry ) { return $this->compare_semver( ACHLAAI_BRIDGE_VERSION, $entry['minBridgeVersion'] ) >= 0; } /** @return bool */ private function safe_fallback( $entry ) { return 'all' === $entry['phase'] && $entry['enabled'] && ! $entry['killSwitch']; } /** @return bool */ private function neutral_manifest_binding( $entry, $last_known_good ) { $expected = null === $last_known_good ? $entry['version'] : $last_known_good; return null === $entry['manifestRollback']['rollbackFromVersion'] && null === $entry['manifestRollback']['reason'] && $entry['manifestRollback']['lastKnownGoodVersion'] === $expected; } /** @return bool */ private function valid_release_version( $value ) { return is_string( $value ) && strlen( $value ) <= 80 && false === strpos( $value, '..' ) && 1 === preg_match( '/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,78}[A-Za-z0-9])?$/', $value ); } /** @return bool */ private function valid_semver( $value ) { return is_string( $value ) && 1 === preg_match( '/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/', $value ); } /** @return int */ private function compare_semver( $a, $b ) { $aa = array_map( 'intval', explode( '.', $a ) ); $bb = array_map( 'intval', explode( '.', $b ) ); for ( $i = 0; $i < 3; $i++ ) { if ( $aa[ $i ] !== $bb[ $i ] ) { return $aa[ $i ] < $bb[ $i ] ? -1 : 1; } } return 0; } /** @return \WP_Error */ private function error( $code ) { return new \WP_Error( $code, __( 'The signed widget release could not be verified.', 'achlaai' ), array( 'status' => 503 ) ); } }