Skip to content
133 changes: 133 additions & 0 deletions src/JWK.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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;
Expand All @@ -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
*
Expand Down Expand Up @@ -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
Expand Up @@ -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
*/
Expand Down
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-----