*
* - 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';
+ /**
+ * Format identification code
+ */
+ const FMT_QUERY = 'CTK?';
+ /**
+ * @var string
+ */
protected $delim;
+ /**
+ * @var \Civi\Crypto\CryptoRegistry|null
+ */
+ private $registry;
+
/**
* CryptoToken constructor.
+ *
+ * @param CryptoRegistry $registry
*/
- public function __construct() {
+ public function __construct($registry = NULL) {
$this->delim = chr(2);
+ $this->registry = $registry;
}
/**
* @return bool
*/
public function isPlainText($plainText) {
- return is_string($plainText) && ($plainText === '' || $plainText{0} !== $this->delim);
+ return is_string($plainText) && ($plainText === '' || $plainText[0] !== $this->delim);
}
/**
*/
public function encrypt($plainText, $keyIdOrTag) {
/** @var CryptoRegistry $registry */
- $registry = \Civi::service('crypto.registry');
+ $registry = $this->getRegistry();
$key = $registry->findKey($keyIdOrTag);
if ($key['suite'] === 'plain') {
/** @var \Civi\Crypto\CipherSuiteInterface $cipherSuite */
$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),
+ ]);
}
/**
}
/** @var CryptoRegistry $registry */
- $registry = \Civi::service('crypto.registry');
+ $registry = $this->getRegistry();
- $parts = explode($this->delim, $token, 4);
- if (count($parts) !== 4 || $parts[1] !== self::VERSION_1) {
- throw new CryptoException("Cannot decrypt token. Invalid format.");
- }
- $keyId = $parts[2];
- $cipherText = base64_decode($parts[3]);
+ $tokenData = $this->parse($token);
- $key = $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");
+ $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 = $registry->findSuite($key['suite']);
- $plainText = $cipherSuite->decrypt($cipherText, $key);
+ $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;
+ }
+
}