Skip to content

Commit de76f82

Browse files
committed
Implement authenticated encryption in community auth
This creates a community auth version 3 (previous one being 2, and 1 is long gone) trhat uses AES_SIV as the encryption method instead of regular AES_CBC, and validates the digests on all accounts. As this gets deployed on servers incrementall, the version has to be specified in the database record for the site. We could have the site indicate this itself, but doing it this way seems safer as it will then just break for any app that accidentally reverts the plugin. Reviewed by Jacob Champion
1 parent 7a42e2a commit de76f82

File tree

8 files changed

+148
-75
lines changed

8 files changed

+148
-75
lines changed

docs/authentication.rst

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,26 +62,26 @@ The flow of an authentication in the 2.0 system is fairly simple:
6262

6363
#. This dictionary of information is then URL-encoded.
6464
#. The resulting URL-encoded string is padded with spaces to an even
65-
16 bytes, and is then AES encrypted with a shared key. This key
66-
is stored in the main website system and indexed by the site id,
67-
and it is stored in the settings of the community website somewhere.
68-
Since this key is what protects the authentication, it should be
69-
treated as very valuable.
70-
#. The resulting encrypted string and the IV used for the encryption are
71-
base64-encoded (in URL mode, meaning it uses - and _ instead of + and /.
65+
16 bytes, and is then AES-SIV encrypted with a shared key and a 16
66+
byte nonce. This key is stored in the main website system and
67+
indexed by the site id, and it is stored in the settings of the
68+
community website somewhere. Since this key is what protects the
69+
authentication, it should be treated as very valuable.
70+
#. The resulting encrypted string, the nonce used for the encryption
71+
and the tag from the digest are base64-encoded (in URL mode,
72+
meaning it uses - and _ instead of + and /.
7273
#. The main website looks up the redirection URL registered for this site
7374
(again indexed by the site id), and constructs an URL of the format
74-
<redirection_url>?i=<iv>&d=<encrypted data>
75+
<redirection_url>?n=<nonce>&d=<encrypted data>&t=<tag>
7576
#. The user browser is redirected to this URL.
7677
#. The community website detects that this is a redirected authentication
7778
response, and starts processing it specifically.
7879
#. Using the shared key, the data is decrypted (while first being base64
79-
decoded, of course)
80+
decoded, of course). Since authenticated encryption using AES-SIV
81+
is used, this step will fail if there has been any tampering with the
82+
data.
8083
#. The resulting string is urldecoded - and if any errors occur in the
81-
decoding, the authentication will fail. This step is guaranteed to fail
82-
if the encryption key is mismatching between the community site and
83-
the main website, since it is going to end up with something that is
84-
definitely not an url-decodeable string.
84+
decoding, the authentication will fail.
8585
#. The community site will look up an existing user record under this
8686
username, or create a new one if one does not exist already (assuming
8787
the site keeps local track of users at all - if it just deals with
@@ -94,10 +94,6 @@ The flow of an authentication in the 2.0 system is fairly simple:
9494
#. If the *d* key is present in the data structure handed over, the
9595
community site implements a site-specific action based on this data,
9696
such as redirecting the user to the original location.
97-
#. *DEPRECATED* If the *su* key is present in the data structure handed over, the
98-
community site redirects to this location. If it's not present, then
99-
the community site will redirect so some default location on the
100-
site.
10197

10298
Logging out
10399
-----------

pgweb/account/admin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ def clean_cryptkey(self):
2727
except Exception:
2828
raise forms.ValidationError("Crypto key must be base64 encoded")
2929

30-
if (len(x) != 16 and len(x) != 24 and len(x) != 32):
31-
raise forms.ValidationError("Crypto key must be 16, 24 or 32 bytes before being base64-encoded")
30+
if (len(x) != 16 and len(x) != 24 and len(x) != 32 and len(x) != 64):
31+
raise forms.ValidationError("Crypto key must be 16, 24, 32 or 64 bytes before being base64-encoded")
3232
return self.cleaned_data['cryptkey']
3333

3434
def clean(self):
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.2.11 on 2025-04-01 10:45
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('account', '0009_cauth_unique_names'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='communityauthsite',
15+
name='version',
16+
field=models.IntegerField(choices=[(2, 2), (3, 3)], default=2),
17+
),
18+
]

