Skip to content

Commit 5485b3b

Browse files
jagermanmajestrate
authored andcommitted
Fix auth-example.py and api docs for new blinded sigs
1 parent 4d1b717 commit 5485b3b

File tree

2 files changed

+148
-65
lines changed

2 files changed

+148
-65
lines changed

api.yaml

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1826,37 +1826,56 @@ components:
18261826
type: apiKey
18271827
name: X-SOGS-Pubkey
18281828
in: header
1829-
description: "The Session ID of the requestor"
1829+
description: >
1830+
The Ed25519 public key of the request. For non-blinded requests this is the root session
1831+
Ed25519 pubkey with '00' prefixed; for blinded requests this begins with '15' and follows
1832+
the blinding procedure detailed elsewhere.
18301833
nonce:
18311834
type: apiKey
18321835
name: X-SOGS-Nonce
18331836
in: header
18341837
description: >
1835-
A unique nonce string, in base64, of exactly 16 base64 characters (96 bits). This must be
1836-
unique for every request from this pubkey within the last 24 hours; nonce reuse will result
1837-
in failed requests. It is typically sufficient to generate 96 bits (12 bytes) or random
1838-
data for each request, but clients are free to use other nonce generation mechanisms if
1839-
desired.
1838+
A unique, random nonce string of exactly 16 source bytes encoded in base64 (i.e. 24 base64
1839+
characters, including two trailing padding characters). This must be unique for every
1840+
request from this pubkey within the last 24 hours; nonce reuse will result in failed
1841+
requests.
18401842
timestamp:
18411843
type: apiKey
18421844
name: X-SOGS-Timestamp
18431845
in: header
18441846
description: >
1845-
Unix timestamp integer (expressed as a string) of the time when the request was initiated
1846-
(to help avoid replay attacks). This timestamp must be within ±24 hours of the server's
1847-
time when the request is received.
1847+
Unix timestamp integer (expressed as a string) of the time when the request was initiated to
1848+
help avoid replay attacks. This timestamp must be within ±24 hours of the server's time
1849+
when the request is received.
18481850
signature:
18491851
type: apiKey
18501852
name: X-SOGS-Signature
18511853
in: header
18521854
description: >
1853-
XEd25519 signature of
1855+
Ed25519 signature of
1856+
1857+
1858+
`SERVER_PUBKEY || NONCE || TIMESTAMP || METHOD || PATH || HBODY`
1859+
1860+
1861+
signed using the client's blinded or unblinded pubkey (from the `X-SOGS-Pubkey` header),
1862+
encoded using base64 (with or without padding).
1863+
1864+
1865+
SERVER_PUBKEY and NONCE are 32- and 16-byte values, respectively (i.e. the nonce here is the
1866+
*decoded* value of the X-SOGS-Nonce header).
1867+
1868+
1869+
TIMESTAMP is the timestamp expressed as a decimal string, encoded in ascii bytes.
1870+
1871+
1872+
METHOD is the ascii request method (`GET`, `POST`, etc.)
18541873
18551874
1856-
`METHOD || PATH || NONCE || TIMESTAMP || SERVER_PUBKEY || BODY`
1875+
PATH is in utf-8 encoded bytes.
18571876
18581877
1859-
signed using the client's Session ID pubkey, using base64 encoding (with or without
1860-
padding).
1878+
HBODY is an empty string (i.e. omitted from the signature) if the request has no body, or
1879+
has an empty body. Otherwise it must be a 64-byte BLAKE2b hash of the request body.
18611880
18621881
# vim:sw=2:et:tw=100

contrib/auth-example.py

Lines changed: 116 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,134 @@
11
# Example script for demonstrating X-SOGS-* authentication calculation.
22

3-
from nacl.bindings import crypto_scalarmult
3+
import nacl.bindings as salt
44
from nacl.public import PrivateKey, PublicKey
5-
from hashlib import blake2b
5+
from nacl.signing import SigningKey
6+
from hashlib import blake2b, sha512
67
from base64 import b64encode
8+
from typing import Optional
79

810
# import time
911
# import nacl.utils
1012

