Skip to content

Commit 673becb

Browse files
authored
Merge branch 'main' into main
2 parents c83f46c + 1494082 commit 673becb

File tree

7 files changed

+209
-40
lines changed

7 files changed

+209
-40
lines changed

src/CachedKeySet.php

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,13 +132,13 @@ public function offsetUnset($offset): void
132132

133133
private function keyIdExists(string $keyId): bool
134134
{
135-
$keySetToCache = null;
136135
if (null === $this->keySet) {
137136
$item = $this->getCacheItem();
138137
// Try to load keys from cache
139138
if ($item->isHit()) {
140139
// item found! Return it
141-
$this->keySet = $item->get();
140+
$jwks = $item->get();
141+
$this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg);
142142
}
143143
}
144144

@@ -148,17 +148,15 @@ private function keyIdExists(string $keyId): bool
148148
}
149149
$request = $this->httpFactory->createRequest('get', $this->jwksUri);
150150
$jwksResponse = $this->httpClient->sendRequest($request);
151-
$jwks = json_decode((string) $jwksResponse->getBody(), true);
152-
$this->keySet = $keySetToCache = JWK::parseKeySet($jwks, $this->defaultAlg);
151+
$jwks = (string) $jwksResponse->getBody();
152+
$this->keySet = JWK::parseKeySet(json_decode($jwks, true), $this->defaultAlg);
153153

154154
if (!isset($this->keySet[$keyId])) {
155155
return false;
156156
}
157-
}
158157

