From fe4d2bdc2ef48f771bcd735e690374941bf8e5fe Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sun, 20 Dec 2020 21:55:58 -0800 Subject: [PATCH] (dev/core#2258) CryptoToken - Add rekey method --- Civi/Crypto/CryptoToken.php | 34 +++++++++++ tests/phpunit/Civi/Crypto/CryptoTokenTest.php | 60 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/Civi/Crypto/CryptoToken.php b/Civi/Crypto/CryptoToken.php index 2a478fd7ce4e..9ee7375ac695 100644 --- a/Civi/Crypto/CryptoToken.php +++ b/Civi/Crypto/CryptoToken.php @@ -143,6 +143,40 @@ public function decrypt($token, $keyIdOrTag = '*') { return $plainText; } + /** + * Re-encrypt an existing token with a newer version of the key. + * + * @param string $oldToken + * @param string $keyTag + * Ex: 'CRED' + * + * @return string|null + * A re-encrypted version of $oldToken, or NULL if there should be no change. + * @throws \Civi\Crypto\Exception\CryptoException + */ + public function rekey($oldToken, $keyTag) { + /** @var \Civi\Crypto\CryptoRegistry $registry */ + $registry = \Civi::service('crypto.registry'); + + $sourceKeys = $registry->findKeysByTag($keyTag); + $targetKey = array_shift($sourceKeys); + + if ($this->isPlainText($oldToken)) { + if ($targetKey['suite'] === 'plain') { + return NULL; + } + } + else { + $tokenData = $this->parse($oldToken); + if ($tokenData['k'] === $targetKey['id'] || !isset($sourceKeys[$tokenData['k']])) { + return NULL; + } + } + + $decrypted = $this->decrypt($oldToken); + return $this->encrypt($decrypted, $targetKey['id']); + } + /** * Parse the content of a token (without decrypting it). * diff --git a/tests/phpunit/Civi/Crypto/CryptoTokenTest.php b/tests/phpunit/Civi/Crypto/CryptoTokenTest.php index d626698df5db..ccd27dcd762f 100644 --- a/tests/phpunit/Civi/Crypto/CryptoTokenTest.php +++ b/tests/phpunit/Civi/Crypto/CryptoTokenTest.php @@ -92,6 +92,66 @@ public function testRoundtrip($inputText, $inputKeyIdOrTag, $expectTokenRegex, $ $this->assertEquals($inputText, $actualText); } + public function testRekeyCiphertext() { + /** @var \Civi\Crypto\CryptoRegistry $cryptoRegistry */ + $cryptoRegistry = \Civi::service('crypto.registry'); + /** @var \Civi\Crypto\CryptoToken $cryptoToken */ + $cryptoToken = \Civi::service('crypto.token'); + + $first = $cryptoToken->encrypt("hello world", 'UNIT-TEST'); + $this->assertRegExp(';k=asdf-key-1;', $first); + $this->assertEquals('hello world', $cryptoToken->decrypt($first)); + + // If the keys haven't changed yet, then rekey() is a null-op. + $second = $cryptoToken->rekey($first, 'UNIT-TEST'); + $this->assertTrue($second === NULL); + + // But if we add a newer key, then rekey() will yield new token. + $cryptoRegistry->addSymmetricKey($cryptoRegistry->parseKey('::foo') + [ + 'tags' => ['UNIT-TEST'], + 'weight' => -100, + 'id' => 'new-key', + ]); + $third = $cryptoToken->rekey($first, 'UNIT-TEST'); + $this->assertNotRegExp(';k=asdf-key-1;', $third); + $this->assertRegExp(';k=new-key;', $third); + $this->assertEquals('hello world', $cryptoToken->decrypt($third)); + } + + public function testRekeyUpgradeDowngradePlaintext() { + /** @var \Civi\Crypto\CryptoRegistry $cryptoRegistry */ + $cryptoRegistry = \Civi::service('crypto.registry'); + /** @var \Civi\Crypto\CryptoToken $cryptoToken */ + $cryptoToken = \Civi::service('crypto.token'); + + // In the first pass, we have no real key. + $cryptoRegistry->addPlainText(['tags' => ['APPLE'], 'weight' => -1]); + $first = $cryptoToken->encrypt("hello world", 'APPLE'); + $this->assertEquals('hello world', $first); + $this->assertEquals('hello world', $cryptoToken->decrypt($first)); + + // If the keys haven't changed yet, then rekey() is a null-op. + $second = $cryptoToken->rekey($first, 'APPLE'); + $this->assertTrue($second === NULL); + + // But if we add a key, then it takes precedence. + $cryptoRegistry->addSymmetricKey($cryptoRegistry->parseKey('::applepie') + [ + 'tags' => ['APPLE'], + 'weight' => -3, + 'id' => 'interim-key', + ]); + $third = $cryptoToken->rekey($first, 'APPLE'); + $this->assertRegExp(';k=interim-key;', $third); + $this->assertEquals('hello world', $cryptoToken->decrypt($third)); + + // But if we add another key with earlier priority, + $cryptoRegistry->addPlainText(['tags' => ['APPLE'], 'weight' => -4]); + $fourth = $cryptoToken->rekey($third, 'APPLE'); + $this->assertEquals('hello world', $fourth); + $this->assertEquals('hello world', $cryptoToken->decrypt($fourth)); + + } + public function testReadPlainTextWithoutRegistry() { // This is performance optimization - don't initialize crypto.registry unless // you actually need it.