11-
# We're going to make a request for:
12-
method = 'GET'
13-
path = '/room/the-best-room/messages/recent?limit=25'
14-
# Should use current integer unix time:
15-
# ts = int(time.time())
16-
# But for this example I'll use this fixed value:
17-
ts = 1642472103
1813

19-
# Server pubkey:
20-
B = PublicKey(bytes.fromhex('c3b3c6f32f0ab5a57f853cc4f30f5da7fda5624b0c77b3fb0829de562ada081d'))
14+
def sha512_multipart(*message_parts):
15+
"""Given any number of arguments, returns the SHA512 hash of them concatenated together. This
16+
also does one level of flatting if any of the given parts are a list or tuple."""
17+
hasher = sha512()
18+
for m in message_parts:
19+
if isinstance(m, list) or isinstance(m, tuple):
20+
for mi in m:
21+
hasher.update(mi)
22+
else:
23+
hasher.update(m)
24+
return hasher.digest()
25+
26+
27+
def blinded_ed25519_signature(message_parts, s: SigningKey, ka: bytes, kA: bytes):
28+
"""
29+
Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with
30+
one tweak to the construction: we add kA into the hashed value that yields r so that we have
31+
domain separation for different blinded pubkeys. (This doesn't affect verification at all).
32+
"""
33+
H_rh = sha512(s.encode()).digest()[32:]
34+
r = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(H_rh, kA, message_parts))
35+
sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r)
36+
HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(sig_R, kA, message_parts))
37+
sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka))
38+
return sig_R + sig_s
39+
40+
41+
def get_signing_headers(
42+
s: SigningKey,
43+
server_pk: bytes,
44+
nonce: bytes,
45+
method: str,
46+
path: str,
47+
timestamp: int,
48+
body,
49+
blinded: bool = True,
50+
):
51+
52+
assert len(server_pk) == 32
53+
assert len(nonce) == 16
54+
55+
if blinded:
56+
# 64-byte blake2b hash then reduce to get the blinding factor:
57+
k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest())
58+
59+
# Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to
60+
# convert to an *x* secret key, which seems wrong--but isn't because converted keys use the
61+
# same secret scalar secret. (And so this is just the most convenient way to get 'a' out of
62+
# a sodium Ed25519 secret key).
63+
a = s.to_curve25519_private_key().encode()
64+
65+
# Our blinded keypair:
66+
ka = salt.crypto_core_ed25519_scalar_mul(k, a)
67+
kA = salt.crypto_scalarmult_ed25519_base_noclamp(ka)
68+
69+
# Blinded session id:
70+
pubkey = '15' + kA.hex()
71+
72+
else:
73+
# For unblinded auth we send our *ed25519* master pubkey in the X-SOGS-Pubkey header with a
74+
# '00' prefix to disambiguate it; the SOGS server will convert it to X25519 to derive our
75+
# X25519 session id.
76+
pubkey = '00' + s.verify_key.encode().hex()
2177

22-
# Don't worry, this isn't an actually used session private key. Also
23-
# note that this is the x25519 priv key, *not* the ed25519 priv key.
24-
a = PrivateKey(bytes.fromhex('881132ee03dbd2da065aa4c94f96081f62142dc8011d1b7a00de83e4aab38ce4'))
25-
A = a.public_key
78+
# We need to sign:
79+
# SERVER_PUBKEY || NONCE || TIMESTAMP || METHOD || PATH || HBODY
80+
# with our blinded key (if blinding) or our Ed25519 master key (if not blinding).
81+
to_sign = [server_pk, nonce, str(ts).encode(), method.encode(), path.encode()]
2682

27-
# 057aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d:
28-
session_id = '05' + A.encode().hex()
83+
# HBODY may be omitted if the body is empty (e.g. for GET requests), and otherwise will be the
84+
# 64-byte BLAKE2b hash of the request body:
85+
if body is not None:
86+
to_sign.append(blake2b(body, digest_size=64).digest())
2987

