From 9c976d32a0c07fa8e8b1e0c5994369787d78d466 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sun, 14 Feb 2021 16:34:49 -0800 Subject: [PATCH] Crypto - Define a service for creating and verifying JSON Web tokens ('crypto.jwt') --- Civi/Core/Container.php | 3 + Civi/Crypto/CryptoJwt.php | 125 ++++++++++++++++++ tests/phpunit/Civi/Crypto/CryptoJwtTest.php | 102 ++++++++++++++ tests/phpunit/Civi/Crypto/CryptoTestTrait.php | 26 +++- 4 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 Civi/Crypto/CryptoJwt.php create mode 100644 tests/phpunit/Civi/Crypto/CryptoJwtTest.php diff --git a/Civi/Core/Container.php b/Civi/Core/Container.php index 446720da8a..7d5e6b8baf 100644 --- a/Civi/Core/Container.php +++ b/Civi/Core/Container.php @@ -224,6 +224,9 @@ class Container { $container->setDefinition('crypto.token', new Definition('Civi\Crypto\CryptoToken', [])) ->setPublic(TRUE); + $container->setDefinition('crypto.jwt', new Definition('Civi\Crypto\CryptoJwt', [])) + ->setPublic(TRUE); + if (empty(\Civi::$statics[__CLASS__]['boot'])) { throw new \RuntimeException('Cannot initialize container. Boot services are undefined.'); } diff --git a/Civi/Crypto/CryptoJwt.php b/Civi/Crypto/CryptoJwt.php new file mode 100644 index 0000000000..9559824118 --- /dev/null +++ b/Civi/Crypto/CryptoJwt.php @@ -0,0 +1,125 @@ +getRegistry()->findKey($keyIdOrTag); + $alg = $this->suiteToAlg($key['suite']); + // Currently, registry only has symmetric keys in $key['key']. For public key-pairs, might need to change. + return JWT::encode($payload, $key['key'], $alg, $key['id']); + } + + /** + * @param string $token + * The JWT token. + * @param string $keyTag + * Lookup valid keys from the CryptoRegistry using $keyTag. + * @return array + * List of validated JWT claims. + * @throws CryptoException + */ + public function decode($token, $keyTag = 'SIGN') { + $keyRows = $this->getRegistry()->findKeysByTag($keyTag); + + // We want to call JWT::decode(), but there's a slight mismatch -- the + // registry contains whitelisted permutations of ($key,$alg), but + // JWT::decode() accepts all permutations ($keys x $algs). + + // Grouping by alg will give proper granularity and also produces one + // call to JWT::decode() in typical usage. + + // Defn: $keysByAlg[$alg][$keyId] === $keyData + $keysByAlg = []; + foreach ($keyRows as $key) { + if ($alg = $this->suiteToAlg($key['suite'])) { + // Currently, registry only has symmetric keys in $key['key']. For public key-pairs, might need to change. + $keysByAlg[$alg][$key['id']] = $key['key']; + } + } + + foreach ($keysByAlg as $alg => $keys) { + try { + return (array) JWT::decode($token, $keys, [$alg]); + } + catch (\UnexpectedValueException $e) { + // Depending on the error, we might able to try other algos + if ( + !preg_match(';unable to lookup correct key;', $e->getMessage()) + && + !preg_match(';Signature verification failed;', $e->getMessage()) + ) { + // Keep our signature independent of the implementation. + throw new CryptoException(get_class($e) . ': ' . $e->getMessage()); + } + } + } + + throw new CryptoException('Signature verification failed'); + } + + /** + * @param string $suite + * Ex: 'jwt-hs256', 'jwt-hs384' + * @return string + * Ex: 'HS256', 'HS384' + */ + protected static function suiteToAlg($suite) { + if (substr($suite, 0, 4) === 'jwt-') { + return strtoupper(substr($suite, 4)); + } + else { + return NULL; + } + } + + /** + * @return CryptoRegistry + */ + protected function getRegistry(): CryptoRegistry { + if ($this->registry === NULL) { + $this->registry = \Civi::service('crypto.registry'); + } + return $this->registry; + } + +} diff --git a/tests/phpunit/Civi/Crypto/CryptoJwtTest.php b/tests/phpunit/Civi/Crypto/CryptoJwtTest.php new file mode 100644 index 0000000000..67611ebe39 --- /dev/null +++ b/tests/phpunit/Civi/Crypto/CryptoJwtTest.php @@ -0,0 +1,102 @@ +setHook('civicrm_crypto', [$this, 'registerExampleKeys']); + JWT::$timestamp = NULL; + } + + public function testSignVerifyExpire() { + /** @var \Civi\Crypto\CryptoJwt $cryptoJwt */ + $cryptoJwt = \Civi::service('crypto.jwt'); + + $enc = $cryptoJwt->encode([ + 'exp' => \CRM_Utils_Time::time() + 60, + 'sub' => 'me', + ], 'SIGN-TEST'); + $this->assertTrue(is_string($enc) && !empty($enc), 'CryptoJwt::encode() should return valid string'); + + $dec = $cryptoJwt->decode($enc, 'SIGN-TEST'); + $this->assertTrue(is_array($dec) && !empty($dec)); + $this->assertEquals('me', $dec['sub']); + + JWT::$timestamp = \CRM_Utils_Time::time() + 90; + try { + $cryptoJwt->decode($enc, 'SIGN-TEST'); + $this->fail('Expected decode to fail with exception'); + } + catch (CryptoException $e) { + $this->assertRegExp(';Expired token;', $e->getMessage()); + } + } + + public function getMixKeyExamples() { + return [ + ['SIGN-TEST', 'SIGN-TEST', TRUE], + ['sign-key-0', 'SIGN-TEST', TRUE], + ['sign-key-1', 'SIGN-TEST', TRUE], + ['sign-key-alt', 'SIGN-TEST', FALSE], + ]; + } + + /** + * @param $encKey + * @param $decKey + * @param $expectOk + * @throws \Civi\Crypto\Exception\CryptoException + * @dataProvider getMixKeyExamples + */ + public function testSignMixKeys($encKey, $decKey, $expectOk) { + /** @var \Civi\Crypto\CryptoJwt $cryptoJwt */ + $cryptoJwt = \Civi::service('crypto.jwt'); + + $enc = $cryptoJwt->encode([ + 'exp' => \CRM_Utils_Time::time() + 60, + 'sub' => 'me', + ], $encKey); + $this->assertTrue(is_string($enc) && !empty($enc), 'CryptoJwt::encode() should return valid string'); + + if ($expectOk) { + $dec = $cryptoJwt->decode($enc, $decKey); + $this->assertTrue(is_array($dec) && !empty($dec)); + $this->assertEquals('me', $dec['sub']); + } + else { + try { + $cryptoJwt->decode($enc, $decKey); + $this->fail('Expected decode to fail with exception'); + } + catch (CryptoException $e) { + $this->assertRegExp(';Signature verification failed;', $e->getMessage()); + } + } + } + + public function testSuiteToAlg() { + $this->assertEquals('HS256', Invasive::call([CryptoJwt::class, 'suiteToAlg'], ['jwt-hs256'])); + $this->assertEquals(NULL, Invasive::call([CryptoJwt::class, 'suiteToAlg'], ['aes-cbc'])); + } + +} diff --git a/tests/phpunit/Civi/Crypto/CryptoTestTrait.php b/tests/phpunit/Civi/Crypto/CryptoTestTrait.php index e20eefdc01..301c5ec4ad 100644 --- a/tests/phpunit/Civi/Crypto/CryptoTestTrait.php +++ b/tests/phpunit/Civi/Crypto/CryptoTestTrait.php @@ -19,6 +19,9 @@ trait CryptoTestTrait { 'aes-cbc:hkdf-sha256:abcd1234abcd1234', 'aes-ctr::abcd1234abcd1234', 'aes-cbc-hs::abcd1234abcd1234', + 'jwt-hs256::abcd1234abcd1234', + 'jwt-hs384:b64:8h5wNGnJbdVHpXms2RwcVx+jxCNdYEsYCdNlPpVgNLRMg9Q2xKYnxSfuihS6YCRi', + 'jwt-hs256::fdsafdsafdsa', ]; } @@ -56,8 +59,27 @@ trait CryptoTestTrait { ]); $this->assertEquals(0, $key['weight']); - $this->assertEquals(4, count($examples)); - $this->assertEquals(4 + $origCount, count($registry->getKeys())); + $key = $registry->addSymmetricKey($registry->parseKey($examples[4]) + [ + 'tags' => ['SIGN-TEST'], + 'id' => 'sign-key-1', + 'weight' => 1, + ]); + $this->assertEquals(1, $key['weight']); + + $key = $registry->addSymmetricKey($registry->parseKey($examples[4]) + [ + 'tags' => ['SIGN-TEST'], + 'id' => 'sign-key-0', + ]); + $this->assertEquals(0, $key['weight']); + + $key = $registry->addSymmetricKey($registry->parseKey($examples[4]) + [ + 'tags' => ['SIGN-TEST-ALT'], + 'id' => 'sign-key-alt', + ]); + $this->assertEquals(0, $key['weight']); + + $this->assertEquals(7, count($examples)); + $this->assertEquals(7 + $origCount, count($registry->getKeys())); } } -- 2.25.1