Skip to content

feat: Add ES256 support to JWK #399

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 14, 2022
133 changes: 133 additions & 0 deletions src/JWK.php
Original file line number Diff line number Diff line change
@@ -20,6 +20,16 @@
*/
class JWK
{
private const OID = '1.2.840.10045.2.1';
private const ASN1_OBJECT_IDENTIFIER = 0x06;
private const ASN1_SEQUENCE = 0x10; // also defined in JWT
private const ASN1_BIT_STRING = 0x03;
private const EC_CURVES = [
'P-256' => '1.2.840.10045.3.1.7', // Len: 64
// 'P-384' => '1.3.132.0.34', // Len: 96 (not yet supported)
// 'P-521' => '1.3.132.0.35', // Len: 132 (not supported)
];

/**
* Parse a set of JWK keys
*
@@ -114,6 +124,26 @@ public static function parseKey(array $jwk, string $defaultAlg = null): ?Key
);
}
return new Key($publicKey, $jwk['alg']);
case 'EC':
if (isset($jwk['d'])) {
// The key is actually a private key
throw new UnexpectedValueException('Key data must be for a public key');
}

if (empty($jwk['crv'])) {
throw new UnexpectedValueException('crv not set');
}

if (!isset(self::EC_CURVES[$jwk['crv']])) {
throw new DomainException('Unrecognised or unsupported EC curve');
}

if (empty($jwk['x']) || empty($jwk['y'])) {
throw new UnexpectedValueException('x and y not set');
}

$publicKey = self::createPemFromCrvAndXYCoordinates($jwk['crv'], $jwk['x'], $jwk['y']);
return new Key($publicKey, $jwk['alg']);
default:
// Currently only RSA is supported
break;
@@ -122,6 +152,45 @@ public static function parseKey(array $jwk, string $defaultAlg = null): ?Key
return null;
}

/**
* Converts the EC JWK values to pem format.
*
* @param string $crv The EC curve (only P-256 is supported)
* @param string $x The EC x-coordinate
* @param string $y The EC y-coordinate
*
* @return string
*/
private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, string $y): string
{
$pem =
self::encodeDER(
self::ASN1_SEQUENCE,
self::encodeDER(
self::ASN1_SEQUENCE,
self::encodeDER(
self::ASN1_OBJECT_IDENTIFIER,
self::encodeOID(self::OID)
)
. self::encodeDER(
self::ASN1_OBJECT_IDENTIFIER,
self::encodeOID(self::EC_CURVES[$crv])
)
) .
self::encodeDER(
self::ASN1_BIT_STRING,
chr(0x00) . chr(0x04)
. JWT::urlsafeB64Decode($x)
. JWT::urlsafeB64Decode($y)
)
);

return sprintf(
"-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n",
wordwrap(base64_encode($pem), 64, "\n", true)
);
}

/**
* Create a public key represented in PEM format from RSA modulus and exponent information
*
@@ -188,4 +257,68 @@ private static function encodeLength(int $length): string

return \pack('Ca*', 0x80 | \strlen($temp), $temp);
}

/**
* Encodes a value into a DER object.
* Also defined in Firebase\JWT\JWT
*
* @param int $type DER tag
* @param string $value the value to encode
* @return string the encoded object
*/
private static function encodeDER(int $type, string $value): string
{
$tag_header = 0;
if ($type === self::ASN1_SEQUENCE) {
$tag_header |= 0x20;
}

// Type
$der = \chr($tag_header | $type);

// Length
$der .= \chr(\strlen($value));

return $der . $value;
}

/**
* Encodes a string into a DER-encoded OID.
*
* @param string $oid the OID string
* @return string the binary DER-encoded OID
*/
private static function encodeOID(string $oid): string
{
$octets = explode('.', $oid);

// Get the first octet
$first = (int) array_shift($octets);
$second = (int) array_shift($octets);
$oid = chr($first * 40 + $second);

// Iterate over subsequent octets
foreach ($octets as $octet) {
if ($octet == 0) {
$oid .= chr(0x00);
continue;
}
$bin = '';

while ($octet) {
$bin .= chr(0x80 | ($octet & 0x7f));
$octet >>= 7;
}
$bin[0] = $bin[0] & chr(0x7f);

// Convert to big endian if necessary
if (pack('V', 65534) == pack('L', 65534)) {
$oid .= strrev($bin);
} else {
$oid .= $bin;
}
}

return $oid;
}
}
24 changes: 19 additions & 5 deletions tests/JWKTest.php
Original file line number Diff line number Diff line change
@@ -127,19 +127,33 @@ public function testDecodeByJwkKeySetTokenExpired()
}

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

$result = JWT::decode($msg, self::$keys);
$jwkSet = json_decode(
file_get_contents(__DIR__ . '/data/' . $jwkFile),
true
);

$keys = JWK::parseKeySet($jwkSet);
$result = JWT::decode($msg, $keys);

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

public function provideDecodeByJwkKeySet()
{
return [
['rsa1-private.pem', 'rsa-jwkset.json', 'RS256'],
['ecdsa256-private.pem', 'ec-jwkset.json', 'ES256'],
];
}

/**
* @depends testParseJwkKeySet
*/
22 changes: 22 additions & 0 deletions tests/data/ec-jwkset.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"keys": [
{
"kty": "EC",
"use": "sig",
"crv": "P-256",
"kid": "jwk1",
"x": "ALXnvdCvbBx35J2bozBkIFHPT747KiYioLK4JquMhZU",
"y": "fAt_rGPqS95Ytwdluh4TNWTmj9xkcAbKGBRpP5kuGBk",
"alg": "ES256"
},
{
"kty": "EC",
"use": "sig",
"crv": "P-256",
"kid": "jwk2",
"x": "mQa0q5FvxPRujxzFazQT1Mo2YJJzuKiXU3svOJ41jhw",
"y": "jAz7UwIl2oOFk06kj42ZFMOXmGMFUGjKASvyYtibCH0",
"alg": "ES256"
}
]
}
4 changes: 4 additions & 0 deletions tests/data/ecdsa256-private.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-----BEGIN PRIVATE KEY-----
MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCD0KvVxLJEzRBQmcEXf
D2okKCNoUwZY8fc1/1Z4aJuJdg==
-----END PRIVATE KEY-----