$now + self::SKEW ) { return self::verdict( 'indeterminate', '', 'created is in the future' ); } if ( $input['expires'] < $now - self::SKEW ) { return self::verdict( 'indeterminate', '', 'signature expired' ); } if ( $input['expires'] - $input['created'] > self::MAX_VALIDITY + self::SKEW ) { return self::verdict( 'indeterminate', '', 'validity window over 24h' ); } $covered = array_map( 'strtolower', $input['components'] ); if ( ! in_array( '@authority', $covered, true ) && ! in_array( '@target-uri', $covered, true ) ) { return self::verdict( 'indeterminate', '', 'signature does not cover @authority' ); } $signer = self::signer_origin( isset( $headers['signature-agent'] ) ? (string) $headers['signature-agent'] : '' ); if ( '' === $signer ) { // Without a Signature-Agent there is no key directory to check against — // out-of-band keyids are a later tier, not a failure. return self::verdict( 'indeterminate', '', 'no usable Signature-Agent' ); } if ( isset( $headers['signature-agent'] ) && ! in_array( 'signature-agent', $covered, true ) ) { return self::verdict( 'indeterminate', '', 'Signature-Agent present but not signed' ); } // Replay: the draft leaves nonce policy to the verifier. Ours: within one // validity window the same nonce never mints a second fresh VERIFIED verdict. // The check is here (a cheap read), but the record is WRITTEN only after the // signature verifies (below) — otherwise a flood of forged signatures with // random nonces would write an unbounded transient each, before any crypto ran. $nonce_key = '' !== $input['nonce'] ? self::NONCE_PREFIX . md5( $signer . '|' . $input['nonce'] ) : ''; if ( '' !== $nonce_key && get_transient( $nonce_key ) ) { return self::verdict( 'indeterminate', $signer, 'nonce replayed' ); } $directory = null === $loader ? self::load_directory( $signer ) : call_user_func( $loader, $signer ); if ( null === $directory ) { return self::verdict( 'indeterminate', $signer, 'key directory unavailable' ); } $public_key = self::find_key( $directory, $input['keyid'], $now ); if ( null === $public_key ) { return self::verdict( 'indeterminate', $signer, 'keyid not in the signer directory' ); } $base = self::signature_base( $covered, $input['params_raw'], $headers ); if ( null === $base ) { return self::verdict( 'indeterminate', $signer, 'covered component unavailable' ); } if ( ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) { return self::verdict( 'indeterminate', $signer, 'sodium unavailable' ); } try { $ok = sodium_crypto_sign_verify_detached( $sig_bytes, $base, $public_key ); } catch ( \Throwable $e ) { return self::verdict( 'indeterminate', $signer, 'verify errored: ' . $e->getMessage() ); } if ( ! $ok ) { return self::verdict( 'failed', $signer, 'signature does not verify against the signer key' ); } // Record the nonce now that the signature is proven — a replay of THIS // verified request inside its validity window is caught by the check above. if ( '' !== $nonce_key ) { set_transient( $nonce_key, 1, max( 60, $input['expires'] - $now ) ); } return self::verdict( 'verified', $signer, '' ); } /** * The https origins this site recognises as named agents. Verification proves * "the operator of {origin} sent this"; THIS list is what upgrades that to * "and that origin is the agent it presents as". Google's is the one origin * its docs commit to; others land here as their operators publish stable * directories (and owners can add their own via the filter). * * @return array origin (lowercase, no trailing slash) => label. */ public static function known_signers() { // Both entries confirmed against LIVE production directories (2026-07-27): // Google documents agent.bot.goog; OpenAI's directory at chatgpt.com even // names itself in its own signature_agent member. $known = array( 'https://agent.bot.goog' => 'Google Agent', 'https://chatgpt.com' => 'OpenAI agent', ); /** * Filter the signature-agent origins recognised as named agents. * * @param array $known origin => human label. */ $known = (array) apply_filters( 'agentimus_known_signature_agents', $known ); $out = array(); foreach ( $known as $origin => $label ) { $origin = untrailingslashit( strtolower( (string) $origin ) ); if ( 0 === strpos( $origin, 'https://' ) ) { $out[ $origin ] = (string) $label; } } return $out; } /* ---------------------------------------------------------------------- * * Parsing — a STRICT, NARROW profile of RFC 8941 structured fields: * exactly what Web Bot Auth senders emit, nothing more. Anything outside * the profile parses to null and fails OPEN upstream. * ---------------------------------------------------------------------- */ /** * Parse Signature-Input and return the first member tagged for our protocol. * * Accepted shape (one or more comma-separated members): * label=("@authority" "signature-agent");created=1700000000;expires=1700000600;keyid="…";tag="web-bot-auth";nonce="…" * * @param string $header Raw header value. * @return array{label:string,components:string[],created:int,expires:int,keyid:string,nonce:string,tag:string,params_raw:string}|false|null * The web-bot-auth member; FALSE when members parsed but none carry our * tag (another protocol — not our business); NULL when nothing parses. */ public static function parse_signature_input( $header ) { $saw_member = false; // Members: label=( …quoted strings… );params — split on commas OUTSIDE quotes/parens. foreach ( self::split_members( $header ) as $member ) { if ( ! preg_match( '/^\s*([a-z0-9_.*-]+)\s*=\s*(\(([^)]*)\)(.*))$/is', $member, $m ) ) { continue; } $saw_member = true; $label = strtolower( trim( $m[1] ) ); $params_raw = trim( $m[2] ); // The inner list WITH its params, verbatim — the base needs these bytes. $inner = $m[3]; $param_str = $m[4]; $components = array(); if ( preg_match_all( '/"([^"]*)"/', $inner, $cm ) ) { $components = $cm[1]; } $params = array( 'created' => 0, 'expires' => 0, 'keyid' => '', 'nonce' => '', 'tag' => '', ); if ( preg_match_all( '/;\s*([a-z0-9_-]+)\s*=\s*("(?:[^"\\\\]|\\\\.)*"|[^;"\s]+)/i', $param_str, $pm, PREG_SET_ORDER ) ) { foreach ( $pm as $p ) { $key = strtolower( $p[1] ); $value = $p[2]; if ( '"' === substr( $value, 0, 1 ) ) { $value = stripcslashes( substr( $value, 1, -1 ) ); } if ( 'created' === $key || 'expires' === $key ) { $params[ $key ] = (int) $value; } elseif ( array_key_exists( $key, $params ) ) { $params[ $key ] = (string) $value; } } } if ( self::TAG !== $params['tag'] ) { continue; // Another protocol's signature — keep looking for ours. } return array( 'label' => $label, 'components' => $components, 'created' => $params['created'], 'expires' => $params['expires'], 'keyid' => $params['keyid'], 'nonce' => $params['nonce'], 'tag' => $params['tag'], 'params_raw' => $params_raw, ); } return $saw_member ? false : null; } /** * Pull the raw signature bytes for one label out of the Signature header * (a dictionary of byte sequences: `label=:base64:`). * * @param string $header Raw Signature header. * @param string $label The member to extract. * @return string|null Raw bytes (Ed25519 = 64), or null. */ public static function parse_signature_bytes( $header, $label ) { if ( ! preg_match( '/(?:^|,)\s*' . preg_quote( $label, '/' ) . '\s*=\s*:([A-Za-z0-9+\/=]+):/', $header, $m ) ) { return null; } $bytes = base64_decode( $m[1], true ); return ( false === $bytes || '' === $bytes ) ? null : $bytes; } /** * The signer origin out of Signature-Agent. Accepts the dictionary form * (`sig="https://signer.example"`, Google's `g="https://agent.bot.goog"`) and * the bare quoted-string form. Only a clean https origin survives: no path, * no port games, no userinfo — this URL gets FETCHED, so it is an SSRF * surface and is validated as one (scheme + shape here, wp_safe_remote_get's * private-range rejection at fetch time). * * @param string $header Raw Signature-Agent value. * @return string Lowercased origin without trailing slash, or ''. */ public static function signer_origin( $header ) { $header = trim( (string) $header ); if ( '' === $header ) { return ''; } $url = ''; if ( preg_match( '/"(https:\/\/[^"]+)"/i', $header, $m ) ) { $url = $m[1]; } elseif ( 0 === stripos( $header, 'https://' ) ) { $url = $header; } if ( '' === $url ) { return ''; } $parts = wp_parse_url( $url ); if ( empty( $parts['scheme'] ) || 'https' !== strtolower( $parts['scheme'] ) || empty( $parts['host'] ) ) { return ''; } if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) { return ''; } if ( isset( $parts['path'] ) && '' !== $parts['path'] && '/' !== $parts['path'] ) { return ''; } if ( isset( $parts['port'] ) && 443 !== (int) $parts['port'] ) { return ''; } $host = strtolower( $parts['host'] ); if ( ! preg_match( '/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/', $host ) || false === strpos( $host, '.' ) ) { return ''; // Malformed or single-label hosts. } if ( false !== filter_var( $host, FILTER_VALIDATE_IP ) || false !== filter_var( trim( $host, '[]' ), FILTER_VALIDATE_IP ) ) { return ''; // Never an IP literal — a key directory lives on a name. } return 'https://' . $host; } /* ---------------------------------------------------------------------- * * Key directory * ---------------------------------------------------------------------- */ /** * The signer's JWKS directory, cached per origin, budget-bounded. Returns the * decoded array or null (unavailable → the caller fails open). * * @param string $origin Validated https origin. * @return array|null */ private static function load_directory( $origin ) { $cache_key = self::DIR_CACHE_PREFIX . md5( $origin ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { return $cached; } if ( ! self::spend_fetch() ) { return null; // Budget spent → fail open, never fetch-storm on a flood. } $response = wp_safe_remote_get( $origin . self::DIRECTORY_PATH, array( 'timeout' => 3, 'redirection' => 1, 'limit_response_size' => self::DIR_MAX_BYTES, 'user-agent' => 'Agentimus/' . AGENTIMUS_VERSION . ' (Web Bot Auth verifier; +https://wordpress.org/plugins/agentimus/)', ) ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { // Cache the MISS briefly so a dead signer origin isn't re-fetched per request. set_transient( $cache_key, array(), 5 * MINUTE_IN_SECONDS ); return null; } $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $body ) || empty( $body['keys'] ) || ! is_array( $body['keys'] ) ) { set_transient( $cache_key, array(), 5 * MINUTE_IN_SECONDS ); return null; } set_transient( $cache_key, $body, self::DIR_CACHE_TTL ); return $body; } /** * Find the Ed25519 key matching a keyid in a directory. The draft's keyid is * the JWK thumbprint (RFC 8037 A.3); real directories also carry `kid`, so * both are accepted. Honors per-key nbf/exp when present. * * @param array $directory Decoded JWKS. * @param string $keyid The signature's keyid. * @param int $now Unix time. * @return string|null Raw 32-byte public key. */ public static function find_key( array $directory, $keyid, $now ) { $keys = isset( $directory['keys'] ) && is_array( $directory['keys'] ) ? $directory['keys'] : array(); // Tolerate a single-key object where a list is expected. if ( isset( $keys['kty'] ) ) { $keys = array( $keys ); } foreach ( $keys as $key ) { if ( ! is_array( $key ) || 'OKP' !== ( $key['kty'] ?? '' ) || 'Ed25519' !== ( $key['crv'] ?? '' ) || empty( $key['x'] ) ) { continue; } if ( isset( $key['nbf'] ) && (int) $key['nbf'] > $now + self::SKEW ) { continue; } if ( isset( $key['exp'] ) && (int) $key['exp'] < $now - self::SKEW ) { continue; } $kid = isset( $key['kid'] ) ? (string) $key['kid'] : ''; $thumbprint = self::jwk_thumbprint( (string) $key['x'] ); if ( ! hash_equals( $keyid, $kid ) && ! hash_equals( $keyid, $thumbprint ) ) { continue; } $raw = self::b64url_decode( (string) $key['x'] ); if ( is_string( $raw ) && defined( 'SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES' ) && SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES === strlen( $raw ) ) { return $raw; } if ( is_string( $raw ) && 32 === strlen( $raw ) ) { return $raw; } } return null; } /** * RFC 8037 A.3 JWK thumbprint for an Ed25519 key: SHA-256 over the canonical * JSON {"crv":"Ed25519","kty":"OKP","x":"…"}, base64url. * * @param string $x The key's x member (base64url). * @return string */ public static function jwk_thumbprint( $x ) { return self::b64url_encode( hash( 'sha256', '{"crv":"Ed25519","kty":"OKP","x":"' . $x . '"}', true ) ); } /* ---------------------------------------------------------------------- * * Signature base (RFC 9421 §2.5, narrowed to our profile) * ---------------------------------------------------------------------- */ /** * Rebuild the signed byte string: one `"component": value` line per covered * component, then the `"@signature-params"` line carrying the Signature-Input * member VERBATIM — re-serializing it would be a byte-for-byte gamble. * * @param string[] $covered Covered component names (lowercased). * @param string $params_raw The inner list + params exactly as received. * @param array $headers Lowercase-keyed request headers (+ host). * @return string|null Null when a covered component can't be produced. */ public static function signature_base( array $covered, $params_raw, array $headers ) { $lines = array(); foreach ( $covered as $component ) { $value = self::component_value( $component, $headers ); if ( null === $value ) { return null; } $lines[] = '"' . $component . '": ' . $value; } $lines[] = '"@signature-params": ' . $params_raw; return implode( "\n", $lines ); } /** * One component's canonical value. Derived components come from the request * context; anything else is a header field (trimmed, inner whitespace * collapsed — the RFC 9421 field rules for the simple cases WBA uses). * * @param string $component Lowercased component name. * @param array $headers Lowercase-keyed request headers (+ host, target-uri…). * @return string|null */ private static function component_value( $component, array $headers ) { if ( '@authority' === $component ) { $host = isset( $headers['host'] ) ? strtolower( trim( (string) $headers['host'] ) ) : ''; if ( '' === $host && function_exists( 'wp_parse_url' ) ) { $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); } $host = preg_replace( '/:443$/', '', $host ); return '' === $host ? null : $host; } if ( '@target-uri' === $component ) { return isset( $headers['@target-uri'] ) ? (string) $headers['@target-uri'] : null; } if ( '@method' === $component ) { return isset( $headers['@method'] ) ? strtoupper( (string) $headers['@method'] ) : null; } if ( '@path' === $component ) { return isset( $headers['@path'] ) ? (string) $headers['@path'] : null; } if ( '@scheme' === $component ) { return 'https'; } if ( '@' === substr( $component, 0, 1 ) ) { return null; // A derived component outside our profile — can't rebuild it faithfully. } if ( ! isset( $headers[ $component ] ) ) { return null; } return trim( preg_replace( '/[ \t]+/', ' ', (string) $headers[ $component ] ) ); } /* ---------------------------------------------------------------------- * * Small utilities * ---------------------------------------------------------------------- */ /** * Split a structured-field header into top-level members on commas outside * quotes and parentheses. * * @param string $header Raw header. * @return string[] */ private static function split_members( $header ) { $members = array(); $depth = 0; $quoted = false; $current = ''; $length = strlen( $header ); for ( $i = 0; $i < $length; $i++ ) { $char = $header[ $i ]; if ( '"' === $char && ( 0 === $i || '\\' !== $header[ $i - 1 ] ) ) { $quoted = ! $quoted; } elseif ( ! $quoted && '(' === $char ) { $depth++; } elseif ( ! $quoted && ')' === $char ) { $depth--; } elseif ( ! $quoted && 0 === $depth && ',' === $char ) { $members[] = $current; $current = ''; continue; } $current .= $char; } if ( '' !== trim( $current ) ) { $members[] = $current; } return $members; } /** * Spend one unit of the per-window directory-fetch budget. * * @return bool False when the window's budget is gone. */ private static function spend_fetch() { /** * Filter the per-minute budget for NEW signer-directory fetches. 0 disables fetching. * * @param int $max Fetches per window. */ $max = (int) apply_filters( 'agentimus_wba_fetch_budget', self::BUDGET_MAX ); if ( $max < 1 ) { return false; } $key = self::BUDGET_KEY . '_' . (int) floor( time() / self::BUDGET_WINDOW ); $count = (int) get_transient( $key ); if ( $count >= $max ) { return false; } set_transient( $key, $count + 1, self::BUDGET_WINDOW * 2 ); return true; } /** * @param string $state Verdict state. * @param string $signer Signer origin ('' when unknown). * @param string $reason Short diagnostic ('' when clean). * @return array{state:string,signer:string,reason:string} */ private static function verdict( $state, $signer, $reason ) { return array( 'state' => $state, 'signer' => $signer, 'reason' => $reason, ); } /** * @param string $data Raw bytes. * @return string */ public static function b64url_encode( $data ) { return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' ); } /** * @param string $data Base64url. * @return string|false */ public static function b64url_decode( $data ) { return base64_decode( strtr( $data, '-_', '+/' ), true ); } }