hack-house/docs/crypto-payment-gate.md
leetcrypt ca1666fbbb docs(sbx): VirtualBox backend spec, crypto pay-gate, save/load PoC
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>
2026-06-03 10:10:44 -07:00

386 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` → returns `user_id, B, salt, room_salt` and enforces a *soft*
capacity / username check (`cmd_chat/server/views.py:30-63`, capacity at `:48`).
- `POST /srp/verify` → verifies the SRP proof `M`, then **commits the session**
and issues the `ws_token` (`cmd_chat/server/views.py:66-111`). The
**authoritative capacity gate is here** (`:84-85`), the proof check at `:87`,
and `session_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 server `H_AMK` (mutual auth, MITM guard)
→ derives the room Fernet → builds the ws URL. The Python client mirrors this
in `cmd_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_id`s, roster, ws-token validity. It *cannot* see: message
plaintext, the room key, sandbox I/O, `_perm`/`_ft` control frames.
- **Owner is authoritative, not the server.** Chat rooms have no owner; the first
user to `/sbx launch` becomes 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
1. **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.)
2. **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.
3. **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.
4. **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).
5. **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).
6. **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.
7. **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.
8. **Fail safe + DoS-resistant.** Payment-backend errors deny entry (don't
fail-open) but never hang the SRP handshake; invoice issuance is rate-limited.
9. **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) | secondsmin | 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):
1. Owner's node generates a hold invoice for the join fee, with a `payment_hash`
it controls and a description that **commits to `user_id`**.
2. Joiner pays; the HTLC is now *accepted* (locked) but **not settled** — the
payer cannot spend it elsewhere and the owner hasn't claimed it.
3. The gate **reserves a seat** (capacity check) and, iff a seat is available,
tells the node to **settle** → owner is paid, joiner is admitted.
4. 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).
```python
# 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:
1. `POST /srp/init` unchanged, **plus** server returns `pay_required: true` and a
`pay_challenge` (random nonce bound to `user_id`) when a gate is active.
2. New `POST /pay/quote {user_id}` → server (after a *soft* capacity check) asks
the node for a **hold invoice** committing `user_id` in its `description_hash`,
returns `{bolt11, payment_hash, amount_sats, lnurl?, expires_at}`.
3. Client shows the invoice (BOLT11 + QR + LNURL) in the TUI and waits.
4. Joiner pays → HTLC accepted (held).
5. `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 return `402`.
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_id` matches, `exp`
fresh, `nonce` unseen (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 invoice
`description_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`/`nonce` for 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/init` returns
`pay_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/verify` automatically once paid.
- **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.rs`
`help_clusters`) — a short note under a new `ACCESS` or extended `KEYS`/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:** `admission` gate, `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 `serve` argv where it'd hit
`ps`): `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`/`cancel` only — **no `onchain`/
`offchain` spend** permissions.
- **Network guard:** default `--pay-network signet|regtest|testnet`; require an
explicit `--pay-network mainnet --i-understand` to 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), `VoucherGate` signature + expiry + replay vectors
(golden Ed25519 cases, mirroring the existing offline SRP vectors in
`test_srp.py` / Rust `Selftest`).
- **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 `Handshake` self-test to optionally carry a
voucher; ensure Rust and Python clients produce identical proof framing.
- **Headless demo:** a `demo-pay-to-join.sh` (sibling of `demo-save-load.sh`)
using a regtest node to film the beat: password → invoice in the TUI → pay →
admitted.
---
## 14. Phased rollout
1. **Phase 0 — interface only.** Add `AdmissionGate` + `NullGate`, wire
`app.ctx.admission`, thread an optional `proof` field through `/srp/verify`.
Default behavior identical to today. (Lowest risk; everything else builds on it.)
2. **Phase 1 — VoucherGate (Tier 2 verify side).** Ed25519 voucher verification on
the server (`--admit-pubkey`), single-use ledger, `402` path + client flag to
present a voucher. Server stays payment-blind. Testable with a CLI signer.
3. **Phase 2 — LightningGate (Tier 1) + `/pay/quote`.** Hold-invoice issuance via
LNbits/LND, settle/cancel atomicity, TUI payment panel + QR. regtest e2e.
4. **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)
1. **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.
2. **Rail:** Lightning only at first? (Recommended.) Monero as a fast-follow for
privacy?
3. **Pricing:** flat sats per join? per-room configurable? time-boxed
(pay-per-hour) vs one-shot entry?
4. **Backend:** LNbits (fastest to integrate, semi-custodial unless self-hosted)
vs direct LND/CLN (more setup, fully self-custodial)?
5. **What payment buys:** plain entry, or also a role (e.g. auto-`/grant` drive)?
Note: roles are owner-broadcast ACL today, so coupling payment→role belongs in
the owner's client, not the relay.