feat(coven): SRP/Fernet crypto parity + multi-user coven foundation ⛧

Begin the coven evolution of cmd-chat (see docs/spec-collaborative-sandbox.md):
a Rust/ratatui client for the unchanged Python Sanic server, plus the
multi-user + zero-knowledge groundwork.

P0 — crypto parity (the spec's #1 risk), proven three ways:
- Hand-rolled SRP-6a (NG_2048, SHA-256, rfc5054 padding) matching pysrp
  byte-for-byte, incl. the fixed b"chat" SRP identity and minimal-vs-256B
  width quirks. Golden-vector unit test + offline selftest.
- Live handshake against the running server (H_AMK verified).
- Cross-language E2E: Python client decrypts a Rust-encrypted Fernet message.

P2 — multi-user coven (server):
- CMD_CHAT_MAX_USERS capacity cap (default 4, infra-for-more).
- Authoritative roster + user_joined broadcasts.
- Free the slot/username on ws disconnect (was held until 1h stale sweep).

Also: fix requirements.txt (was UTF-16, unparseable by pip).

coven/ : Rust crate (crypto.rs proven; main.rs spike CLI: selftest/handshake/srpm)
docs/  : full feature spec for the 6 requested features.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-05-30 11:47:25 -07:00
parent dc1b5e5ccf
commit 82a04f3e12
11 changed files with 3030 additions and 1 deletions
+37
View File
@@ -15,6 +15,18 @@ def generate_ws_token(user_id: str, secret: bytes) -> str:
return hmac.new(secret, user_id.encode(), hashlib.sha256).hexdigest()
def _roster_frame(app: Sanic) -> str:
"""Authoritative presence snapshot — all coven members converge on this."""
users = app.ctx.session_store.get_all()
return json.dumps(
{
"type": "roster",
"users": [{"user_id": u.user_id, "username": u.username} for u in users],
"capacity": app.ctx.max_users,
}
)
async def srp_init(request: Request, app: Sanic) -> HTTPResponse:
try:
client_ip = get_client_ip(request)
@@ -33,6 +45,9 @@ async def srp_init(request: Request, app: Sanic) -> HTTPResponse:
if app.ctx.session_store.username_exists(username):
return response.json({"error": "Username taken"}, status=409)
if app.ctx.session_store.count() >= app.ctx.max_users:
return response.json({"error": "Coven full"}, status=409)
user_id, B, salt = app.ctx.srp_manager.init_auth(username, client_public)
return response.json(
@@ -64,6 +79,11 @@ async def srp_verify(request: Request, app: Sanic) -> HTTPResponse:
client_proof = base64.b64decode(client_proof_b64)
# Authoritative capacity gate — the slot is only consumed once a session
# is actually added here (init is best-effort / racy).
if app.ctx.session_store.count() >= app.ctx.max_users:
return response.json({"error": "Coven full"}, status=409)
H_AMK, session_key = app.ctx.srp_manager.verify_auth(user_id, client_proof)
fernet_key = base64.urlsafe_b64encode(session_key[:32])
@@ -115,6 +135,19 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
try:
await send_state(ws, app)
# Announce arrival to everyone already present, then a fresh roster.
await manager.broadcast(
json.dumps(
{
"type": "user_joined",
"user_id": user_id,
"username": session.username,
}
),
exclude_user=user_id,
)
await manager.broadcast(_roster_frame(app))
async for data in ws:
if data is None:
break
@@ -140,6 +173,9 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
pass
finally:
await manager.disconnect(user_id)
# Free the slot + username so the coven can be rejoined (was previously
# held until the 1h stale sweep, which also blocked the name).
app.ctx.session_store.remove(user_id)
await manager.broadcast(
json.dumps(
{
@@ -148,6 +184,7 @@ async def chat_ws(request: Request, ws: Websocket, app: Sanic) -> None:
}
)
)
await manager.broadcast(_roster_frame(app))
async def health(request: Request, app: Sanic) -> HTTPResponse: