Skip to content

Commit 190bbd1

Browse files
jagermanmajestrate
authored andcommitted
X-SOGS-* auth: use ed25519 sigs instead of hashes
This makes the X-SOGS-* not muck around with X25519 pubkeys at all; instead we now simply always have either a straight ed25519 pubkey (starting with 00, and from which we can convert to X to get the session id) or a blinded ed25519 pubkey starting with 15, which we leave as is for the SOGS-side blinded session id.
1 parent 056ad79 commit 190bbd1

File tree

7 files changed

+212
-189
lines changed

7 files changed

+212
-189
lines changed

sogs/crypto.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
with open(config.KEY_FILE, 'rb') as f:
3030
_privkey = PrivateKey(f.read())
3131

32+
_privkey_bytes = _privkey.encode()
33+
3234
server_pubkey = _privkey.public_key
3335

3436
server_pubkey_bytes = server_pubkey.encode()
@@ -37,15 +39,15 @@
3739
server_pubkey_hex = server_pubkey.encode(HexEncoder).decode('ascii')
3840
server_pubkey_base64 = server_pubkey.encode(Base64Encoder).decode('ascii')
3941

40-
_junk_parser = pyonionreq.junk.Parser(privkey=_privkey.encode(), pubkey=server_pubkey.encode())
42+
_junk_parser = pyonionreq.junk.Parser(privkey=_privkey_bytes, pubkey=server_pubkey_bytes)
4143
parse_junk = _junk_parser.parse_junk
4244

4345

4446
def verify_sig_from_pk(data, sig, pk):
4547
return VerifyKey(pk).verify(data, sig)
4648

4749

48-
_server_signkey = SigningKey(_privkey.encode())
50+
_server_signkey = SigningKey(_privkey_bytes)
4951

5052
server_verify = _server_signkey.verify_key.verify
5153

@@ -55,7 +57,7 @@ def verify_sig_from_pk(data, sig, pk):
5557
def server_encrypt(pk, data):
5658
nonce = secrets.token_bytes(12)
5759
pk = X25519PublicKey.from_public_bytes(pk)
58-
sk = X25519PrivateKey.from_private_bytes(_privkey.encode())
60+
sk = X25519PrivateKey.from_private_bytes(_privkey_bytes)
5961
secret = hmac.digest(b'LOKI', sk.exchange(pk), 'SHA256')
6062
return nonce + AESGCM(secret).encrypt(nonce, data, None)
6163

sogs/routes/auth.py

Lines changed: 77 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
from flask import request, abort, Response, g
88
import string
99
import time
10-
from nacl.bindings import crypto_scalarmult
10+
from nacl.signing import VerifyKey
11+
from nacl.exceptions import BadSignatureError
12+
import nacl.bindings as salt
1113
import sqlalchemy.exc
1214
from functools import wraps
1315

