(dev/core#2258) CryptoToken - Add service for processing stored ciphertext
authorTim Otten <totten@civicrm.org>
Wed, 16 Dec 2020 23:19:09 +0000 (15:19 -0800)
committerTim Otten <totten@civicrm.org>
Sat, 19 Dec 2020 04:54:30 +0000 (20:54 -0800)
Civi/Core/Container.php
Civi/Crypto/CryptoToken.php [new file with mode: 0644]
tests/phpunit/Civi/Crypto/CryptoTokenTest.php [new file with mode: 0644]

index 6c8ac4ffbc25c772036fee9105170a12c5a736d3..fb3f0b9d79129554f7af4eaeae9dd7b4292208e4 100644 (file)
@@ -221,6 +221,11 @@ class Container {
     $container->setDefinition('crypto.registry', new Definition('Civi\Crypto\CryptoService'))
       ->setFactory(__CLASS__ . '::createCryptoRegistry')->setPublic(TRUE);
 
+    $container->setDefinition('crypto.token', new Definition(
+      'Civi\Crypto\CryptoToken',
+      [new Reference('crypto.registry')]
+    ))->setPublic(TRUE);
+
     if (empty(\Civi::$statics[__CLASS__]['boot'])) {
       throw new \RuntimeException('Cannot initialize container. Boot services are undefined.');
     }
diff --git a/Civi/Crypto/CryptoToken.php b/Civi/Crypto/CryptoToken.php
new file mode 100644 (file)
index 0000000..a14ef8c
--- /dev/null
@@ -0,0 +1,140 @@
+<?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;
+
+/**
+ * The "Crypto Token" service supports a token format suitable for
+ * storing specific values in the database with encryption. Characteristics:
+ *
+ * - Primarily designed to defend confidentiality in case of data-leaks
+ *   (SQL injections, lost backups, etc).
+ * - NOT appropriate for securing data-transmission. Data-transmission
+ *   requires more protections (eg mandatory TTLs + signatures). If you need
+ *   that, consider adding a JWT/JWS/JWE implementation.
+ * - Data-format allows phase-in/phase-out. If you have a datum that was written
+ *   with an old key or with no key, it will still be readable.
+ *
+ * USAGE: The "encrypt()" and "decrypt()" methods are the primary interfaces.
+ *
+ *   $encrypted = Civi::service('crypto.token')->encrypt('my-mail-password, 'KEY_ID_OR_TAG');
+ *   $decrypted = Civi::service('crypto.token')->decrypt($encrypted, '*');
+ *
+ * FORMAT: An encoded token may be in either of these formats:
+ *
+ *   - Plain text: Any string which does not begin with chr(2)
+ *   - Encrypted text: A string in the format:
+ *        TOKEN := DLM + VERSION + DLM + KEY_ID + DLM + CIPHERTEXT
+ *        DLM := ASCII CHAR #2
+ *        VERSION := String, 4-digit, alphanumeric (as in "CTK0")
+ *        KEY_ID := String, alphanumeric and symbols "_-.,:;=+/\"
+ *
+ * @package Civi\Crypto
+ */
+class CryptoToken {
+
+  const VERSION_1 = 'CTK0';
+
+  /**
+   * @var CryptoRegistry
+   */
+  protected $registry;
+
+  protected $delim;
+
+  /**
+   * CryptoToken constructor.
+   * @param \Civi\Crypto\CryptoRegistry $registry
+   */
+  public function __construct(\Civi\Crypto\CryptoRegistry $registry) {
+    $this->delim = chr(2);
+    $this->registry = $registry;
+  }
+
+  /**
+   * Determine if a string looks like plain-text.
+   *
+   * @param string $plainText
+   * @return bool
+   */
+  public function isPlainText($plainText) {
+    return is_string($plainText) && ($plainText === '' || $plainText{0} !== $this->delim);
+  }
+
+  /**
+   * Create an encrypted token (given the plaintext).
+   *
+   * @param string $plainText
+   *   The secret value to encode (e.g. plain-text password).
+   * @param string|string[] $keyIdOrTag
+   *   List of key IDs or key tags to check. First available match wins.
+   * @return string
+   *   A token
+   * @throws \Civi\Crypto\Exception\CryptoException
+   */
+  public function encrypt($plainText, $keyIdOrTag) {
+    $key = $this->registry->findKey($keyIdOrTag);
+    if ($key['suite'] === 'plain') {
+      if (!$this->isPlainText($plainText)) {
+        throw new CryptoException("Cannot use plaintext encoding for data with reserved delimiter.");
+      }
+      return $plainText;
+    }
+
+    /** @var \Civi\Crypto\CipherSuiteInterface $cipherSuite */
+    $cipherSuite = $this->registry->findSuite($key['suite']);
+    $cipherText = $cipherSuite->encrypt($plainText, $key);
+    return $this->delim . self::VERSION_1 . $this->delim . $key['id'] . $this->delim . base64_encode($cipherText);
+  }
+
+  /**
+   * Get the plaintext (given an encrypted token).
+   *
+   * @param string $token
+   * @param string|string[] $keyIdOrTag
+   *   Whitelist of acceptable keys. Wildcard '*' will allow it to use
+   *   any/all available means to decode the token.
+   * @return string
+   * @throws \Civi\Crypto\Exception\CryptoException
+   */
+  public function decrypt($token, $keyIdOrTag = '*') {
+    $keyIdOrTag = (array) $keyIdOrTag;
+
+    if ($this->isPlainText($token)) {
+      if (in_array('*', $keyIdOrTag) || in_array('plain', $keyIdOrTag)) {
+        return $token;
+      }
+      else {
+        throw new CryptoException("Cannot decrypt token. Unexpected key: plain");
+      }
+    }
+
+    $parts = explode($this->delim, $token);
+    if ($parts[1] !== self::VERSION_1) {
+      throw new CryptoException("Unrecognized encoding");
+    }
+    $keyId = $parts[2];
+    $cipherText = base64_decode($parts[3]);
+
+    $key = $this->registry->findKey($keyId);
+    if (!in_array('*', $keyIdOrTag) && !in_array($keyId, $keyIdOrTag) && empty(array_intersect($keyIdOrTag, $key['tags']))) {
+      throw new CryptoException("Cannot decrypt token. Unexpected key: $keyId");
+    }
+
+    /** @var \Civi\Crypto\CipherSuiteInterface $cipherSuite */
+    $cipherSuite = $this->registry->findSuite($key['suite']);
+    $plainText = $cipherSuite->decrypt($cipherText, $key);
+    return $plainText;
+  }
+
+}
diff --git a/tests/phpunit/Civi/Crypto/CryptoTokenTest.php b/tests/phpunit/Civi/Crypto/CryptoTokenTest.php
new file mode 100644 (file)
index 0000000..961166a
--- /dev/null
@@ -0,0 +1,69 @@
+<?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;
+
+/**
+ * Test major use-cases of the 'crypto.token' service.
+ */
+class CryptoTokenTest extends \CiviUnitTestCase {
+
+  use CryptoTestTrait;
+
+  protected function setUp() {
+    parent::setUp();
+    \CRM_Utils_Hook::singleton()->setHook('civicrm_crypto', [$this, 'registerExampleKeys']);
+  }
+
+  public function testIsPlainText() {
+    $token = \Civi::service('crypto.token');
+
+    $this->assertFalse($token->isPlainText(chr(2)));
+    $this->assertFalse($token->isPlainText(chr(2) . 'asdf'));
+
+    $this->assertTrue($token->isPlainText(\CRM_Utils_Array::implodePadded(['a', 'b', 'c'])));
+    $this->assertTrue($token->isPlainText(""));
+    $this->assertTrue($token->isPlainText("\r"));
+    $this->assertTrue($token->isPlainText("\n"));
+  }
+
+  public function getExampleTokens() {
+    return [
+      // [ 'Plain text', 'Encryption Key ID', 'expectTokenRegex', 'expectTokenLen', 'expectPlain' ]
+      ['hello world. can you see me', 'plain', '/^hello world. can you see me/', 27, TRUE],
+      ['hello world. i am secret.', 'UNIT-TEST', '/^.CTK0.asdf-key-1./', 81, FALSE],
+      ['hello world. we b secret.', 'asdf-key-0', '/^.CTK0.asdf-key-0./', 81, FALSE],
+      ['hello world. u ur secret.', 'asdf-key-1', '/^.CTK0.asdf-key-1./', 81, FALSE],
+      ['hello world. he z secret.', 'asdf-key-2', '/^.CTK0.asdf-key-2./', 73, FALSE],
+      ['hello world. whos secret.', 'asdf-key-3', '/^.CTK0.asdf-key-3./', 125, FALSE],
+    ];
+  }
+
+  /**
+   * @param string $inputText
+   * @param string $inputKeyIdOrTag
+   * @param string $expectTokenRegex
+   * @param int $expectTokenLen
+   * @param bool $expectPlain
+   *
+   * @dataProvider getExampleTokens
+   */
+  public function testRoundtrip($inputText, $inputKeyIdOrTag, $expectTokenRegex, $expectTokenLen, $expectPlain) {
+    $token = \Civi::service('crypto.token')->encrypt($inputText, $inputKeyIdOrTag);
+    $this->assertRegExp($expectTokenRegex, $token);
+    $this->assertEquals($expectTokenLen, strlen($token));
+    $this->assertEquals($expectPlain, \Civi::service('crypto.token')->isPlainText($token));
+
+    $actualText = \Civi::service('crypto.token')->decrypt($token);
+    $this->assertEquals($inputText, $actualText);
+  }
+
+}