|
1 | 1 | # Example script for demonstrating X-SOGS-* authentication calculation.
|
2 | 2 |
|
3 |
| -from nacl.bindings import crypto_scalarmult |
4 |
| -from nacl.public import PrivateKey, PublicKey |
5 |
| -from hashlib import blake2b |
| 3 | +import nacl.bindings as salt |
| 4 | +from nacl.signing import SigningKey |
| 5 | +from hashlib import blake2b, sha512 |
6 | 6 | from base64 import b64encode
|
7 | 7 |
|
8 | 8 | # import time
|
9 | 9 | # import nacl.utils
|
10 | 10 |
|
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 |
18 | 11 |
|
19 |
| -# Server pubkey: |
20 |
| -B = PublicKey(bytes.fromhex('c3b3c6f32f0ab5a57f853cc4f30f5da7fda5624b0c77b3fb0829de562ada081d')) |
| 12 | +def sha512_multipart(*message_parts): |
| 13 | + """Given any number of arguments, returns the SHA512 hash of them concatenated together. This |
| 14 | + also does one level of flatting if any of the given parts are a list or tuple.""" |
| 15 | + hasher = sha512() |
| 16 | + for m in message_parts: |
| 17 | + if isinstance(m, list) or isinstance(m, tuple): |
| 18 | + for mi in m: |
| 19 | + hasher.update(mi) |
| 20 | + else: |
| 21 | + hasher.update(m) |
| 22 | + return hasher.digest() |
| 23 | + |
| 24 | + |
| 25 | +def blinded_ed25519_signature(message_parts, s: SigningKey, ka: bytes, kA: bytes): |
| 26 | + """ |
| 27 | + Constructs an Ed25519 signature from a root Ed25519 key and a blinded scalar/pubkey pair, with |
| 28 | + one tweak to the construction: we add kA into the hashed value that yields r so that we have |
| 29 | + domain separation for different blinded pubkeys. (This doesn't affect verification at all). |
| 30 | + """ |
| 31 | + H_rh = sha512(s.encode()).digest()[32:] |
| 32 | + r = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(H_rh, kA, message_parts)) |
| 33 | + sig_R = salt.crypto_scalarmult_ed25519_base_noclamp(r) |
| 34 | + HRAM = salt.crypto_core_ed25519_scalar_reduce(sha512_multipart(sig_R, kA, message_parts)) |
| 35 | + sig_s = salt.crypto_core_ed25519_scalar_add(r, salt.crypto_core_ed25519_scalar_mul(HRAM, ka)) |
| 36 | + return sig_R + sig_s |
| 37 | + |
| 38 | + |
| 39 | +def get_signing_headers( |
| 40 | + s: SigningKey, |
| 41 | + server_pk: bytes, |
| 42 | + nonce: bytes, |
| 43 | + method: str, |
| 44 | + path: str, |
| 45 | + timestamp: int, |
| 46 | + body, |
| 47 | + blinded: bool = True, |
| 48 | +): |
| 49 | + |
| 50 | + assert len(server_pk) == 32 |
| 51 | + assert len(nonce) == 16 |
| 52 | + |
| 53 | + if blinded: |
| 54 | + # 64-byte blake2b hash then reduce to get the blinding factor: |
| 55 | + k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest()) |
| 56 | + |
| 57 | + # Calculate k*a. To get 'a' (the Ed25519 private key scalar) we call the sodium function to |
| 58 | + # convert to an *x* secret key, which seems wrong--but isn't because converted keys use the |
| 59 | + # same secret scalar secret. (And so this is just the most convenient way to get 'a' out of |
| 60 | + # a sodium Ed25519 secret key). |
| 61 | + a = s.to_curve25519_private_key().encode() |
| 62 | + |
| 63 | + # Our blinded keypair: |
| 64 | + ka = salt.crypto_core_ed25519_scalar_mul(k, a) |
| 65 | + kA = salt.crypto_scalarmult_ed25519_base_noclamp(ka) |
| 66 | + |
| 67 | + # Blinded session id: |
| 68 | + pubkey = '15' + kA.hex() |
| 69 | + |
| 70 | + else: |
| 71 | + # For unblinded auth we send our *ed25519* master pubkey in the X-SOGS-Pubkey header with a |
| 72 | + # '00' prefix to disambiguate it; the SOGS server will convert it to X25519 to derive our |
| 73 | + # X25519 session id. |
| 74 | + pubkey = '00' + s.verify_key.encode().hex() |
21 | 75 |
|
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 |
| 76 | + # We need to sign: |
| 77 | + # SERVER_PUBKEY || NONCE || TIMESTAMP || METHOD || PATH || HBODY |
| 78 | + # with our blinded key (if blinding) or our Ed25519 master key (if not blinding). |
| 79 | + to_sign = [server_pk, nonce, str(ts).encode(), method.encode(), path.encode()] |
26 | 80 |
|
27 |
| -# 057aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d: |
28 |
| -session_id = '05' + A.encode().hex() |
| 81 | + # HBODY may be omitted if the body is empty (e.g. for GET requests), and otherwise will be the |
| 82 | + # 64-byte BLAKE2b hash of the request body: |
| 83 | + if body is not None: |
| 84 | + to_sign.append(blake2b(body, digest_size=64).digest()) |
29 | 85 |
|
30 |
| -# We should do something like this here: |
| 86 | + if blinded: |
| 87 | + sig = blinded_ed25519_signature(to_sign, s, ka, kA) |
| 88 | + else: |
| 89 | + sig = s.sign(b''.join(to_sign)).signature |
| 90 | + |
| 91 | + return { |
| 92 | + 'X-SOGS-Pubkey': pubkey, |
| 93 | + 'X-SOGS-Timestamp': str(ts), |
| 94 | + 'X-SOGS-Nonce': b64encode(nonce).decode(), |
| 95 | + 'X-SOGS-Signature': b64encode(sig).decode(), |
| 96 | + } |
| 97 | + |
| 98 | + |
| 99 | +# Session "master" ed25519 key: |
| 100 | +s = SigningKey(bytes.fromhex('c010d89eccbaf5d1c6d19df766c6eedf965d4a28a56f87c9fc819edb59896dd9')) |
| 101 | +# Server pubkey: |
| 102 | +B = bytes.fromhex('c3b3c6f32f0ab5a57f853cc4f30f5da7fda5624b0c77b3fb0829de562ada081d') |
| 103 | +# Random 16-byte nonce |
31 | 104 | # 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(): |
| 105 | +nonce = bytes.fromhex('09d0799f2295990182c3ab3406fbfc5b') # fixed for reproducible example |
| 106 | +# ts = int(time.time()) |
| 107 | +ts = 1642472103 # for reproducible example |
| 108 | +method, path = 'GET', '/room/the-best-room/messages/recent?limit=25' |
| 109 | + |
| 110 | +print("Unblinded headers:") |
| 111 | +sig_headers = get_signing_headers(s, B, nonce, method, path, ts, body=None, blinded=False) |
| 112 | +for h, v in sig_headers.items(): |
64 | 113 | print(f"{h}: {v}")
|
65 | 114 |
|
| 115 | +print("\nBlinded headers:") |
| 116 | +sig_headers = get_signing_headers(s, B, nonce, method, path, ts, body=None, blinded=True) |
| 117 | +for h, v in sig_headers.items(): |
| 118 | + print(f"{h}: {v}") |
| 119 | + |
| 120 | + |
66 | 121 | # Prints:
|
67 |
| -# X-SOGS-Pubkey: 057aecdcade88d881d2327ab011afd2e04c2ec6acffc9e9df45aaf78a151bd2f7d |
| 122 | +# Unblinded headers: |
| 123 | +# X-SOGS-Pubkey: 00bac6e71efd7dfa4a83c98ed24f254ab2c267f9ccdb172a5280a0444ad24e89cc |
| 124 | +# X-SOGS-Timestamp: 1642472103 |
| 125 | +# X-SOGS-Nonce: CdB5nyKVmQGCw6s0Bvv8Ww== |
| 126 | +# X-SOGS-Signature: xxLpXHbomAJMB9AtGMyqvBsXrdd2040y+Ol/IKzElWfKJa3EYZRv1GLO6CTLhrDFUwVQe8PPltyGs54Kd7O5Cg== # noqa E501 |
| 127 | +# |
| 128 | +# Blinded headers: |
| 129 | +# X-SOGS-Pubkey: 1598932d4bccbe595a8789d7eb1629cefc483a0eaddc7e20e8fe5c771efafd9af5 |
68 | 130 | # X-SOGS-Timestamp: 1642472103
|
69 | 131 | # X-SOGS-Nonce: CdB5nyKVmQGCw6s0Bvv8Ww==
|
70 |
| -# X-SOGS-Hash: 0wToLPfUpUSGHGT8n9VIJev5SJ97hUvQTRqBowpnWTqfGb+ldTRa9mU1 |
| 132 | +# X-SOGS-Signature: n4HK33v7gkcz/3pZuWvzmOlY+AbzbpEN1K12dtCc8Gw0m4iP5gUddGKKLEbmoWNhqJeY2S81Lm9uK2DBBN8aCg== # noqa E501 |
0 commit comments