From 6185874db8a90787b404301979d21661ad4169b4 Mon Sep 17 00:00:00 2001 From: Kelvin Mo Date: Tue, 25 Jan 2022 08:20:49 +1100 Subject: [PATCH] Add support for ECDH-DS algorithms (#43) --- CHANGELOG.md | 6 + README.md | 6 + composer.json | 1 + .../Crypt/AESWrappedKeyAlgorithm.php | 116 ++++++ src/SimpleJWT/Crypt/AlgorithmFactory.php | 2 + src/SimpleJWT/Crypt/ECDH.php | 203 +++++++++++ src/SimpleJWT/Crypt/ECDH_AESKeyWrap.php | 91 +++++ src/SimpleJWT/Crypt/PBES2.php | 33 +- src/SimpleJWT/Keys/ECKey.php | 115 +++++- src/SimpleJWT/Util/BigNum.php | 339 ++++++++++++++++++ tests/ECDHTest.php | 108 ++++++ tests/ECKeyTest.php | 62 ++++ tests/{KeyTest.php => KeyPEMImportTest.php} | 2 +- tests/openssl.cnf | 4 + 14 files changed, 1046 insertions(+), 42 deletions(-) create mode 100644 src/SimpleJWT/Crypt/AESWrappedKeyAlgorithm.php create mode 100644 src/SimpleJWT/Crypt/ECDH.php create mode 100644 src/SimpleJWT/Crypt/ECDH_AESKeyWrap.php create mode 100644 src/SimpleJWT/Util/BigNum.php create mode 100644 tests/ECDHTest.php create mode 100644 tests/ECKeyTest.php rename tests/{KeyTest.php => KeyPEMImportTest.php} (98%) create mode 100644 tests/openssl.cnf diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab74d3..5471a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. +## Unreleased + +- Added: Support for Elliptic Curve Diffie-Hellman Ephemeral Static algorithms + ## Version 0.5.3 - Fixed: typos in documentation leading to deprecation error (#39) @@ -20,6 +24,8 @@ All notable changes to this project will be documented in this file. ## Version 0.5.0 - Added: Support for AES GCM family of algorithms +- Added: Support for Elliptic Curve Diffie-Hellman key derivation + algorithm - Changed: SimpleJWT\JWT::decode() no longer supports $format parameter (format is automatically detected) - Changed: SimpleJWT\JWT::deserialise() no longer supports $format parameter diff --git a/README.md b/README.md index e2babc1..2790e1d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ SimpleJWT is a simple JSON web token library written in PHP. * RSAES with OAEP (RSA-OAEP, RSA-OAEP-256) * AES key wrap (A128KW, A192KW, A256KW) * PBES2 (PBES2-HS256+A128KW, PBES2-HS384+A192KW, PBES2-HS512+A256KW) + * Elliptic Curve Diffie-Hellman (ECDH-ES) - requires PHP 7.3 or later - Content encryption algorithms * AES_CBC_HMAC_SHA2 family (A128CBC-HS256, A192CBC-HS384, A256CBC-HS512) * AES GCM family (A128GCM, A192GCM, A256GCM) - requires PHP 7.1 or later @@ -28,9 +29,14 @@ SimpleJWT is a simple JSON web token library written in PHP. ## Requirements - PHP 7.1.0 or later +- `gmp` extension - `hash` extension - `openssl` extension +A working `openssl.cnf` configuration is also required if the `ECDH-ES` +algorithm is used. See the [PHP manual](https://www.php.net/manual/en/openssl.installation.php) +for details. + ## Installation You can install via [Composer](http://getcomposer.org/). diff --git a/composer.json b/composer.json index 46c0d38..74a8ce2 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "php": "^7.1 || ^8.0", "ext-openssl": "*", "ext-hash": "*", + "ext-gmp": "*", "symfony/console": "^4.0 || ^5.0" }, "require-dev": { diff --git a/src/SimpleJWT/Crypt/AESWrappedKeyAlgorithm.php b/src/SimpleJWT/Crypt/AESWrappedKeyAlgorithm.php new file mode 100644 index 0000000..d829406 --- /dev/null +++ b/src/SimpleJWT/Crypt/AESWrappedKeyAlgorithm.php @@ -0,0 +1,116 @@ +aeskw = new AESKeyWrap(null); + } else { + list($dummy, $aeskw_alg) = explode('+', $alg, 2); + $this->aeskw = new AESKeyWrap($aeskw_alg); + } + + parent::__construct($alg); + } + + /** + * Returns the supported AES Key Wrap algorithms + * + * @return array an array of AES Key Wrap algorithms + */ + protected function getAESKWAlgs() { + return $this->aeskw->getSupportedAlgs(); + } + + /** + * Returns the required key size for the AES key wrap key + * + * @return int the key size, in bits + */ + protected function getAESKWKeySize() { + $criteria = $this->aeskw->getKeyCriteria(); + return $criteria[Key::SIZE_PROPERTY]; + } + + /** + * Wraps a key using the AES Key Wrap algorithm + * + * @param string $plain_key the key to wrap as a binary string + * @param string $wrapping_key the key wrapping key as a binary string + * @param array &$headers the JWE header, which can be modified + * @return string the wrapped key as a binary string + */ + protected function wrapKey($plain_key, $wrapping_key, &$headers) { + $keys = $this->createKeySet($wrapping_key); + return $this->aeskw->encryptKey($plain_key, $keys, $headers); + } + + /** + * Unwraps a key using the AES Key Wrap algorithm + * + * @param string $encrypted_key the key to unwrap as a binary string + * @param string $unwrapping_key the key wrapping key as a binary string + * @param array $headers the JWE header, which can be modified + * @return string the unwrapped key as a binary string + */ + protected function unwrapKey($encrypted_key, $unwrapping_key, $headers) { + $keys = $this->createKeySet($unwrapping_key); + return $this->aeskw->decryptKey($encrypted_key, $keys, $headers); + } + + private function createKeySet($key) { + $keys = new KeySet(); + $keys->add(new SymmetricKey($key, 'bin')); + return $keys; + } +} + +?> \ No newline at end of file diff --git a/src/SimpleJWT/Crypt/AlgorithmFactory.php b/src/SimpleJWT/Crypt/AlgorithmFactory.php index 637814e..7e881c0 100644 --- a/src/SimpleJWT/Crypt/AlgorithmFactory.php +++ b/src/SimpleJWT/Crypt/AlgorithmFactory.php @@ -59,6 +59,8 @@ class AlgorithmFactory { '/^RSA-OAEP-256$/' => 'SimpleJWT\Crypt\RSAES', '/^A\d+KW$/' => 'SimpleJWT\Crypt\AESKeyWrap', '/^PBES2-HS\d+\\+A\d+KW$/' => 'SimpleJWT\Crypt\PBES2', + '/^ECDH-ES$/' => 'SimpleJWT\Crypt\ECDH', + '/^ECDH-ES\\+A\d+KW$/' => 'SimpleJWT\Crypt\ECDH_AESKeyWrap', // Content encryption algorithms '/^A\d+CBC-HS\d+$/' => 'SimpleJWT\Crypt\AESCBC_HMACSHA2', diff --git a/src/SimpleJWT/Crypt/ECDH.php b/src/SimpleJWT/Crypt/ECDH.php new file mode 100644 index 0000000..e15e916 --- /dev/null +++ b/src/SimpleJWT/Crypt/ECDH.php @@ -0,0 +1,203 @@ +default_key_size = $default_key_size; + } + + public function getSupportedAlgs() { + if (defined('OPENSSL_KEYTYPE_EC') && function_exists('openssl_pkey_derive')) { + // openssl_pkey_derive is made available from PHP 7.3? + return ['ECDH-ES']; + } else { + // Not supported + return []; + } + } + + public function getKeyCriteria() { + return ['kty' => 'EC', '@use' => 'enc', '@key_ops' => 'deriveKey']; + } + + public function deriveKey($keys, &$headers, $kid = null) { + $key = $this->selectKey($keys, $kid); + if ($key == null) { + throw new CryptException('Key not found or is invalid'); + } + + // 1. Get the required key length and alg input into Concat KDF + if (isset($headers['enc'])) { + try { + $enc = AlgorithmFactory::create($headers['enc'], Algorithm::ENCRYPTION_ALGORITHM); + $size = $enc->getCEKSize(); + } catch (\UnexpectedValueException $e) { + throw new CryptException('Unexpected enc algorithm', $e); + } + } elseif ($this->default_key_size != null) { + $size = $this->default_key_size; + } else { + throw new CryptException('Key size not specified'); + } + + if ($this->getAlg() == 'ECDH-ES') { + $alg = $headers['enc']; + } else { + $alg = $this->getAlg(); + } + + // 2. If 'epk' header is present, check the ephemeral public key for compatibility + // against (our) private key specified in $key + // + // Otherwise, generate the ephemeral public key based on the recipient's public + // key specified in $key + if (isset($headers['epk'])) { + // (a) Load the ephemeral public key + $ephemeral_public_key = KeyFactory::create($headers['epk'], 'php'); + if (!($ephemeral_public_key instanceof ECKey)) { + throw new CryptException("Invalid epk: not an EC key"); + } + + // (b) Check that $key is a private key + if ($key->isPublic()) { + throw new CryptException('Key is a public key; private key expected'); + } + + // (c) Check whether the epk is on the private key's curve to mitigate + // against invalid curve attacks + if (!$key->isOnSameCurve($ephemeral_public_key)) { + throw new CryptException('Invalid epk: incompatible curve'); + } + + // (d) Set the ECDH keys + $dh_public_key = $ephemeral_public_key; + $dh_private_key = $key; + } else { + // (a) Check that $key is a public key (i.e. the recipient's) + if (!$key->isPublic()) { + throw new CryptException('Key is a private key; public key expected'); + } + + // (b) Create an ephemeral key pair with the same curve as the recipient's public + // key, then set the epk header + $crv = $key->getCurve(); + $ephemeral_private_key = $this->createEphemeralKey($crv); + $ephemeral_public_key = $ephemeral_private_key->getPublicKey(); + $headers['epk'] = $ephemeral_public_key->getKeyData(); + + // (c) Set the ECDH keys + $dh_public_key = $key; + $dh_private_key = $ephemeral_private_key; + } + + // 3. Calculate agreement key (Z) + $Z = $this->deriveAgreementKey($dh_public_key, $dh_private_key); + + // 4. Derive key from Concat KDF + $apu = (isset($headers['apu'])) ? $headers['apu'] : ''; + $apv = (isset($headers['apv'])) ? $headers['apv'] : ''; + return $this->concatKDF($Z, $alg, $size, $apu, $apv); + } + + protected function createEphemeralKey($crv) { + if (!isset(ECKey::$curves[$crv])) throw new \InvalidArgumentException('Curve not found'); + $openssl_curve_name = ECKey::$curves[$crv]['openssl']; + + if (!in_array($openssl_curve_name, openssl_get_curve_names())) + throw new CryptException('Unable to create ephemeral key: unsupported curve'); + + // Note openssl.cnf needs to be correctly configured for this to work. + // See https://www.php.net/manual/en/openssl.installation.php for the + // appropriate location of this configuration file + $pkey = openssl_pkey_new([ + 'curve_name' => $openssl_curve_name, + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ]); + if ($pkey === false) throw new CryptException('Unable to create ephemeral key (is openssl.cnf missing?)'); + + // Note openssl.cnf needs to be correctly configured for this to work. + // See https://www.php.net/manual/en/openssl.installation.php for the + // appropriate location of this configuration file + $result = openssl_pkey_export($pkey, $pem); + if ($result === false) throw new CryptException('Unable to create ephemeral key'); + + return new ECKey($pem, 'pem'); + } + + private function deriveAgreementKey($public_key, $private_key) { + assert(function_exists('openssl_pkey_derive')); + + $public_key_res = openssl_pkey_get_public($public_key->toPEM()); + if ($public_key_res === false) throw new CryptException('Public key load error: ' . openssl_error_string()); + + $private_key_res = openssl_pkey_get_private($private_key->toPEM()); + if ($private_key_res === false) throw new CryptException('Private key load error: ' . openssl_error_string()); + + $result = openssl_pkey_derive($public_key_res, $private_key_res); + if ($result === false) throw new CryptException('Key agreement error: ' . openssl_error_string()); + return $result; + } + + private function concatKDF($Z, $alg, $size, $apu = '', $apv = '') { + $apu = ($apu == null) ? '' : Util::base64url_decode($apu); + $apv = ($apv == null) ? '' : Util::base64url_decode($apv); + + $input = pack('N', 1) + . $Z + . pack('N', strlen($alg)) . $alg + . pack('N', strlen($apu)) . $apu + . pack('N', strlen($apv)) . $apv + . pack('N', $size); + + return substr(hash('sha256', $input, true), 0, $size / 8); + } +} + +?> \ No newline at end of file diff --git a/src/SimpleJWT/Crypt/ECDH_AESKeyWrap.php b/src/SimpleJWT/Crypt/ECDH_AESKeyWrap.php new file mode 100644 index 0000000..7386cb6 --- /dev/null +++ b/src/SimpleJWT/Crypt/ECDH_AESKeyWrap.php @@ -0,0 +1,91 @@ +ecdh = new ECDH(null); + } else { + list($ecdh_alg, $dummy) = explode('+', $alg, 2); + + $size = $this->getAESKWKeySize(); + $this->ecdh = new ECDH($ecdh_alg, $size); + } + } + + public function getSupportedAlgs() { + if (len($this->ecdh->getSupportedAlgs()) == 0) return []; + + $aeskw_algs = $this->getAESKWAlgs(); + return array_map(function ($alg) { return 'ECDH-ES+' . $alg; }, $aeskw_algs); + } + + public function getKeyCriteria() { + return $this->ecdh->getKeyCriteria(); + } + + public function deriveKey($keys, &$headers, $kid = null) { + return $this->ecdh->deriveKey($key, $headers, $kid); + } + + public function encryptKey($cek, $keys, &$headers, $kid = null) { + return $this->wrapKey($cek, $keys, $headers, $kid); + } + + public function decryptKey($encrypted_key, $keys, $headers, $kid = null) { + return $this->unwrapKey($encrypted_key, $keys, $headers, $kid); + } +} + +?> \ No newline at end of file diff --git a/src/SimpleJWT/Crypt/PBES2.php b/src/SimpleJWT/Crypt/PBES2.php index f367627..ef4b16a 100644 --- a/src/SimpleJWT/Crypt/PBES2.php +++ b/src/SimpleJWT/Crypt/PBES2.php @@ -45,7 +45,7 @@ * * @see https://tools.ietf.org/html/rfc7518#section-4.8 */ -class PBES2 extends Algorithm implements KeyEncryptionAlgorithm { +class PBES2 extends AESWrappedKeyAlgorithm { static protected $alg_params = [ 'PBES2-HS256+A128KW' => ['hash' => 'sha256'], @@ -54,7 +54,6 @@ class PBES2 extends Algorithm implements KeyEncryptionAlgorithm { ]; protected $hash_alg; - protected $aeskw; protected $iterations = 4096; @@ -62,17 +61,14 @@ public function __construct($alg) { parent::__construct($alg); if ($alg != null) { - list ($dummy, $aeskw_alg) = explode('+', $alg, 2); $this->hash_alg = self::$alg_params[$alg]['hash']; - $this->aeskw = new AESKeyWrap($aeskw_alg); } } public function getSupportedAlgs() { $results = []; - $aeskw = new AESKeyWrap(null); - $aeskw_algs = $aeskw->getSupportedAlgs(); + $aeskw_algs = $this->getAESKWAlgs(); $hash_algs = hash_algos(); foreach (self::$alg_params as $alg => $params) { @@ -114,8 +110,8 @@ public function encryptKey($cek, $keys, &$headers, $kid = null) { throw new CryptException('Key not found or is invalid'); } - $derived_keyset = $this->getKeySetFromPassword($key->toBinary(), $headers); - return $this->aeskw->encryptKey($cek, $derived_keyset, $headers); + $derived_key = $this->generateKeyFromPassword($key->toBinary(), $headers); + return $this->wrapKey($cek, $derived_key, $headers); } public function decryptKey($encrypted_key, $keys, $headers, $kid = null) { @@ -124,18 +120,8 @@ public function decryptKey($encrypted_key, $keys, $headers, $kid = null) { throw new CryptException('Key not found or is invalid'); } - $derived_keyset = $this->getKeySetFromPassword($key->toBinary(), $headers); - return $this->aeskw->decryptKey($encrypted_key, $derived_keyset, $headers); - } - - /** - * Returns the required key size for the AES key wrap key - * - * @return int the key size, in bits - */ - protected function getAESKWKeySize() { - $criteria = $this->aeskw->getKeyCriteria(); - return $criteria[Key::SIZE_PROPERTY]; + $derived_key = $this->generateKeyFromPassword($key->toBinary(), $headers); + return $this->unwrapKey($encrypted_key, $derived_key, $headers); } /** @@ -148,13 +134,10 @@ protected function generateSaltInput() { return Util::random_bytes(8); } - private function getKeySetFromPassword($password, $headers) { + private function generateKeyFromPassword($password, $headers) { $salt = $headers['alg'] . "\x00" . Util::base64url_decode($headers['p2s']); - $hash = hash_pbkdf2($this->hash_alg, $password, $salt, $headers['p2c'], $this->getAESKWKeySize() / 8, true); - $keys = new KeySet(); - $keys->add(new SymmetricKey($hash, 'bin')); - return $keys; + return hash_pbkdf2($this->hash_alg, $password, $salt, $headers['p2c'], $this->getAESKWKeySize() / 8, true); } } ?> diff --git a/src/SimpleJWT/Keys/ECKey.php b/src/SimpleJWT/Keys/ECKey.php index c8b9f6f..c96c07c 100644 --- a/src/SimpleJWT/Keys/ECKey.php +++ b/src/SimpleJWT/Keys/ECKey.php @@ -36,6 +36,7 @@ namespace SimpleJWT\Keys; use SimpleJWT\Util\ASN1; +use SimpleJWT\Util\BigNum; use SimpleJWT\Util\Util; /** @@ -53,11 +54,40 @@ class ECKey extends Key { const P384_OID = '1.3.132.0.34'; const P521_OID = '1.3.132.0.35'; + // Curve parameters are from http://www.secg.org/sec2-v2.pdf static $curves = [ - self::P256_OID => ['crv' => 'P-256', 'len' => 64], - self::SECP256K1_OID => ['crv' => 'secp256k1', 'len' => 64], - self::P384_OID => ['crv' => 'P-384', 'len' => 96], - self::P521_OID => ['crv' => 'P-521', 'len' => 132], + 'P-256' => [ + 'oid' => self::P256_OID, + 'openssl' => 'prime256v1', // = secp256r1 + 'len' => 64, + 'a' => 'FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC', + 'b' => '5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B', + 'p' => 'FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF', + ], + 'P-384' => [ + 'oid' => self::P384_OID, + 'openssl' => 'secp384r1', + 'len' => 96, + 'a' => 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFFFF0000000000000000FFFFFFFC', + 'b' => 'B3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013875AC656398D8A2ED19D2A85C8EDD3EC2AEF', + 'p' => 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFFFF0000000000000000FFFFFFFF', + ], + 'P-521' => [ + 'oid' => self::P521_OID, + 'openssl' => 'secp521r1', + 'len' => 132, + 'a' => '01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC', + 'b' => '0051953EB9618E1C9A1F929A21A0B68540EEA2DA725B99B315F3B8B489918EF109E156193951EC7E937B1652C0BD3BB1BF073573DF883D2C34F1EF451FD46B503F00', + 'p' => '01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', + ], + 'secp256k1' => [ + 'oid' => self::SECP256K1_OID, + 'openssl' => 'secp256k1', + 'len' => 64, + 'a' => '0000000000000000000000000000000000000000000000000000000000000000', + 'b' => '0000000000000000000000000000000000000000000000000000000000000007', + 'p' => 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F', + ], ]; /** @@ -98,9 +128,10 @@ public function __construct($data, $format, $password = null, $alg = 'PBES2-HS25 $algorithm = ASN1::decodeOID($algorithm); if ($algorithm != self::EC_OID) throw new KeyException('Not EC key'); - $offset += ASN1::readDER($der, $offset, $curve); // OBJECT IDENTIFIER - parameters - $curve = ASN1::decodeOID($curve); - if (!isset(self::$curves, $curve)) throw new KeyException('Unrecognised EC parameter: ' . $curve); + $offset += ASN1::readDER($der, $offset, $curve_oid); // OBJECT IDENTIFIER - parameters + $curve_oid = ASN1::decodeOID($curve_oid); + $curve = $this->getCurveNameFromOID($curve_oid); + if ($curve == null) throw new KeyException('Unrecognised EC parameter: ' . $curve_oid); $len = self::$curves[$curve]['len']; @@ -113,7 +144,7 @@ public function __construct($data, $format, $password = null, $alg = 'PBES2-HS25 $y = substr($point, 1 + $len / 2); $jwk['kty'] = self::KTY; - $jwk['crv'] = self::$curves[$curve]['crv']; + $jwk['crv'] = $curve; $jwk['x'] = Util::base64url_encode($x); $jwk['y'] = Util::base64url_encode($y); } elseif (preg_match(self::PEM_PRIVATE, $data, $matches)) { @@ -129,9 +160,10 @@ public function __construct($data, $format, $password = null, $alg = 'PBES2-HS25 $offset += ASN1::readDER($der, $offset, $d); // OCTET STRING [d] $offset += ASN1::readDER($der, $offset, $data); // SEQUENCE[0] - $offset += ASN1::readDER($der, $offset, $curve); // OBJECT IDENTIFIER - parameters - $curve = ASN1::decodeOID($curve); - if (!isset(self::$curves, $curve)) throw new KeyException('Unrecognised EC parameter: ' . $curve); + $offset += ASN1::readDER($der, $offset, $curve_oid); // OBJECT IDENTIFIER - parameters + $curve_oid = ASN1::decodeOID($curve_oid); + $curve = $this->getCurveNameFromOID($curve_oid); + if ($curve == null) throw new KeyException('Unrecognised EC parameter: ' . $curve_oid); $len = self::$curves[$curve]['len']; @@ -145,7 +177,7 @@ public function __construct($data, $format, $password = null, $alg = 'PBES2-HS25 $y = substr($point, 1 + $len / 2); $jwk['kty'] = self::KTY; - $jwk['crv'] = self::$curves[$curve]['crv']; + $jwk['crv'] = $curve; $jwk['d'] = Util::base64url_encode($d); $jwk['x'] = Util::base64url_encode($x); $jwk['y'] = Util::base64url_encode($y); @@ -168,6 +200,47 @@ public function isPublic() { return !isset($this->data['d']); } + /** + * Checks whether this EC key is valid, in that its `x` and `y` values satisfies + * the elliptic curve function specified by the `crv` value. + * + * This check is required to prevent invalid curve attacks, whereby an + * untrusted key contains `x` and `y` parameters are not on the curve, which + * may result in differential attacks + * + * @return true if the EC key is valid + * @see https://auth0.com/blog/critical-vulnerability-in-json-web-encryption/ + */ + public function isValid() { + $x = new BigNum(Util::base64url_decode($this->data['x']), 256); + $y = new BigNum(Util::base64url_decode($this->data['y']), 256); + + $crv = $this->data['crv']; + $a = new BigNum(hex2bin(self::$curves[$crv]['a']), 256); + $b = new BigNum(hex2bin(self::$curves[$crv]['b']), 256); + $p = new BigNum(hex2bin(self::$curves[$crv]['p']), 256); + + // Check whether y^2 mod p = (x^3 + ax + b) mod p + $y2modp = $y->powmod(new BigNum(2), $p); + $x3axbmodp = $x->pow(new BigNum(3))->add($a->mul($x))->add($b)->mod($p); + + return ($y2modp->cmp($x3axbmodp) === 0); + } + + /** + * Checks whether another EC key is on the same curve as this key. + * + * @param ECKey $public_key the public key to check + * @return true if the EC key is on the same curve + * @see https://auth0.com/blog/critical-vulnerability-in-json-web-encryption/ + */ + public function isOnSameCurve($public_key) { + if (!($public_key instanceof ECKey)) return false; + if (!Util::secure_compare($this->data['crv'], $public_key->data['crv'])) return false; + + return ($this->isValid() && $public_key->isValid()); + } + public function getPublicKey() { return new ECKey([ 'kid' => $this->data['kid'], @@ -179,7 +252,7 @@ public function getPublicKey() { } public function toPEM() { - $oid = $this->getOID($this->data['crv']); + $oid = self::$curves[$this->data['crv']]['oid']; if ($oid == null) throw new KeyException('Unrecognised EC curve'); if ($this->isPublic()) { @@ -205,14 +278,24 @@ public function toPEM() { } } + /** + * Gets the elliptic curve for the key. The elliptic curve is specified in + * the `crv` parameter. + * + * @return string the elliptic curve + */ + public function getCurve() { + return $this->data['crv']; + } + protected function getThumbnailMembers() { // https://tools.ietf.org/html/rfc7638#section-3.2 return ['crv', 'kty', 'x', 'y']; } - private function getOID($crv) { - foreach (self::$curves as $oid => $params) { - if ($params['crv'] == $crv) return $oid; + private function getCurveNameFromOID($curve_oid) { + foreach (self::$curves as $crv => $params) { + if ($params['oid'] == $curve_oid) return $crv; } return null; } diff --git a/src/SimpleJWT/Util/BigNum.php b/src/SimpleJWT/Util/BigNum.php new file mode 100644 index 0000000..3e23e86 --- /dev/null +++ b/src/SimpleJWT/Util/BigNum.php @@ -0,0 +1,339 @@ +value = gmp_init($str, 10); + return; + break; + case 256: + $bytes = array_merge(unpack('C*', $str)); + + $value = (new BigNum(0))->value; + + foreach ($bytes as $byte) { + $value = $this->_mul($value, 256); + $value = $this->_add($value, (new BigNum($byte))->value); + } + $this->value = $value; + return; + break; + default: + if (!is_integer($base) || ($base < 2) || ($base > 36)) return FALSE; + + $value = (new BigNum(0))->value; + + for ($i = 0; $i < strlen($str); $i++) { + $value = $this->_mul($value, $base); + $value = $this->_add($value, (new BigNum(base_convert($str[$i], $base, 10)))->value); + } + $this->value = $value; + return; + } + + throw new \RuntimeException(); + } + + /** + * Converts a bignum into a string representation (base 2 to 36) or a byte stream + * (base 256) + * + * @param int $base an integer between 2 and 36, or 256 + * @return string the converted bignum + */ + function val($base = 10) { + switch ($base) { + case 10: + return gmp_strval($this->value, 10); + break; + + case 256: + $cmp = $this->_cmp($this->value, 0); + if ($cmp < 0) { + return FALSE; + } + + if ($cmp == 0) { + return "\x00"; + } + + $bytes = array(); + $num = $this->value; + + while ($this->_cmp($num, 0) > 0) { + array_unshift($bytes, $this->_mod($num, 256)); + $num = $this->_div($num, 256); + } + + if ($bytes && ($bytes[0] > 127)) { + array_unshift($bytes, 0); + } + + $byte_stream = ''; + foreach ($bytes as $byte) { + $byte_stream .= pack('C', $byte); + } + + return $byte_stream; + break; + default: + if (!is_integer($base) || ($base < 2) || ($base > 36)) return FALSE; + + $cmp = $this->_cmp($this->value, 0); + if ($cmp < 0) { + return FALSE; + } + + if ($cmp == 0) { + return "0"; + } + + $str = ''; + $num = $this->value; + + while ($this->_cmp($num, 0) > 0) { + $r = gmp_intval($this->_mod($num, $base)); + $str = base_convert($r, 10, $base) . $str; + $num = $this->_div($num, $base); + } + + return $str; + } + + return FALSE; + } + + /** + * Adds two bignums + * + * @param BigNum $b + * @return BigNum a bignum representing this + b + */ + function add($b) { + $result = new BigNum(0); + $result->value = $this->_add($this->value, $b->value); + return $result; + } + + /** + * Multiplies two bignums + * + * @param BigNum $b + * @return BigNum a bignum representing this * b + */ + function mul($b) { + $result = new BigNum(0); + $result->value = $this->_mul($this->value, $b->value); + return $result; + } + + /** + * Raise base to power exp + * + * @param BigNum $exp the exponent + * @return BigNum a bignum representing this ^ exp + */ + function pow($exp) { + $result = new BigNum(0); + $result->value = $this->_pow($this->value, $exp->value); + return $result; + } + + /** + * Divides two bignums + * + * @param BigNum $b + * @return BigNum a bignum representing this / b + */ + function div($b) { + $result = new BigNum(0); + $result->value = $this->_div($this->value, $b->value); + return $result; + } + + /** + * Returns n modulo d + * + * @param BigNum $d + * @return BigNum a bignum representing this mod d + */ + function mod($d) { + $result = new BigNum(0); + $result->value = $this->_mod($this->value, $d->value); + return $result; + } + + /** + * Raise a number into power with modulo + * + * @param BigNum $exp the exponent + * @param BigNum $mod the modulo + * @return BigNum a bignum representing this ^ exp mod mod + */ + function powmod($exp, $mod) { + $result = new BigNum(0); + $result->value = $this->_powmod($this->value, $exp->value, $mod->value); + return $result; + } + + /** + * Compares two bignum + * + * @param BigNum $b + * @return int positive value if this > b, zero if this = b and a negative value if this < b + */ + function cmp($b) { + return $this->_cmp($this->value, $b->value); + } + + /** + * Returns a string representation. + * + * @return string + */ + function __toString() { + return $this->val(); + } + + /** + * Adds two bignums + * + * @param resource $a + * @param resource $b + * @return resource a bignum representing a + b + */ + protected function _add($a, $b) { + return gmp_add($a, $b); + } + + /** + * Multiplies two bignums + * + * @param resource $a + * @param resource $b + * @return resource a bignum representing a * b + */ + protected function _mul($a, $b) { + return gmp_mul($a, $b); + } + + /** + * Divides two bignums + * + * @param resource $a + * @param resource $b + * @return resource a bignum representing a / b + */ + protected function _div($a, $b) { + return gmp_div($a, $b); + } + + /** + * Raise base to power exp + * + * @param resource $base the base + * @param mixed $exp the exponent, as an integer or a bignum + * @return resource a bignum representing base ^ exp + */ + function _pow($base, $exp) { + if ((is_resource($exp) && (get_resource_type($exp) == 'GMP integer')) + || (is_object($exp) && (get_class($exp) == 'GMP'))) + $exp = gmp_intval($exp); + return gmp_pow($base, $exp); + } + + /** + * Returns n modulo d + * + * @param resource $n + * @param resource $d + * @return resource a bignum representing n mod d + */ + protected function _mod($n, $d) { + return gmp_mod($n, $d); + } + + /** + * Raise a number into power with modulo + * + * @param resource $base the base + * @param resource $exp the exponent + * @param resource $mod the modulo + * @return resource a bignum representing base ^ exp mod mod + */ + protected function _powmod($base, $exp, $mod) { + return gmp_powm($base, $exp, $mod); + } + + /** + * Compares two bignum + * + * @param resource $a + * @param resource $b + * @return int positive value if a > b, zero if a = b and a negative value if a < b + */ + protected function _cmp($a, $b) { + return gmp_cmp($a, $b); + } +} + +?> diff --git a/tests/ECDHTest.php b/tests/ECDHTest.php new file mode 100644 index 0000000..513476d --- /dev/null +++ b/tests/ECDHTest.php @@ -0,0 +1,108 @@ +getSupportedAlgs())) { + $this->markTestSkipped('Alg not available: ECDH-ES'); + return false; + } else { + return true; + } + } + + protected function getECDHStub() { + // From Appendix C of RFC 7518 + $ephemeral_key = new ECKey([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'd' => '0_NxaRPUMQoAJt50Gz8YiTr8gRTwyEaCumd-MToTmIo', + 'x' => 'gI0GAILBdu7T53akrFmMyGcsF3n5dO7MmwNBHKW5SV0', + 'y' => 'SLW_xSffzlPWrHEVI30DHM_4egVwt3NQqeUD7nMFpps' + ], 'php'); + + $stub = $this->getMockBuilder('SimpleJWT\Crypt\ECDH') + ->setMethods(['createEphemeralKey'])->setConstructorArgs(['ECDH-ES'])->getMock(); + + $stub->method('createEphemeralKey')->willReturn($ephemeral_key); + + return $stub; + } + + protected function getPrivateKeySet() { + $set = new KeySet(); + + $set->add(new ECKey([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'd' => 'VEmDZpDXXK8p8N0Cndsxs924q6nS1RXFASRl6BfUqdw', + 'x' => 'weNJy2HscCSM6AEDTDg04biOvhFhyyWvOHQfeF_PxMQ', + 'y' => 'e8lnCO-AlStT-NJVX-crhB7QRYhiix03illJOVAOyck' + ], 'php')); + + return $set; + } + + private function getPublicKeySet() { + $private = $this->getPrivateKeySet(); + $set = new KeySet(); + + foreach ($private->getKeys() as $key) { + $set->add($key->getPublicKey()); + } + + return $set; + } + + public function testProduceECDH() { + if (!$this->isAlgAvailable()) return; + + $ecdh = $this->getECDHStub(); + $keys = $this->getPublicKeySet(); + $headers = [ + 'alg' => 'ECDH-ES', + 'enc' => 'A128GCM', + 'apu' => 'QWxpY2U', + 'apv' => 'Qm9i' + ]; + + $result = $ecdh->deriveKey($keys, $headers); + + $this->assertArrayHasKey('epk', $headers); + $this->assertEquals('gI0GAILBdu7T53akrFmMyGcsF3n5dO7MmwNBHKW5SV0', $headers['epk']['x']); + $this->assertArrayNotHasKey('d', $headers['epk']); + + $this->assertEquals('VqqN6vgjbSBcIijNcacQGg', Util::base64url_encode($result)); + } + + public function testConsumeECDH() { + if (!$this->isAlgAvailable()) return; + + $ecdh = $this->getECDHStub(); + $keys = $this->getPrivateKeySet(); + $headers = [ + 'alg' => 'ECDH-ES', + 'enc' => 'A128GCM', + 'apu' => 'QWxpY2U', + 'apv' => 'Qm9i', + 'epk' => [ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'gI0GAILBdu7T53akrFmMyGcsF3n5dO7MmwNBHKW5SV0', + 'y' => 'SLW_xSffzlPWrHEVI30DHM_4egVwt3NQqeUD7nMFpps' + ] + ]; + + $result = $ecdh->deriveKey($keys, $headers); + $this->assertEquals('VqqN6vgjbSBcIijNcacQGg', Util::base64url_encode($result)); + } + +} + +?> diff --git a/tests/ECKeyTest.php b/tests/ECKeyTest.php new file mode 100644 index 0000000..9d2cb82 --- /dev/null +++ b/tests/ECKeyTest.php @@ -0,0 +1,62 @@ + 'EC', + 'crv' => 'P-256', + 'x'=> 'weNJy2HscCSM6AEDTDg04biOvhFhyyWvOHQfeF_PxMQ', + 'y'=> 'e8lnCO-AlStT-NJVX-crhB7QRYhiix03illJOVAOyck', + 'd'=> 'VEmDZpDXXK8p8N0Cndsxs924q6nS1RXFASRl6BfUqdw' + ], 'php'); + + $malicious_public = new ECKey([ + 'kty' => 'EC', + 'x' => 'gTli65eTQ7z-Bh147ff8K3m7k2UiDiG2LpYkWAaFJCc', + 'y' => 'cLAnjKa4bzjD7DJVPwa9EPrRzMG7rONgsiUD-kf30Fs', + 'crv' => 'P-256' + ], 'php'); + + $this->assertEquals(false, $private->isOnSameCurve($malicious_public)); + } + + public function testInvalid() { + $malicious_public = new ECKey([ + 'kty' => 'EC', + 'x' => 'gTli65eTQ7z-Bh147ff8K3m7k2UiDiG2LpYkWAaFJCc', + 'y' => 'cLAnjKa4bzjD7DJVPwa9EPrRzMG7rONgsiUD-kf30Fs', + 'crv' => 'P-256' + ], 'php'); + $this->assertEquals(false, $malicious_public->isValid()); + } + + public function testValid() { + $private = new ECKey([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x'=> 'weNJy2HscCSM6AEDTDg04biOvhFhyyWvOHQfeF_PxMQ', + 'y'=> 'e8lnCO-AlStT-NJVX-crhB7QRYhiix03illJOVAOyck', + 'd'=> 'VEmDZpDXXK8p8N0Cndsxs924q6nS1RXFASRl6BfUqdw' + ], 'php'); + $public = new ECKey([ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'gI0GAILBdu7T53akrFmMyGcsF3n5dO7MmwNBHKW5SV0', + 'y' => 'SLW_xSffzlPWrHEVI30DHM_4egVwt3NQqeUD7nMFpps' + ], 'php'); + + $this->assertEquals(true, $private->isValid()); + $this->assertEquals(true, $public->isValid()); + } +} + +?> \ No newline at end of file diff --git a/tests/KeyTest.php b/tests/KeyPEMImportTest.php similarity index 98% rename from tests/KeyTest.php rename to tests/KeyPEMImportTest.php index 606dd6f..5f95009 100644 --- a/tests/KeyTest.php +++ b/tests/KeyPEMImportTest.php @@ -4,7 +4,7 @@ use SimpleJWT\Keys\ECKey; use PHPUnit\Framework\TestCase; -class KeyTest extends TestCase { +class KeyPEMImportTest extends TestCase { public function testRSA() { $pem = file_get_contents('rsa_private.pem'); $key = new RSAKey($pem, 'pem'); diff --git a/tests/openssl.cnf b/tests/openssl.cnf new file mode 100644 index 0000000..b66dc62 --- /dev/null +++ b/tests/openssl.cnf @@ -0,0 +1,4 @@ +# Minimal openssl.cnf file required to create and export EC private keys + +[ req ] +default_bits = 2048