Merge pull request #23154 from civicrm/5.49
[civicrm-core.git] / Civi / Crypto / CryptoToken.php
index a14ef8c54802a430be450bedb5689d66d900c0ca..afe415d844f85b62315ef5cc5de36c775d5e9aa8 100644 (file)
@@ -34,29 +34,38 @@ use Civi\Crypto\Exception\CryptoException;
  *
  *   - 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 "_-.,:;=+/\"
+ *        TOKEN := DLM + FMT + QUERY
+ *        DLM := ASCII char #2
+ *        FMT := String, 4-digit, alphanumeric (as in "CTK?")
+ *        QUERY := String, URL-encoded key-value pairs,
+ *           "k", the key ID (alphanumeric and symbols "_-.,:;=+/\")
+ *           "t", the text (base64-encoded ciphertext)
  *
  * @package Civi\Crypto
  */
 class CryptoToken {
 
-  const VERSION_1 = 'CTK0';
-
   /**
-   * @var CryptoRegistry
+   * Format identification code
    */
-  protected $registry;
+  const FMT_QUERY = 'CTK?';
 
+  /**
+   * @var string
+   */
   protected $delim;
 
+  /**
+   * @var \Civi\Crypto\CryptoRegistry|null
+   */
+  private $registry;
+
   /**
    * CryptoToken constructor.
-   * @param \Civi\Crypto\CryptoRegistry $registry
+   *
+   * @param CryptoRegistry $registry
    */
-  public function __construct(\Civi\Crypto\CryptoRegistry $registry) {
+  public function __construct($registry = NULL) {
     $this->delim = chr(2);
     $this->registry = $registry;
   }
@@ -68,7 +77,7 @@ class CryptoToken {
    * @return bool
    */
   public function isPlainText($plainText) {
-    return is_string($plainText) && ($plainText === '' || $plainText{0} !== $this->delim);
+    return is_string($plainText) && ($plainText === '' || $plainText[0] !== $this->delim);
   }
 
   /**
@@ -83,7 +92,10 @@ class CryptoToken {
    * @throws \Civi\Crypto\Exception\CryptoException
    */
   public function encrypt($plainText, $keyIdOrTag) {
-    $key = $this->registry->findKey($keyIdOrTag);
+    /** @var CryptoRegistry $registry */
+    $registry = $this->getRegistry();
+
+    $key = $registry->findKey($keyIdOrTag);
     if ($key['suite'] === 'plain') {
       if (!$this->isPlainText($plainText)) {
         throw new CryptoException("Cannot use plaintext encoding for data with reserved delimiter.");
@@ -92,9 +104,13 @@ class CryptoToken {
     }
 
     /** @var \Civi\Crypto\CipherSuiteInterface $cipherSuite */
-    $cipherSuite = $this->registry->findSuite($key['suite']);
+    $cipherSuite = $registry->findSuite($key['suite']);
     $cipherText = $cipherSuite->encrypt($plainText, $key);
-    return $this->delim . self::VERSION_1 . $this->delim . $key['id'] . $this->delim . base64_encode($cipherText);
+
+    return $this->delim . self::FMT_QUERY . \http_build_query([
+      'k' => $key['id'],
+      't' => \CRM_Utils_String::base64UrlEncode($cipherText),
+    ]);
   }
 
   /**
@@ -119,22 +135,87 @@ class CryptoToken {
       }
     }
 
-    $parts = explode($this->delim, $token);
-    if ($parts[1] !== self::VERSION_1) {
-      throw new CryptoException("Unrecognized encoding");
-    }
-    $keyId = $parts[2];
-    $cipherText = base64_decode($parts[3]);
+    /** @var CryptoRegistry $registry */
+    $registry = $this->getRegistry();
 
-    $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");
+    $tokenData = $this->parse($token);
+
+    $key = $registry->findKey($tokenData['k']);
+    if (!in_array('*', $keyIdOrTag) && !in_array($tokenData['k'], $keyIdOrTag) && empty(array_intersect($keyIdOrTag, $key['tags']))) {
+      throw new CryptoException("Cannot decrypt token. Unexpected key: {$tokenData['k']}");
     }
 
     /** @var \Civi\Crypto\CipherSuiteInterface $cipherSuite */
-    $cipherSuite = $this->registry->findSuite($key['suite']);
-    $plainText = $cipherSuite->decrypt($cipherText, $key);
+    $cipherSuite = $registry->findSuite($key['suite']);
+    $plainText = $cipherSuite->decrypt($tokenData['t'], $key);
     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 = $this->getRegistry();
+
+    $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).
+   *
+   * @param string $token
+   *
+   * @return array
+   * @throws \Civi\Crypto\Exception\CryptoException
+   */
+  public function parse($token): array {
+    $fmt = substr($token, 1, 4);
+    switch ($fmt) {
+      case self::FMT_QUERY:
+        $tokenData = [];
+        parse_str(substr($token, 5), $tokenData);
+        $tokenData['t'] = \CRM_Utils_String::base64UrlDecode($tokenData['t']);
+        break;
+
+      default:
+        throw new CryptoException("Cannot decrypt token. Invalid format.");
+    }
+    return $tokenData;
+  }
+
+  /**
+   * @return CryptoRegistry
+   */
+  protected function getRegistry(): CryptoRegistry {
+    if ($this->registry === NULL) {
+      $this->registry = \Civi::service('crypto.registry');
+    }
+    return $this->registry;
+  }
+
 }