pgweb/account/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class CommunityAuthSite(models.Model):
1818
apiurl = models.URLField(max_length=200, null=False, blank=True)
1919
cryptkey = models.CharField(max_length=100, null=False, blank=False,
2020
help_text="Use tools/communityauth/generate_cryptkey.py to create a key")
21+
version = models.IntegerField(choices=((2, 2), (3, 3)), default=2)
2122
comment = models.TextField(null=False, blank=True)
2223
org = models.ForeignKey(CommunityAuthOrg, null=False, blank=False, on_delete=models.CASCADE)
2324
cooloff_hours = models.PositiveIntegerField(null=False, blank=False, default=0,

pgweb/account/views.py

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -722,17 +722,33 @@ def communityauth(request, siteid):
722722
# the first block more random..
723723
s = "t=%s&%s" % (int(time.time()), urllib.parse.urlencode(info))
724724

725-
# Encrypt it with the shared key (and IV!)
726-
r = Random.new()
727-
iv = r.read(16) # Always 16 bytes for AES
728-
encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_CBC, iv)
729-
cipher = encryptor.encrypt(s.encode('ascii') + b' ' * (16 - (len(s) % 16))) # Pad to even 16 bytes
725+
if site.version == 3:
726+
# v3 = authenticated encryption
727+
r = Random.new()
728+
nonce = r.read(16)
729+
encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_SIV, nonce=nonce)
730+
cipher, tag = encryptor.encrypt_and_digest(s.encode('ascii'))
731+
redirparams = {
732+
'd': base64.b64encode(cipher, b"-_").decode('ascii'),
733+
'n': base64.b64encode(nonce, b"-_").decode('ascii'),
734+
't': base64.b64encode(tag, b"-_").decode('ascii'),
735+
}
736+
else:
737+
# v2 = plain AES
738+
# Encrypt it with the shared key (and IV!)
739+
r = Random.new()
740+
iv = r.read(16) # Always 16 bytes for AES
741+
encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_CBC, iv)
742+
cipher = encryptor.encrypt(s.encode('ascii') + b' ' * (16 - (len(s) % 16))) # Pad to even 16 bytes
743+
redirparams = {
744+
'i': base64.b64encode(iv, b"-_").decode('ascii'),
745+
'd': base64.b64encode(cipher, b"-_").decode('ascii'),
746+
}
730747

731748
# Generate redirect
732-
return HttpResponseRedirect("%s?i=%s&d=%s" % (
749+
return HttpResponseRedirect("%s?%s" % (
733750
site.redirecturl,
734-
base64.b64encode(iv, b"-_").decode('ascii'),
735-
base64.b64encode(cipher, b"-_").decode('ascii'),
751+
urllib.parse.urlencode(redirparams),
736752
))
737753

738754

@@ -769,18 +785,30 @@ def communityauth_consent(request, siteid):
769785
})
770786

771787

772-
def _encrypt_site_response(site, s):
773-
# Encrypt it with the shared key (and IV!)
774-
r = Random.new()
775-
iv = r.read(16) # Always 16 bytes for AES
776-
encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_CBC, iv)
777-
cipher = encryptor.encrypt(s.encode('ascii') + b' ' * (16 - (len(s) % 16))) # Pad to even 16 bytes
788+
def _encrypt_site_response(site, s, version):
789+
if version == 3:
790+
# Use authenticated encryption
791+
r = Random.new()
792+
nonce = r.read(16)
793+
encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_SIV, nonce=nonce)
794+
cipher, tag = encryptor.encrypt_and_digest(s.encode('ascii'))
778795

779-
# Base64-encode the response, just to be consistent
780-
return "%s&%s" % (
781-
base64.b64encode(iv, b'-_').decode('ascii'),
782-
base64.b64encode(cipher, b'-_').decode('ascii'),
783-
)
796+
return "&".join((
797+
base64.b64encode(nonce, b'-_').decode('ascii'),
798+
base64.b64encode(cipher, b'-_').decode('ascii'),
799+
base64.b64encode(tag, b'-_').decode('ascii'),
800+
))
801+
else:
802+
# Encrypt it with the shared key (and IVs)
803+
r = Random.new()
804+
iv = r.read(16) # Always 16 bytes for AES
805+
encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_CBC, iv)
806+
cipher = encryptor.encrypt(s.encode('ascii') + b' ' * (16 - (len(s) % 16))) # Pad to even 16 bytes
807+
808+
return "&".join((
809+
base64.b64encode(iv, b'-_').decode('ascii'),
810+
base64.b64encode(cipher, b'-_').decode('ascii'),
811+
))
784812

