Crypto - Define a service for creating and verifying JSON Web tokens ('crypto.jwt')
authorTim Otten <totten@civicrm.org>
Mon, 15 Feb 2021 00:34:49 +0000 (16:34 -0800)
committerTim Otten <totten@civicrm.org>
Mon, 15 Feb 2021 04:33:49 +0000 (20:33 -0800)
Civi/Core/Container.php
Civi/Crypto/CryptoJwt.php [new file with mode: 0644]
tests/phpunit/Civi/Crypto/CryptoJwtTest.php [new file with mode: 0644]
tests/phpunit/Civi/Crypto/CryptoTestTrait.php

index 446720da8a0f9ef34bb43f237c8e12a9b2c6642d..7d5e6b8baf17b20891bbee406377e620f0dc0951 100644 (file)
@@ -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 (file)
index 0000000..9559824
--- /dev/null
@@ -0,0 +1,125 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Crypto;
+
+use Civi\Crypto\Exception\CryptoException;
+use Firebase\JWT\JWT;
+
+/**
+ * The "Crypto JWT" service supports a token format suitable for
+ * exchanging/transmitting with external consumers (e.g. web-browsers).
+ * It integrates with the CryptoRegistry (which is a source of valid signing keys).
+ *
+ * By default, tokens are signed and validated using any 'SIGN'ing keys
+ * (ie 'CIVICRM_SIGN_KEYS').
+ *
+ * @package Civi\Crypto
+ * @see https://jwt.io/
+ */
+class CryptoJwt {
+
+  /**
+   * @var \Civi\Crypto\CryptoRegistry
+   */
+  protected $registry;
+
+  /**
+   * @param array $payload
+   *   List of JWT claims. See IANA link below.
+   * @param string $keyIdOrTag
+   *   Choose a valid key from the CryptoRegistry using $keyIdOrTag.
+   * @return string
+   * @throws \Civi\Crypto\Exception\CryptoException
+   *
+   * @see https://www.iana.org/assignments/jwt/jwt.xhtml
+   */
+  public function encode($payload, $keyIdOrTag = 'SIGN') {
+    $key = $this->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 (file)
index 0000000..67611eb
--- /dev/null
@@ -0,0 +1,102 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Crypto;
+
+use Civi\Crypto\Exception\CryptoException;
+use Civi\Test\Invasive;
+use Firebase\JWT\JWT;
+
+/**
+ * Test major use-cases of the 'crypto.token' service.
+ */
+class CryptoJwtTest extends \CiviUnitTestCase {
+
+  use CryptoTestTrait;
+
+  protected function setUp() {
+    parent::setUp();
+    \CRM_Utils_Hook::singleton()->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']));
+  }
+
+}
index e20eefdc01de0ae2523f7c5cfc7b212b4e68d647..301c5ec4add4b3354e8ebfe144b111d0932803bd 100644 (file)
@@ -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()));
   }
 
 }