Skip to content

Commit 4f2dd8b

Browse files
committed
refactor: take the blinded signing from libsession rather than porting it
session_util is libsession-util's Python binding. It covers the blinded key pair, the blinded signature and the X25519 to Ed25519 map, which removes the hand-rolled Ed25519 nonce construction and the field arithmetic behind it. It ships as a deb rather than a wheel, so pip cannot reach it and a virtualenv needs --system-site-packages. ban.py is run by hand from a checkout, so this reaches no deployed job. pynacl stays for the blinding factor, the two candidate blinded ids, and the curve check. xed25519.pubkey maps any 32 bytes to a point-shaped result without judging it, so that check is what rejects a Session ID mistyped out of a ticket and it now has its own tests. The blinded signature is no longer checked against a fixed vector: it is not deterministic across implementations, because the nonce derivation is not part of what a verifier checks. It is verified under the blinded pubkey instead, which is what pysogs does with it.
1 parent a751307 commit 4f2dd8b

4 files changed

Lines changed: 104 additions & 53 deletions

File tree

‎README.md‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -622,13 +622,34 @@ yet. Deleting messages is the step that needs the blinded form — see
622622
### Tests
623623

624624
```sh
625+
sudo apt install python3-session-util # see "Dependencies" below
625626
pip install -r sogs_moderation/requirements.txt
626627
python -m unittest discover -s sogs_moderation -v
627628
```
628629

629-
The signing tests check our request signatures against the vectors pysogs publishes in
630-
its own `contrib/auth-example.py`, so a broken port of that construction fails here
631-
rather than at the server.
630+
The blinded signature is checked by verifying it under the blinded pubkey rather than
631+
against a fixed vector. A blinded signature is not deterministic across implementations,
632+
because the nonce derivation is not part of what a verifier checks, and pysogs accepts it
633+
as a plain Ed25519 signature under that pubkey. The unblinded signature is deterministic
634+
and is still checked against pysogs' published vector.
635+
636+
### Dependencies
637+
638+
The blinded request signatures come from `session_util`, libsession-util's Python
639+
binding. It is published as a deb rather than a wheel, so `pip` cannot reach it:
640+
641+
```sh
642+
# https://deb.oxen.io has the repository setup
643+
sudo apt install python3-session-util
644+
```
645+
646+
It is a compiled extension built per Python minor version, so a Python upgrade needs a
647+
matching package, and a virtualenv needs `--system-site-packages` to see it. This is why
648+
`ban.py` is run from a checkout by hand rather than deployed anywhere.
649+
650+
pynacl stays for the blinding factor and the two candidate blinded ids, which the binding
651+
does not expose. [session-foundation/libsession-python#2](https://github.com/session-foundation/libsession-python/pull/2)
652+
adds `blind15_id`; until a release carries it, that derivation is ours.
632653

633654
## Workflow Failure Notificaiton
634655

‎sogs_moderation/ban.py‎

Lines changed: 26 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@
6161
Which of the two blinded ids an account is stored under is not derivable (the sign is
6262
lost in blinding), so the deletion tries each in turn and the first non-404 answers.
6363
64+
## Dependencies
65+
66+
The blinded request signatures come from session_util, libsession-util's Python binding,
67+
which ships as a deb rather than a wheel: `apt install python3-session-util` from
68+
https://deb.oxen.io. It is compiled per Python minor version, so a virtualenv needs
69+
--system-site-packages. pynacl stays for the blinding factor and the two candidate
70+
blinded ids, which the binding does not expose.
71+
6472
## Configuration
6573
6674
The server is SOGS_URL/SOGS_PUBKEY below, fixed in the script. This bans people from
@@ -98,21 +106,29 @@
98106
import sys
99107
import time
100108
from base64 import b64encode
101-
from hashlib import blake2b, sha512
109+
from hashlib import blake2b
102110

103111
import nacl.bindings as sodium
104112
import requests
105113
from nacl.signing import SigningKey
106114

115+
try:
116+
from session_util import blinding, xed25519
117+
except ImportError as e: # pragma: no cover - environment, not logic
118+
raise SystemExit(
119+
"session_util is missing. It is libsession-util's Python binding, and it is not on "
120+
"PyPI — install it from the Session apt repository:\n"
121+
" https://deb.oxen.io\n"
122+
" sudo apt install python3-session-util\n"
123+
"A virtualenv needs --system-site-packages to see it."
124+
) from e
125+
107126
SESSION_ID_RE = re.compile(r'\A05[0-9a-fA-F]{64}\Z')
108127

109128
# The server we moderate. Not configurable on purpose — see "Configuration" above.
110129
SOGS_URL = 'https://open.getsession.org'
111130
SOGS_PUBKEY = 'a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238'
112131

113-
# Curve25519 field order, for the u -> y birational map in ed25519_pubkey().
114-
FIELD_P = 2**255 - 19
115-
116132
HTTP_TIMEOUT = 30
117133

118134

@@ -137,42 +153,13 @@ def __init__(self, sid, outcome, total_steps):
137153
f"({label} -> {code}: {str(body)[:200]})")
138154