159-
if ($keySetToCache) {
160158
$item = $this->getCacheItem();
161-
$item->set($keySetToCache);
159+
$item->set($jwks);
162160
if ($this->expiresAfter) {
163161
$item->expiresAfter($this->expiresAfter);
164162
}

src/JWK.php

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@
2020
*/
2121
class JWK
2222
{
23+
private const OID = '1.2.840.10045.2.1';
24+
private const ASN1_OBJECT_IDENTIFIER = 0x06;
25+
private const ASN1_SEQUENCE = 0x10; // also defined in JWT
26+
private const ASN1_BIT_STRING = 0x03;
27+
private const EC_CURVES = [
28+
'P-256' => '1.2.840.10045.3.1.7', // Len: 64
29+
// 'P-384' => '1.3.132.0.34', // Len: 96 (not yet supported)
30+
// 'P-521' => '1.3.132.0.35', // Len: 132 (not supported)
31+
];
32+
2333
/**
2434
* Parse a set of JWK keys
2535
*
@@ -114,6 +124,26 @@ public static function parseKey(array $jwk, string $defaultAlg = null): ?Key
114124
);
115125
}
116126
return new Key($publicKey, $jwk['alg']);
127+
case 'EC':
128+
if (isset($jwk['d'])) {
129+
// The key is actually a private key
130+
throw new UnexpectedValueException('Key data must be for a public key');
131+
}
132+
133+
if (empty($jwk['crv'])) {
134+
throw new UnexpectedValueException('crv not set');
135+
}
136+
137+
if (!isset(self::EC_CURVES[$jwk['crv']])) {
138+
throw new DomainException('Unrecognised or unsupported EC curve');
139+
}
140+
141+
if (empty($jwk['x']) || empty($jwk['y'])) {
142+
throw new UnexpectedValueException('x and y not set');
143+
}
144+
145+
$publicKey = self::createPemFromCrvAndXYCoordinates($jwk['crv'], $jwk['x'], $jwk['y']);
146+
return new Key($publicKey, $jwk['alg']);
117147
default:
118148
// Currently only RSA is supported
119149
break;
@@ -122,6 +152,45 @@ public static function parseKey(array $jwk, string $defaultAlg = null): ?Key
122152
return null;
123153
}
124154

155+
/**
156+
* Converts the EC JWK values to pem format.
157+
*
158+
* @param string $crv The EC curve (only P-256 is supported)
159+
* @param string $x The EC x-coordinate
160+
* @param string $y The EC y-coordinate
161+
*
162+
* @return string
163+
*/
164+
private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, string $y): string
165+
{
166+
$pem =
167+
self::encodeDER(
168+
self::ASN1_SEQUENCE,
169+
self::encodeDER(
170+
self::ASN1_SEQUENCE,
171+
self::encodeDER(
172+
self::ASN1_OBJECT_IDENTIFIER,
173+
self::encodeOID(self::OID)
174+
)
175+
. self::encodeDER(
176+
self::ASN1_OBJECT_IDENTIFIER,
177+
self::encodeOID(self::EC_CURVES[$crv])
178+
)
179+
) .
180+
self::encodeDER(
181+
self::ASN1_BIT_STRING,
182+
\chr(0x00) . \chr(0x04)
183+
. JWT::urlsafeB64Decode($x)
184+
. JWT::urlsafeB64Decode($y)
185+
)
186+
);
187+
188+
return sprintf(
189+
"-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n",
190+
wordwrap(base64_encode($pem), 64, "\n", true)
191+
);
192+
}
193+
125194
/**
126195
* Create a public key represented in PEM format from RSA modulus and exponent information
127196
*
@@ -162,11 +231,9 @@ private static function createPemFromModulusAndExponent(
162231
$rsaOID . $rsaPublicKey
163232
);
164233

165-
$rsaPublicKey = "-----BEGIN PUBLIC KEY-----\r\n" .
234+
return "-----BEGIN PUBLIC KEY-----\r\n" .
166235
\chunk_split(\base64_encode($rsaPublicKey), 64) .
167236
'-----END PUBLIC KEY-----';
168-
169-
return $rsaPublicKey;
170237
}
171238

172239
/**
@@ -188,4 +255,68 @@ private static function encodeLength(int $length): string
188255

189256
return \pack('Ca*', 0x80 | \strlen($temp), $temp);
190257
}
258+
259+
/**
260+
* Encodes a value into a DER object.
261+
* Also defined in Firebase\JWT\JWT
262+
*
263+
* @param int $type DER tag
264+
* @param string $value the value to encode
265+
* @return string the encoded object
266+
*/
267+
private static function encodeDER(int $type, string $value): string
268+
{
269+
$tag_header = 0;
270+
if ($type === self::ASN1_SEQUENCE) {
271+
$tag_header |= 0x20;
272+
}
273+
274+
// Type
275+
$der = \chr($tag_header | $type);
276+
277+
// Length
278+
$der .= \chr(\strlen($value));
279+
280+
return $der . $value;
281+
}
282+
283+
/**
284+
* Encodes a string into a DER-encoded OID.
285+
*
286+
* @param string $oid the OID string
287+
* @return string the binary DER-encoded OID
288+
*/
289+
private static function encodeOID(string $oid): string
290+
{
291+
$octets = explode('.', $oid);
292+
293+
// Get the first octet
294+
$first = (int) array_shift($octets);
295+
$second = (int) array_shift($octets);
296+
$oid = \chr($first * 40 + $second);
297+
298+
// Iterate over subsequent octets
299+
foreach ($octets as $octet) {
300+
if ($octet == 0) {
301+
$oid .= \chr(0x00);
302+
continue;
303+
}
304+
$bin = '';
305+
306+
while ($octet) {
307+
$bin .= \chr(0x80 | ($octet & 0x7f));
308+
$octet >>= 7;
309+
}
310+
$bin[0] = $bin[0] & \chr(0x7f);
311+
312+
// Convert to big endian if necessary
313+
if (pack('V', 65534) == pack('L', 65534)) {
314+
$oid .= strrev($bin);
315+
} else {
316+
$oid .= $bin;
317+
}
318+
}
319+
320+
return $oid;
321+
}
191322
}