@@ -16,8 +18,9 @@
1618
# We handle authentication through 4 headers included in the outermost request (e.g. which typically
1719
# means the onion request):
1820
#
19-
# X-SOGS-Pubkey -- the blinded session_id of the user, in its typical hex representation. This is
20-
# typically a blinded id starting with "bb" rather than "05".
21+
# X-SOGS-Pubkey -- Ed25519 pubkey of the user. If blinded, this starts with "15" and the pubkey is
22+
# the user's blinded session id on the SOGS. If *unblinded* this starts with "00", the remainder is
23+
# an Ed25519 pubkey, and we convert it to an X25519 pubkey to determine the user's 05... session id.
2124
#
2225
# X-SOGS-Nonce -- a unique 128-bit (16 byte) request nonce, encoded in either base64 (22 chars (or
2326
# 24 with optional padding)) or hex (32 characters). This nonce may not be reused with this pubkey
@@ -26,45 +29,31 @@
2629
# X-SOGS-Timestamp -- unix integer timestamp, expressed in the usual human (base 10) notation. The
2730
# timestamp must be with ±24 hours of the SOGS server time when the request is received.
2831
#
29-
# X-SOGS-Hash -- base64 encoding of the keyed hash of:
32+
# X-SOGS-Signature -- Ed25519 signature (passed in base64 encoding) of:
3033
#
31-
# METHOD || PATH || TIMESTAMP || BODY
34+
# SERVER_PUBKEY || NONCE || TIMESTAMP || METHOD || PATH || HBODY
3235
#
33-
# using a Blake2B 42-byte keyed hash (to be obviously different from things like 32-byte pubkeys and
34-
# 64-byte signatures, and because 42 encodes cleanly into base64), where the hash is calculated as:
36+
# where HBODY is 64-byte blake2b hash of the body *if* the request has a non-empty body, and is
37+
# empty (omitted) otherwise.
3538
#
36-
# a (≡ user x25519 privkey, *not* including 05 Session prefix)
37-
# A (≡ user x25519 pubkey)
38-
# B (≡ server pubkey)
39-
#
40-
# q = a*B
41-
# shared_key = Blake2B(
42-
# q || A || B,
43-
# digest_size=42,
44-
# salt=nonce,
45-
# person=b'sogs.shared_keys')
46-
# hash = Blake2B(
47-
# data=M || P || T || B,
48-
# digest_size=42,
49-
# key=shared_key,
50-
# salt=nonce,
51-
# person=b'sogs.auth_header')
39+
# This value is signed using the blinded or unblinded Ed25519 pubkey given in the -Pubkey header.
5240
#
5341
# For example, for a GET request to '/capabilities?required=sogs' to a server with pubkey
5442
# fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 the request headers could be:
5543
#
56-
# X-SOGS-Pubkey: 050123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
44+
# X-SOGS-Pubkey: 150123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
5745
# X-SOGS-Nonce: IYUVSYbLlTgmnigr/H3Tdg==
5846
# X-SOGS-Timestamp: 1642079887
59-
# X-SOGS-Hash: ...
60-
#
61-
# Where ... is the 56-character base64 encoding of the 42-byte value obtained by hashing:
47+
# X-SOGS-Signature: ...
6248
#
63-
# b'GET/capabilities?required=sogs1642079887'
64-
# ^^^###########################^^^^^^^^^^
65-
# METHOD PATH (incl. query) TIMESTAMP (empty BODY)
49+
# Where ... is the 88-character (including 2 padding chars) base64 encoding of the 64-byte value
50+
# obtained by signing:
6651
#
67-
# using the blake2b hash as described above.
52+
# b'xxx...xxxYYY...YYY1642079887GET/capabilities?required=sogs'
53+
# ^^^^^^^^^#########^^^^^^^^^^###^^^^^^^^^^^^^^^^^^^^^^^^^^^
54+
# `- server pubkey, 32B | | | |
55+
# `- nonce, 16B | | | |
56+
# TIMESTAMP METHOD PATH `- (no body hash, because no body)
6857
#
6958
# Or for a onion POST request with a body:
7059
#
@@ -76,24 +65,24 @@
7665
# "X-SOGS-Pubkey": "050123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
7766
# "X-SOGS-Nonce": "5f9369b79449a7dfa07d123b697c84f6", # random, hex encoded
7867
# "X-SOGS-Timestamp": "1642080374",
79-
# "X-SOGS-Hash": "...",
68+
# "X-SOGS-Signature": "...",
8069
# }}
8170
#
82-
# where now the hash field is the base64 encoding of the hash of the value:
83-
#
84-
# b'POST/some/endpoint1642080374{"a":1}'
85-
# ^^^^##############^^^^^^^^^^#######
86-
# METHOD ENDPOINT TIMESTAMP BODY
71+
# you would calculate the 64-byte blake2b hash of '{"a":1}' (the onion request inner body), then
72+
# sign:
8773
#
88-
# (Note that the hash here is identical whether submitted via direct POST request or wrapped in an
89-
# onion request; an onion request is described for exposition).
74+
# b'xxx...xxxYYY...YYY1642080374POST/some/endpointHHH...HHH'
75+
# ^^^^^^^^^#########^^^^^^^^^^####^^^^^^^^^^^^^^#########
76+
# `- server pubkey, 32B | | | |
77+
# `- nonce, 16B | | | |
78+
# TIMESTAMP METHOD PATH HASH-BODY (64 bytes)
9079
#
9180
# For batch requests the X-SOGS-* headers are applied once, on the outermost batch request, *not* on
9281
# the individual subrequests; the authorization applies to all subrequests.
9382
#
94-
# NB: legacy sogs endpoints (that is: endpoint paths without a leading /) will not work with this
95-
# authentication mechanism; in order to call them you must invoke them with a leading `/legacy/`
96-
# prefix (e.g. `GET /legacy/rooms`).
83+
# NB: legacy sogs endpoints (that is: endpoint paths without a leading /) require specifying the
84+
# path in the signature message as `/legacy/whatever` even if just `whatever` is being used in the
85+
# onion request "endpoint" parameter).
9786

