diff --git a/src/JWK.php b/src/JWK.php index 7f225701..c90de4e1 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -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; + } } diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 0bd4f636..b8c24f98 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -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 */ diff --git a/tests/data/ec-jwkset.json b/tests/data/ec-jwkset.json new file mode 100644 index 00000000..46ed8cf9 --- /dev/null +++ b/tests/data/ec-jwkset.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/tests/data/ecdsa256-private.pem b/tests/data/ecdsa256-private.pem new file mode 100644 index 00000000..02b8f1b8 --- /dev/null +++ b/tests/data/ecdsa256-private.pem @@ -0,0 +1,4 @@ +-----BEGIN PRIVATE KEY----- +MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCD0KvVxLJEzRBQmcEXf +D2okKCNoUwZY8fc1/1Z4aJuJdg== +-----END PRIVATE KEY----- \ No newline at end of file