From 0b3c6e27fcdd58d747cea054d536e319a38ca5be Mon Sep 17 00:00:00 2001 From: terrafrost Date: Sat, 20 Aug 2022 06:18:54 -0500 Subject: [PATCH] add JSON Web Key (JWK) support --- phpseclib/Common/Functions/Strings.php | 4 +- phpseclib/Crypt/Common/Formats/Keys/JWK.php | 69 +++++++ phpseclib/Crypt/EC/Formats/Keys/JWK.php | 190 ++++++++++++++++++++ phpseclib/Crypt/RSA/Formats/Keys/JWK.php | 142 +++++++++++++++ tests/Unit/Crypt/EC/KeyTest.php | 92 ++++++++++ tests/Unit/Crypt/RSA/LoadKeyTest.php | 82 +++++++++ 6 files changed, 577 insertions(+), 2 deletions(-) create mode 100644 phpseclib/Crypt/Common/Formats/Keys/JWK.php create mode 100644 phpseclib/Crypt/EC/Formats/Keys/JWK.php create mode 100644 phpseclib/Crypt/RSA/Formats/Keys/JWK.php diff --git a/phpseclib/Common/Functions/Strings.php b/phpseclib/Common/Functions/Strings.php index 50a8c580..eac793a6 100644 --- a/phpseclib/Common/Functions/Strings.php +++ b/phpseclib/Common/Functions/Strings.php @@ -470,11 +470,11 @@ abstract class Strings */ public static function base64url_encode($data) { - // return self::base64_encode(str_replace(['+', '/'], ['-', '_'], $data)); + // return str_replace(['+', '/'], ['-', '_'], self::base64_encode($data)); return function_exists('sodium_bin2base64') ? sodium_bin2base64($data, SODIUM_BASE64_VARIANT_URLSAFE) : - Base64::encode($data); + Base64UrlSafe::encode($data); } /** diff --git a/phpseclib/Crypt/Common/Formats/Keys/JWK.php b/phpseclib/Crypt/Common/Formats/Keys/JWK.php new file mode 100644 index 00000000..1a2f0ecf --- /dev/null +++ b/phpseclib/Crypt/Common/Formats/Keys/JWK.php @@ -0,0 +1,69 @@ + + * @copyright 2015 Jim Wigginton + * @license http://www.opensource.org/licenses/mit-license.html MIT License + * @link http://phpseclib.sourceforge.net + */ + +namespace phpseclib3\Crypt\Common\Formats\Keys; + +use phpseclib3\Common\Functions\Strings; + +/** + * JSON Web Key Formatted Key Handler + * + * @author Jim Wigginton + */ +abstract class JWK +{ + /** + * Break a public or private key down into its constituent components + * + * @param string $key + * @param string $password + * @return array + */ + public static function load($key, $password = '') + { + if (!Strings::is_stringable($key)) { + throw new \UnexpectedValueException('Key should be a string - not a ' . gettype($key)); + } + + $key = preg_replace('#\s#', '', $key); // remove whitespace + + if (PHP_VERSION_ID >= 73000) { + $key = json_decode($key, null, 512, JSON_THROW_ON_ERROR); + } else { + $key = json_decode($key); + if (!$key) { + throw new \RuntimeException('Unable to decode JSON'); + } + } + + if (isset($key->kty)) { + return $key; + } + + if (count($key->keys) != 1) { + throw new \RuntimeException('Although the JWK key format supports multiple keys phpseclib does not'); + } + + return $key->keys[0]; + } + + /** + * Wrap a key appropriately + * + * @return string + */ + protected static function wrapKey(array $key, array $options) + { + return json_encode(['keys' => [$key + $options]]); + } +} diff --git a/phpseclib/Crypt/EC/Formats/Keys/JWK.php b/phpseclib/Crypt/EC/Formats/Keys/JWK.php new file mode 100644 index 00000000..f79f7861 --- /dev/null +++ b/phpseclib/Crypt/EC/Formats/Keys/JWK.php @@ -0,0 +1,190 @@ + + * @copyright 2015 Jim Wigginton + * @license http://www.opensource.org/licenses/mit-license.html MIT License + * @link http://phpseclib.sourceforge.net + */ + +namespace phpseclib3\Crypt\EC\Formats\Keys; + +use phpseclib3\Crypt\EC\BaseCurves\Base as BaseCurve; +use phpseclib3\Common\Functions\Strings; +use phpseclib3\Crypt\Common\Formats\Keys\JWK as Progenitor; +use phpseclib3\Crypt\EC\BaseCurves\TwistedEdwards as TwistedEdwardsCurve; +use phpseclib3\Crypt\EC\Curves\Ed25519; +use phpseclib3\Crypt\EC\Curves\secp256r1; +use phpseclib3\Crypt\EC\Curves\secp384r1; +use phpseclib3\Crypt\EC\Curves\secp521r1; +use phpseclib3\Crypt\EC\Curves\secp256k1; +use phpseclib3\Exception\UnsupportedCurveException; +use phpseclib3\Math\BigInteger; + +/** + * JWK Formatted EC Handler + * + * @author Jim Wigginton + */ +abstract class JWK extends Progenitor +{ + use Common; + + /** + * Break a public or private key down into its constituent components + * + * @param string $key + * @param string $password optional + * @return array + */ + public static function load($key, $password = '') + { + $key = parent::load($key, $password); + + switch ($key->kty) { + case 'EC': + switch ($key->crv) { + case 'P-256': + case 'P-384': + case 'P-521': + case 'secp256k1': + break; + default: + throw new UnsupportedCurveException('Only P-256, P-384, P-521 and secp256k1 curves are accepted (' . $key->crv . ' provided)'); + } + break; + case 'OKP': + switch ($key->crv) { + case 'Ed25519': + case 'Ed448': + break; + default: + throw new UnsupportedCurveException('Only Ed25519 and Ed448 curves are accepted (' . $key->crv . ' provided)'); + } + break; + default: + throw new \Exception('Only EC and OKP JWK keys are supported'); + } + + $curve = '\phpseclib3\Crypt\EC\Curves\\' . str_replace('P-', 'nistp', $key->crv); + $curve = new $curve; + + if ($curve instanceof TwistedEdwardsCurve) { + $QA = self::extractPoint(Strings::base64url_decode($key->x), $curve); + if (!isset($key->d)) { + return compact('curve', 'QA'); + } + $dA = $curve->extractSecret(Strings::base64url_decode($key->d)); + return compact('curve', 'dA', 'QA'); + } + + $QA = [ + $curve->convertInteger(new BigInteger(Strings::base64url_decode($key->x), 256)), + $curve->convertInteger(new BigInteger(Strings::base64url_decode($key->y), 256)) + ]; + + if (!$curve->verifyPoint($QA)) { + throw new \RuntimeException('Unable to verify that point exists on curve'); + } + + if (!isset($key->d)) { + return compact('curve', 'QA'); + } + + $dA = new BigInteger(Strings::base64url_decode($key->d), 256); + + $curve->rangeCheck($dA); + + return compact('curve', 'dA', 'QA'); + } + + /** + * Returns the alias that corresponds to a curve + * + * @return string + */ + private static function getAlias(BaseCurve $curve) + { + switch (true) { + case $curve instanceof secp256r1: + return 'P-256'; + case $curve instanceof secp384r1: + return 'P-384'; + case $curve instanceof secp521r1: + return 'P-521'; + case $curve instanceof secp256k1: + return 'secp256k1'; + } + + $reflect = new \ReflectionClass($curve); + $curveName = $reflect->isFinal() ? + $reflect->getParentClass()->getShortName() : + $reflect->getShortName(); + throw new UnsupportedCurveException("$curveName is not a supported curve"); + } + + /** + * Return the array superstructure for an EC public key + * + * @param \phpseclib3\Crypt\EC\BaseCurves\Base $curve + * @param \phpseclib3\Math\Common\FiniteField\Integer[] $publicKey + * @return array + */ + private static function savePublicKeyHelper(BaseCurve $curve, array $publicKey) + { + if ($curve instanceof TwistedEdwardsCurve) { + return [ + 'kty' => 'OKP', + 'crv' => $curve instanceof Ed25519 ? 'Ed25519' : 'Ed448', + 'x' => Strings::base64url_encode($curve->encodePoint($publicKey)) + ]; + } + + return [ + 'kty' => 'EC', + 'crv' => self::getAlias($curve), + 'x' => Strings::base64url_encode($publicKey[0]->toBytes()), + 'y' => Strings::base64url_encode($publicKey[1]->toBytes()) + ]; + } + + /** + * Convert an EC public key to the appropriate format + * + * @param \phpseclib3\Crypt\EC\BaseCurves\Base $curve + * @param \phpseclib3\Math\Common\FiniteField\Integer[] $publicKey + * @param array $options optional + * @return string + */ + public static function savePublicKey(BaseCurve $curve, array $publicKey, array $options = []) + { + $key = self::savePublicKeyHelper($curve, $publicKey); + + return self::wrapKey($key, $options); + } + + /** + * Convert a private key to the appropriate format. + * + * @param \phpseclib3\Math\BigInteger $privateKey + * @param \phpseclib3\Crypt\EC\Curves\Ed25519 $curve + * @param \phpseclib3\Math\Common\FiniteField\Integer[] $publicKey + * @param string $password optional + * @param array $options optional + * @return string + */ + public static function savePrivateKey(BigInteger $privateKey, BaseCurve $curve, array $publicKey, $password = '', array $options = []) + { + $key = self::savePublicKeyHelper($curve, $publicKey); + $key['d'] = $curve instanceof TwistedEdwardsCurve ? + $privateKey->secret : + $privateKey->toBytes(); + $key['d'] = Strings::base64url_encode($key['d']); + + return self::wrapKey($key, $options); + } +} diff --git a/phpseclib/Crypt/RSA/Formats/Keys/JWK.php b/phpseclib/Crypt/RSA/Formats/Keys/JWK.php new file mode 100644 index 00000000..87f543de --- /dev/null +++ b/phpseclib/Crypt/RSA/Formats/Keys/JWK.php @@ -0,0 +1,142 @@ + + * @copyright 2015 Jim Wigginton + * @license http://www.opensource.org/licenses/mit-license.html MIT License + * @link http://phpseclib.sourceforge.net + */ + +namespace phpseclib3\Crypt\RSA\Formats\Keys; + +use phpseclib3\Common\Functions\Strings; +use phpseclib3\Crypt\Common\Formats\Keys\JWK as Progenitor; +use phpseclib3\Math\BigInteger; + +/** + * JWK Formatted RSA Handler + * + * @author Jim Wigginton + */ +abstract class JWK extends Progenitor +{ + /** + * Break a public or private key down into its constituent components + * + * @param string $key + * @param string $password optional + * @return array + */ + public static function load($key, $password = '') + { + $key = parent::load($key, $password); + + if ($key->kty != 'RSA') { + throw new \RuntimeException('Only RSA JWK keys are supported'); + } + + $count = $publicCount = 0; + $vars = ['n', 'e', 'd', 'p', 'q', 'dp', 'dq', 'qi']; + foreach ($vars as $var) { + if (!isset($key->$var) || !is_string($key->$var)) { + continue; + } + $count++; + $value = new BigInteger(Strings::base64url_decode($key->$var), 256); + switch ($var) { + case 'n': + $publicCount++; + $components['modulus'] = $value; + break; + case 'e': + $publicCount++; + $components['publicExponent'] = $value; + break; + case 'd': + $components['privateExponent'] = $value; + break; + case 'p': + $components['primes'][1] = $value; + break; + case 'q': + $components['primes'][2] = $value; + break; + case 'dp': + $components['exponents'][1] = $value; + break; + case 'dq': + $components['exponents'][2] = $value; + break; + case 'qi': + $components['coefficients'][2] = $value; + } + } + + if ($count == count($vars)) { + return $components + ['isPublicKey' => false]; + } + + if ($count == 2 && $publicCount == 2) { + return $components + ['isPublicKey' => true]; + } + + throw new \UnexpectedValueException('Key does not have an appropriate number of RSA parameters'); + } + + /** + * Convert a private key to the appropriate format. + * + * @param \phpseclib3\Math\BigInteger $n + * @param \phpseclib3\Math\BigInteger $e + * @param \phpseclib3\Math\BigInteger $d + * @param array $primes + * @param array $exponents + * @param array $coefficients + * @param string $password optional + * @param array $options optional + * @return string + */ + public static function savePrivateKey(BigInteger $n, BigInteger $e, BigInteger $d, array $primes, array $exponents, array $coefficients, $password = '', array $options = []) + { + if (count($primes) != 2) { + throw new \InvalidArgumentException('JWK does not support multi-prime RSA keys'); + } + + $key = [ + 'kty' => 'RSA', + 'n' => Strings::base64url_encode($n->toBytes()), + 'e' => Strings::base64url_encode($e->toBytes()), + 'd' => Strings::base64url_encode($d->toBytes()), + 'p' => Strings::base64url_encode($primes[1]->toBytes()), + 'q' => Strings::base64url_encode($primes[2]->toBytes()), + 'dp' => Strings::base64url_encode($exponents[1]->toBytes()), + 'dq' => Strings::base64url_encode($exponents[2]->toBytes()), + 'qi' => Strings::base64url_encode($coefficients[2]->toBytes()) + ]; + + return self::wrapKey($key, $options); + } + + /** + * Convert a public key to the appropriate format + * + * @param \phpseclib3\Math\BigInteger $n + * @param \phpseclib3\Math\BigInteger $e + * @param array $options optional + * @return string + */ + public static function savePublicKey(BigInteger $n, BigInteger $e, array $options = []) + { + $key = [ + 'kty' => 'RSA', + 'n' => Strings::base64url_encode($n->toBytes()), + 'e' => Strings::base64url_encode($e->toBytes()) + ]; + + return self::wrapKey($key, $options); + } +} diff --git a/tests/Unit/Crypt/EC/KeyTest.php b/tests/Unit/Crypt/EC/KeyTest.php index 8b15b5af..2dbf6fcf 100644 --- a/tests/Unit/Crypt/EC/KeyTest.php +++ b/tests/Unit/Crypt/EC/KeyTest.php @@ -574,4 +574,96 @@ MIIEDwIBADATBgcqhkjOPQIBBggqhkjOPQMBBwSCA/MwggPvAgEBBIID6P////// $key = PublicKeyLoader::load($key, 'test'); $this->assertInstanceOf(PrivateKey::class, $key); } + + public function testECasJWK() + { + // keys are from https://datatracker.ietf.org/doc/html/rfc7517#appendix-A + + $plaintext = 'zzz'; + + $key = ' {"keys": + [ + {"kty":"EC", + "crv":"P-256", + "x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", + "d":"870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE", + "use":"enc", + "kid":"1"} + ] + }'; + + $keyWithoutWS = preg_replace('#\s#', '', $key); + + $key = PublicKeyLoader::load($key); + + $phpseclibKey = str_replace('=', '', $key->toString('JWK', [ + 'use' => 'enc', + 'kid' => '1' + ])); + + $this->assertSame($keyWithoutWS, $phpseclibKey); + + $sig = $key->sign($plaintext); + + $key = '{"keys": + [ + {"kty":"EC", + "crv":"P-256", + "x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", + "use":"enc", + "kid":"1"} + ] + }'; + + $keyWithoutWS = preg_replace('#\s#', '', $key); + + $key = PublicKeyLoader::load($key); + + $phpseclibKey = str_replace('=', '', $key->toString('JWK', [ + 'use' => 'enc', + 'kid' => '1' + ])); + + $this->assertSame($keyWithoutWS, $phpseclibKey); + + $this->assertTrue($key->verify($plaintext, $sig)); + } + + public function testEd25519asJWK() + { + // keys are from https://www.rfc-editor.org/rfc/rfc8037.html#appendix-A + + $plaintext = 'zzz'; + + $key = ' {"kty":"OKP","crv":"Ed25519", + "x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "d":"nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A"}'; + + $keyWithoutWS = preg_replace('#\s#', '', $key); + $keyWithoutWS = '{"keys":[' . $keyWithoutWS . ']}'; + + $key = PublicKeyLoader::load($key); + + $phpseclibKey = str_replace('=', '', $key->toString('JWK')); + + $this->assertSame($keyWithoutWS, $phpseclibKey); + + $sig = $key->sign($plaintext); + + $key = ' {"kty":"OKP","crv":"Ed25519", + "x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}'; + + $keyWithoutWS = preg_replace('#\s#', '', $key); + $keyWithoutWS = '{"keys":[' . $keyWithoutWS . ']}'; + + $key = PublicKeyLoader::load($key); + + $phpseclibKey = str_replace('=', '', $key->toString('JWK')); + + $this->assertSame($keyWithoutWS, $phpseclibKey); + + $this->assertTrue($key->verify($plaintext, $sig)); + } } diff --git a/tests/Unit/Crypt/RSA/LoadKeyTest.php b/tests/Unit/Crypt/RSA/LoadKeyTest.php index 3cbbcd97..dc250e38 100644 --- a/tests/Unit/Crypt/RSA/LoadKeyTest.php +++ b/tests/Unit/Crypt/RSA/LoadKeyTest.php @@ -1277,4 +1277,86 @@ LrIZULwMa4nI4Y+RkFftEponSYw= $key = PublicKeyLoader::load($key, 'test'); $this->assertInstanceOf(PrivateKey::class, $key); } + + public function testJWK() + { + // keys are from https://datatracker.ietf.org/doc/html/rfc7517#appendix-A + + $plaintext = 'zzz'; + + $key = ' {"keys": + [ + {"kty":"RSA", + "n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4 + cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMst + n64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2Q + vzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbIS + D08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw + 0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e":"AQAB", + "d":"X4cTteJY_gn4FYPsXB8rdXix5vwsg1FLN5E3EaG6RJoVH-HLLKD9 + M7dx5oo7GURknchnrRweUkC7hT5fJLM0WbFAKNLWY2vv7B6NqXSzUvxT0_YSfqij + wp3RTzlBaCxWp4doFk5N2o8Gy_nHNKroADIkJ46pRUohsXywbReAdYaMwFs9tv8d + _cPVY3i07a3t8MN6TNwm0dSawm9v47UiCl3Sk5ZiG7xojPLu4sbg1U2jx4IBTNBz + nbJSzFHK66jT8bgkuqsk0GjskDJk19Z4qwjwbsnn4j2WBii3RL-Us2lGVkY8fkFz + me1z0HbIkfz0Y6mqnOYtqc0X4jfcKoAC8Q", + "p":"83i-7IvMGXoMXCskv73TKr8637FiO7Z27zv8oj6pbWUQyLPQBQxtPV + nwD20R-60eTDmD2ujnMt5PoqMrm8RfmNhVWDtjjMmCMjOpSXicFHj7XOuVIYQyqV + WlWEh6dN36GVZYk93N8Bc9vY41xy8B9RzzOGVQzXvNEvn7O0nVbfs", + "q":"3dfOR9cuYq-0S-mkFLzgItgMEfFzB2q3hWehMuG0oCuqnb3vobLyum + qjVZQO1dIrdwgTnCdpYzBcOfW5r370AFXjiWft_NGEiovonizhKpo9VVS78TzFgx + kIdrecRezsZ-1kYd_s1qDbxtkDEgfAITAG9LUnADun4vIcb6yelxk", + "dp":"G4sPXkc6Ya9y8oJW9_ILj4xuppu0lzi_H7VTkS8xj5SdX3coE0oim + YwxIi2emTAue0UOa5dpgFGyBJ4c8tQ2VF402XRugKDTP8akYhFo5tAA77Qe_Nmtu + YZc3C3m3I24G2GvR5sSDxUyAN2zq8Lfn9EUms6rY3Ob8YeiKkTiBj0", + "dq":"s9lAH9fggBsoFR8Oac2R_E2gw282rT2kGOAhvIllETE1efrA6huUU + vMfBcMpn8lqeW6vzznYY5SSQF7pMdC_agI3nG8Ibp1BUb0JUiraRNqUfLhcQb_d9 + GF4Dh7e74WbRsobRonujTYN1xCaP6TO61jvWrX-L18txXw494Q_cgk", + "qi":"GyM_p6JrXySiz1toFgKbWV-JdI3jQ4ypu9rbMWx3rQJBfmt0FoYzg + UIZEVFEcOqwemRN81zoDAaa-Bk0KWNGDjJHZDdDmFhW3AN7lI-puxk_mHZGJ11rx + yR8O55XLSe3SPmRfKwZI6yU24ZxvQKFYItdldUKGzO6Ia6zTKhAVRU", + "alg":"RS256", + "kid":"2011-04-29"} + ] + }'; + + $keyWithoutWS = preg_replace('#\s#', '', $key); + + $key = PublicKeyLoader::load($key); + + $phpseclibKey = str_replace('=', '', $key->toString('JWK', [ + 'alg' => 'RS256', + 'kid' => '2011-04-29' + ])); + + $this->assertSame($keyWithoutWS, $phpseclibKey); + + $sig = $key->sign($plaintext); + + $key = ' {"kty":"RSA", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx + 4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMs + tn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2 + QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbI + SD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqb + w0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e":"AQAB", + "alg":"RS256", + + "kid":"2011-04-29"}'; + + $keyWithoutWS = preg_replace('#\s#', '', $key); + $keyWithoutWS = '{"keys":[' . $keyWithoutWS . ']}'; + + $key = PublicKeyLoader::load($key); + + $phpseclibKey = str_replace('=', '', $key->toString('JWK', [ + 'alg' => 'RS256', + 'kid' => '2011-04-29' + ])); + + $this->assertSame($keyWithoutWS, $phpseclibKey); + + $this->assertTrue($key->verify($plaintext, $sig)); + } }