Skip to content

Commit 64ed2e5

Browse files
authored
feat: Add ES256 support to JWK (#399)
1 parent d28e6df commit 64ed2e5

File tree

4 files changed

+178
-5
lines changed

4 files changed

+178
-5
lines changed

src/JWK.php

Lines changed: 133 additions & 0 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
*
@@ -188,4 +257,68 @@ private static function encodeLength(int $length): string
188257

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

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)