Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions Jwt/src/JWK.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use DomainException;
use InvalidArgumentException;
use OpenSSLAsymmetricKey;
use OpenSSLCertificate;
use UnexpectedValueException;

/**
Expand Down Expand Up @@ -31,6 +33,12 @@ class JWK
// 'P-521' => '1.3.132.0.35', // Len: 132 (not supported)
];

private const OPENSSL_EC_CURVES = [
'prime256v1' => 'P-256',
'secp256k1' => 'secp256k1',
'secp384r1' => 'P-384',
];

// Known standard curves from the IANA JOSE registry which are not supported
private const KNOWN_UNSUPPORTED_EC_CURVES = [
'P-521', // RFC 7518
Expand Down Expand Up @@ -197,6 +205,127 @@ public static function parseKey(#[\SensitiveParameter] array $jwk, ?string $defa
return null;
}

/**
* Create a JWK array from a Key, OpenSSL key/certificate, or key string
*
* ```php
* use Firebase\JWT\JWK;
* use Firebase\JWT\JWT;
*
* $jwk = JWK::createFromKey($privateKey);
* $dpopProof = JWT::encode($payload, $privateKey, 'ES256', null, [
* 'typ' => 'dpop+jwt',
* 'jwk' => $jwk,
* ]);
* ```
*
* @param Key|OpenSSLAsymmetricKey|OpenSSLCertificate|string $key
* @param string|null $alg Optional algorithm to include in the JWK (defaults to $key->getAlgorithm() when $key
* is a Key)
* @param string|null $kid Optional key ID to include in the JWK
*
* @return array<string, string> The JWK as an associative array
*
* @throws InvalidArgumentException Provided key is empty or invalid for the algorithm
* @throws UnexpectedValueException Provided key type is unsupported
* @throws DomainException OpenSSL failure or unrecognised EC curve
*/
public static function createFromKey(
#[\SensitiveParameter] Key|OpenSSLAsymmetricKey|OpenSSLCertificate|string $key,
?string $alg = null,
?string $kid = null
): array {
if ($key instanceof Key) {
$alg = $alg ?? $key->getAlgorithm();
$key = $key->getKeyMaterial();
}

if (\is_string($key) && $key === '') {
throw new InvalidArgumentException('Key material must not be empty');
}

if ($alg === 'EdDSA') {
if (!\is_string($key)) {
throw new InvalidArgumentException('key must be a string when using EdDSA');
}
$lines = \array_filter(\explode("\n", $key));
$rawKey = JWT::urlsafeB64Decode((string) \end($lines));
if (\strlen($rawKey) === 64) {
$rawKey = \substr($rawKey, 32, 32);
} elseif (\strlen($rawKey) !== 32) {
throw new DomainException('Invalid EdDSA key length');
}
$jwk = [
'kty' => 'OKP',
'crv' => 'Ed25519',
'x' => JWT::urlsafeB64Encode($rawKey),
];
} elseif ($alg !== null && isset(JWT::$supported_algs[$alg]) && JWT::$supported_algs[$alg][0] === 'hash_hmac') {
if (!\is_string($key)) {
throw new InvalidArgumentException('key must be a string when using hmac');
}
$jwk = [
'kty' => 'oct',
'k' => JWT::urlsafeB64Encode($key),
];
} else {
if ($key instanceof OpenSSLAsymmetricKey) {
$openSslKey = $key;
} elseif (\is_string($key) && \str_contains($key, 'PRIVATE KEY')) {
$openSslKey = \openssl_pkey_get_private($key);
} else {
$openSslKey = \openssl_pkey_get_public($key);
}

if (false === $openSslKey) {
throw new DomainException('OpenSSL unable to validate key');
}

$details = \openssl_pkey_get_details($openSslKey);
if (false === $details) {
throw new DomainException('OpenSSL error: ' . \openssl_error_string());
}

switch ($details['type']) {
case \OPENSSL_KEYTYPE_RSA:
$jwk = [
'kty' => 'RSA',
'n' => JWT::urlsafeB64Encode($details['rsa']['n']),
'e' => JWT::urlsafeB64Encode($details['rsa']['e']),
];
break;
case \OPENSSL_KEYTYPE_EC:
$curveName = $details['ec']['curve_name'] ?? '';
if (!isset(self::OPENSSL_EC_CURVES[$curveName])) {
throw new DomainException('Unrecognised EC curve');
}
$coordLength = (int) \ceil($details['bits'] / 8);
$jwk = [
'kty' => 'EC',
'crv' => self::OPENSSL_EC_CURVES[$curveName],
'x' => JWT::urlsafeB64Encode(
\str_pad($details['ec']['x'], $coordLength, "\x00", \STR_PAD_LEFT)
),
'y' => JWT::urlsafeB64Encode(
\str_pad($details['ec']['y'], $coordLength, "\x00", \STR_PAD_LEFT)
),
];
break;
default:
throw new UnexpectedValueException('Unsupported OpenSSL key type');
}
}

if ($alg !== null) {
$jwk['alg'] = $alg;
}
if ($kid !== null) {
$jwk['kid'] = $kid;
}

return $jwk;
}