785813

786814
@queryparams('s', 'e', 'n', 'u')
@@ -812,7 +840,7 @@ def communityauth_search(request, siteid):
812840
'se': [a.email for a in u.secondaryemail_set.all()],
813841
} for u in users])
814842

815-
return HttpResponse(_encrypt_site_response(site, j))
843+
return HttpResponse(_encrypt_site_response(site, j, site.version))
816844

817845

818846
def communityauth_getkeys(request, siteid, since=None):
@@ -828,7 +856,7 @@ def communityauth_getkeys(request, siteid, since=None):
828856

829857
j = json.dumps([{'u': k.user.username, 's': k.sshkey.replace("\r", "\n")} for k in keys])
830858

831-
return HttpResponse(_encrypt_site_response(site, j))
859+
return HttpResponse(_encrypt_site_response(site, j, site.version))
832860

833861

834862
@csrf_exempt

tools/communityauth/generate_cryptkey.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
import base64
1010

1111
if __name__ == "__main__":
12-
print("The next row contains a 32-byte (256-bit) symmetric crypto key.")
12+
print("The next row contains a 64-byte (512-bit) symmetric crypto key.")
1313
print("This key should be used to integrate a community auth site.")
1414
print("Note that each site should have it's own key!!")
1515
print("")
1616

1717
r = Random.new()
18-
key = r.read(32)
18+
key = r.read(64)
1919
print(base64.b64encode(key).decode('ascii'))

tools/communityauth/sample/django/auth.py

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
from urllib.parse import urlencode, parse_qs
4242
import requests
4343
from Cryptodome.Cipher import AES
44-
from Cryptodome.Hash import SHA
44+
from Cryptodome.Hash import SHA256
4545
from Cryptodome import Random
4646
import time
4747

@@ -75,15 +75,19 @@ def login(request):
7575
s = "t=%s&%s" % (int(time.time()), urlencode({'r': request.GET['next']}))
7676
# Now encrypt it
7777
r = Random.new()
78-
iv = r.read(16)
79-
encryptor = AES.new(SHA.new(settings.SECRET_KEY.encode('ascii')).digest()[:16], AES.MODE_CBC, iv)
80-
cipher = encryptor.encrypt(s.encode('ascii') + b' ' * (16 - (len(s) % 16))) # pad to 16 bytes
81-
82-
return HttpResponseRedirect("%s?d=%s$%s" % (
83-
settings.PGAUTH_REDIRECT,
84-
base64.b64encode(iv, b"-_").decode('utf8'),
85-
base64.b64encode(cipher, b"-_").decode('utf8'),
86-
))
78+
nonce = r.read(16)
79+
encryptor = AES.new(
80+
SHA256.new(settings.SECRET_KEY.encode('ascii')).digest()[:32], AES.MODE_SIV, nonce=nonce
81+
)
82+
cipher, tag = encryptor.encrypt_and_digest(s.encode('ascii'))
83+
84+
return HttpResponseRedirect("%s?%s" % (settings.PGAUTH_REDIRECT, urlencode({
85+
'd': '$'.join((
86+
base64.b64encode(nonce, b"-_").decode('utf8'),
87+
base64.b64encode(cipher, b"-_").decode('utf8'),
88+
base64.b64encode(tag, b"-_").decode('utf8'),
89+
)),
90+
})))
8791
else:
8892
return HttpResponseRedirect(settings.PGAUTH_REDIRECT)
8993

@@ -103,17 +107,24 @@ def auth_receive(request):
103107
# This was a logout request
104108
return HttpResponseRedirect('/')
105109

106-
if 'i' not in request.GET:
107-
return HttpResponse("Missing IV in url!", status=400)
110+
if 'n' not in request.GET:
111+
return HttpResponse("Missing nonce in url!", status=400)
108112
if 'd' not in request.GET:
109113
return HttpResponse("Missing data in url!", status=400)
114+
if 't' not in request.GET:
115+
return HttpResponse("Missing tag in url!", status=400)
110116

