(dev/core#2258) CryptoToken - Add rekey method
authorTim Otten <totten@civicrm.org>
Mon, 21 Dec 2020 05:55:58 +0000 (21:55 -0800)
committerTim Otten <totten@civicrm.org>
Wed, 30 Dec 2020 21:39:27 +0000 (13:39 -0800)
Civi/Crypto/CryptoToken.php
tests/phpunit/Civi/Crypto/CryptoTokenTest.php

index 2a478fd7ce4ef715962569f5b885ae1f47c91293..9ee7375ac6950c9e0cf7e803ee246d24e5ef74cf 100644 (file)
@@ -143,6 +143,40 @@ class CryptoToken {
     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).
    *
index d626698df5dbdecdb016584c3b79c96d7dda50e2..ccd27dcd762fff6df30696476a4e3efc022ce1f1 100644 (file)
@@ -92,6 +92,66 @@ class CryptoTokenTest extends \CiviUnitTestCase {
     $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.