Add the VirtualBox sandbox design spec (headless 4th backend + share-an- appliance GUI mode with detect-first install), the crypto pay-to-join gate design, and the save/load PoC writeup with its demo/film driver scripts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
21 KiB
Design: crypto-currency "pay-to-join" gate (second gate after the password)
Status: research + plan (no code yet) Goal: require a crypto payment in addition to the SRP password before a client is admitted to a hack-house room, without weakening the existing end-to-end-encryption / zero-knowledge-relay properties.
1. What the gate must respect (current architecture)
Grounded in the present code so the design slots in cleanly:
- Two-step SRP handshake over HTTP, then a WebSocket upgrade.
POST /srp/init→ returnsuser_id, B, salt, room_saltand enforces a soft capacity / username check (cmd_chat/server/views.py:30-63, capacity at:48).POST /srp/verify→ verifies the SRP proofM, then commits the session and issues thews_token(cmd_chat/server/views.py:66-111). The authoritative capacity gate is here (:84-85), the proof check at:87, andsession_store.add(session)at:97.GET (ws) /ws/chat?user_id=…&ws_token=…→ HMAC-checks the token and joins the room (cmd_chat/server/views.py:114-149).
- Rust client mirror:
hh/src/net.rs:44-104(authenticate) does init →process_challenge→ verify → checks serverH_AMK(mutual auth, MITM guard) → derives the room Fernet → builds the ws URL. The Python client mirrors this incmd_chat/client/client.py. - Zero-knowledge relay. The server never derives the room key
(
HKDF(password, room_salt, "cmd-chat-room-key")is computed client-side only) and only relays Fernet ciphertext. It can see: usernames, IPs, timestamps,user_ids, roster, ws-token validity. It cannot see: message plaintext, the room key, sandbox I/O,_perm/_ftcontrol frames. - Owner is authoritative, not the server. Chat rooms have no owner; the first
user to
/sbx launchbecomes sandbox owner and the owner's client broadcasts the ACL (_perm:acl) encrypted — the server relays it blindly. This is the precedent we lean on for the trust-minimized tier below. - One password per server instance (
serve --password); a "room" is just a running server. No room-creation endpoint, no persistent accounts, no allow-lists/bans. Capacity defaults to 4 (CMD_CHAT_MAX_USERS). Per-IP rate limit 10/60s on both SRP endpoints.
Key consequence: payment is a money trust concern, orthogonal to the E2E message trust concern. We must not route the room key or plaintext through any payment logic, and we should keep the relay as close to "dumb" as possible.
2. Design principles / security requirements
- Non-custodial. The app never holds user funds or spend-capable keys. Funds go directly to the owner's node/wallet. We only issue invoices and verify receipt. (Custody would add huge security + regulatory burden.)
- Two independent gates. Password (SRP) and payment are separate; failing either denies entry. Payment is checked only after SRP succeeds, so an unpaid attacker who lacks the password learns nothing and never sees a payment request.
- Keep the relay dumb where possible. Prefer designs where the server checks a signature or a preimage rather than running chain logic. Full chain access lives in an owner-operated component.
- Atomic pay-to-join. Never take money for a seat that isn't granted. Use a capacity reservation + Lightning hold invoice so payment settles iff the seat is actually granted; otherwise it auto-cancels (refund).
- Single-use, identity-bound proofs. Every proof is bound to one
user_id- room + nonce, expires quickly, and is burned on use (anti-replay / anti-share).
- No fiat price oracle in the hot path. Price in the native unit (sats / atomic units). Optional fiat display is cosmetic and never gates admission.
- Least-privilege keys, never in the repo. Invoice/read-only node credentials via env; no admin/spend keys. Testnet/regtest/signet by default; mainnet is an explicit opt-in flag.
- Fail safe + DoS-resistant. Payment-backend errors deny entry (don't fail-open) but never hang the SRP handshake; invoice issuance is rate-limited.
- Privacy-aware. Prefer rails that don't dox the payer's whole graph (Lightning ≫ on-chain; Monero strongest).
3. Currency / rail choice
| Rail | Settlement | Fees | Privacy | Proof primitive | Verdict |
|---|---|---|---|---|---|
| Bitcoin Lightning | ~instant | ~0 | good | payment preimage (sha256(P)=hash) | Recommended |
| Monero | ~2 min | low | best | tx proof (tx key + addr) | strong privacy alt |
| On-chain L2 stablecoin (USDC on Base/Arbitrum) | seconds–min | low | poor | tx receipt + log | fiat-stable alt |
| On-chain BTC / ETH L1 | 10 min / minutes | high | poor | tx receipt | bad UX, avoid |
Recommendation: Bitcoin Lightning. It matches the ephemeral, instant, micro-fee nature of "pay a few sats to join a terminal room," and the preimage is a self-verifying proof of payment that needs no trusted third party if the verifier knows the invoice's payment hash. The hold-invoice variant gives us atomic seat reservation for free.
The implementation is built behind an interface (§5) so Monero / L2-stablecoin backends can be added without touching the handshake.
4. The core idea: hold-invoice pay-to-join
Lightning hold invoices (a.k.a. HODL invoices, LND addHoldInvoice /
CLN holdinvoice) let the receiver accept a payment (funds locked by the
payer) and decide later whether to settle (claim) or cancel (refund):
- Owner's node generates a hold invoice for the join fee, with a
payment_hashit controls and a description that commits touser_id. - Joiner pays; the HTLC is now accepted (locked) but not settled — the payer cannot spend it elsewhere and the owner hasn't claimed it.
- The gate reserves a seat (capacity check) and, iff a seat is available, tells the node to settle → owner is paid, joiner is admitted.
- If no seat / timeout / error → cancel the invoice → joiner is auto-refunded, nobody paid for a seat they didn't get.
This is the cleanest way to satisfy principle #4 (atomicity).
5. Pluggable AdmissionGate abstraction
Introduce one small interface so the payment rail is swappable and the "no gate" path is the existing behavior (zero risk to current deployments).
# cmd_chat/server/admission.py (illustrative)
class AdmissionGate(Protocol):
async def quote(self, user_id: str, username: str) -> Quote | None:
"""Return a payable Quote (invoice/LNURL/address + opaque challenge),
or None if no payment is required (free room)."""
async def verify(self, user_id: str, proof: dict) -> bool:
"""True iff `proof` settles `quote` for this user_id, single-use,
unexpired, correct amount. Reserves+settles atomically for hold invoices."""
# backends:
# NullGate -> always free (today's behavior; default)
# LightningGate -> Tier 1, self-hosted LNbits/LND/CLN
# VoucherGate -> Tier 2, verifies owner-signed Ed25519 admission vouchers
# (future) MoneroGate, EvmGate
Server wiring (cmd_chat/server/factory.py): app.ctx.admission = build_gate(cfg).
New serve flags (all optional; default = NullGate = unchanged):
cmd_chat.py serve <ip> <port> --password <pw> \
--pay lightning \
--pay-amount-sats 500 \
--pay-backend lnbits --pay-url https://lnbits.local --pay-key-env HH_LNBITS_INVOICE_KEY \
--pay-network signet
# or, trust-minimized:
--pay voucher --admit-pubkey <ed25519-pub-b64>
6. Two implementation tiers
Tier 1 — server-mediated Lightning gate (MVP, pragmatic)
The server talks to an owner-operated LN backend (LNbits invoice key, or LND/CLN gRPC with an invoice-only macaroon). Justified because in the common self-hosted case the owner is the server operator, so trusting the server to confirm payment is trusting yourself.
Flow:
POST /srp/initunchanged, plus server returnspay_required: trueand apay_challenge(random nonce bound touser_id) when a gate is active.- New
POST /pay/quote {user_id}→ server (after a soft capacity check) asks the node for a hold invoice committinguser_idin itsdescription_hash, returns{bolt11, payment_hash, amount_sats, lnurl?, expires_at}. - Client shows the invoice (BOLT11 + QR + LNURL) in the TUI and waits.
- Joiner pays → HTLC accepted (held).
POST /srp/verify {user_id, M, payment_hash}→ server: SRP-verify → authoritative capacity gate → ask node "is this invoice ACCEPTED, amount ok, user_id committed, not yet used?" → if yes settle + mark used + add session- issue
ws_token; if no seat/timeout → cancel (refund) and return402.
- issue
HTTP semantics: 402 Payment Required (+ a small JSON {error, pay_required, pay_challenge}) when payment is missing/invalid; 409 still means full.
Tier 2 — owner-signed admission vouchers (trust-minimized, end state)
Mirrors the existing "owner broadcasts the ACL, server relays blindly" model and keeps the server payment-blind (no node creds, no chain access on the relay).
Components:
- The owner runs a tiny doorman next to their node (could live in the Rust
client or a sidecar): an LNURL-pay endpoint + voucher signer holding an
Ed25519 key. The server is started with only the public key
(
--admit-pubkey). - A joiner pays the owner directly (LNURL/BOLT11). On settlement the doorman signs
an admission voucher:
voucher = Ed25519_sign( sk_owner, {user_id, room_id, amount, nonce, exp} ). - Joiner submits the voucher at
POST /srp/verify {…, voucher}. - Server verifies: signature against
admit_pubkey,user_idmatches,expfresh,nonceunseen (single-use ledger), then admits. The server verifies a signature, not a payment — it never sees funds, invoices, or chain data.
Tradeoff: the owner/doorman must be reachable to issue vouchers; the relay no longer needs to be trusted with money. This is the philosophically correct end-state for a zero-knowledge relay; Tier 1 is the faster path to ship.
7. End-to-end sequence (Tier 1, hold-invoice)
client relay (server) owner LN node
| POST /srp/init {A, user} | |
|-------------------------------->| soft cap/username check |
|<-- {user_id,B,salt,room_salt, | |
| pay_required, pay_challenge}| |
| process_challenge | |
| POST /pay/quote {user_id} | |
|-------------------------------->| addHoldInvoice(desc_hash= |
| | H(user_id|nonce|room)) ----->|
|<-- {bolt11, payment_hash, amt, |<------- invoice ---------------|
| lnurl, expires_at} | |
| [TUI shows invoice/QR; pay] ------------------- pay ----------->| (HTLC accepted/held)
| POST /srp/verify {user_id,M, | |
| payment_hash} | |
|-------------------------------->| SRP verify (:87) |
| | AUTHORITATIVE cap gate (:84) |
| | lookupInvoice == ACCEPTED? --->|
| | amount ok? user_id committed? |
| | unused? -> settle ----------->| (owner paid)
| | session_store.add (:97) |
|<-- {H_AMK, ws_token} | |
| check H_AMK (MITM guard) | |
| ws /ws/chat?user_id&ws_token | |
|================ joined =========| |
(on any failure between verify+settle: node.cancelInvoice -> refund, return 402)
8. Anti-replay, binding, atomicity (the security-critical bits)
- Per-join invoice. A fresh invoice/nonce per join attempt; never a static address. Static addresses enable replay and can't bind identity.
- Identity binding. Commit
user_id(and room id) into the invoicedescription_hash(BOLT11) or the voucher payload, so a proof minted for one joiner can't admit another — defeats proof theft by a malicious relay/peer. - Single-use ledger. In-RAM set of consumed
payment_hash/noncefor the server's lifetime; reject reuse. (Matches the project's ephemeral, RAM-only ethos — no DB needed.) - Expiry. Short invoice/voucher TTL (e.g. 5 min) tied to the SRP
pay_challenge; expired ⇒ re-quote. - Atomic seat. Capacity is reserved at the authoritative gate
(
views.py:84-85) and the hold invoice is only settled after the seat is secured; otherwise cancel→refund. Consider a short-lived "seat hold" so two payers don't race the last seat (reserve before settle, release on failure). - Amount check. Enforce
amount >= price; reject underpayment; for overpay, settle and (optionally) note credit — never silently keep extra without policy.
9. Client UX (Rust TUI + Python)
- Connect command gains optional payment handling. When
/srp/initreturnspay_required, the client:- fetches a quote, renders a payment panel: amount (sats), a copyable BOLT11,
an
lnurl:/lightning:URI, and a QR code (ratatui can draw a QR via a unicode/half-block widget; Python client prints an ASCII QR). - shows live status:
waiting for payment → received (held) → admitted. - then proceeds to
/srp/verifyautomatically once paid.
- fetches a quote, renders a payment panel: amount (sats), a copyable BOLT11,
an
- CLI flags for non-interactive/automation:
--pay-bolt11-out <file>(dump the invoice) or--voucher <file>(Tier 2, present a pre-obtained voucher). - Help menu: add to the existing clustered help (
hh/src/ui.rshelp_clusters) — a short note under a newACCESSor extendedKEYS/intro cluster: "rooms may require a Lightning payment to join; you'll be shown an invoice after the password check." - Failure copy:
402⇒ "this house requires payment to enter"; expired ⇒ "invoice expired — press R to refresh"; full-after-pay ⇒ "house filled while paying — you were refunded."
10. State, config, key management
- New server ctx:
admissiongate,paid_nonces(single-use set),seat_holds(transient reservations). All in-RAM, cleared on restart — consistent with the existing ephemeral stores (cmd_chat/server/stores.py). - Secrets via env only (never in repo, never in
serveargv where it'd hitps):HH_LNBITS_INVOICE_KEY,HH_LND_MACAROON_PATH(invoice-only macaroon),HH_LND_TLS_CERT, etc. Pass names on the CLI (--pay-key-env), read values from the environment. - Least privilege: LNbits invoice/read key (not admin); LND macaroon baked
to
invoices:write/read+invoices:settle/cancelonly — noonchain/offchainspend permissions. - Network guard: default
--pay-network signet|regtest|testnet; require an explicit--pay-network mainnet --i-understandto use real funds.
11. Threat model
| Threat | Mitigation |
|---|---|
| Replay a proof to join repeatedly / share with friends | per-join invoice + single-use nonce ledger + user_id binding in description_hash/voucher |
| Malicious relay steals the preimage to join itself | identity-bound invoice (preimage only admits the committed user_id); Tier 2 removes relay from the money path entirely |
| Pay but no seat (race / full) | hold invoice + seat reservation; cancel→auto-refund on failure; clear UX |
| Payment-backend down → fail-open | gate denies entry on backend error (fail-closed); never silently admits |
| Invoice-spam DoS / griefing | reuse existing per-IP rate limit (helpers.py) on /pay/quote; cap concurrent unpaid holds per IP; short TTLs |
| Front-running the last seat | reserve seat before settle; release on abort |
| Fiat-oracle manipulation | price natively in sats; no oracle in the admission path |
| Key leakage | invoice/read-only creds, env-only, no spend keys; mainnet behind explicit flag |
| MITM on the HTTP leg | unchanged SRP H_AMK mutual-auth guard (net.rs:87-90); run behind TLS in prod (today's --no-tls is dev-only) |
| Privacy deanonymization | Lightning over on-chain; document Monero option; never log payer metadata beyond the ephemeral nonce |
| Regulatory/custody risk | strictly non-custodial; funds never touch app-controlled spend keys |
12. Privacy & legal notes (design guidance, not legal advice)
- Privacy: Lightning leaks far less than on-chain; the relay should log only the ephemeral nonce/payment_hash, never amounts tied to usernames/IPs longer than the session. Offer Monero for the strongest payer privacy.
- Compliance: staying non-custodial (funds go owner→owner, app never holds spend keys) keeps this closest to "the owner accepts tips/entry fees," but money-transmission / KYC-AML obligations vary by jurisdiction and volume. Flag this to the operator; do not build custody or fiat on/off-ramps into the app. This document is engineering guidance, not legal advice.
13. Testing strategy
- Unit:
NullGate(free),VoucherGatesignature + expiry + replay vectors (golden Ed25519 cases, mirroring the existing offline SRP vectors intest_srp.py/ RustSelftest). - Integration (regtest): spin LND/CLN in
regtest(Polar or docker), drive a full pay-to-join: quote → pay → settle → join; and the negative paths (underpay, expire, full-after-pay→cancel/refund, backend-down→deny). - Interop: extend the Rust live
Handshakeself-test to optionally carry a voucher; ensure Rust and Python clients produce identical proof framing. - Headless demo: a
demo-pay-to-join.sh(sibling ofdemo-save-load.sh) using a regtest node to film the beat: password → invoice in the TUI → pay → admitted.
14. Phased rollout
- Phase 0 — interface only. Add
AdmissionGate+NullGate, wireapp.ctx.admission, thread an optionalprooffield through/srp/verify. Default behavior identical to today. (Lowest risk; everything else builds on it.) - Phase 1 — VoucherGate (Tier 2 verify side). Ed25519 voucher verification on
the server (
--admit-pubkey), single-use ledger,402path + client flag to present a voucher. Server stays payment-blind. Testable with a CLI signer. - Phase 2 — LightningGate (Tier 1) +
/pay/quote. Hold-invoice issuance via LNbits/LND, settle/cancel atomicity, TUI payment panel + QR. regtest e2e. - Phase 3 — owner doorman (Tier 2 issue side) + LNURL, so payment is fully owner-authoritative and the relay never touches money. Optional Monero backend.
15. File-change map (when we implement)
| Area | File(s) | Change |
|---|---|---|
| Gate interface + backends | cmd_chat/server/admission.py (new) |
AdmissionGate, NullGate, VoucherGate, LightningGate |
| Server wiring + flags | cmd_chat/server/factory.py, cmd_chat.py |
build gate from --pay* flags; ctx state |
| Init advertises gate | cmd_chat/server/views.py:30-63 |
add pay_required, pay_challenge to /srp/init |
| New quote endpoint | cmd_chat/server/routes.py, views.py |
POST /pay/quote (Tier 1) |
| Verify enforces payment | cmd_chat/server/views.py:66-111 |
accept proof/payment_hash; gate between :87 and :97; 402 path; settle/cancel |
| Single-use + seat state | cmd_chat/server/stores.py |
paid_nonces, seat_holds (in-RAM) |
| Python client UX | cmd_chat/client/client.py |
quote fetch, invoice display/QR, wait-for-pay, send proof |
| Rust client UX | hh/src/net.rs:44-104, hh/src/app.rs, hh/src/ui.rs |
quote step, payment panel + QR, proof in verify, help entry |
| Owner doorman (Tier 2) | new sidecar or hh/ subcommand |
LNURL-pay + Ed25519 voucher signer (holds keys) |
| Tests | cmd_chat/tests/, Rust Cmd::* |
gate unit + regtest integration + interop |
16. Open decisions (need owner input)
- Trust posture: ship Tier 1 (server-mediated, simplest) first, or hold out for Tier 2 (relay stays payment-blind)? Recommendation: Phase 0→1 (voucher verify) gets us trust-minimized verification quickly; add Lightning issuance (Phase 2) for UX.
- Rail: Lightning only at first? (Recommended.) Monero as a fast-follow for privacy?
- Pricing: flat sats per join? per-room configurable? time-boxed (pay-per-hour) vs one-shot entry?
- Backend: LNbits (fastest to integrate, semi-custodial unless self-hosted) vs direct LND/CLN (more setup, fully self-custodial)?
- What payment buys: plain entry, or also a role (e.g. auto-
/grantdrive)? Note: roles are owner-broadcast ACL today, so coupling payment→role belongs in the owner's client, not the relay.