src/JWT.php

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ public static function decode(
9898
throw new InvalidArgumentException('Key may not be empty');
9999
}
100100
$tks = \explode('.', $jwt);
101-
if (\count($tks) != 3) {
101+
if (\count($tks) !== 3) {
102102
throw new UnexpectedValueException('Wrong number of segments');
103103
}
104104
list($headb64, $bodyb64, $cryptob64) = $tks;
@@ -136,7 +136,7 @@ public static function decode(
136136
// OpenSSL expects an ASN.1 DER sequence for ES256/ES384 signatures
137137
$sig = self::signatureToDER($sig);
138138
}
139-
if (!self::verify("$headb64.$bodyb64", $sig, $key->getKeyMaterial(), $header->alg)) {
139+
if (!self::verify("${headb64}.${bodyb64}", $sig, $key->getKeyMaterial(), $header->alg)) {
140140
throw new SignatureInvalidException('Signature verification failed');
141141
}
142142

@@ -293,28 +293,29 @@ private static function verify(
293293
$success = \openssl_verify($msg, $signature, $keyMaterial, $algorithm); // @phpstan-ignore-line
294294
if ($success === 1) {
295295
return true;
296-
} elseif ($success === 0) {
296+
}
297+
if ($success === 0) {
297298
return false;
298299
}
299300
// returns 1 on success, 0 on failure, -1 on error.
300301
throw new DomainException(
301302
'OpenSSL error: ' . \openssl_error_string()
302303
);
303304
case 'sodium_crypto':
304-
if (!\function_exists('sodium_crypto_sign_verify_detached')) {
305-
throw new DomainException('libsodium is not available');
306-
}
307-
if (!\is_string($keyMaterial)) {
308-
throw new InvalidArgumentException('key must be a string when using EdDSA');
309-
}
310-
try {
311-
// The last non-empty line is used as the key.
312-
$lines = array_filter(explode("\n", $keyMaterial));
313-
$key = base64_decode((string) end($lines));
314-
return sodium_crypto_sign_verify_detached($signature, $msg, $key);
315-
} catch (Exception $e) {
316-
throw new DomainException($e->getMessage(), 0, $e);
317-
}
305+
if (!\function_exists('sodium_crypto_sign_verify_detached')) {
306+
throw new DomainException('libsodium is not available');
307+
}
308+
if (!\is_string($keyMaterial)) {
309+
throw new InvalidArgumentException('key must be a string when using EdDSA');
310+
}
311+
try {
312+
// The last non-empty line is used as the key.
313+
$lines = array_filter(explode("\n", $keyMaterial));
314+
$key = base64_decode((string) end($lines));
315+
return sodium_crypto_sign_verify_detached($signature, $msg, $key);
316+
} catch (Exception $e) {
317+
throw new DomainException($e->getMessage(), 0, $e);
318+
}
318319
case 'hash_hmac':
319320
default:
320321
if (!\is_string($keyMaterial)) {
@@ -510,7 +511,7 @@ private static function signatureToDER(string $sig): string
510511
{
511512
// Separate the signature into r-value and s-value
512513
$length = max(1, (int) (\strlen($sig) / 2));
513-
list($r, $s) = \str_split($sig, $length > 0 ? $length : 1);
514+
list($r, $s) = \str_split($sig, $length);
514515

515516
// Trim leading zeros
516517
$r = \ltrim($r, "\x00");
@@ -610,7 +611,7 @@ private static function readDER(string $der, int $offset = 0): array
610611
}
611612

612613
// Value
613-
if ($type == self::ASN1_BIT_STRING) {
614+
if ($type === self::ASN1_BIT_STRING) {
614615
$pos++; // Skip the first contents octet (padding indicator)
615616
$data = \substr($der, $pos, $len - 1);
616617
$pos += $len - 1;

tests/CachedKeySetTest.php

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ public function testKeyIdIsCached()
120120
$cacheItem->isHit()
121121
->willReturn(true);
122122
$cacheItem->get()
123-
->willReturn(JWK::parseKeySet(json_decode($this->testJwks1, true)));
123+
->willReturn($this->testJwks1);
124124

125125
$cache = $this->prophesize(CacheItemPoolInterface::class);
126126
$cache->getItem($this->testJwksUriKey)
@@ -146,7 +146,7 @@ public function testCachedKeyIdRefresh()
146146
->willReturn(true);
147147
$cacheItem->get()
148148
->shouldBeCalledOnce()
149-
->willReturn(JWK::parseKeySet(json_decode($this->testJwks1, true)));
149+
->willReturn($this->testJwks1);
150150
$cacheItem->set(Argument::any())
151151
->shouldBeCalledOnce()
152152
->will(function () {
@@ -213,16 +213,15 @@ public function testCacheItemWithExpiresAfter()
213213
public function testJwtVerify()
214214
{
215215
$privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem');
216-
$payload = array('sub' => 'foo', 'exp' => strtotime('+10 seconds'));
216+
$payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')];
217217
$msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1');
218218

219219
$cacheItem = $this->prophesize(CacheItemInterface::class);
220220
$cacheItem->isHit()
221221
->willReturn(true);
222222
$cacheItem->get()
223-
->willReturn(JWK::parseKeySet(
224-
json_decode(file_get_contents(__DIR__ . '/data/rsa-jwkset.json'), true)
225-
));
223+
->willReturn(file_get_contents(__DIR__ . '/data/rsa-jwkset.json')
224+
);
226225

227226
$cache = $this->prophesize(CacheItemPoolInterface::class);
228227
$cache->getItem($this->testJwksUriKey)

tests/JWKTest.php

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,19 +127,33 @@ public function testDecodeByJwkKeySetTokenExpired()
127127
}
128128

129129
/**
130-
* @depends testParseJwkKeySet
130+
* @dataProvider provideDecodeByJwkKeySet
131131
*/
132-
public function testDecodeByJwkKeySet()
132+
public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg)
133133
{
134-
$privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem');
134+
$privKey1 = file_get_contents(__DIR__ . '/data/' . $pemFile);
135135
$payload = ['sub' => 'foo', 'exp' => strtotime('+10 seconds')];
136-
$msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1');
136+
$msg = JWT::encode($payload, $privKey1, $alg, 'jwk1');
137137

138-
$result = JWT::decode($msg, self::$keys);
138+
$jwkSet = json_decode(
139+
file_get_contents(__DIR__ . '/data/' . $jwkFile),
140+
true
141+
);
142+
143+
$keys = JWK::parseKeySet($jwkSet);
144+
$result = JWT::decode($msg, $keys);
139145

140146
$this->assertEquals('foo', $result->sub);
141147
}
142148

149+
public function provideDecodeByJwkKeySet()
150+
{
151+
return [
152+
['rsa1-private.pem', 'rsa-jwkset.json', 'RS256'],
153+
['ecdsa256-private.pem', 'ec-jwkset.json', 'ES256'],
154+
];
155+
}
156+
143157
/**
144158
* @depends testParseJwkKeySet
145159
*/

tests/data/ec-jwkset.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"keys": [
3+
{
4+
"kty": "EC",
5+
"use": "sig",
6+
"crv": "P-256",
7+
"kid": "jwk1",
8+
"x": "ALXnvdCvbBx35J2bozBkIFHPT747KiYioLK4JquMhZU",
9+
"y": "fAt_rGPqS95Ytwdluh4TNWTmj9xkcAbKGBRpP5kuGBk",
10+
"alg": "ES256"
11+
},
12+
{
13+
"kty": "EC",
14+
"use": "sig",
15+
"crv": "P-256",
16+
"kid": "jwk2",
17+
"x": "mQa0q5FvxPRujxzFazQT1Mo2YJJzuKiXU3svOJ41jhw",
18+
"y": "jAz7UwIl2oOFk06kj42ZFMOXmGMFUGjKASvyYtibCH0",
19+
"alg": "ES256"
20+
}
21+
]
22+
}

tests/data/ecdsa256-private.pem

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-----BEGIN PRIVATE KEY-----
2+
MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCD0KvVxLJEzRBQmcEXf
3+
D2okKCNoUwZY8fc1/1Z4aJuJdg==
4+
-----END PRIVATE KEY-----

0 commit comments

Comments
 (0)