PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.1
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.1
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / myyoast-client / infrastructure / crypto / jwt-signer.php

jwt-signer.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.1, at src/myyoast-client/infrastructure/crypto/jwt-signer.php

221 lines 8.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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