| 1 |
<?php |
| 2 |
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure. |
| 3 |
|
| 4 |
namespace Yoast\WP\SEO\MyYoast_Client\Infrastructure\Crypto; |
| 5 |
|
| 6 |
use Exception; |
| 7 |
use JsonException; |
| 8 |
use Random\RandomException; |
| 9 |
use SensitiveParameter; |
| 10 |
use SodiumException; |
| 11 |
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\Encoding\Base64url; |
| 12 |
|
| 13 |
/** |
| 14 |
* Creates and signs JWTs using Ed25519 (EdDSA) via libsodium. |
| 15 |
* |
| 16 |
* Supports compact serialization (header.payload.signature) as used by |
| 17 |
* DPoP proofs, client assertions, and other OAuth/OIDC JWTs. |
| 18 |
*/ |
| 19 |
class JWT_Signer { |
| 20 |
|
| 21 |
private const SIGNING_ALG = 'EdDSA'; |
| 22 |
|
| 23 |
/** |
| 24 |
* Signs a JWT with the given header and payload using an Ed25519 private key. |
| 25 |
* |
| 26 |
* @param array<string, string|int|array<string, string>> $header The JWT header (e.g. typ, alg, jwk/kid). |
| 27 |
* @param array<string, string|int|array<string, string>> $payload The JWT payload claims. |
| 28 |
* @param string $private_key The 64-byte Ed25519 secret key (keypair format). |
| 29 |
* |
| 30 |
* @return string The compact-serialized JWT (header.payload.signature). |
| 31 |
* |
| 32 |
* @throws JWT_Signing_Exception If signing fails. |
| 33 |
*/ |
| 34 |
public function sign( |
| 35 |
array $header, |
| 36 |
array $payload, |
| 37 |
// phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPNativeAttributeFound -- No-op on PHP < 8.2; redacts parameter from stack traces on PHP 8.2+. |
| 38 |
#[SensitiveParameter] |
| 39 |
string $private_key |
| 40 |
): string { |
| 41 |
try { |
| 42 |
// phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.FoundWithAdditionalParams -- JSON_THROW_ON_ERROR is required to surface encoding errors as typed exceptions. |
| 43 |
$header_b64 = Base64url::encode( \wp_json_encode( $header, \JSON_THROW_ON_ERROR ) ); |
| 44 |
// phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.FoundWithAdditionalParams -- JSON_THROW_ON_ERROR is required to surface encoding errors as typed exceptions. |
| 45 |
$payload_b64 = Base64url::encode( \wp_json_encode( $payload, \JSON_THROW_ON_ERROR ) ); |
| 46 |
} |
| 47 |
catch ( JsonException $e ) { |
| 48 |
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. |
| 49 |
throw new JWT_Signing_Exception( 'JWT encoding failed: ' . $e->getMessage(), 0, $e ); |
| 50 |
} |
| 51 |
|
| 52 |
$signing_input = $header_b64 . '.' . $payload_b64; |
| 53 |
|
| 54 |
try { |
| 55 |
$signature = \sodium_crypto_sign_detached( $signing_input, $private_key ); |
| 56 |
} |
| 57 |
catch ( SodiumException $e ) { |
| 58 |
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. |
| 59 |
throw new JWT_Signing_Exception( 'JWT signing failed: ' . $e->getMessage(), 0, $e ); |
| 60 |
} |
| 61 |
|
| 62 |
$signature_b64 = Base64url::encode( $signature ); |
| 63 |
|
| 64 |
return $signing_input . '.' . $signature_b64; |
| 65 |
} |
| 66 |
|
| 67 |
/** |
| 68 |
* Creates a client_assertion JWT for private_key_jwt authentication. |
| 69 |
* |
| 70 |
* @param string $client_id The registered client_id. |
| 71 |
* @param string $token_endpoint The token endpoint URL (used as audience). |
| 72 |
* @param Key_Pair $key_pair The key pair to sign with. |
| 73 |
* |
| 74 |
* @return string The signed client_assertion JWT. |
| 75 |
* |
| 76 |
* @throws JWT_Signing_Exception If signing fails or jti generation fails. |
| 77 |
*/ |
| 78 |
public function create_client_assertion( string $client_id, string $token_endpoint, Key_Pair $key_pair ): string { |
| 79 |
$now = \time(); |
| 80 |
|
| 81 |
$header = [ |
| 82 |
'alg' => self::SIGNING_ALG, |
| 83 |
'kid' => $key_pair->get_kid(), |
| 84 |
]; |
| 85 |
|
| 86 |
try { |
| 87 |
$jti = $this->generate_jti(); |
| 88 |
} catch ( Exception $e ) { |
| 89 |
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. |
| 90 |
throw new JWT_Signing_Exception( 'Failed to generate jti for client_assertion: ' . $e->getMessage(), 0, $e ); |
| 91 |
} |
| 92 |
|
| 93 |
$payload = [ |
| 94 |
'iss' => $client_id, |
| 95 |
'sub' => $client_id, |
| 96 |
'aud' => $token_endpoint, |
| 97 |
'iat' => $now, |
| 98 |
'nbf' => $now, |
| 99 |
'exp' => ( $now + ( \MINUTE_IN_SECONDS * 2 ) ), |
| 100 |
'jti' => $jti, |
| 101 |
]; |
| 102 |
|
| 103 |
return $this->sign( $header, $payload, $key_pair->get_private_key() ); |
| 104 |
} |
| 105 |
|
| 106 |
/** |
| 107 |
* Verifies a JWT signature and validates standard time-based claims (RFC 7519). |
| 108 |
* |
| 109 |
* Checks the Ed25519 signature, then validates: |
| 110 |
* - exp: rejects expired tokens (with clock-skew tolerance) |
| 111 |
* - nbf: rejects tokens not yet valid (with clock-skew tolerance), if present |
| 112 |
* - iat: rejects tokens issued unreasonably far in the past, if present |
| 113 |
* |
| 114 |
* Does NOT validate application-level claims (iss, aud, nonce, etc.). |
| 115 |
* |
| 116 |
* @param string $jwt The compact-serialized JWT. |
| 117 |
* @param string $public_key The 32-byte Ed25519 public key. |
| 118 |
* @param int $leeway Clock-skew tolerance in seconds for exp/nbf checks. |
| 119 |
* |
| 120 |
* @return array{header: array<string, string|int|array<string, string>>, payload: array<string, string|int|array<string, string>>} The decoded header and payload. |
| 121 |
* |
| 122 |
* @throws JWT_Signature_Exception If the signature is invalid, the JWT is malformed, or the token was tampered with. |
| 123 |
* @throws JWT_Validation_Exception If the token's time-based claims are invalid (expired, not yet valid, or too old). |
| 124 |
* |
| 125 |
* phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- JWT_Validation_Exception is thrown by validate_time_claims(). |
| 126 |
*/ |
| 127 |
public function verify( string $jwt, string $public_key, int $leeway = 60 ): array { |
| 128 |
$parts = \explode( '.', $jwt ); |
| 129 |
if ( \count( $parts ) !== 3 ) { |
| 130 |
throw new JWT_Signature_Exception( 'Invalid JWT format: expected 3 segments.' ); |
| 131 |
} |
| 132 |
|
| 133 |
$this->verify_signature( $parts, $public_key ); |
| 134 |
|
| 135 |
$header = \json_decode( Base64url::decode( $parts[0] ), true ); |
| 136 |
$payload = \json_decode( Base64url::decode( $parts[1] ), true ); |
| 137 |
|
| 138 |
if ( ! \is_array( $header ) || ! \is_array( $payload ) ) { |
| 139 |
throw new JWT_Signature_Exception( 'Invalid JWT payload encoding.' ); |
| 140 |
} |
| 141 |
|
| 142 |
$this->validate_time_claims( $payload, $leeway ); |
| 143 |
|
| 144 |
return [ |
| 145 |
'header' => $header, |
| 146 |
'payload' => $payload, |
| 147 |
]; |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* Generates a unique JWT ID (jti). |
| 152 |
* |
| 153 |
* @return string A unique identifier. |
| 154 |
* |
| 155 |
* @throws RandomException If random bytes generation fails. |
| 156 |
*/ |
| 157 |
public function generate_jti(): string { |
| 158 |
return Base64url::encode( \random_bytes( 16 ) ); |
| 159 |
} |
| 160 |
|
| 161 |
/** |
| 162 |
* Verifies the Ed25519 signature of a split JWT. |
| 163 |
* |
| 164 |
* @param array<int, string> $parts The three JWT segments (header, payload, signature). |
| 165 |
* @param string $public_key The 32-byte Ed25519 public key. |
| 166 |
* |
| 167 |
* @return void |
| 168 |
* |
| 169 |
* @throws JWT_Signature_Exception If the signature is invalid, malformed, or verification errors. |
| 170 |
*/ |
| 171 |
private function verify_signature( array $parts, string $public_key ): void { |
| 172 |
$signing_input = $parts[0] . '.' . $parts[1]; |
| 173 |
$signature = Base64url::decode( $parts[2] ); |
| 174 |
|
| 175 |
if ( $signature === false ) { |
| 176 |
throw new JWT_Signature_Exception( 'Invalid JWT signature encoding.' ); |
| 177 |
} |
| 178 |
|
| 179 |
try { |
| 180 |
$valid = \sodium_crypto_sign_verify_detached( $signature, $signing_input, $public_key ); |
| 181 |
} |
| 182 |
catch ( Exception $e ) { |
| 183 |
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message. |
| 184 |
throw new JWT_Signature_Exception( 'JWT signature verification error: ' . $e->getMessage(), 0, $e ); |
| 185 |
} |
| 186 |
|
| 187 |
if ( ! $valid ) { |
| 188 |
throw new JWT_Signature_Exception( 'JWT signature verification failed.' ); |
| 189 |
} |
| 190 |
} |
| 191 |
|
| 192 |
/** |
| 193 |
* Validates RFC 7519 time-based claims (exp, nbf, iat). |
| 194 |
* |
| 195 |
* @param array<string, string|int|array<string, string>> $payload The decoded JWT payload. |
| 196 |
* @param int $leeway Clock-skew tolerance in seconds for exp/nbf. |
| 197 |
* |
| 198 |
* @return void |
| 199 |
* |
| 200 |
* @throws JWT_Validation_Exception If any time claim is invalid. |
| 201 |
*/ |
| 202 |
private function validate_time_claims( array $payload, int $leeway ): void { |
| 203 |
$now = \time(); |
| 204 |
|
| 205 |
// RFC 7519 Section 4.1.4: reject expired tokens. |
| 206 |
if ( isset( $payload['exp'] ) && ( $payload['exp'] + $leeway ) < $now ) { |
| 207 |
throw new JWT_Validation_Exception( 'JWT has expired.' ); |
| 208 |
} |
| 209 |
|
| 210 |
// RFC 7519 Section 4.1.5: reject tokens not yet valid. |
| 211 |
if ( isset( $payload['nbf'] ) && $payload['nbf'] > ( $now + $leeway ) ) { |
| 212 |
throw new JWT_Validation_Exception( 'JWT is not yet valid (nbf claim is in the future).' ); |
| 213 |
} |
| 214 |
|
| 215 |
// RFC 7519 Section 4.1.6: reject tokens issued unreasonably far in the past. |
| 216 |
if ( isset( $payload['iat'] ) && $payload['iat'] < ( $now - \HOUR_IN_SECONDS ) ) { |
| 217 |
throw new JWT_Validation_Exception( 'JWT iat claim is too old.' ); |
| 218 |
} |
| 219 |
} |
| 220 |
} |
| 221 |
|