Skip to content

Commit 1e10567

Browse files
committed
feat: add ec256 support to JWK
1 parent 8bcbcf8 commit 1e10567

File tree

2 files changed

+150
-5
lines changed

2 files changed

+150
-5
lines changed

src/JWK.php

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@
2020
*/
2121
class JWK
2222
{
23+
private static $oid = '1.2.840.10045.2.1';
24+
private static $asn1ObjectIdentifier = 0x06;
25+
private static $asn1Integer = 0x02; // also defined in JWT
26+
private static $asn1Sequence = 0x10; // also defined in JWT
27+
private static $asn1BitString = 0x03;
28+
private static $curves = [
29+
'P-256' => '1.2.840.10045.3.1.7', // Len: 64
30+
// 'P-384' => '1.3.132.0.34', // Len: 96 (not yet supported)
31+
// 'P-521' => '1.3.132.0.35', // Len: 132 (not supported)
32+
];
33+
2334
/**
2435
* Parse a set of JWK keys
2536
*
@@ -103,12 +114,132 @@ public static function parseKey(array $jwk)
103114
);
104115
}
105116
return new Key($publicKey, $jwk['alg']);
117+
case 'EC':
118+
if (isset($jwk['d'])) {
119+
// The key is actually a private key
120+
throw new UnexpectedValueException('Key data must be for a public key');
121+
}
122+
123+
if (empty($jwk['crv'])) {
124+
throw new UnexpectedValueException('crv not set');
125+
}
126+
127+
if (!isset(self::$curves[$jwk['crv']])) {
128+
throw new DomainException('Unrecognised or unsupported EC curve');
129+
}
130+
131+
if (empty($jwk['x']) || empty($jwk['y'])) {
132+
throw new UnexpectedValueException('x and y not set');
133+
}
134+
135+
$oid = self::$curves[$jwk['crv']];
136+
$publicKey = self::ecJwkToPem($oid, $jwk['x'], $jwk['y']);
137+
return new Key($publicKey, $jwk['alg']);
106138
default:
107139
// Currently only RSA is supported
108140
break;
109141
}
110142
}
111143

144+
/**
145+
* Encodes a string into a DER-encoded OID.
146+
*
147+
* @param string $oid the OID string
148+
* @return string the binary DER-encoded OID
149+
*/
150+
private static function encodeOID($oid)
151+
{
152+
$octets = explode('.', $oid);
153+
154+
// Get the first octet
155+
$oid = chr(array_shift($octets) * 40 + array_shift($octets));
156+
157+
// Iterate over subsequent octets
158+
foreach ($octets as $octet) {
159+
if ($octet == 0) {
160+
$oid .= chr(0x00);
161+
continue;
162+
}
163+
$bin = '';
164+
165+
while ($octet) {
166+
$bin .= chr(0x80 | ($octet & 0x7f));
167+
$octet >>= 7;
168+
}
169+
$bin[0] = $bin[0] & chr(0x7f);
170+
171+
// Convert to big endian if necessary
172+
if (pack('V', 65534) == pack('L', 65534)) {
173+
$oid .= strrev($bin);
174+
} else {
175+
$oid .= $bin;
176+
}
177+
}
178+
179+
return $oid;
180+
}
181+
182+
/**
183+
* Converts the EC JWK values to pem format.
184+
*
185+
* @param string $oid the OID string
186+
* @param string $x
187+
* @return string $y
188+
*/
189+
private static function ecJwkToPem($oid, $x, $y)
190+
{
191+
$pem =
192+
self::encodeDER(
193+
self::$asn1Sequence,
194+
self::encodeDER(
195+
self::$asn1Sequence,
196+
self::encodeDER(
197+
self::$asn1ObjectIdentifier,
198+
self::encodeOID(self::$oid)
199+
)
200+
. self::encodeDER(
201+
self::$asn1ObjectIdentifier,
202+
self::encodeOID($oid)
203+
)
204+
) .
205+
self::encodeDER(
206+
self::$asn1BitString,
207+
chr(0x00) . chr(0x04)
208+
. JWT::urlsafeB64Decode($x)
209+
. JWT::urlsafeB64Decode($y)
210+
)
211+
);
212+
213+
return sprintf(
214+
"-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n",
215+
wordwrap(base64_encode($pem), 64, "\n", true)
216+
);
217+
}
218+
219+
/**
220+
* Encodes a value into a DER object.
221+
* Also defined in Firebase\JWT\JWT
222+
*
223+
* @param int $type DER tag
224+
* @param string $value the value to encode
225+
* @return string the encoded object
226+
*/
227+
private static function encodeDER($type, $value)
228+
{
229+
$tag_header = 0;
230+
if ($type === self::$asn1Sequence) {
231+
$tag_header |= 0x20;
232+
}
233+
234+
// Type
235+
$der = \chr($tag_header | $type);
236+
237+
// Length
238+
$der .= \chr(\strlen($value));
239+
240+
return $der . $value;
241+
}
242+
112243
/**
113244
* Create a public key represented in PEM format from RSA modulus and exponent information
114245
*

tests/JWKTest.php

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

121121
/**
122-
* @depends testParseJwkKeySet
122+
* @dataProvider provideDecodeByJwkKeySet
123123
*/
124-
public function testDecodeByJwkKeySet()
124+
public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg)
125125
{
126-
$privKey1 = file_get_contents(__DIR__ . '/data/rsa1-private.pem');
126+
$privKey1 = file_get_contents(__DIR__ . '/data/' . $pemFile);
127127
$payload = array('sub' => 'foo', 'exp' => strtotime('+10 seconds'));
128-
$msg = JWT::encode($payload, $privKey1, 'RS256', 'jwk1');
128+
$msg = JWT::encode($payload, $privKey1, $alg, 'jwk1');
129129

130-
$result = JWT::decode($msg, self::$keys, array('RS256'));
130+
$jwkSet = json_decode(
131+
file_get_contents(__DIR__ . '/data/' . $jwkFile),
132+
true
133+
);
134+
135+
$keys = JWK::parseKeySet($jwkSet);
136+
$result = JWT::decode($msg, $keys, array($alg));
131137

132138
$this->assertEquals("foo", $result->sub);
133139
}
134140

141+
public function provideDecodeByJwkKeySet()
142+
{
143+
return [
144+
['rsa1-private.pem', 'rsa-jwkset.json', 'RS256'],
145+
['ecdsa256-private.pem', 'ec-jwkset.json', 'ES256'],
146+
];
147+
}
148+
135149
/**
136150
* @depends testParseJwkKeySet
137151
*/

0 commit comments

Comments
 (0)