CRM_Core_Key - Provide more debugging hints about `qfKey`s
authorTim Otten <totten@civicrm.org>
Tue, 8 Dec 2020 23:15:45 +0000 (15:15 -0800)
committerTim Otten <totten@civicrm.org>
Fri, 11 Dec 2020 22:08:35 +0000 (14:08 -0800)
Overview
--------

The `qfKey` parameter is a security mechanism (CSRF). The content of `qfKey` is also inscrutable - so when there's a problem
with `qfKey`, it can be difficult to determine the origin/nature of the problem.

Before
------

The `qfKey` provides a digital signature based on (1) session ID, (2) the
form being processed, (3) a random private-key (unique to the user/session).
In some cases, the `qfKey` also has a random nonce appended to distinguish
between concurrent tabs that work with the same form.

* Ex: `2abc4b7f23d9ae4dfcdbb0c692cc666e5f11256fe84ace7662b0e075834b81958` (w/o nonce)
* Ex: `2abc4b7f23d9ae4dfcdbb0c692cc666e5f11256fe84ace7662b0e075834b81958_9527` (w/nonce)

If there is a logic problem where the `qfKey` of form A gets mixed-up with
form B, then it's extremely hard to understand the mismatch All you see are
two long random codes.

* Inputted qfKey: `2abc4b7f23d9ae4dfcdbb0c692cc666e5f11256fe84ace7662b0e075834b81958`
* Expected fqKey: `89874b7a23192e4d6aab10c622ca369e5e11226f784a5c7652b95075536b81a5e`

After
-----

The `qfKey` has a prefix to indicate the token's intended usage (as well as a digital signature).

* Ex: `CRMContactControllerSearch154eitbf74v3ko0sw94k08gogo8448asdfw4goggkkwgkww08c` (w/o nonce)
* Ex: `CRMContactControllerSearch154eitbf74v3ko0sw94k08gogo8448asdfw4goggkkwgkww08c_9355` (w/ nonce)

If there is a logic problem where the `qfKey` of form A gets mixed-up with
form B, then the prefix can help you understand. Compare:

* Inputted qfKey: `CRMContactControllerSearch154eitbf74v3ko0sw94k08gogo8448asdfw4goggkkwgkww08c`
* Expected qfKey: `CRMContributeFormContribution5o2due205mp0384080sgc8omw8kwggoksw47sswchs80gw0kgs`

This tells you that there's logical mismatch - it's trying to render the contribution
screen using the key for the contact-search screen.

Comments
--------

* The identity of the `formname` is not sensitive information.
* The `qfKey` might look prettier with more delimeters (`{formname}_{signature}_{nonce}`). However, I belive there are random bits of existing code
  which use `explode('_')` to split apart the signature and the nonce. This formula seems to be drop-in/interoperable.
* I switched the encoding of the signature from hex (0-9a-f) to base-36 (0-9a-z) to make it a bit shorter.

CRM/Core/Key.php

index 688c0f8cffdc31b5fb71b4f2612d6659bfb39e5d..505bd3588d5e441b78a1bf84d99b00c3b81c3b86 100644 (file)
@@ -32,10 +32,10 @@ class CRM_Core_Key {
   const HASH_ALGO = 'sha256';
 
   /**
-   * The length of a generated signature/digest (expressed in hex digits).
+   * The minimum length of a generated signature/digest (expressed in base36 digits).
    * @var int
    */
-  const HASH_LENGTH = 64;
+  const HASH_LENGTH = 25;
 
   public static $_key = NULL;
 
@@ -121,7 +121,8 @@ class CRM_Core_Key {
       $k = $key;
     }
 
-    if (!hash_equals($k, self::sign($name))) {
+    $expected = self::sign($name);
+    if (!hash_equals($k, $expected)) {
       return NULL;
     }
     return $key;
@@ -141,7 +142,7 @@ class CRM_Core_Key {
    */
   public static function valid($key) {
     // ensure that hash is a hex number (of expected length)
-    return preg_match('#[0-9a-f]{' . self::HASH_LENGTH . '}#i', $key) ? TRUE : FALSE;
+    return preg_match('#^[0-9a-zA-Z]{' . self::HASH_LENGTH . ',}+(_\d+)?$#', $key) ? TRUE : FALSE;
   }
 
   /**
@@ -157,8 +158,10 @@ class CRM_Core_Key {
     if (strpos($sessionID, $delim) !== FALSE || strpos($name, $delim) !== FALSE) {
       throw new \RuntimeException("Failed to generate signature. Malformed session-id or form-name.");
     }
+    // The "prefix" gives some advisory details to help with debugging.
+    $prefix = preg_replace('/[^a-zA-Z0-9]/', '', $name);
     // Note: Unsure why $sessionID is included, but it's always been there, and it doesn't seem harmful.
-    return hash_hmac(self::HASH_ALGO, $sessionID . $delim . $name, $privateKey);
+    return $prefix . base_convert(hash_hmac(self::HASH_ALGO, $sessionID . $delim . $name, $privateKey), 16, 36);
 
   }