30-
# We should do something like this here:
88+
if blinded:
89+
sig = blinded_ed25519_signature(to_sign, s, ka, kA)
90+
else:
91+
sig = s.sign(b''.join(to_sign)).signature
92+
93+
return {
94+
'X-SOGS-Pubkey': pubkey,
95+
'X-SOGS-Timestamp': str(ts),
96+
'X-SOGS-Nonce': b64encode(nonce).decode(),
97+
'X-SOGS-Signature': b64encode(sig).decode(),
98+
}
99+
100+
101+
# Session "master" ed25519 key:
102+
s = SigningKey(bytes.fromhex('c010d89eccbaf5d1c6d19df766c6eedf965d4a28a56f87c9fc819edb59896dd9'))
103+
# Server pubkey:
104+
B = bytes.fromhex('c3b3c6f32f0ab5a57f853cc4f30f5da7fda5624b0c77b3fb0829de562ada081d')
105+
# Random 16-byte nonce
31106
# nonce = nacl.utils.random(16)
32-
# but for this example I'll use this random nonce:
33-
nonce = b'\t\xd0y\x9f"\x95\x99\x01\x82\xc3\xab4\x06\xfb\xfc['
34-
35-
# Shared key calculation:
36-
q = crypto_scalarmult(a.encode(), B.encode()) + A.encode() + B.encode()
37-
r = blake2b(q, digest_size=42, salt=nonce, person=b'sogs.shared_keys').digest()
38-
39-
# Final hash calculation; start without the body:
40-
hasher = blake2b(
41-
method.encode() + path.encode() + str(ts).encode(),
42-
digest_size=42,
43-
key=r,
44-
salt=nonce,
45-
person=b'sogs.auth_header',
46-
)
47-
48-
# Now add the body to the hash, if applicable. For this GET request
49-
# there is no body, so this update does nothing. For a POST request this
50-
# this would be the body bytes. (By using a separate update call I avoid
51-
# having to copy the body again, which is good if the body is large).
52-
hasher.update(b'')
53-
54-
h = hasher.digest()
55-
56-
headers = {
57-
'X-SOGS-Pubkey': session_id,
58-
'X-SOGS-Timestamp': str(ts),
59-
'X-SOGS-Nonce': b64encode(nonce).decode(),
60-
'X-SOGS-Hash': b64encode(h).decode(),
61-
}
62-
63-
for h, v in headers.items():
107+
nonce = bytes.fromhex('09d0799f2295990182c3ab3406fbfc5b') # fixed for reproducible example
108+
# ts = int(time.time())
109+
ts = 1642472103 # for reproducible example
110+
method, path = 'GET', '/room/the-best-room/messages/recent?limit=25'
111+
112+
print("Unblinded headers:")
113+
sig_headers = get_signing_headers(s, B, nonce, method, path, ts, body=None, blinded=False)
114+
for h, v in sig_headers.items():
64115
print(f"{h}: {v}")
65116

117+
print("\nBlinded headers:")
118+
sig_headers = get_signing_headers(s, B, nonce, method, path, ts, body=None, blinded=True)
119+
for h, v in sig_headers.items():
120+
print(f"{h}: {v}")
121+
122+
66123
# Prints:
67-
# X-SOGS-Pubkey: 057aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d
124+
# Unblinded headers:
125+
# X-SOGS-Pubkey: 00bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc
126+
# X-SOGS-Timestamp: 1642472103
127+
# X-SOGS-Nonce: CdB5nyKVmQGCw6s0Bvv8Ww==
128+
# X-SOGS-Signature: xxLpXHbomAJMB9AtGMyqvBsXrdd2040y+Ol/IKzElWfKJa3EYZRv1GLO6CTLhrDFUwVQe8PPltyGs54Kd7O5Cg==
129+
#
130+
# Blinded headers:
131+
# X-SOGS-Pubkey: 1598932d4bccbe595a8789d7eb1629cefc483a0eaddc7e20e8fe5c771efafd9af5
68132
# X-SOGS-Timestamp: 1642472103
69133
# X-SOGS-Nonce: CdB5nyKVmQGCw6s0Bvv8Ww==
70-
# X-SOGS-Hash: 0wToLPfUpUSGHGT8n9VIJev5SJ97hUvQTRqBowpnWTqfGb+ldTRa9mU1
134+
# X-SOGS-Signature: n4HK33v7gkcz/3pZuWvzmOlY+AbzbpEN1K12dtCc8Gw0m4iP5gUddGKKLEbmoWNhqJeY2S81Lm9uK2DBBN8aCg==

0 commit comments

Comments
 (0)