9887

9988
def abort_with_reason(code, msg, warn=True):
@@ -187,11 +176,11 @@ def handle_http_auth():
187176

188177
g.user_reauth = False
189178

190-
pk, nonce, ts_str, hash_in = (
191-
request.headers.get(f"X-SOGS-{h}") for h in ('Pubkey', 'Nonce', 'Timestamp', 'Hash')
179+
pk, nonce, ts_str, sig_in = (
180+
request.headers.get(f"X-SOGS-{h}") for h in ('Pubkey', 'Nonce', 'Timestamp', 'Signature')
192181
)
193182

194-
missing = sum(x is None or x == '' for x in (pk, nonce, ts_str, hash_in))
183+
missing = sum(x is None or x == '' for x in (pk, nonce, ts_str, sig_in))
195184
# If all are missing then we have no user
196185
if missing == 4:
197186
g.user = None
@@ -204,11 +193,32 @@ def handle_http_auth():
204193
)
205194

206195
# Parameter input validation
207-
if len(pk) != 66 or pk[0:2] not in ("05", "15") or not all(x in string.hexdigits for x in pk):
196+
197+
try:
198+
pk = utils.decode_hex_or_b64(pk, 33)
199+
except Exception:
208200
abort_with_reason(
209201
http.BAD_REQUEST, "Invalid authentication: X-SOGS-Pubkey is not a valid 66-hex digit id"
210202
)
211-
A = bytes.fromhex(pk[2:])
203+
204+
if pk[0] not in (0x00, 0x15):
205+
abort_with_reason(
206+
http.BAD_REQUEST, "Invalid authentication: X-SOGS-Pubkey must be 00- or 15- prefixed"
207+
)
208+
blinded_pk = pk[0] == 0x15
209+
pk = pk[1:]
210+
if not salt.crypto_core_ed25519_is_valid_point(pk):
211+
abort_with_reason(
212+
http.BAD_REQUEST,
213+
"Invalid authentication: given X-SOGS-Signature is not a valid Ed25519 pubkey",
214+
)
215+
pk = VerifyKey(pk)
216+
if blinded_pk:
217+
session_id = '15' + pk.encode().hex()
218+
else:
219+
# TODO: if "blinding required" config option is set then reject the request here
220+
session_id = '05' + pk.to_curve25519_public_key().encode().hex()
221+
212222

213223
try:
214224
nonce = utils.decode_hex_or_b64(nonce, 16)
@@ -219,10 +229,10 @@ def handle_http_auth():
219229
)
220230

