diff --git a/src/Bundle/Resources/config/commands.php b/src/Bundle/Resources/config/commands.php index 85f78a03..0c7bf1dc 100644 --- a/src/Bundle/Resources/config/commands.php +++ b/src/Bundle/Resources/config/commands.php @@ -18,6 +18,7 @@ use Jose\Component\Console\OptimizeRsaKeyCommand; use Jose\Component\Console\P12CertificateLoaderCommand; use Jose\Component\Console\PemConverterCommand; +use Jose\Component\Console\Pkcs8ConverterCommand; use Jose\Component\Console\PublicKeyCommand; use Jose\Component\Console\PublicKeysetCommand; use Jose\Component\Console\RotateKeysetCommand; @@ -50,6 +51,7 @@ $container->set(OkpKeysetGeneratorCommand::class); $container->set(P12CertificateLoaderCommand::class); $container->set(PemConverterCommand::class); + $container->set(Pkcs8ConverterCommand::class); $container->set(PublicKeyCommand::class); $container->set(PublicKeysetCommand::class); $container->set(RotateKeysetCommand::class); diff --git a/src/Library/Console/Pkcs8ConverterCommand.php b/src/Library/Console/Pkcs8ConverterCommand.php new file mode 100644 index 00000000..68989469 --- /dev/null +++ b/src/Library/Console/Pkcs8ConverterCommand.php @@ -0,0 +1,58 @@ +setHelp( + 'This command converts a RSA, EC or OKP key into a PKCS#8 key. As PKCS#8 only covers private keys, public keys are converted into a SubjectPublicKeyInfo structure.' + ) + ->addArgument('jwk', InputArgument::REQUIRED, 'The key'); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $jwk = $input->getArgument('jwk'); + if (! is_string($jwk)) { + throw new InvalidArgumentException('Invalid JWK'); + } + $json = JsonConverter::decode($jwk); + if (! is_array($json)) { + throw new InvalidArgumentException('Invalid JWK.'); + } + $key = new JWK($json); + + $pem = match ($key->get('kty')) { + 'RSA' => RSAKey::createFromJWK($key)->toPEM(), + 'EC' => ECKey::convertToPKCS8PEM($key), + 'OKP' => OKPKey::convertToPKCS8PEM($key), + default => throw new InvalidArgumentException('Not a RSA, EC or OKP key.'), + }; + $output->write($pem); + + return self::SUCCESS; + } +} diff --git a/src/Library/Core/Util/ECKey.php b/src/Library/Core/Util/ECKey.php index 30eda8d1..b97ad4ca 100644 --- a/src/Library/Core/Util/ECKey.php +++ b/src/Library/Core/Util/ECKey.php @@ -7,6 +7,13 @@ use InvalidArgumentException; use Jose\Component\Core\JWK; use RuntimeException; +use SpomkyLabs\Pki\ASN1\Type\Constructed\Sequence; +use SpomkyLabs\Pki\ASN1\Type\Primitive\BitString; +use SpomkyLabs\Pki\ASN1\Type\Primitive\Integer; +use SpomkyLabs\Pki\ASN1\Type\Primitive\ObjectIdentifier; +use SpomkyLabs\Pki\ASN1\Type\Primitive\OctetString; +use SpomkyLabs\Pki\ASN1\Type\Tagged\ExplicitlyTaggedType; +use SpomkyLabs\Pki\CryptoEncoding\PEM; use function extension_loaded; use function is_array; use function is_string; @@ -19,6 +26,11 @@ */ final readonly class ECKey { + /** + * OID of the id-ecPublicKey algorithm identifier. + */ + private const EC_PUBLIC_KEY_OID = '1.2.840.10045.2.1'; + public static function convertToPEM(JWK $jwk): string { if ($jwk->has('d')) { @@ -28,6 +40,49 @@ public static function convertToPEM(JWK $jwk): string return self::convertPublicKeyToPEM($jwk); } + /** + * Converts the key into a PKCS#8 PEM. As PKCS#8 only covers private keys, public keys are converted into a + * SubjectPublicKeyInfo structure, which is the format expected by the tools consuming PKCS#8 private keys. + */ + public static function convertToPKCS8PEM(JWK $jwk): string + { + if ($jwk->has('d')) { + return self::convertPrivateKeyToPKCS8PEM($jwk); + } + + return self::convertPublicKeyToPEM($jwk); + } + + /** + * Converts the private key into a PKCS#8 (RFC 5208) PEM, i.e. a PrivateKeyInfo structure wrapping the RFC 5915 + * ECPrivateKey. The curve is only carried by the algorithm identifier: the optional "parameters" field of the + * inner ECPrivateKey is left out to avoid the duplication, exactly as OpenSSL does. + */ + public static function convertPrivateKeyToPKCS8PEM(JWK $jwk): string + { + $curve = $jwk->get('crv'); + if (! is_string($curve)) { + throw new InvalidArgumentException('Unable to get the curve'); + } + $length = (int) ceil(self::getCurveSize($curve) / 8); + $ecPrivateKey = Sequence::create( + Integer::create(1), + OctetString::create(self::getPrivateKeyBytes($jwk, $length)), + ExplicitlyTaggedType::create(1, BitString::create(self::getKey($jwk))), + ); + $privateKeyInfo = Sequence::create( + Integer::create(0), + Sequence::create( + ObjectIdentifier::create(self::EC_PUBLIC_KEY_OID), + ObjectIdentifier::create(self::getCurveOid($curve)), + ), + OctetString::create($ecPrivateKey->toDER()), + ); + + return PEM::create(PEM::TYPE_PRIVATE_KEY, $privateKeyInfo->toDER()) + ->string(); + } + public static function convertPublicKeyToPEM(JWK $jwk): string { $der = match ($jwk->get('crv')) { @@ -133,6 +188,24 @@ private static function createECKeyUsingOpenSSL(string $curve): array ]; } + /** + * Returns the OID of the named curve, as used by the AlgorithmIdentifier of the PKCS#8 and SubjectPublicKeyInfo + * structures. + */ + private static function getCurveOid(string $curve): string + { + return match ($curve) { + 'P-256' => '1.2.840.10045.3.1.7', + 'secp256k1' => '1.3.132.0.10', + 'P-384' => '1.3.132.0.34', + 'P-521' => '1.3.132.0.35', + 'BP-256' => '1.3.36.3.3.2.8.1.1.7', + 'BP-384' => '1.3.36.3.3.2.8.1.1.11', + 'BP-512' => '1.3.36.3.3.2.8.1.1.13', + default => throw new InvalidArgumentException(sprintf('The curve "%s" is not supported.', $curve)), + }; + } + private static function getOpensslCurveName(string $curve): string { return match ($curve) { @@ -346,11 +419,7 @@ private static function bp512PrivateKey(JWK $jwk): string */ private static function getPrivateKeyOctets(JWK $jwk, int $length): string { - $d = $jwk->get('d'); - if (! is_string($d)) { - throw new InvalidArgumentException('Unable to get the private key'); - } - $data = unpack('H*', str_pad(Base64UrlSafe::decodeNoPadding($d), $length, "\0", STR_PAD_LEFT)); + $data = unpack('H*', self::getPrivateKeyBytes($jwk, $length)); if (! is_array($data) || ! isset($data[1]) || ! is_string($data[1])) { throw new InvalidArgumentException('Unable to get the private key'); } @@ -358,6 +427,19 @@ private static function getPrivateKeyOctets(JWK $jwk, int $length): string return $data[1]; } + /** + * Returns the binary representation of the private key, left-padded to the size of the curve. + */ + private static function getPrivateKeyBytes(JWK $jwk, int $length): string + { + $d = $jwk->get('d'); + if (! is_string($d)) { + throw new InvalidArgumentException('Unable to get the private key'); + } + + return str_pad(Base64UrlSafe::decodeNoPadding($d), $length, "\0", STR_PAD_LEFT); + } + private static function getKey(JWK $jwk): string { $crv = $jwk->get('crv'); diff --git a/src/Library/Core/Util/OKPKey.php b/src/Library/Core/Util/OKPKey.php new file mode 100644 index 00000000..f1206bc7 --- /dev/null +++ b/src/Library/Core/Util/OKPKey.php @@ -0,0 +1,105 @@ +has('d')) { + return self::convertPrivateKeyToPKCS8PEM($jwk); + } + + return self::convertPublicKeyToPEM($jwk); + } + + /** + * Converts the private key into a PKCS#8 (RFC 5208) PEM. The public key is deliberately left out of the structure: + * the resulting OneAsymmetricKey stays at version 0, which is what RFC 8410 section 7 recommends and what the + * widely deployed PKCS#8 parsers expect. + */ + public static function convertPrivateKeyToPKCS8PEM(JWK $jwk): string + { + $privateKey = self::createPrivateKey($jwk); + + return PrivateKeyInfo::create($privateKey->algorithmIdentifier(), $privateKey->toDER()) + ->toPEM() + ->string(); + } + + /** + * Converts the public key into a SubjectPublicKeyInfo (RFC 5280) PEM. + */ + public static function convertPublicKeyToPEM(JWK $jwk): string + { + return self::createPublicKey($jwk) + ->publicKeyInfo() + ->toPEM() + ->string(); + } + + private static function createPrivateKey(JWK $jwk): PrivateKey + { + $curve = self::getParameter($jwk, 'crv'); + $d = Base64UrlSafe::decodeNoPadding(self::getParameter($jwk, 'd')); + + return match ($curve) { + 'Ed25519' => Ed25519PrivateKey::create($d), + 'Ed448' => Ed448PrivateKey::create($d), + 'X25519' => X25519PrivateKey::create($d), + 'X448' => X448PrivateKey::create($d), + default => throw new InvalidArgumentException(sprintf('The curve "%s" is not supported.', $curve)), + }; + } + + private static function createPublicKey(JWK $jwk): PublicKey + { + $curve = self::getParameter($jwk, 'crv'); + $x = Base64UrlSafe::decodeNoPadding(self::getParameter($jwk, 'x')); + + return match ($curve) { + 'Ed25519' => Ed25519PublicKey::create($x), + 'Ed448' => Ed448PublicKey::create($x), + 'X25519' => X25519PublicKey::create($x), + 'X448' => X448PublicKey::create($x), + default => throw new InvalidArgumentException(sprintf('The curve "%s" is not supported.', $curve)), + }; + } + + private static function getParameter(JWK $jwk, string $parameter): string + { + $value = $jwk->get($parameter); + if (! is_string($value)) { + throw new InvalidArgumentException(sprintf('Unable to get the "%s" parameter', $parameter)); + } + + return $value; + } +} diff --git a/tests/Bundle/JoseFramework/Functional/Console/ConsoleTest.php b/tests/Bundle/JoseFramework/Functional/Console/ConsoleTest.php index 87b98810..2913c4fa 100644 --- a/tests/Bundle/JoseFramework/Functional/Console/ConsoleTest.php +++ b/tests/Bundle/JoseFramework/Functional/Console/ConsoleTest.php @@ -32,6 +32,7 @@ public static function allCommandsAreAvailable(): void 'key:optimize', 'key:load:p12', 'key:convert:pkcs1', + 'key:convert:pkcs8', 'keyset:convert:public', 'keyset:rotate', 'key:generate:rsa', diff --git a/tests/Component/Console/KeyConversionCommandTest.php b/tests/Component/Console/KeyConversionCommandTest.php index 01e8eead..67bbc0c7 100644 --- a/tests/Component/Console/KeyConversionCommandTest.php +++ b/tests/Component/Console/KeyConversionCommandTest.php @@ -4,22 +4,28 @@ namespace Jose\Tests\Component\Console; +use InvalidArgumentException; use Jose\Component\Console\GetThumbprintCommand; use Jose\Component\Console\KeyFileLoaderCommand; use Jose\Component\Console\OptimizeRsaKeyCommand; use Jose\Component\Console\P12CertificateLoaderCommand; use Jose\Component\Console\PemConverterCommand; +use Jose\Component\Console\Pkcs8ConverterCommand; use Jose\Component\Console\PublicKeyCommand; use Jose\Component\Console\PublicKeysetCommand; use Jose\Component\Console\X509CertificateLoaderCommand; use Jose\Component\Core\JWK; use Jose\Component\Core\JWKSet; +use Jose\Component\Core\Util\Base64UrlSafe; use Jose\Component\Core\Util\JsonConverter; +use Jose\Component\KeyManagement\JWKFactory; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; +use const STR_PAD_LEFT; /** * @internal @@ -156,6 +162,98 @@ public function iCanConvertAnEcKeyIntoPKCS1(): void static::assertStringContainsString('-----BEGIN EC PRIVATE KEY-----', $content); } + #[Test] + public function iCanConvertARsaKeyIntoPKCS8(): void + { + $jwk = JWKFactory::createRSAKey(2048); + + $content = self::convertIntoPKCS8($jwk); + + static::assertStringStartsWith('-----BEGIN PRIVATE KEY-----', $content); + self::assertSameKeyMaterial($jwk, JWKFactory::createFromKey($content)); + } + + #[Test] + #[DataProvider('ecCurves')] + public function iCanConvertAnEcKeyIntoPKCS8(string $curve, int $size): void + { + $jwk = JWKFactory::createECKey($curve); + + $content = self::convertIntoPKCS8($jwk); + + static::assertStringStartsWith('-----BEGIN PRIVATE KEY-----', $content); + $details = self::getKeyDetails($content); + static::assertSame(self::getRawParameter($jwk, 'd', $size), str_pad($details['ec']['d'], $size, "\0", STR_PAD_LEFT)); + static::assertSame(self::getRawParameter($jwk, 'x', $size), str_pad($details['ec']['x'], $size, "\0", STR_PAD_LEFT)); + static::assertSame(self::getRawParameter($jwk, 'y', $size), str_pad($details['ec']['y'], $size, "\0", STR_PAD_LEFT)); + } + + #[Test] + #[DataProvider('okpCurves')] + public function iCanConvertAnOkpKeyIntoPKCS8(string $curve): void + { + $jwk = JWKFactory::createOKPKey($curve); + + $content = self::convertIntoPKCS8($jwk); + + static::assertStringStartsWith('-----BEGIN PRIVATE KEY-----', $content); + self::assertSameKeyMaterial($jwk, JWKFactory::createFromKey($content)); + } + + /** + * PKCS#8 only covers private keys. Public keys are converted into a SubjectPublicKeyInfo structure. + */ + #[Test] + #[DataProvider('publicKeys')] + public function iCanConvertAPublicKeyIntoSubjectPublicKeyInfo(JWK $jwk): void + { + $content = self::convertIntoPKCS8($jwk->toPublic()); + + static::assertStringStartsWith('-----BEGIN PUBLIC KEY-----', $content); + self::assertSameKeyMaterial($jwk->toPublic(), JWKFactory::createFromKey($content)); + } + + #[Test] + public function iCannotConvertAnOctKeyIntoPKCS8(): void + { + $this->expectException(InvalidArgumentException::class); + + self::convertIntoPKCS8(JWKFactory::createOctKey(256)); + } + + /** + * @return iterable + */ + public static function ecCurves(): iterable + { + yield 'P-256' => ['P-256', 32]; + yield 'secp256k1' => ['secp256k1', 32]; + yield 'P-384' => ['P-384', 48]; + yield 'P-521' => ['P-521', 66]; + yield 'BP-256' => ['BP-256', 32]; + yield 'BP-384' => ['BP-384', 48]; + yield 'BP-512' => ['BP-512', 64]; + } + + /** + * @return iterable + */ + public static function okpCurves(): iterable + { + yield 'Ed25519' => ['Ed25519']; + yield 'X25519' => ['X25519']; + } + + /** + * @return iterable + */ + public static function publicKeys(): iterable + { + yield 'RSA' => [JWKFactory::createRSAKey(2048)]; + yield 'EC' => [JWKFactory::createECKey('P-256')]; + yield 'OKP' => [JWKFactory::createOKPKey('Ed25519')]; + } + #[Test] public function iCanConvertAPrivateKeyIntoPublicKey(): void { @@ -233,4 +331,53 @@ public function iCanGetTheThumbprintOfAKey(): void $content = $output->fetch(); static::assertSame('NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs', $content); } + + /** + * The order of the key parameters is irrelevant and depends on the way the key has been loaded. + */ + private static function assertSameKeyMaterial(JWK $expected, JWK $actual): void + { + $expectedValues = $expected->all(); + $actualValues = $actual->all(); + ksort($expectedValues); + ksort($actualValues); + + static::assertSame($expectedValues, $actualValues); + } + + private static function convertIntoPKCS8(JWK $jwk): string + { + $input = new ArrayInput([ + 'jwk' => JsonConverter::encode($jwk), + ]); + $output = new BufferedOutput(); + $command = new Pkcs8ConverterCommand(); + $command->run($input, $output); + + return $output->fetch(); + } + + /** + * @return array{ec: array{d: string, x: string, y: string}} + */ + private static function getKeyDetails(string $pem): array + { + $key = openssl_pkey_get_private($pem); + static::assertNotFalse($key, 'The PKCS#8 key is not readable by OpenSSL'); + $details = openssl_pkey_get_details($key); + static::assertIsArray($details); + + return $details; + } + + /** + * Returns the binary value of the given parameter, left-padded to the size of the curve. + */ + private static function getRawParameter(JWK $jwk, string $parameter, int $size): string + { + $value = $jwk->get($parameter); + static::assertIsString($value); + + return str_pad(Base64UrlSafe::decodeNoPadding($value), $size, "\0", STR_PAD_LEFT); + } }