From 1e10567c2949bf5b2019509dcb189587421b5f58 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 9 Feb 2022 13:06:06 -0600 Subject: [PATCH 1/7] feat: add ec256 support to JWK --- src/JWK.php | 131 ++++++++++++++++++++++++++++++++++++++++++++++ tests/JWKTest.php | 24 +++++++-- 2 files changed, 150 insertions(+), 5 deletions(-) diff --git a/src/JWK.php b/src/JWK.php index c5506548..c650cd9d 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -20,6 +20,17 @@ */ class JWK { + private static $oid = '1.2.840.10045.2.1'; + private static $asn1ObjectIdentifier = 0x06; + private static $asn1Integer = 0x02; // also defined in JWT + private static $asn1Sequence = 0x10; // also defined in JWT + private static $asn1BitString = 0x03; + private static $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 * @@ -103,12 +114,132 @@ public static function parseKey(array $jwk) ); } 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::$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'); + } + + $oid = self::$curves[$jwk['crv']]; + $publicKey = self::ecJwkToPem($oid, $jwk['x'], $jwk['y']); + return new Key($publicKey, $jwk['alg']); default: // Currently only RSA is supported break; } } + /** + * 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($oid) + { + $octets = explode('.', $oid); + + // Get the first octet + $oid = chr(array_shift($octets) * 40 + array_shift($octets)); + + // 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; + } + + /** + * Converts the EC JWK values to pem format. + * + * @param string $oid the OID string + * @param string $x + * @return string $y + */ + private static function ecJwkToPem($oid, $x, $y) + { + $pem = + self::encodeDER( + self::$asn1Sequence, + self::encodeDER( + self::$asn1Sequence, + self::encodeDER( + self::$asn1ObjectIdentifier, + self::encodeOID(self::$oid) + ) + . self::encodeDER( + self::$asn1ObjectIdentifier, + self::encodeOID($oid) + ) + ) . + self::encodeDER( + self::$asn1BitString, + 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) + ); + } + + /** + * 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($type, $value) + { + $tag_header = 0; + if ($type === self::$asn1Sequence) { + $tag_header |= 0x20; + } + + // Type + $der = \chr($tag_header | $type); + + // Length + $der .= \chr(\strlen($value)); + + return $der . $value; + } + /** * Create a public key represented in PEM format from RSA modulus and exponent information * diff --git a/tests/JWKTest.php b/tests/JWKTest.php index c580f40f..d63afd17 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -119,19 +119,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 = array('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, array('RS256')); + $jwkSet = json_decode( + file_get_contents(__DIR__ . '/data/' . $jwkFile), + true + ); + + $keys = JWK::parseKeySet($jwkSet); + $result = JWT::decode($msg, $keys, array($alg)); $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 */ From 9b6a65671fbfac3191401e7de5710a774165521f Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 9 Feb 2022 14:09:10 -0600 Subject: [PATCH 2/7] add test data --- tests/data/ec-jwkset.json | 22 ++++++++++++++++++++++ tests/data/ecdsa256-private.pem | 4 ++++ 2 files changed, 26 insertions(+) create mode 100644 tests/data/ec-jwkset.json create mode 100644 tests/data/ecdsa256-private.pem 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 From 53b38944be601fd79ad8ae787508f9b4b2cefde1 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 16 Feb 2022 07:04:57 -0800 Subject: [PATCH 3/7] Update JWKTest.php --- tests/JWKTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/JWKTest.php b/tests/JWKTest.php index 41a511cb..4baefe8a 100644 --- a/tests/JWKTest.php +++ b/tests/JWKTest.php @@ -121,7 +121,7 @@ public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg) { $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'); $jwkSet = json_decode( file_get_contents(__DIR__ . '/data/' . $jwkFile), @@ -129,7 +129,7 @@ public function testDecodeByJwkKeySet($pemFile, $jwkFile, $alg) ); $keys = JWK::parseKeySet($jwkSet); - $result = JWT::decode($msg, $keys, [$alg]); + $result = JWT::decode($msg, $keys); $this->assertEquals("foo", $result->sub); } From c627e839993ffe491ec0747f228f848f51a0cec4 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 16 Feb 2022 10:49:59 -0800 Subject: [PATCH 4/7] add typing to private methods --- src/JWK.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/JWK.php b/src/JWK.php index f4abd25f..20135caf 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -152,7 +152,7 @@ public static function parseKey(array $jwk): ?Key * @param string $oid the OID string * @return string the binary DER-encoded OID */ - private static function encodeOID($oid) + private static function encodeOID(string $oid): string { $octets = explode('.', $oid); @@ -229,7 +229,7 @@ private static function ecJwkToPem($oid, $x, $y) * @param string $value the value to encode * @return string the encoded object */ - private static function encodeDER($type, $value) + private static function encodeDER(int $type, string $value): string { $tag_header = 0; if ($type === self::$asn1Sequence) { @@ -255,7 +255,7 @@ private static function encodeDER($type, $value) * * @uses encodeLength */ - private static function createPemFromModulusAndExponent($n, $e) + private static function createPemFromModulusAndExponent(string $n, string $e): string { $modulus = JWT::urlsafeB64Decode($n); $publicExponent = JWT::urlsafeB64Decode($e); @@ -301,7 +301,7 @@ private static function createPemFromModulusAndExponent($n, $e) * @param int $length * @return string */ - private static function encodeLength($length) + private static function encodeLength(int $length): string { if ($length <= 0x7F) { return \chr($length); From 10e135a660847b56a3fff42606877724833f6adb Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 16 Feb 2022 11:03:14 -0800 Subject: [PATCH 5/7] misc refactorings --- src/JWK.php | 165 ++++++++++++++++++++++++++-------------------------- 1 file changed, 83 insertions(+), 82 deletions(-) diff --git a/src/JWK.php b/src/JWK.php index 20135caf..e207638d 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -20,12 +20,12 @@ */ class JWK { - private static $oid = '1.2.840.10045.2.1'; - private static $asn1ObjectIdentifier = 0x06; - private static $asn1Integer = 0x02; // also defined in JWT - private static $asn1Sequence = 0x10; // also defined in JWT - private static $asn1BitString = 0x03; - private static $curves = [ + private const OID = '1.2.840.10045.2.1'; + private const ASN1_OBJECT_IDENTIFIER = 0x06; + private const ASN1_INTEGER = 0x02; // also defined in JWT + 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) @@ -127,7 +127,7 @@ public static function parseKey(array $jwk): ?Key throw new UnexpectedValueException('crv not set'); } - if (!isset(self::$curves[$jwk['crv']])) { + if (!isset(self::EC_CURVES[$jwk['crv']])) { throw new DomainException('Unrecognised or unsupported EC curve'); } @@ -135,8 +135,7 @@ public static function parseKey(array $jwk): ?Key throw new UnexpectedValueException('x and y not set'); } - $oid = self::$curves[$jwk['crv']]; - $publicKey = self::ecJwkToPem($oid, $jwk['x'], $jwk['y']); + $publicKey = self::createPemFromCrvAndXYCoordinates($jwk['crv'], $jwk['x'], $jwk['y']); return new Key($publicKey, $jwk['alg']); default: // Currently only RSA is supported @@ -146,69 +145,33 @@ public static function parseKey(array $jwk): ?Key return null; } - /** - * 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 - $oid = chr(array_shift($octets) * 40 + array_shift($octets)); - - // 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; - } - /** * Converts the EC JWK values to pem format. * - * @param string $oid the OID string - * @param string $x - * @return string $y + * @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 ecJwkToPem($oid, $x, $y) + private static function createPemFromCrvAndXYCoordinates(string $crv, string $x, string $y): string { $pem = self::encodeDER( - self::$asn1Sequence, + self::ASN1_SEQUENCE, self::encodeDER( - self::$asn1Sequence, + self::ASN1_SEQUENCE, self::encodeDER( - self::$asn1ObjectIdentifier, - self::encodeOID(self::$oid) + self::ASN1_OBJECT_IDENTIFIER, + self::encodeOID(self::OID) ) . self::encodeDER( - self::$asn1ObjectIdentifier, - self::encodeOID($oid) + self::ASN1_OBJECT_IDENTIFIER, + self::encodeOID(self::EC_CURVES[$crv]) ) ) . self::encodeDER( - self::$asn1BitString, + self::ASN1_BIT_STRING, chr(0x00) . chr(0x04) . JWT::urlsafeB64Decode($x) . JWT::urlsafeB64Decode($y) @@ -221,30 +184,6 @@ private static function ecJwkToPem($oid, $x, $y) ); } - /** - * 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::$asn1Sequence) { - $tag_header |= 0x20; - } - - // Type - $der = \chr($tag_header | $type); - - // Length - $der .= \chr(\strlen($value)); - - return $der . $value; - } - /** * Create a public key represented in PEM format from RSA modulus and exponent information * @@ -311,4 +250,66 @@ 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 + $oid = chr(array_shift($octets) * 40 + array_shift($octets)); + + // 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; + } } From 97c4289bf5c64ff4e4526f61f96218e6d00598f0 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 13 Apr 2022 14:50:52 -0700 Subject: [PATCH 6/7] Update JWK.php --- src/JWK.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/JWK.php b/src/JWK.php index fab13412..8269ae9e 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -22,7 +22,6 @@ class JWK { private const OID = '1.2.840.10045.2.1'; private const ASN1_OBJECT_IDENTIFIER = 0x06; - private const ASN1_INTEGER = 0x02; // also defined in JWT private const ASN1_SEQUENCE = 0x10; // also defined in JWT private const ASN1_BIT_STRING = 0x03; private const EC_CURVES = [ From 765860e70c2b8d579f9ea42ab9136c9f9d7da8a3 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Wed, 13 Apr 2022 14:54:20 -0700 Subject: [PATCH 7/7] Update JWK.php --- src/JWK.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/JWK.php b/src/JWK.php index 8269ae9e..9577550c 100644 --- a/src/JWK.php +++ b/src/JWK.php @@ -285,7 +285,9 @@ private static function encodeOID(string $oid): string $octets = explode('.', $oid); // Get the first octet - $oid = chr(array_shift($octets) * 40 + array_shift($octets)); + $first = (int) array_shift($octets); + $second = (int) array_shift($octets); + $oid = chr($first * 40 + $second); // Iterate over subsequent octets foreach ($octets as $octet) {