221231
try:
222-
hash_in = utils.decode_hex_or_b64(hash_in, 42)
232+
sig_in = utils.decode_hex_or_b64(sig_in, 64)
223233
except Exception:
224234
abort_with_reason(
225-
http.BAD_REQUEST, "Invalid authentication: X-SOGS-Hash is not base64[56] or hex[84]"
235+
http.BAD_REQUEST, "Invalid authentication: X-SOGS-Signature is not base64[88]"
226236
)
227237

228238
try:
@@ -240,7 +250,7 @@ def handle_http_auth():
240250
http.TOO_EARLY, "Invalid authentication: X-SOGS-Timestamp is too far from current time"
241251
)
242252

243-
user = User(session_id=pk, autovivify=True, touch=False)
253+
user = User(session_id=session_id, autovivify=True, touch=False)
244254
if user.banned:
245255
# If the user is banned don't even bother verifying the signature because we want to reject
246256
# the request whether or not the signature validation passes.
@@ -251,33 +261,34 @@ def handle_http_auth():
251261
except sqlalchemy.exc.IntegrityError:
252262
abort_with_reason(http.TOO_EARLY, "Invalid authentication: X-SOGS-Nonce cannot be reused")
253263

254-
# Hash validation
255264

256-
# shared_key is hash of a*B || A || B = b*A || A || B where b/B is the server keypair and A is
257-
# the session id pubkey.
258-
shared_key = blake2b(
259-
crypto_scalarmult(crypto._privkey.encode(), A) + A + crypto.server_pubkey_bytes,
260-
digest_size=42,
261-
salt=nonce,
262-
person=b'sogs.shared_keys',
265+
# Signature validation
266+
267+
# Signature should be on:
268+
# SERVER_PUBKEY || NONCE || TIMESTAMP || METHOD || PATH || HBODY
269+
to_verify = (
270+
crypto.server_pubkey_bytes
271+
+ nonce
272+
+ ts_str.encode()
273+
+ request.method.encode()
274+
+ request.path.encode()
263275
)
264276

265-
parts = [request.method.encode() + request.path.encode()]
266277
# Work around flask deficiency: we can't use request.full_path above because it *adds* a `?`
267278
# even if there wasn't one in the original request. So work around it by only appending if
268279
# there is a query string and, officially, don't accept `?` followed by an empty query string in
269280
# the auth request data (if you have no query string then don't append the ?).
270281
if len(request.query_string):
271-
parts.append(b'?' + request.query_string)
272-
parts.append(ts_str.encode())
282+
to_verify = to_verify + b'?' + request.query_string
283+
273284
if len(request.data):
274-
parts.append(request.data)
285+
to_verify = to_verify + blake2b(request.data, digest_size=64)
275286

276-
if hash_in != blake2b(
277-
parts, digest_size=42, key=shared_key, salt=nonce, person=b'sogs.auth_header'
278-
):
287+
try:
288+
pk.verify(to_verify, sig_in)
289+
except BadSignatureError:
279290
abort_with_reason(
280-
http.UNAUTHORIZED, "Invalid authentication: X-SOGS-Hash authentication failed"
291+
http.UNAUTHORIZED, "Invalid authentication: X-SOGS-Signature verification failed"
281292
)
282293

283294
user.touch()

tests/auth.py

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
from nacl.public import PublicKey, PrivateKey
1+
from nacl.signing import VerifyKey, SigningKey
2+
from nacl.public import PublicKey
23
from typing import Optional
34
import time
4-
from hashlib import blake2b
5-
from nacl.bindings import crypto_scalarmult
5+
from sogs.hashing import blake2b, sha512
6+
import nacl.bindings as salt
67
from nacl.utils import random
78

89
import sogs.utils
@@ -14,64 +15,65 @@ def x_sogs_nonce():
1415

1516

1617
def x_sogs_raw(
17-
a: PrivateKey,
18-
A: PublicKey,
18+
s: SigningKey,
1919
B: PublicKey,
2020
method: str,
2121
full_path: str,
2222
body: Optional[bytes] = None,
23+
*,
2324
b64_nonce: bool = True,
24-
id_prefix: str = '05',
25+
blinded: bool = False,
2526
timestamp_off: int = 0,
2627
):
2728
"""
2829
Calculates X-SOGS-* headers.
2930
30-
Returns 4 elements: the headers dict, the nonce bytes, timestamp int, and hash bytes.
31+
Returns 4 elements: the headers dict, the nonce bytes, timestamp int, and signature bytes.
3132
32-
Use x_sogs(...) instead if you don't need the nonce/timestamp/hash values.
33+
Use x_sogs(...) instead if you don't need the nonce/timestamp/signature values.
3334
"""
3435
n = x_sogs_nonce()
3536
ts = int(time.time()) + timestamp_off
3637

37-
a_bytes, A_bytes, B_bytes = (x.encode() for x in (a, A, B))
38+
if blinded:
39+
a = s.to_curve25519_private_key().encode()
40+
k = salt.crypto_core_ed25519_scalar_reduce(blake2b(sogs.crypto.server_pubkey_bytes, digest_size=64))
41+
ka = salt.crypto_core_ed25519_scalar_mul(k, a)
42+
kA = salt.crypto_scalarmult_ed25519_base_noclamp(ka)
43+
pubkey = '15' + kA.hex()
44+
else:
45+
pubkey = '00' + s.verify_key.encode().hex()
46+
47+
48+
to_sign = [B.encode(), n, str(ts).encode(), method.encode(), full_path.encode()]
49+
if body:
50+
to_sign.append(blake2b(body, digest_size=64))
51+
52+
if blinded:
53+
H_rh = sha512(s.encode())[32:]
54+
r = salt.crypto_core_ed25519_scalar_reduce(sha512([H_rh, kA, *to_sign]))
55+
sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r)
56+
HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512([sig_R, kA, *to_sign]))
57+
sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka))
58+
sig = sig_R + sig_s
59+
60+
else:
61+
sig = s.sign(b''.join(to_sign)).signature
3862