/**
* Converts the EC JWK values to pem format.
*
Expand Down
2 changes: 1 addition & 1 deletion Jwt/src/JWT.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ public static function decode(
* @param string $alg Supported algorithms are 'ES384','ES256', 'ES256K', 'HS256',
* 'HS384', 'HS512', 'RS256', 'RS384', and 'RS512'
* @param string $keyId
* @param array<string, string|string[]> $head An array with header elements to attach
* @param array<string, mixed> $head An array with header elements to attach
*
* @return string A signed JWT
*
Expand Down
128 changes: 128 additions & 0 deletions Jwt/tests/Unit/JWKTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -289,4 +289,132 @@ public function testParseKey()

$this->assertEquals(str_replace("\r\n", "\n", $expectedPublicKey), $keyData['key']);
}

public function testCreateFromKeyRsa()
{
$jwkSet = json_decode(
file_get_contents(__DIR__ . '/data/rsa-jwkset.json'),
true
);
$expectedJwk = $jwkSet['keys'][0];

$privatePem = file_get_contents(__DIR__ . '/data/rsa1-private.pem');
$publicPem = file_get_contents(__DIR__ . '/data/rsa1-public.pub');

$this->assertEquals($expectedJwk, JWK::createFromKey($privatePem, 'RS256', 'jwk1'));
$this->assertEquals($expectedJwk, JWK::createFromKey(new Key($publicPem, 'RS256'), null, 'jwk1'));
$this->assertEquals(
['kty' => 'RSA', 'n' => $expectedJwk['n'], 'e' => $expectedJwk['e']],
JWK::createFromKey(openssl_pkey_get_public($publicPem))
);
}

/**
* @dataProvider provideCreateFromKeyEc
*/
public function testCreateFromKeyEc($pemFile, $jwkIndex, $alg, $kid)
{
$jwkSet = json_decode(
file_get_contents(__DIR__ . '/data/ec-jwkset.json'),
true
);
$expectedJwk = $jwkSet['keys'][$jwkIndex];
unset($expectedJwk['use']);

$pem = file_get_contents(__DIR__ . '/data/' . $pemFile);
$jwk = JWK::createFromKey($pem, $alg, $kid);

$this->assertEquals($expectedJwk, $jwk);

$msg = JWT::encode(['sub' => 'dpop'], $pem, $alg, null, [
'typ' => 'dpop+jwt',
'jwk' => JWK::createFromKey($pem),
]);
$headers = new \stdClass();
$decoded = JWT::decode($msg, JWK::parseKey($jwk), $headers);
$this->assertSame('dpop', $decoded->sub);
$this->assertSame('dpop+jwt', $headers->typ);
$this->assertSame($expectedJwk['x'], $headers->jwk->x);
}

public function provideCreateFromKeyEc()
{
return [
['ecdsa256-private.pem', 0, 'ES256', 'jwk1'],
['ecdsa384-private.pem', 3, 'ES384', 'jwk4'],
];
}

public function testCreateFromKeySecp256k1()
{
$privatePem = file_get_contents(__DIR__ . '/data/secp256k1-private.pem');
$publicPem = file_get_contents(__DIR__ . '/data/secp256k1-public.pem');

$jwk = JWK::createFromKey($privatePem, 'ES256K', 'jwk3');
$this->assertSame('EC', $jwk['kty']);
$this->assertSame('secp256k1', $jwk['crv']);
$this->assertSame('ES256K', $jwk['alg']);
$this->assertSame('jwk3', $jwk['kid']);
$this->assertEquals($jwk, JWK::createFromKey($publicPem, 'ES256K', 'jwk3'));

$parsedKey = JWK::parseKey($jwk);
$this->assertEquals(
trim(str_replace("\r\n", "\n", $publicPem)),
trim($parsedKey->getKeyMaterial())
);
}

public function testCreateFromKeyEdDsa()
{
$jwkSet = json_decode(
file_get_contents(__DIR__ . '/data/ed25519-jwkset.json'),
true
);
$expectedJwk = $jwkSet['keys'][0];

$pubKey = file_get_contents(__DIR__ . '/data/ed25519-1.pub');
$secKey = file_get_contents(__DIR__ . '/data/ed25519-1.sec');

$this->assertEquals($expectedJwk, JWK::createFromKey(new Key($pubKey, 'EdDSA'), null, 'jwk1'));
$this->assertEquals($expectedJwk, JWK::createFromKey($secKey, 'EdDSA', 'jwk1'));
}

public function testCreateFromKeyOctet()
{
$jwkSet = json_decode(
file_get_contents(__DIR__ . '/data/octet-jwkset.json'),
true
);
$keys = JWK::parseKeySet($jwkSet);

foreach ($jwkSet['keys'] as $expectedJwk) {
$kid = $expectedJwk['kid'];
$this->assertEquals($expectedJwk, JWK::createFromKey($keys[$kid], null, $kid));
}
}

public function testCreateFromKeyEmptyThrowsException()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Key material must not be empty');

JWK::createFromKey('');
}

public function testCreateFromKeyInvalidKeyThrowsException()
{
$this->expectException(DomainException::class);
$this->expectExceptionMessage('OpenSSL unable to validate key');

JWK::createFromKey('invalid-key-material');
}

public function testCreateFromKeyUnsupportedCurveThrowsException()
{
$this->expectException(DomainException::class);
$this->expectExceptionMessage('Unrecognised EC curve');

$pem = file_get_contents(__DIR__ . '/data/ecdsa192-private.pem');
JWK::createFromKey($pem, 'ES256');
}
}
Loading