139155

140-
def sha512_parts(*parts):
141-
hasher = sha512()
142-
for part in parts:
143-
if isinstance(part, (list, tuple)):
144-
for p in part:
145-
hasher.update(p)
146-
else:
147-
hasher.update(part)
148-
return hasher.digest()
149-
150-
151156
def blinding_factor(server_pubkey: bytes) -> bytes:
152157
return sodium.crypto_core_ed25519_scalar_reduce(blake2b(server_pubkey, digest_size=64).digest())
153158

154159

155-
def blinded_keys(server_pubkey: bytes, signing_key: SigningKey):
156-
"""Returns (ka, kA): our blinded private scalar and blinded pubkey for this server."""
157-
k = blinding_factor(server_pubkey)
158-
# to_curve25519_private_key() is the sodium-supported way to get 'a', the Ed25519
159-
# private scalar: the converted X25519 key is that same scalar.
160-
a = signing_key.to_curve25519_private_key().encode()
161-
ka = sodium.crypto_core_ed25519_scalar_mul(k, a)
162-
return ka, sodium.crypto_scalarmult_ed25519_base_noclamp(ka)
163-
164-
165-
def blinded_signature(message_parts, signing_key: SigningKey, ka: bytes, kA: bytes) -> bytes:
166-
"""
167-
Ed25519 signature under the blinded key, with kA mixed into the hash that yields r
168-
so that different blinded pubkeys are domain separated. Verification is unaffected.
169-
"""
170-
h_rh = sha512(signing_key.encode()).digest()[32:]
171-
r = sodium.crypto_core_ed25519_scalar_reduce(sha512_parts(h_rh, kA, message_parts))
172-
sig_r = sodium.crypto_scalarmult_ed25519_base_noclamp(r)
173-
hram = sodium.crypto_core_ed25519_scalar_reduce(sha512_parts(sig_r, kA, message_parts))
174-
sig_s = sodium.crypto_core_ed25519_scalar_add(r, sodium.crypto_core_ed25519_scalar_mul(hram, ka))
175-
return sig_r + sig_s
160+
def blinded_pubkey(server_pubkey: bytes, signing_key: SigningKey) -> bytes:
161+
"""Our blinded (15) pubkey for this server: the id the server knows us by."""
162+
return blinding.blind15_key_pair(signing_key.encode(), server_pubkey).pubkey
176163

177164

