directory = $directory; $this->nonces = $nonces; } /** * @param int $now Unix time. * @return array{status:string,name:string,detail:string} */ public function check( AgentStamp_Request $request, $now ) { $neutral = array( 'status' => 'neutral', 'name' => '', 'detail' => '', ); $input = isset( $request->headers['signature-input'] ) ? $request->headers['signature-input'] : ''; $sig = isset( $request->headers['signature'] ) ? $request->headers['signature'] : ''; if ( '' === trim( $input ) && '' === trim( $sig ) ) { return $neutral; } if ( '' === trim( $input ) || '' === trim( $sig ) ) { return $this->invalid( 'A signature header was incomplete.' ); } if ( ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) { return $this->invalid( 'This server cannot check Ed25519 signatures (sodium is missing).' ); } $members = self::split_dictionary( $input ); if ( empty( $members ) ) { return $this->invalid( 'Signature-Input could not be read.' ); } $last_detail = 'Signature could not be checked.'; foreach ( $members as $member ) { $result = $this->verify_member( $member, $sig, $request, (int) $now ); if ( 'valid' === $result['status'] ) { return $result; } $last_detail = $result['detail']; } return $this->invalid( $last_detail ); } /** * @param array{label:string,raw:string} $member * @param string $signature_header * @param int $now * @return array{status:string,name:string,detail:string} */ private function verify_member( array $member, $signature_header, AgentStamp_Request $request, $now ) { $parsed = self::parse_member( $member['raw'] ); if ( null === $parsed ) { return $this->invalid( 'Signature-Input could not be read.' ); } $params = $parsed['params']; foreach ( array( 'created', 'expires', 'keyid', 'nonce' ) as $required ) { if ( ! isset( $params[ $required ] ) || '' === $params[ $required ] ) { return $this->invalid( 'Signature is missing ' . $required . '.' ); } } $created = (int) $params['created']; $expires = (int) $params['expires']; if ( $created > $now + self::SKEW_SECONDS ) { return $this->invalid( 'Signature created timestamp is in the future.' ); } if ( $expires < $now - self::SKEW_SECONDS ) { return $this->invalid( 'Signature has expired.' ); } if ( $expires <= $created || ( $expires - $created ) > self::MAX_LIFETIME ) { return $this->invalid( 'Signature lifetime is not acceptable.' ); } if ( isset( $params['tag'] ) && 'web-bot-auth' !== $params['tag'] ) { return $this->invalid( 'Signature tag is not web-bot-auth.' ); } $bytes = self::signature_bytes( $signature_header, $member['label'] ); if ( null === $bytes ) { return $this->invalid( 'Signature bytes could not be read.' ); } $agent_header = isset( $request->headers['signature-agent'] ) ? $request->headers['signature-agent'] : ''; $directory = AgentStamp_Directory::directory_url_from_agent_header( $agent_header ); if ( null === $directory ) { return $this->invalid( 'Signature-Agent is not a public https directory.' ); } $keys = $this->directory->fetch_keys( $directory['url'] ); if ( ! is_array( $keys ) || empty( $keys ) ) { return $this->invalid( 'Signing key directory could not be read.' ); } $public = null; foreach ( $keys as $jwk ) { if ( ! is_array( $jwk ) ) { continue; } $thumb = self::ed25519_thumbprint( $jwk ); if ( null !== $thumb && strlen( $thumb ) === strlen( $params['keyid'] ) && hash_equals( $thumb, $params['keyid'] ) ) { $public = self::raw_public_key( $jwk ); break; } } if ( null === $public ) { return $this->invalid( 'Signature key id does not match the published key.' ); } $base = self::signature_base( $parsed['components'], $member['raw'], $request ); if ( null === $base ) { return $this->invalid( 'Signature covers a component this store cannot reconstruct.' ); } try { $ok = sodium_crypto_sign_verify_detached( $bytes, $base, $public ); } catch ( Exception $e ) { return $this->invalid( 'Signature could not be checked.' ); } if ( ! $ok ) { return $this->invalid( 'Signature does not match the request.' ); } if ( ! $this->nonces->claim( $params['nonce'], $expires ) ) { return $this->invalid( 'Signature nonce was already used.' ); } return array( 'status' => 'valid', 'name' => AgentStamp_Agents::name_for_host( $directory['host'] ), 'detail' => 'Signed Web Bot Auth key for ' . $directory['host'] . '.', ); } /** * @param string $detail * @return array{status:string,name:string,detail:string} */ private function invalid( $detail ) { return array( 'status' => 'invalid', 'name' => '', 'detail' => $detail, ); } /** * @param array $components * @param string $raw_member * @return string|null */ public static function signature_base( array $components, $raw_member, AgentStamp_Request $request ) { $lines = array(); foreach ( $components as $component ) { if ( self::unsupported_component_params( $component['params'] ) ) { return null; } $value = self::component_value( $component['name'], $component['params'], $request ); if ( null === $value ) { return null; } $lines[] = '"' . $component['name'] . '"' . $component['params'] . ': ' . $value; } $lines[] = '"@signature-params": ' . $raw_member; return implode( "\n", $lines ); } /** * @param string $params * @return bool */ private static function unsupported_component_params( $params ) { if ( '' === $params ) { return false; } if ( 1 === preg_match( '/^;key="[A-Za-z0-9_-]+"$/', $params ) ) { return false; } return true; } /** * @param string $name * @param string $params * @return string|null */ private static function component_value( $name, $params, AgentStamp_Request $request ) { $parts = wp_parse_url( $request->url ); if ( ! is_array( $parts ) || empty( $parts['host'] ) || empty( $parts['scheme'] ) ) { return null; } switch ( $name ) { case '@method': return strtoupper( $request->method ); case '@scheme': return strtolower( $parts['scheme'] ); case '@authority': $host = strtolower( $parts['host'] ); $port = isset( $parts['port'] ) ? (int) $parts['port'] : 0; $default = ( 'https' === strtolower( $parts['scheme'] ) ) ? 443 : 80; if ( $port && $port !== $default ) { return $host . ':' . $port; } return $host; case '@path': return isset( $parts['path'] ) ? $parts['path'] : '/'; case '@query': return isset( $parts['query'] ) ? '?' . $parts['query'] : '?'; case '@request-target': $path = isset( $parts['path'] ) ? $parts['path'] : '/'; return isset( $parts['query'] ) ? $path . '?' . $parts['query'] : $path; case '@target-uri': return $request->url; default: if ( isset( $name[0] ) && '@' === $name[0] ) { return null; } if ( ! isset( $request->headers[ $name ] ) ) { return null; } $header = $request->headers[ $name ]; if ( 1 === preg_match( '/^;key="([A-Za-z0-9_-]+)"$/', $params, $match ) ) { return self::dictionary_member( $header, $match[1] ); } return $header; } } /** * @param string $header * @param string $key * @return string|null */ private static function dictionary_member( $header, $key ) { $prefix = $key . '="'; foreach ( explode( ',', $header ) as $member ) { $member = trim( $member ); if ( 0 === strpos( $member, $prefix ) ) { $end = strpos( $member, '"', strlen( $prefix ) ); if ( false === $end ) { return null; } return substr( $member, strlen( $prefix ), $end - strlen( $prefix ) ); } } return null; } /** * @param array{kty?:string,crv?:string,x?:string} $jwk * @return string|null */ public static function ed25519_thumbprint( array $jwk ) { if ( ! isset( $jwk['kty'], $jwk['crv'], $jwk['x'] ) ) { return null; } if ( 'OKP' !== $jwk['kty'] || 'Ed25519' !== $jwk['crv'] ) { return null; } if ( ! is_string( $jwk['x'] ) || 1 !== preg_match( '/^[A-Za-z0-9_-]+$/', $jwk['x'] ) ) { return null; } $json = '{"crv":"Ed25519","kty":"OKP","x":"' . $jwk['x'] . '"}'; return rtrim( strtr( base64_encode( hash( 'sha256', $json, true ) ), '+/', '-_' ), '=' ); } /** * @param array{x?:string} $jwk * @return string|null */ private static function raw_public_key( array $jwk ) { if ( empty( $jwk['x'] ) || ! is_string( $jwk['x'] ) ) { return null; } $bytes = self::base64url_decode( $jwk['x'] ); if ( null === $bytes || 32 !== strlen( $bytes ) ) { return null; } return $bytes; } /** * @param string $header * @param string $label * @return string|null */ private static function signature_bytes( $header, $label ) { $pattern = '/' . preg_quote( $label, '/' ) . '=:([A-Za-z0-9+\/=_-]+):/'; if ( 1 !== preg_match( $pattern, $header, $match ) ) { return null; } $b64 = strtr( $match[1], '-_', '+/' ); $pad = strlen( $b64 ) % 4; if ( 0 !== $pad ) { $b64 .= str_repeat( '=', 4 - $pad ); } $bytes = base64_decode( $b64, true ); if ( false === $bytes || 64 !== strlen( $bytes ) ) { return null; } return $bytes; } /** * @param string $value * @return string|null */ private static function base64url_decode( $value ) { $b64 = strtr( $value, '-_', '+/' ); $pad = strlen( $b64 ) % 4; if ( 0 !== $pad ) { $b64 .= str_repeat( '=', 4 - $pad ); } $bytes = base64_decode( $b64, true ); return false === $bytes ? null : $bytes; } /** * @param string $raw Everything after label= * @return array{components:array,params:array}|null */ public static function parse_member( $raw ) { $raw = trim( $raw ); if ( '' === $raw || '(' !== $raw[0] ) { return null; } $depth = 0; $length = strlen( $raw ); $end = null; for ( $i = 0; $i < $length; $i++ ) { if ( '(' === $raw[ $i ] ) { $depth++; } elseif ( ')' === $raw[ $i ] ) { $depth--; if ( 0 === $depth ) { $end = $i; break; } } } if ( null === $end ) { return null; } $inner = substr( $raw, 1, $end - 1 ); $components = self::parse_components( $inner ); $params = self::parse_params( substr( $raw, $end + 1 ) ); if ( null === $components || null === $params || empty( $components ) ) { return null; } return array( 'components' => $components, 'params' => $params, ); } /** * @param string $inner * @return array|null */ private static function parse_components( $inner ) { $components = array(); $length = strlen( $inner ); $i = 0; while ( $i < $length ) { if ( ' ' === $inner[ $i ] ) { $i++; continue; } if ( '"' !== $inner[ $i ] ) { return null; } $i++; $start = $i; while ( $i < $length && '"' !== $inner[ $i ] ) { $i++; } if ( $i >= $length ) { return null; } $name = strtolower( substr( $inner, $start, $i - $start ) ); $i++; $params = ''; while ( $i < $length && ';' === $inner[ $i ] ) { $params .= ';'; $i++; $pstart = $i; while ( $i < $length && '=' !== $inner[ $i ] && ';' !== $inner[ $i ] && ' ' !== $inner[ $i ] ) { $i++; } $params .= substr( $inner, $pstart, $i - $pstart ); if ( $i < $length && '=' === $inner[ $i ] ) { $params .= '='; $i++; if ( $i < $length && '"' === $inner[ $i ] ) { $params .= '"'; $i++; while ( $i < $length && '"' !== $inner[ $i ] ) { $params .= $inner[ $i ]; $i++; } if ( $i >= $length ) { return null; } $params .= '"'; $i++; } else { while ( $i < $length && ';' !== $inner[ $i ] && ' ' !== $inner[ $i ] ) { $params .= $inner[ $i ]; $i++; } } } } $components[] = array( 'name' => $name, 'params' => $params, ); } return $components; } /** * @param string $raw * @return array|null */ private static function parse_params( $raw ) { $params = array(); $length = strlen( $raw ); $i = 0; while ( $i < $length ) { if ( ' ' === $raw[ $i ] ) { $i++; continue; } if ( ';' !== $raw[ $i ] ) { return null; } $i++; $start = $i; while ( $i < $length && '=' !== $raw[ $i ] && ';' !== $raw[ $i ] ) { $i++; } $name = substr( $raw, $start, $i - $start ); if ( '' === $name || $i >= $length || '=' !== $raw[ $i ] ) { return null; } $i++; if ( $i < $length && '"' === $raw[ $i ] ) { $i++; $vstart = $i; while ( $i < $length && '"' !== $raw[ $i ] ) { $i++; } if ( $i >= $length ) { return null; } $params[ $name ] = substr( $raw, $vstart, $i - $vstart ); $i++; } else { $vstart = $i; while ( $i < $length && ';' !== $raw[ $i ] ) { $i++; } $params[ $name ] = substr( $raw, $vstart, $i - $vstart ); } } return $params; } /** * @param string $header * @return array */ public static function split_dictionary( $header ) { $header = trim( $header ); $members = array(); $length = strlen( $header ); $start = 0; $quote = false; $depth = 0; for ( $i = 0; $i < $length; $i++ ) { $char = $header[ $i ]; if ( '"' === $char ) { $quote = ! $quote; continue; } if ( $quote ) { continue; } if ( '(' === $char ) { $depth++; } elseif ( ')' === $char && $depth > 0 ) { $depth--; } elseif ( ',' === $char && 0 === $depth ) { $piece = trim( substr( $header, $start, $i - $start ) ); $item = self::label_and_raw( $piece ); if ( null !== $item ) { $members[] = $item; } $start = $i + 1; } } $piece = trim( substr( $header, $start ) ); $item = self::label_and_raw( $piece ); if ( null !== $item ) { $members[] = $item; } return $members; } /** * @param string $piece * @return array{label:string,raw:string}|null */ private static function label_and_raw( $piece ) { $eq = strpos( $piece, '=' ); if ( false === $eq || 0 === $eq ) { return null; } $label = substr( $piece, 0, $eq ); $raw = substr( $piece, $eq + 1 ); if ( 1 !== preg_match( '/^[A-Za-z0-9_-]+$/', $label ) || '' === $raw || '(' !== $raw[0] ) { return null; } return array( 'label' => $label, 'raw' => $raw, ); } } /** * Published keys for one Signature-Agent directory URL. */ interface AgentStamp_Key_Directory { /** * @param string $directory_url * @return array> */ public function fetch_keys( $directory_url ); } /** * Remembers signature nonces so a captured request cannot be replayed. */ interface AgentStamp_Nonce_Store { /** * @param string $nonce * @param int $expires_at * @return bool True the first time this nonce is claimed. */ public function claim( $nonce, $expires_at ); }