111117
# Set up an AES object and decrypt the data we received
112118
try:
113-
decryptor = AES.new(base64.b64decode(settings.PGAUTH_KEY),
114-
AES.MODE_CBC,
115-
base64.b64decode(str(request.GET['i']), "-_"))
116-
s = decryptor.decrypt(base64.b64decode(str(request.GET['d']), "-_")).rstrip(b' ').decode('utf8')
119+
decryptor = AES.new(
120+
base64.b64decode(settings.PGAUTH_KEY),
121+
AES.MODE_SIV,
122+
nonce=base64.b64decode(str(request.GET['n']), "-_"),
123+
)
124+
s = decryptor.decrypt_and_verify(
125+
base64.b64decode(str(request.GET['d']), "-_"),
126+
base64.b64decode(str(request.GET['t']), "-_"),
127+
).rstrip(b' ').decode('utf8')
117128
except UnicodeDecodeError:
118129
return HttpResponse("Badly encoded data found", 400)
119130
except Exception:
@@ -200,11 +211,16 @@ def auth_receive(request):
200211
# Finally, check of we have a data package that tells us where to
201212
# redirect the user.
202213
if 'd' in data:
203-
(ivs, datas) = data['d'][0].split('$')
204-
decryptor = AES.new(SHA.new(settings.SECRET_KEY.encode('ascii')).digest()[:16],
205-
AES.MODE_CBC,
206-
base64.b64decode(ivs, b"-_"))
207-
s = decryptor.decrypt(base64.b64decode(datas, "-_")).rstrip(b' ').decode('utf8')
214+
(nonces, datas, tags) = data['d'][0].split('$')
215+
decryptor = AES.new(
216+
SHA256.new(settings.SECRET_KEY.encode('ascii')).digest()[:32],
217+
AES.MODE_SIV,
218+
nonce=base64.b64decode(nonces, b"-_"),
219+
)
220+
s = decryptor.decrypt_and_verify(
221+
base64.b64decode(datas, "-_"),
222+
base64.b64decode(tags, "-_"),
223+
).rstrip(b' ').decode('utf8')
208224
try:
209225
rdata = parse_qs(s, strict_parsing=True)
210226
except ValueError:
@@ -304,17 +320,24 @@ def user_search(searchterm=None, userid=None):
304320
r = requests.get(
305321
'{0}search/'.format(settings.PGAUTH_REDIRECT),
306322
params=q,
323+
timeout=10,
307324
)
308325
if r.status_code != 200:
309326
return []
310327

311-
(ivs, datas) = r.text.encode('utf8').split(b'&')
328+
(nonces, datas, tags) = r.text.encode('utf8').split(b'&')
312329

313330
# Decryption time
314-
decryptor = AES.new(base64.b64decode(settings.PGAUTH_KEY),
315-
AES.MODE_CBC,
316-
base64.b64decode(ivs, "-_"))
317-
s = decryptor.decrypt(base64.b64decode(datas, "-_")).rstrip(b' ').decode('utf8')
331+
decryptor = AES.new(
332+
base64.b64decode(settings.PGAUTH_KEY),
333+
AES.MODE_SIV,
334+
nonce=base64.b64decode(nonces, "-_")
335+
)
336+
s = decryptor.decrypt_and_verify(
337+
base64.b64decode(datas, "-_"),
338+
base64.b64decode(tags, "-_"),
339+
).rstrip(b' ').decode('utf8')
340+
318341
j = json.loads(s)
319342

320343
return j

tools/communityauth/test_auth.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,19 @@
5454
s = "t=%s&%s" % (int(time.time() + 300), urllib.parse.urlencode(info))
5555

5656
r = Random.new()
57-
iv = r.read(16)
58-
encryptor = AES.new(base64.b64decode(options.key), AES.MODE_CBC, iv)
59-
cipher = encryptor.encrypt(s.encode('ascii') + b' ' * (16 - (len(s) % 16)))
57+
nonce = r.read(16)
58+
encryptor = AES.new(
59+
base64.b64decode(options.key),
60+
AES.MODE_SIV,
61+
nonce=nonce,
62+
)
63+
cipher, tag = encryptor.encrypt_and_digest(s.encode('ascii'))
64+
65+
redirparams = {
66+
'd': base64.b64encode(cipher, b"-_").decode('ascii'),
67+
'n': base64.b64encode(nonce, b"-_").decode('ascii'),
68+
't': base64.b64encode(tag, b"-_").decode('ascii'),
69+
}
6070

6171
print("Paste the following after the receiving url:")
62-
print("?i=%s&d=%s" % (
63-
base64.b64encode(iv, b"-_").decode('ascii'),
64-
base64.b64encode(cipher, b"-_").decode('ascii'),
65-
))
72+
print("?" + urllib.parse.urlencode(redirparams))

0 commit comments

Comments
 (0)