7bd972f39c
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>
189 lines
6.7 KiB
Python
189 lines
6.7 KiB
Python
"""Interop gate for the pure-Python SRP shim.
|
|
|
|
The server authenticates with the real ``srp`` package (C-extension backend, see
|
|
cmd_chat/server/srp_auth.py). SRP is unforgiving: any mismatch in the N/g group,
|
|
k/x/u derivation, or the M1 / H_AMK hash inputs makes authentication fail. These
|
|
tests force the *pure* client (cmd_chat/client/_srp_pure) through a full
|
|
handshake and assert success — so a broken constant can never ship silently.
|
|
"""
|
|
|
|
import base64
|
|
|
|
import pytest
|
|
import srp
|
|
|
|
from cmd_chat.client import _srp_pure as pure
|
|
|
|
# The client always runs in RFC 5054 mode (client.py calls srp.rfc5054_enable()
|
|
# at import). Match that here for both the real lib and the shim.
|
|
srp.rfc5054_enable()
|
|
pure.rfc5054_enable()
|
|
|
|
PASSWORD = b"testpassword" # matches conftest's SRPAuthManager("testpassword")
|
|
|
|
|
|
def test_pure_user_authenticates_against_real_verifier():
|
|
"""Directly cross-check the shim's User against the real srp.Verifier —
|
|
the same class the server instantiates."""
|
|
salt, vkey = srp.create_salted_verification_key(
|
|
b"chat", PASSWORD, hash_alg=srp.SHA256
|
|
)
|
|
|
|
usr = pure.User(b"chat", PASSWORD, hash_alg=pure.SHA256)
|
|
_, A = usr.start_authentication()
|
|
|
|
svr = srp.Verifier(b"chat", salt, vkey, A, hash_alg=srp.SHA256)
|
|
s, B = svr.get_challenge()
|
|
assert B is not None
|
|
|
|
M = usr.process_challenge(s, B)
|
|
assert M is not None, "pure client failed the SRP-6a safety checks"
|
|
|
|
H_AMK = svr.verify_session(M)
|
|
assert H_AMK is not None, "real Verifier rejected the pure client's proof M1"
|
|
|
|
usr.verify_session(H_AMK)
|
|
assert usr.authenticated(), "pure client rejected the server's H_AMK"
|
|
assert svr.authenticated()
|
|
|
|
|
|
def test_pure_user_wrong_password_is_rejected():
|
|
"""Negative control: a bad password must not authenticate — proves the
|
|
positive test isn't passing for a trivial reason."""
|
|
salt, vkey = srp.create_salted_verification_key(
|
|
b"chat", PASSWORD, hash_alg=srp.SHA256
|
|
)
|
|
|
|
usr = pure.User(b"chat", b"wrongpassword", hash_alg=pure.SHA256)
|
|
_, A = usr.start_authentication()
|
|
|
|
svr = srp.Verifier(b"chat", salt, vkey, A, hash_alg=srp.SHA256)
|
|
s, B = svr.get_challenge()
|
|
|
|
M = usr.process_challenge(s, B)
|
|
# Either the server rejects M outright, or (defensively) our client refuses
|
|
# to accept the resulting proof — never authenticated.
|
|
assert svr.verify_session(M) is None
|
|
assert not svr.authenticated()
|
|
|
|
|
|
def test_pure_client_full_http_handshake(test_client):
|
|
"""End-to-end against a real running server room: run the exact
|
|
/srp/init -> /srp/verify flow client.py performs, but forced through the
|
|
pure implementation, and assert the room authenticates the phone operator."""
|
|
username = "phone-op"
|
|
|
|
usr = pure.User(b"chat", PASSWORD, hash_alg=pure.SHA256)
|
|
_, A = usr.start_authentication()
|
|
|
|
_, init_resp = test_client.post(
|
|
"/srp/init",
|
|
json={"username": username, "A": base64.b64encode(A).decode()},
|
|
)
|
|
assert init_resp.status == 200, init_resp.text
|
|
init = init_resp.json
|
|
|
|
user_id = init["user_id"]
|
|
B = base64.b64decode(init["B"])
|
|
salt = base64.b64decode(init["salt"])
|
|
assert "room_salt" in init # client derives the Fernet room key from this
|
|
|
|
M = usr.process_challenge(salt, B)
|
|
assert M is not None
|
|
|
|
_, verify_resp = test_client.post(
|
|
"/srp/verify",
|
|
json={
|
|
"user_id": user_id,
|
|
"username": username,
|
|
"M": base64.b64encode(M).decode(),
|
|
},
|
|
)
|
|
assert verify_resp.status == 200, verify_resp.text
|
|
verify = verify_resp.json
|
|
|
|
H_AMK = base64.b64decode(verify["H_AMK"])
|
|
usr.verify_session(H_AMK)
|
|
|
|
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
|