feat(srp): pure-Python server Verifier so Termux can HOST a room

The srp package's C-extension has no aarch64/bionic wheel, so a phone in
Termux couldn't run the room server. Add the server side of SRP-6a
(create_salted_verification_key + Verifier) to the pure-Python shim and
fall srp_auth.py back to it on ImportError.

Proven live: a laptop (C-ext srp client) authenticated against a room
HOSTED ON THE PHONE (pure Verifier) over Tailscale — cross-implementation
handshake with matching session keys. Locked in with interop tests
covering real-client-vs-pure-Verifier, pure-vs-pure, and a wrong-password
negative control.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-07-07 21:39:17 -07:00
parent 1c36325d07
commit 7bd972f39c
3 changed files with 203 additions and 6 deletions
+120 -5
View File
@@ -5,7 +5,7 @@ often fails to build under Termux/Android. This module reproduces the *exact*
client-side derivation of that package's pure-Python reference (``srp._pysrp``)
with ``rfc5054_enable()`` active, using only the standard library.
Only the surface ``cmd_chat/client/client.py`` consumes is provided:
The client surface ``cmd_chat/client/client.py`` consumes:
rfc5054_enable() # module-level toggle
SHA256 # hash-alg constant
@@ -15,10 +15,19 @@ Only the surface ``cmd_chat/client/client.py`` consumes is provided:
usr.verify_session(H_AMK) # sets authenticated flag
usr.authenticated() # -> bool
The server uses the real ``srp`` library (see cmd_chat/server/srp_auth.py), so
every constant, padding rule, and hash input here mirrors ``srp._pysrp`` byte
for byte — otherwise the M1 / H_AMK proofs would not match and auth would fail.
Correctness is gated by tests/test_srp_pure_interop.py.
The server surface ``cmd_chat/server/srp_auth.py`` consumes (so a phone/Termux
box with no ``srp`` C-ext can *host* a room, not only join one):
salt, vkey = create_salted_verification_key(b"chat", pw, hash_alg=SHA256)
svr = Verifier(b"chat", salt, vkey, A, hash_alg=SHA256)
s, B = svr.get_challenge() # -> (salt, B_bytes)
H_AMK = svr.verify_session(M) # -> H_AMK or None
K = svr.get_session_key()
Both sides mirror ``srp._pysrp`` byte for byte — every constant, padding rule,
and hash input — otherwise the M1 / H_AMK proofs would not match the real
library and auth would fail. Correctness is gated by
tests/test_srp_pure_interop.py (pure↔real in both directions).
"""
import hashlib
@@ -334,3 +343,109 @@ class User:
def verify_session(self, host_HAMK) -> None:
if self.H_AMK == host_HAMK:
self._authenticated = True
# ── server side ──────────────────────────────────────────────────────────────
def create_salted_verification_key(
username,
password,
hash_alg=SHA1,
ng_type=NG_2048,
n_hex=None,
g_hex=None,
salt_len=4,
):
"""Return ``(salt, vkey)`` for a password — mirrors srp._pysrp exactly.
``v = g**x mod N`` where ``x = H(salt, H(username:password))``; the salt is
``salt_len`` random bytes. This is what the room stores in place of the
password (SRP never sees the password on the wire)."""
if ng_type == NG_CUSTOM and (n_hex is None or g_hex is None):
raise ValueError("Both n_hex and g_hex are required when ng_type = NG_CUSTOM")
hash_class = _hash_map[hash_alg]
N, g = _get_ng(ng_type, n_hex, g_hex)
_s = _long_to_bytes(_get_random(salt_len))
_v = _long_to_bytes(pow(g, _gen_x(hash_class, _s, username, password), N))
return _s, _v
class Verifier:
"""Server side of the SRP-6a exchange, matching srp._pysrp.Verifier's surface."""
def __init__(
self,
username,
bytes_s,
bytes_v,
bytes_A,
hash_alg=SHA1,
ng_type=NG_2048,
n_hex=None,
g_hex=None,
bytes_b=None,
):
if ng_type == NG_CUSTOM and (n_hex is None or g_hex is None):
raise ValueError("Both n_hex and g_hex are required when ng_type = NG_CUSTOM")
if bytes_b and len(bytes_b) != 32:
raise ValueError("32 bytes required for bytes_b")
self.A = _bytes_to_long(bytes_A)
self.M = None
self.K = None
self.H_AMK = None
self._authenticated = False
self.I = username
self.s = bytes_s
self.v = _bytes_to_long(bytes_v)
N, g = _get_ng(ng_type, n_hex, g_hex)
hash_class = _hash_map[hash_alg]
k = _bytes_to_long(_H(hash_class, N, g, width=len(_long_to_bytes(N))))
self.hash_class = hash_class
self.N = N
self.g = g
self.k = k
# SRP-6a safety check: abort (no challenge) if A is a multiple of N.
self.safety_failed = False
if (self.A % N) == 0:
self.safety_failed = True
self.B = None
return
if bytes_b:
self.b = _bytes_to_long(bytes_b)
else:
self.b = _get_random_of_length(32)
self.B = (k * self.v + pow(g, self.b, N)) % N
self.u = _bytes_to_long(_H(hash_class, self.A, self.B, width=len(_long_to_bytes(N))))
self.S = pow(self.A * pow(self.v, self.u, N), self.b, N)
self.K = hash_class(_long_to_bytes(self.S)).digest()
self.M = _calculate_M(hash_class, N, g, self.I, self.s, self.A, self.B, self.K)
self.H_AMK = _calculate_H_AMK(hash_class, self.A, self.M, self.K)
def authenticated(self) -> bool:
return self._authenticated
def get_username(self):
return self.I
def get_ephemeral_secret(self) -> bytes:
return _long_to_bytes(self.b)
def get_session_key(self):
return self.K if self._authenticated else None
def get_challenge(self):
"""Return ``(salt, B_bytes)`` or ``(None, None)`` if the safety check failed."""
if self.safety_failed:
return None, None
return (self.s, _long_to_bytes(self.B))
def verify_session(self, user_M):
"""Check the client proof M; return H_AMK (bytes) on success, else None."""
if not self.safety_failed and user_M == self.M:
self._authenticated = True
return self.H_AMK
return None
+4 -1
View File
@@ -3,7 +3,10 @@ from dataclasses import dataclass, field
from typing import Optional
from uuid import uuid4
import srp
try:
import srp
except ImportError: # no aarch64 wheel / C-ext build (Termux) — use the shim
from ..client import _srp_pure as srp
srp.rfc5054_enable()
+79
View File
@@ -107,3 +107,82 @@ def test_pure_client_full_http_handshake(test_client):
assert usr.authenticated(), "server accepted us but H_AMK did not match"
assert verify["ws_token"], "no ws_token issued — cannot join the room"
# --- Inverse direction: the pure *Verifier* (server side) --------------------
# When the server itself runs on Termux (no srp C-extension — e.g. a room HOSTED
# ON THE PHONE) srp_auth.py falls back to pure.Verifier. These tests exercise
# that server surface, so a broken constant in the shim's Verifier can't ship.
def test_real_user_authenticates_against_pure_verifier():
"""A real srp.User (C-ext client, e.g. a laptop) must authenticate against
the pure Verifier — the exact cross-implementation handshake proven live
when the laptop joined a phone-hosted room."""
salt, vkey = srp.create_salted_verification_key(
b"chat", PASSWORD, hash_alg=srp.SHA256
)
usr = srp.User(b"chat", PASSWORD, hash_alg=srp.SHA256)
_, A = usr.start_authentication()
svr = pure.Verifier(b"chat", salt, vkey, A, hash_alg=pure.SHA256)
s, B = svr.get_challenge()
assert B is not None, "pure Verifier aborted the SRP-6a safety check"
M = usr.process_challenge(s, B)
assert M is not None, "real client failed the SRP-6a safety checks"
H_AMK = svr.verify_session(M)
assert H_AMK is not None, "pure Verifier rejected the real client's proof M1"
usr.verify_session(H_AMK)
assert usr.authenticated(), "real client rejected the pure Verifier's H_AMK"
assert svr.authenticated()
# Both sides must derive the identical session key or encryption breaks.
assert usr.get_session_key() == svr.get_session_key()
def test_pure_user_authenticates_against_pure_verifier():
"""Both ends on Termux (pure client + pure Verifier) — a room hosted and
joined entirely without the C-extension must still complete the handshake."""
salt, vkey = pure.create_salted_verification_key(
b"chat", PASSWORD, hash_alg=pure.SHA256
)
usr = pure.User(b"chat", PASSWORD, hash_alg=pure.SHA256)
_, A = usr.start_authentication()
svr = pure.Verifier(b"chat", salt, vkey, A, hash_alg=pure.SHA256)
s, B = svr.get_challenge()
assert B is not None
M = usr.process_challenge(s, B)
assert M is not None
H_AMK = svr.verify_session(M)
assert H_AMK is not None
usr.verify_session(H_AMK)
assert usr.authenticated()
assert svr.authenticated()
assert usr.get_session_key() == svr.get_session_key()
def test_pure_verifier_rejects_wrong_password():
"""Negative control for the pure Verifier: a bad password must never
authenticate, proving the positive tests aren't passing trivially."""
salt, vkey = srp.create_salted_verification_key(
b"chat", PASSWORD, hash_alg=srp.SHA256
)
usr = srp.User(b"chat", b"wrongpassword", hash_alg=srp.SHA256)
_, A = usr.start_authentication()
svr = pure.Verifier(b"chat", salt, vkey, A, hash_alg=pure.SHA256)
s, B = svr.get_challenge()
M = usr.process_challenge(s, B)
assert svr.verify_session(M) is None
assert not svr.authenticated()
assert svr.get_session_key() is None