Implement community auth using ChaCha20_Poly1305 encryption
authorMagnus Hagander <magnus@hagander.net>
Tue, 12 Aug 2025 14:41:28 +0000 (16:41 +0200)
committerMagnus Hagander <magnus@hagander.net>
Tue, 12 Aug 2025 14:53:13 +0000 (16:53 +0200)
This is called version 4 (though version 3 is the preferred version so
they are not strictly in increasing number order), and is intended to be
used for platforms that don't support AES-SIV encryption.

docs/authentication.rst
pgweb/account/migrations/0010_communityauthsite_version.py
pgweb/account/models.py
pgweb/account/views.py
tools/communityauth/generate_cryptkey.py

index b63e250c40ba79f352f123e305319dd33ebd6919..181c189e778b5f2e25c102b7b6a95f201acb9e9c 100644 (file)
@@ -1,5 +1,5 @@
-Community authentication 2.0
-============================
+Community authentication 2.0-4.0
+================================
 While the old community authentication system was simply having the
 clients call a PostgreSQL function on the main website server, version
 2.0 of the system uses browser redirects to perform this. This allows
@@ -63,7 +63,8 @@ The flow of an authentication in the 2.0 system is fairly simple:
 #. This dictionary of information is then URL-encoded.
 #. The resulting URL-encoded string is padded with spaces to an even
    16 bytes, and is then AES-SIV encrypted with a shared key and a 16
-   byte nonce. This key is stored in the main website system and
+   byte nonce (v4 uses ChaCha20_Poly1305 with standard size key and nonce,
+   but v3 is the preferred version). This key is stored in the main website system and
    indexed by the site id, and it is stored in the settings of the
    community website somewhere.  Since this key is what protects the
    authentication, it should be treated as very valuable.
@@ -77,7 +78,7 @@ The flow of an authentication in the 2.0 system is fairly simple:
 #. The community website detects that this is a redirected authentication
    response, and starts processing it specifically.
 #. Using the shared key, the data is decrypted (while first being base64
-   decoded, of course). Since authenticated encryption using AES-SIV
+   decoded, of course). Since authenticated encryption using AES-SIV or ChaCha20_Poly1305
    is used, this step will fail if there has been any tampering with the
    data.
 #. The resulting string is urldecoded - and if any errors occur in the
@@ -115,6 +116,17 @@ The flow for a logout request is trivial:
    at the URL <redirection_url>?s=logout (where redirection_url is the
    same URL as when logging in)
 
+Versions
+--------
+The different versions are primarily different in that they use different
+encryption algorithms.
+
+v2 uses standard AES without authentication. This version is *deprecated*.
+v3 uses AES-SIV authenticated encryption. This is the *recommended* vcersion.
+v4 uses ChaCha20_Poly1305 authenticated encryption, for platforms that don't
+   support AES-SIV.
+
+
 Searching
 ---------
 The community authentication system also supports an API for searching for
index 1b4ab2b86bac8e722c55b374da6426385d79fec0..00574d23b0664e699ebd406d559853aa3a23bcc6 100644 (file)
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='communityauthsite',
             name='version',
-            field=models.IntegerField(choices=[(2, 2), (3, 3)], default=2),
+            field=models.IntegerField(choices=[(2, 2), (3, 3), (4, 4)], default=2),
         ),
     ]
index 55459b9ad4134a2cb6db742038341919903177d5..6ee715b2ba52c1d6a0627e44cb0bb31f617fab1c 100644 (file)
@@ -18,7 +18,7 @@ class CommunityAuthSite(models.Model):
     apiurl = models.URLField(max_length=200, null=False, blank=True)
     cryptkey = models.CharField(max_length=100, null=False, blank=False,
                                 help_text="Use tools/communityauth/generate_cryptkey.py to create a key")
-    version = models.IntegerField(choices=((2, 2), (3, 3)), default=2)
+    version = models.IntegerField(choices=((2, 2), (3, 3), (4, 4)), default=2)
     comment = models.TextField(null=False, blank=True)
     org = models.ForeignKey(CommunityAuthOrg, null=False, blank=False, on_delete=models.CASCADE)
     cooloff_hours = models.PositiveIntegerField(null=False, blank=False, default=0,
index 809d4bea9f9ef974d42cb8ecd089b9688f648bef..d674f3fc3e26c7f48ebb06b408d672edfb40d618 100644 (file)
@@ -17,6 +17,7 @@ from django.db.models import Q, Prefetch
 import base64
 import urllib.parse
 from Cryptodome.Cipher import AES
+from Cryptodome.Cipher import ChaCha20_Poly1305
 from Cryptodome import Random
 import time
 import json
@@ -721,11 +722,14 @@ def communityauth(request, siteid):
     # the first block more random..
     s = "t=%s&%s" % (int(time.time()), urllib.parse.urlencode(info))
 
-    if site.version == 3:
-        # v3 = authenticated encryption
+    if site.version in (3, 4):
+        # v3 = authenticated encryption, v4 = authenticated encryption with XChaCha20-Poly1305
         r = Random.new()
-        nonce = r.read(16)
-        encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_SIV, nonce=nonce)
+        nonce = r.read(16 if site.version == 3 else 24)
+        if site.version == 3:
+            encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_SIV, nonce=nonce)
+        else:
+            encryptor = ChaCha20_Poly1305.new(key=base64.b64decode(site.cryptkey), nonce=nonce)
         cipher, tag = encryptor.encrypt_and_digest(s.encode('ascii'))
         redirparams = {
             'd': base64.urlsafe_b64encode(cipher),
@@ -785,11 +789,14 @@ def communityauth_consent(request, siteid):
 
 
 def _encrypt_site_response(site, s, version):
-    if version == 3:
-        # Use authenticated encryption
+    if version in (3, 4):
+        # Use authenticated encryption (v3 = SIV, v4 = ChaCha20_Poly1305
         r = Random.new()
-        nonce = r.read(16)
-        encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_SIV, nonce=nonce)
+        nonce = r.read(16 if site.version == 3 else 24)
+        if site.version == 3:
+            encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_SIV, nonce=nonce)
+        else:
+            encryptor = ChaCha20_Poly1305.new(key=site.cryptkey, nonce=nonce)
         cipher, tag = encryptor.encrypt_and_digest(s.encode('ascii'))
 
         return "&".join((
index ee70d7f0982b533d3ce1bfdade026f62b5e6f50e..ba84399a8fbecc4b759c166a209ba37f1d60e058 100755 (executable)
@@ -7,13 +7,29 @@
 
 from Cryptodome import Random
 import base64
+import sys
+
+
+def usage():
+    print("Usage: generate_cryptkey.py <version>")
+    print("")
+    print("Version must be 3 or 4, representing the version of community authentication encryption to use")
+    sys.exit(0)
+
 
 if __name__ == "__main__":
+    if len(sys.argv) != 2:
+        usage()
+    if sys.argv[1] not in ("3", "4"):
+        usage()
+
+    version = int(sys.argv[1])
+
     print("The next row contains a 64-byte (512-bit) symmetric crypto key.")
     print("This key should be used to integrate a community auth site.")
     print("Note that each site should have it's own key!!")
     print("")
 
     r = Random.new()
-    key = r.read(64)
+    key = r.read(64 if version == 3 else 32)
     print(base64.b64encode(key).decode('ascii'))