--- /dev/null
+<?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;
+ }
+
+}
--- /dev/null
+<?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);
+ }
+
+}