3963
h = {
40-
'X-SOGS-Pubkey': id_prefix + A_bytes.hex(),
64+
'X-SOGS-Pubkey': pubkey,
4165
'X-SOGS-Nonce': sogs.utils.encode_base64(n) if b64_nonce else n.hex(),
4266
'X-SOGS-Timestamp': str(ts),
67+
'X-SOGS-Signature': sogs.utils.encode_base64(sig)
4368
}
4469

45-
# Deliberately using hashlib (rather than nacl) here to use an independent blake2b
46-
# implementation from the sogs code.
47-
shared_key = blake2b(
48-
crypto_scalarmult(a_bytes, B_bytes) + A_bytes + B_bytes,
49-
digest_size=42,
50-
salt=n,
51-
person=b'sogs.shared_keys',
52-
).digest()
53-
54-
hasher = blake2b(
55-
method.encode() + full_path.encode() + h['X-SOGS-Timestamp'].encode(),
56-
digest_size=42,
57-
key=shared_key,
58-
salt=n,
59-
person=b'sogs.auth_header',
60-
)
61-
if body is not None and len(body):
62-
hasher.update(body)
63-
hsh = hasher.digest()
64-
h['X-SOGS-Hash'] = sogs.utils.encode_base64(hsh)
65-
66-
return h, n, ts, hsh
70+
return h, n, ts, sig
6771

6872

6973
def x_sogs(*args, **kwargs):
7074
return x_sogs_raw(*args, **kwargs)[0]
7175

7276

7377
def x_sogs_for(user, *args, **kwargs):
74-
a = user.privkey
75-
A = a.public_key
7678
B = sogs.crypto.server_pubkey
77-
return x_sogs(a, A, B, *args, **kwargs, id_prefix=user.session_id[0:2])
79+
return x_sogs(user.ed_key, B, *args, blinded=user.is_blinded, **kwargs)

0 commit comments

Comments
 (0)