178165
def auth_headers(signing_key, server_pubkey, method, path, timestamp, nonce, body, blinded):
@@ -188,9 +175,8 @@ def auth_headers(signing_key, server_pubkey, method, path, timestamp, nonce, bod
188175
to_sign.append(blake2b(body, digest_size=64).digest())
189176

190177
if blinded:
191-
ka, kA = blinded_keys(server_pubkey, signing_key)
192-
pubkey = '15' + kA.hex()
193-
sig = blinded_signature(to_sign, signing_key, ka, kA)
178+
pubkey = '15' + blinded_pubkey(server_pubkey, signing_key).hex()
179+
sig = blinding.blind15_sign(signing_key.encode(), server_pubkey, b''.join(to_sign))
194180
else:
195181
pubkey = '00' + signing_key.verify_key.encode().hex()
196182
sig = signing_key.sign(b''.join(to_sign)).signature
@@ -215,8 +201,7 @@ def ed25519_pubkey(session_id: str) -> bytes:
215201
in blinded_ids() fails on a point that is not on the curve, and a Session ID
216202
mistyped out of a ticket is exactly how that happens.
217203
"""
218-
u = int.from_bytes(bytes.fromhex(session_id[2:]), 'little')
219-
y = ((u - 1) * pow(u + 1, FIELD_P - 2, FIELD_P) % FIELD_P).to_bytes(32, 'little')
204+
y = xed25519.pubkey(bytes.fromhex(session_id[2:]))
220205
if not sodium.crypto_core_ed25519_is_valid_point(y):
221206
raise SogsError(f"{session_id} is not a usable Session ID: not a valid public key")
222207
return y

‎sogs_moderation/requirements.txt‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1-
# What ban.py needs. The suite is stdlib unittest and imports nothing else.
1+
# What ban.py needs from PyPI. The suite is stdlib unittest and imports nothing else.
22
requests==2.32.3
33

4-
# Ed25519 signing and the scalar arithmetic the blinded request signatures are built
5-
# from (nacl.bindings). pysogs verifies those signatures with the same primitives.
4+
# The blinding factor and the two candidate blinded ids are still derived here: the
5+
# scalar arithmetic for those is not exposed by session_util yet.
66
pynacl==1.6.2
7+
8+
# NOT INSTALLABLE FROM HERE. The blinded request signatures come from session_util,
9+
# libsession-util's Python binding, which is published as a deb rather than a wheel:
10+
#
11+
# https://deb.oxen.io -> sudo apt install python3-session-util
12+
#
13+
# It is a compiled extension built per Python minor version, so a virtualenv needs
14+
# --system-site-packages to see it.

‎sogs_moderation/test_ban.py‎

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99
import contextlib
1010
import io
1111
import unittest
12+
from base64 import b64decode
13+
from hashlib import blake2b
1214
from unittest import mock
1315

14-
from nacl.signing import SigningKey
16+
from nacl.exceptions import BadSignatureError
17+
from nacl.signing import SigningKey, VerifyKey
1518

1619
import ban
1720

@@ -26,6 +29,15 @@
2629
BLINDED_NEG = '1598932d4bccbe595a8789d7eb1629cefc483a0eaddc7e20e8fe5c771efafd9af5'
2730

2831

32+
def signed_bytes(body=None):
33+
"""The exact bytes auth_headers signs, so a signature can be verified here."""
34+
parts = [bytes.fromhex(SERVER_PUBKEY), bytes.fromhex(NONCE),
35+
str(TIMESTAMP).encode(), b'GET', PATH.encode()]
36+
if body:
37+
parts.append(blake2b(body, digest_size=64).digest())
38+
return b''.join(parts)
39+
40+
2941
def headers(blinded, body=None):
3042
return ban.auth_headers(
3143
SigningKey(bytes.fromhex(SEED)),
@@ -51,13 +63,24 @@ def test_unblinded(self):
5163
)
5264

5365
def test_blinded(self):
66+
"""Checked by verification rather than against a fixed vector: a blinded signature
67+
is not deterministic across implementations, because the nonce derivation is not
68+
part of what a verifier checks. pysogs verifies it as a plain Ed25519 signature
69+
under the blinded pubkey, so that is the property worth asserting."""
5470
h = headers(blinded=True)
5571
self.assertEqual(h['X-SOGS-Pubkey'], BLINDED_NEG)
56-
self.assertEqual(
57-
h['X-SOGS-Signature'],
58-
'gYqpWZX6fnF4Gb2xQM3xaXs0WIYEI49+B8q4mUUEg8Rw0ObaHUWfoWjMHMArAtP9QlORfiydsKWz1o6zdPVeCQ==',
72+
VerifyKey(bytes.fromhex(BLINDED_NEG[2:])).verify(
73+
signed_bytes(), b64decode(h['X-SOGS-Signature'])
5974
)
6075

76+
def test_blinded_signature_is_rejected_under_the_wrong_key(self):
77+
"""Guards the check above: verify() must be capable of failing here."""
78+
h = headers(blinded=True)
79+
with self.assertRaises(BadSignatureError):
80+
VerifyKey(bytes.fromhex(BLINDED_ABS[2:])).verify(
81+
signed_bytes(), b64decode(h['X-SOGS-Signature'])
82+
)
83+
6184
def test_body_changes_the_signature(self):
6285
self.assertNotEqual(
6386
headers(blinded=True)['X-SOGS-Signature'],
@@ -74,9 +97,23 @@ def test_both_blinded_variants(self):
7497
ban.blinded_ids(SESSION_ID, bytes.fromhex(SERVER_PUBKEY)), (BLINDED_ABS, BLINDED_NEG)
7598
)
7699

100+
def test_hex_that_is_not_a_key_is_refused(self):
101+
"""xed25519.pubkey maps any 32 bytes to a point-shaped result without judging it,
102+
so the curve check is the whole defence against a Session ID mistyped out of a
103+
ticket. Both of these are the right shape and neither is a key."""
104+
for bad in ('ff' * 32, '00' * 32):
105+
with self.assertRaises(ban.SogsError):
106+
ban.ed25519_pubkey('05' + bad)
107+
108+
def test_a_real_session_id_survives_that_check(self):
109+
"""Guards the test above: the check must not simply reject everything."""
110+
self.assertEqual(len(ban.ed25519_pubkey(SESSION_ID)), 32)
111+
77112
def test_the_signing_key_blinds_to_one_of_them(self):
78-
"""The key's own blinded pubkey must be one of the two we would look it up under."""
79-
_, kA = ban.blinded_keys(bytes.fromhex(SERVER_PUBKEY), SigningKey(bytes.fromhex(SEED)))
113+
"""Ties the two together: libsession derives our own blinded pubkey, blinded_ids
114+
derives the candidates for an account whose key we do not hold, and the first must
115+
be one of the second or the two disagree about what this server calls us."""
116+
kA = ban.blinded_pubkey(bytes.fromhex(SERVER_PUBKEY), SigningKey(bytes.fromhex(SEED)))
80117
self.assertIn('15' + kA.hex(), ban.blinded_ids(SESSION_ID, bytes.fromhex(SERVER_PUBKEY)))
81118

82119

0 commit comments

Comments
 (0)