7
7
from flask import request , abort , Response , g
8
8
import string
9
9
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
11
13
import sqlalchemy .exc
12
14
from functools import wraps
13
15
16
18
# We handle authentication through 4 headers included in the outermost request (e.g. which typically
17
19
# means the onion request):
18
20
#
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.
21
24
#
22
25
# X-SOGS-Nonce -- a unique 128-bit (16 byte) request nonce, encoded in either base64 (22 chars (or
23
26
# 24 with optional padding)) or hex (32 characters). This nonce may not be reused with this pubkey
26
29
# X-SOGS-Timestamp -- unix integer timestamp, expressed in the usual human (base 10) notation. The
27
30
# timestamp must be with ±24 hours of the SOGS server time when the request is received.
28
31
#
29
- # X-SOGS-Hash -- base64 encoding of the keyed hash of:
32
+ # X-SOGS-Signature -- Ed25519 signature (passed in base64 encoding) of:
30
33
#
31
- # METHOD || PATH || TIMESTAMP || BODY
34
+ # SERVER_PUBKEY || NONCE || TIMESTAMP || METHOD || PATH || HBODY
32
35
#
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.
35
38
#
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.
52
40
#
53
41
# For example, for a GET request to '/capabilities?required=sogs' to a server with pubkey
54
42
# fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 the request headers could be:
55
43
#
56
- # X-SOGS-Pubkey: 050123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
44
+ # X-SOGS-Pubkey: 150123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
57
45
# X-SOGS-Nonce: IYUVSYbLlTgmnigr/H3Tdg==
58
46
# 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: ...
62
48
#
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:
66
51
#
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)
68
57
#
69
58
# Or for a onion POST request with a body:
70
59
#
76
65
# "X-SOGS-Pubkey": "050123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
77
66
# "X-SOGS-Nonce": "5f9369b79449a7dfa07d123b697c84f6", # random, hex encoded
78
67
# "X-SOGS-Timestamp": "1642080374",
79
- # "X-SOGS-Hash ": "...",
68
+ # "X-SOGS-Signature ": "...",
80
69
# }}
81
70
#
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:
87
73
#
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)
90
79
#
91
80
# For batch requests the X-SOGS-* headers are applied once, on the outermost batch request, *not* on
92
81
# the individual subrequests; the authorization applies to all subrequests.
93
82
#
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 ).
97
86
98
87
99
88
def abort_with_reason (code , msg , warn = True ):
@@ -187,11 +176,11 @@ def handle_http_auth():
187
176
188
177
g .user_reauth = False
189
178
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 ' )
192
181
)
193
182
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 ))
195
184
# If all are missing then we have no user
196
185
if missing == 4 :
197
186
g .user = None
@@ -204,11 +193,32 @@ def handle_http_auth():
204
193
)
205
194
206
195
# 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 :
208
200
abort_with_reason (
209
201
http .BAD_REQUEST , "Invalid authentication: X-SOGS-Pubkey is not a valid 66-hex digit id"
210
202
)
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
+
212
222
213
223
try :
214
224
nonce = utils .decode_hex_or_b64 (nonce , 16 )
@@ -219,10 +229,10 @@ def handle_http_auth():
219
229
)
220
230
221
231
try :
222
- hash_in = utils .decode_hex_or_b64 (hash_in , 42 )
232
+ sig_in = utils .decode_hex_or_b64 (sig_in , 64 )
223
233
except Exception :
224
234
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 ]"
226
236
)
227
237
228
238
try :
@@ -240,7 +250,7 @@ def handle_http_auth():
240
250
http .TOO_EARLY , "Invalid authentication: X-SOGS-Timestamp is too far from current time"
241
251
)
242
252
243
- user = User (session_id = pk , autovivify = True , touch = False )
253
+ user = User (session_id = session_id , autovivify = True , touch = False )
244
254
if user .banned :
245
255
# If the user is banned don't even bother verifying the signature because we want to reject
246
256
# the request whether or not the signature validation passes.
@@ -251,33 +261,34 @@ def handle_http_auth():
251
261
except sqlalchemy .exc .IntegrityError :
252
262
abort_with_reason (http .TOO_EARLY , "Invalid authentication: X-SOGS-Nonce cannot be reused" )
253
263
254
- # Hash validation
255
264
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 ()
263
275
)
264
276
265
- parts = [request .method .encode () + request .path .encode ()]
266
277
# Work around flask deficiency: we can't use request.full_path above because it *adds* a `?`
267
278
# even if there wasn't one in the original request. So work around it by only appending if
268
279
# there is a query string and, officially, don't accept `?` followed by an empty query string in
269
280
# the auth request data (if you have no query string then don't append the ?).
270
281
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
+
273
284
if len (request .data ):
274
- parts . append (request .data )
285
+ to_verify = to_verify + blake2b (request .data , digest_size = 64 )
275
286
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 :
279
290
abort_with_reason (
280
- http .UNAUTHORIZED , "Invalid authentication: X-SOGS-Hash authentication failed"
291
+ http .UNAUTHORIZED , "Invalid authentication: X-SOGS-Signature verification failed"
281
292
)
282
293
283
294
user .touch ()
0 commit comments