callback_public_key ) ) { return null; } return $connection->callback_public_key; } /** * Compute the key id for a base64url-encoded Ed25519 public key. * * Defined by the backend as the first 16 hex chars of * sha256(raw public key bytes). Matches the X-Mintall-Callback-Key-Id header. * * @param string $pubkey_b64url Base64url-encoded raw public key. * @return string 16 lowercase hex chars. */ public static function key_id( $pubkey_b64url ) { $raw = self::base64url_decode( $pubkey_b64url ); return bin2hex( substr( hash( 'sha256', $raw, true ), 0, 8 ) ); } /** * The key id for the currently stored public key, or null if none. * * @return string|null */ public static function current_key_id() { $pubkey = self::get_public_key(); return null !== $pubkey ? self::key_id( $pubkey ) : null; } /** Replay tolerance, in seconds, for the timestamp header (±5 min). */ const REPLAY_WINDOW_SECONDS = 300; /** * Verify an incoming signed callback request. * * The backend signs each callback with the connection-scoped Ed25519 * private key. Authenticity is established by four checks, all of which * must pass: * 1. The three signature headers are present. * 2. The timestamp is within ±300s of now (replay protection). * 3. The Key-Id header matches the fingerprint of the stored public key. * 4. The Ed25519 signature validates over the canonical message * "{ts}\n{METHOD}\n{route_path}\n{sha256_hex(raw_body)}". * * The raw request body is hashed exactly as received — never re-serialize * it, or the hash (and therefore the signature) will not match. * * @param \WP_REST_Request $req The incoming request. * @param string $route_path Route within the namespace that was * signed, e.g. "/internal/protection/resolve". * NOT the full /wp-json/... path. * @return bool True only if the signature is valid and fresh. */ public static function verify( $req, $route_path ) { if ( ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) { return false; } $ts = $req->get_header( 'x_mintall_callback_timestamp' ); $signature = $req->get_header( 'x_mintall_callback_signature' ); $key_id = $req->get_header( 'x_mintall_callback_key_id' ); if ( empty( $ts ) || empty( $signature ) || empty( $key_id ) ) { return false; } if ( ! ctype_digit( (string) $ts ) ) { return false; } if ( abs( time() - (int) $ts ) > self::REPLAY_WINDOW_SECONDS ) { return false; } $pubkey = self::get_public_key(); if ( null === $pubkey ) { return false; } // Constant-time comparison of the key fingerprint to reject callbacks // signed for a different (e.g. rotated) keypair before any crypto. if ( ! hash_equals( self::key_id( $pubkey ), (string) $key_id ) ) { return false; } $raw = $req->get_body(); $canonical = $ts . "\n" . strtoupper( $req->get_method() ) . "\n" . $route_path . "\n" . hash( 'sha256', $raw, false ); $sig_bytes = self::base64url_decode( $signature ); $pubkey_bytes = self::base64url_decode( $pubkey ); // Ed25519 signatures are 64 bytes and public keys 32; bail before // sodium throws on malformed input. if ( strlen( $sig_bytes ) !== SODIUM_CRYPTO_SIGN_BYTES || strlen( $pubkey_bytes ) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES ) { return false; } return sodium_crypto_sign_verify_detached( $sig_bytes, $canonical, $pubkey_bytes ); } /** * Decode a base64url string (RFC 4648 §5, no padding) to raw bytes. * * @param string $data * @return string */ public static function base64url_decode( $data ) { $b64 = strtr( $data, '-_', '+/' ); $pad = strlen( $b64 ) % 4; if ( $pad ) { $b64 .= str_repeat( '=', 4 - $pad ); } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- base64url decode, not code. return (string) base64_decode( $b64, true ); } /** * Encode raw bytes as a base64url string (no padding). * * @param string $data * @return string */ public static function base64url_encode( $data ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- base64url encode, not code. return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' ); } }