From 8d3452c40914685bfcbdd48ce9e471415e4d1d51 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 16 Dec 2020 15:19:09 -0800 Subject: [PATCH] (dev/core#2258) CryptoToken - Add service for processing stored ciphertext --- Civi/Core/Container.php | 5 + Civi/Crypto/CryptoToken.php | 140 ++++++++++++++++++ tests/phpunit/Civi/Crypto/CryptoTokenTest.php | 69 +++++++++ 3 files changed, 214 insertions(+) create mode 100644 Civi/Crypto/CryptoToken.php create mode 100644 tests/phpunit/Civi/Crypto/CryptoTokenTest.php diff --git a/Civi/Core/Container.php b/Civi/Core/Container.php index 6c8ac4ffbc..fb3f0b9d79 100644 --- a/Civi/Core/Container.php +++ b/Civi/Core/Container.php @@ -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 index 0000000000..a14ef8c548 --- /dev/null +++ b/Civi/Crypto/CryptoToken.php @@ -0,0 +1,140 @@ +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 index 0000000000..961166a89a --- /dev/null +++ b/tests/phpunit/Civi/Crypto/CryptoTokenTest.php @@ -0,0 +1,69 @@ +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); + } + +} -- 2.25.1