diff --git a/src/Key/Jwk.php b/src/Key/Jwk.php index 80fef9f..0ca69f3 100644 --- a/src/Key/Jwk.php +++ b/src/Key/Jwk.php @@ -10,6 +10,7 @@ * * @author Ralf Lang * @author Torben Dannhauer + * @author Jean Charles Delépine */ namespace Horde\Jwt\Key; @@ -52,6 +53,36 @@ public static function fromPublicKeyPem(string $pem): array }; } + /** + * Deserialize a JWK to a PublicKey (RFC 7517). + * + * Supports RSA (kty=RSA, parameters n/e) and EC keys on curves + * P-256, P-384 and P-521 (kty=EC, parameters crv/x/y). + * + * Typical use case: verifying JWTs signed by an external OIDC provider. + * The provider's JWKS endpoint returns an array of JWK objects; pass each + * one to this method to obtain a PublicKey suitable for Rs256Verifier or + * Es256Verifier. + * + * @param array $jwk A single JWK object (one entry from the + * 'keys' array of a JWKS document) + * @throws InvalidArgumentException on missing/invalid parameters or + * unsupported key type / EC curve + */ + public static function toPublicKey(array $jwk): PublicKey + { + $kty = (string) ($jwk['kty'] ?? ''); + + return match ($kty) { + 'RSA' => self::rsaJwkToPublicKey($jwk), + 'EC' => self::ecJwkToPublicKey($jwk), + '' => throw new InvalidArgumentException('JWK missing required "kty" parameter'), + default => throw new InvalidArgumentException( + "Unsupported JWK key type '$kty'; only RSA and EC are supported" + ), + }; + } + /** * @return array */ @@ -95,4 +126,120 @@ private static function base64UrlEncodeInt(string $binaryInt): string { return rtrim(strtr(base64_encode($binaryInt), '+/', '-_'), '='); } + + /** + * @param array $jwk + */ + private static function rsaJwkToPublicKey(array $jwk): PublicKey + { + $n = $jwk['n'] ?? ''; + $e = $jwk['e'] ?? ''; + + if ($n === '' || $e === '') { + throw new InvalidArgumentException('RSA JWK missing required parameters "n" and/or "e"'); + } + + $modulus = self::base64UrlDecode($n); + $exponent = self::base64UrlDecode($e); + + $encLen = static function (int $len): string { + if ($len < 128) { + return chr($len); + } + if ($len < 256) { + return chr(0x81) . chr($len); + } + return chr(0x82) . chr($len >> 8) . chr($len & 0xff); + }; + + $encInt = static function (string $bytes) use ($encLen): string { + if (ord($bytes[0]) > 0x7f) { + $bytes = "\x00" . $bytes; + } + return "\x02" . $encLen(strlen($bytes)) . $bytes; + }; + + $inner = $encInt($modulus) . $encInt($exponent); + $seq = "\x30" . $encLen(strlen($inner)) . $inner; + $algId = "\x30\x0d\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01\x05\x00"; + $bitStr = "\x03" . $encLen(strlen($seq) + 1) . "\x00" . $seq; + $spki = "\x30" . $encLen(strlen($algId) + strlen($bitStr)) . $algId . $bitStr; + + $pem = "-----BEGIN PUBLIC KEY-----\n" + . chunk_split(base64_encode($spki), 64, "\n") + . "-----END PUBLIC KEY-----\n"; + + return PublicKey::fromString($pem); + } + + /** + * @param array $jwk + */ + private static function ecJwkToPublicKey(array $jwk): PublicKey + { + $crv = (string) ($jwk['crv'] ?? ''); + $x = $jwk['x'] ?? ''; + $y = $jwk['y'] ?? ''; + + if ($crv === '' || $x === '' || $y === '') { + throw new InvalidArgumentException('EC JWK missing required parameters "crv", "x" and/or "y"'); + } + + $curveMap = [ + 'P-256' => ['prime256v1', 32], + 'P-384' => ['secp384r1', 48], + 'P-521' => ['secp521r1', 66], + ]; + + if (!isset($curveMap[$crv])) { + throw new InvalidArgumentException("Unsupported EC curve '$crv'; supported: P-256, P-384, P-521"); + } + + [$curveName, $coordSize] = $curveMap[$crv]; + + $xBytes = str_pad(self::base64UrlDecode($x), $coordSize, "\x00", STR_PAD_LEFT); + $yBytes = str_pad(self::base64UrlDecode($y), $coordSize, "\x00", STR_PAD_LEFT); + + $point = "\x04" . $xBytes . $yBytes; + + $oidMap = [ + 'prime256v1' => "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07", + 'secp384r1' => "\x06\x05\x2b\x81\x04\x00\x22", + 'secp521r1' => "\x06\x05\x2b\x81\x04\x00\x23", + ]; + + $ecOid = "\x06\x07\x2a\x86\x48\xce\x3d\x02\x01"; + $curveOid = $oidMap[$curveName]; + $algSeq = "\x30" . chr(strlen($ecOid) + strlen($curveOid)) . $ecOid . $curveOid; + + $encLen = static function (int $len): string { + if ($len < 128) { + return chr($len); + } + if ($len < 256) { + return chr(0x81) . chr($len); + } + return chr(0x82) . chr($len >> 8) . chr($len & 0xff); + }; + + $bitStr = "\x03" . $encLen(strlen($point) + 1) . "\x00" . $point; + $spki = "\x30" . $encLen(strlen($algSeq) + strlen($bitStr)) . $algSeq . $bitStr; + + $pem = "-----BEGIN PUBLIC KEY-----\n" + . chunk_split(base64_encode($spki), 64, "\n") + . "-----END PUBLIC KEY-----\n"; + + return PublicKey::fromString($pem); + } + + private static function base64UrlDecode(string $data): string + { + $padded = strtr($data, '-_', '+/'); + $padded .= str_repeat('=', (4 - strlen($padded) % 4) % 4); + $decoded = base64_decode($padded, strict: true); + if ($decoded === false) { + throw new InvalidArgumentException("Invalid base64url data: $data"); + } + return $decoded; + } } diff --git a/test/Key/JwkTest.php b/test/Key/JwkTest.php index 36f74a5..9317a8f 100644 --- a/test/Key/JwkTest.php +++ b/test/Key/JwkTest.php @@ -10,11 +10,21 @@ * * @author Ralf Lang * @author Torben Dannhauer + * @author Jean Charles Delépine */ namespace Horde\Jwt\Test\Key; +use Horde\Jwt\Exception\InvalidTokenException; use Horde\Jwt\Key\Jwk; +use Horde\Jwt\Key\PrivateKey; +use Horde\Jwt\Key\PublicKey; +use Horde\Jwt\Signer\Es256Signer; +use Horde\Jwt\Signer\Rs256Signer; +use Horde\Jwt\TokenDecoder; +use Horde\Jwt\TokenEncoder; +use Horde\Jwt\Verifier\Es256Verifier; +use Horde\Jwt\Verifier\Rs256Verifier; use InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -54,4 +64,144 @@ public function testRejectsInvalidPem(): void $this->expectException(InvalidArgumentException::class); Jwk::fromPublicKeyPem('not a key'); } + + public function testRsaJwkRoundTrip(): void + { + $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($key, $privPem); + $privKey = PrivateKey::fromString($privPem); + $pubKey = PublicKey::fromPrivateKey($privKey); + + $jwk = Jwk::fromPublicKey($pubKey); + $restoredKey = Jwk::toPublicKey($jwk); + + $this->assertInstanceOf(PublicKey::class, $restoredKey); + + $token = (new TokenEncoder())->encode(['sub' => 'alice'], new Rs256Signer($privKey)); + $verified = (new TokenDecoder())->decode($token->toString(), new Rs256Verifier($restoredKey)); + + $this->assertSame('alice', $verified->getSubject()); + } + + public function testEcP256JwkRoundTrip(): void + { + $key = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]); + openssl_pkey_export($key, $privPem); + $privKey = PrivateKey::fromString($privPem); + $pubKey = PublicKey::fromPrivateKey($privKey); + + $jwk = Jwk::fromPublicKey($pubKey); + $restoredKey = Jwk::toPublicKey($jwk); + + $this->assertInstanceOf(PublicKey::class, $restoredKey); + + $token = (new TokenEncoder())->encode(['sub' => 'bob'], new Es256Signer($privKey)); + $verified = (new TokenDecoder())->decode($token->toString(), new Es256Verifier($restoredKey)); + + $this->assertSame('bob', $verified->getSubject()); + } + + public function testEcP384JwkRoundTrip(): void + { + $key = openssl_pkey_new(['curve_name' => 'secp384r1', 'private_key_type' => OPENSSL_KEYTYPE_EC]); + openssl_pkey_export($key, $privPem); + $privKey = PrivateKey::fromString($privPem); + $pubKey = PublicKey::fromPrivateKey($privKey); + + $jwk = Jwk::fromPublicKey($pubKey); + $restoredKey = Jwk::toPublicKey($jwk); + + $this->assertInstanceOf(PublicKey::class, $restoredKey); + } + + public function testEcP521JwkRoundTrip(): void + { + $key = openssl_pkey_new(['curve_name' => 'secp521r1', 'private_key_type' => OPENSSL_KEYTYPE_EC]); + openssl_pkey_export($key, $privPem); + $privKey = PrivateKey::fromString($privPem); + $pubKey = PublicKey::fromPrivateKey($privKey); + + $jwk = Jwk::fromPublicKey($pubKey); + $restoredKey = Jwk::toPublicKey($jwk); + + $this->assertInstanceOf(PublicKey::class, $restoredKey); + } + + public function testRsaJwkWithExtraFieldsIgnored(): void + { + $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($key, $privPem); + $jwk = array_merge( + Jwk::fromPublicKey(PublicKey::fromPrivateKey(PrivateKey::fromString($privPem))), + ['kid' => 'key-1', 'alg' => 'RS256'] + ); + + $restoredKey = Jwk::toPublicKey($jwk); + $this->assertInstanceOf(PublicKey::class, $restoredKey); + } + + public function testWrongKeyCannotVerify(): void + { + $keyA = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + $keyB = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($keyA, $privPemA); + openssl_pkey_export($keyB, $privPemB); + + $privA = PrivateKey::fromString($privPemA); + $privB = PrivateKey::fromString($privPemB); + $pubB = Jwk::toPublicKey(Jwk::fromPublicKey(PublicKey::fromPrivateKey($privB))); + + $token = (new TokenEncoder())->encode(['sub' => 'alice'], new Rs256Signer($privA)); + + $this->expectException(InvalidTokenException::class); + (new TokenDecoder())->decode($token->toString(), new Rs256Verifier($pubB)); + } + + public function testMissingKtyThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/missing required "kty"/i'); + + Jwk::toPublicKey(['n' => 'abc', 'e' => 'AQAB']); + } + + public function testUnsupportedKtyThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Unsupported JWK key type/i'); + + Jwk::toPublicKey(['kty' => 'OKP', 'crv' => 'Ed25519', 'x' => 'abc']); + } + + public function testRsaJwkMissingNThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/missing required parameters/i'); + + Jwk::toPublicKey(['kty' => 'RSA', 'e' => 'AQAB']); + } + + public function testRsaJwkMissingEThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/missing required parameters/i'); + + Jwk::toPublicKey(['kty' => 'RSA', 'n' => 'abc123']); + } + + public function testEcJwkMissingCrvThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/missing required parameters/i'); + + Jwk::toPublicKey(['kty' => 'EC', 'x' => 'abc', 'y' => 'def']); + } + + public function testEcJwkUnsupportedCurveThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Unsupported EC curve/i'); + + Jwk::toPublicKey(['kty' => 'EC', 'crv' => 'P-224', 'x' => 'abc', 'y' => 'def']); + } }