| 1 | # π€ The home server |
| 2 | |
| 3 | A tiny, anonymous, content-blind, passkey-gated blob store β the optional |
| 4 | backup half of the onboarding design |
| 5 | (`docs/onboarding-and-home-server.md` in the design repo). It lets a member |
| 6 | keep an **encrypted** list of their chat servers (and, later, credentials) |
| 7 | restorable on a fresh device with one passkey ceremony. |
| 8 | |
| 9 | It is **not** a Matrix-style homeserver: it holds no messages, no roster, no |
| 10 | identity any instance recognises. It disappearing inconveniences you exactly |
| 11 | once β you lose the backup, not the chats. |
| 12 | |
| 13 | ## The additive-only invariant (non-negotiable) |
| 14 | |
| 15 | **A client with no enrolment record makes no requests to any home server, |
| 16 | ever** β no passive pings, no "check for a backup" lookups. Enrolment is |
| 17 | created only by an explicit user action on this page. A web-only user who |
| 18 | declines never appears in any home-server log. Instances are not involved at |
| 19 | all; the only relationship is client β home. |
| 20 | |
| 21 | ## Who sees what |
| 22 | |
| 23 | | Party | Sees | |
| 24 | |---|---| |
| 25 | | An instance | your per-instance public key + ciphertext (unchanged β instances don't know this server exists) | |
| 26 | | The home server (if enrolled) | one WebAuthn credential public key per passkey, one opaque **padded** blob, device link tokens (hashed), IPs/timing | |
| 27 | | A stolen device link token | ciphertext reads + overwrites (the server keeps the last 5 versions; destructive ops need a fresh ceremony) | |
| 28 | | A compromised enrolled device | the blob β but it already holds live sessions to every instance in it, so nothing new | |
| 29 | | Nobody | the relationship between your identities on different instances, except your own devices | |
| 30 | |
| 31 | ## What it stores (per user) |
| 32 | |
| 33 | - **users** β a random id. No email, no username, no display name; the |
| 34 | credential is the identity. |
| 35 | - **credentials** β WebAuthn credential public keys (+ sign count/flags). |
| 36 | - **one blob** β opaque client-encrypted bytes. The plaintext is padded to a |
| 37 | fixed 16 KiB *before* encryption so length leaks nothing; the last 5 |
| 38 | versions are retained as overwrite protection. |
| 39 | - **device link tokens** β bearer tokens minted after a verified ceremony, |
| 40 | stored **hashed** (like the messenger's device tokens). |
| 41 | - **ceremony proofs** β 5-minute hashed proofs minted by a verified ceremony; |
| 42 | the only credential destructive ops accept. |
| 43 | |
| 44 | ## Crypto (all client-side, `web/static/crypto.js`) |
| 45 | |
| 46 | Passkey PRF output β HKDF β per-credential **KEK**. A random long-lived |
| 47 | **DEK** encrypts the blob (AES-GCM); the DEK is wrapped once per enrolled |
| 48 | passkey's KEK (envelope encryption β a second passkey is recovery redundancy). |
| 49 | The PRF output never leaves the client; the serializer strips PRF results |
| 50 | before anything is sent. No PRF support = no enrolment on that device; |
| 51 | add-device links remain the universal fallback. |
| 52 | |
| 53 | ## API |
| 54 | |
| 55 | ``` |
| 56 | POST /api/register/begin WebAuthn attestation options (rate-limited). |
| 57 | POST /api/register/finish verify; create user + credential; mint link token |
| 58 | + ceremony proof. With a FRESH ceremony proof on |
| 59 | begin, adds a passkey to that account instead |
| 60 | (a link token is refused β see below). |
| 61 | POST /api/login/begin discoverable assertion options (rate-limited). |
| 62 | POST /api/login/finish verify; mint link token + ceremony proof. |
| 63 | GET /api/blob bearer link token; If-None-Match: "<version>" β 304. |
| 64 | PUT /api/blob bearer link token; If-Match required, 409 on race; |
| 65 | hard size cap. |
| 66 | GET /api/devices bearer link token; ids + timestamps only. |
| 67 | DELETE /api/devices/<id> X-Ceremony-Proof (fresh ceremony) β NOT a link token. |
| 68 | DELETE /api/credentials/<id> X-Ceremony-Proof; refuses to remove the last |
| 69 | passkey (that would orphan the blob). |
| 70 | DELETE /api/blob X-Ceremony-Proof (fresh ceremony). |
| 71 | ``` |
| 72 | |
| 73 | The asymmetry is deliberate: a link token authorises exactly blob read/write |
| 74 | and the devices listing β nothing else. Anything that *changes who can get in* |
| 75 | (adding a passkey, removing one, revoking devices, deleting the blob) requires |
| 76 | a live passkey ceremony β a stolen token can't lock you out, and it can't |
| 77 | enrol an attacker's passkey to escalate itself into ceremony power either. |
| 78 | |
| 79 | ## Build, run, deploy |
| 80 | |
| 81 | ```sh |
| 82 | go build ./home # single static binary, UI embedded |
| 83 | LCHAT_HOME_ORIGIN=http://localhost:1748 ./home & # env: ADDR, LCHAT_HOME_DB |
| 84 | go test ./home |
| 85 | ``` |
| 86 | |
| 87 | `LCHAT_HOME_ORIGIN` is the public origin (the WebAuthn RP identity) β pick the |
| 88 | hostname once; changing it orphans existing passkeys. In production it runs as |
| 89 | the `lchat-home` systemd service on the router box (unix socket, fronted by the |
| 90 | router): build the `home` binary, copy it up, `systemctl restart lchat-home` β |
| 91 | data-safe; the DB at `/data/home.db` is never touched by a redeploy. |
| 92 | |
| 93 | ### Optional: serving the planet-scale guide at /infrastructure/ |
| 94 | |
| 95 | `LCHAT_HOME_INFRASTRUCTURE_DIR=<path>` serves the operator guide |
| 96 | (`docs/planet-scale-guide/` in this repo) at `/infrastructure/`, read |
| 97 | straight from that directory on disk β unset (the default) means the route |
| 98 | 404s. The guide's source stays in the repo; only a deploy step copies its |
| 99 | built files onto the box: |
| 100 | |
| 101 | ```sh |
| 102 | scp -r docs/planet-scale-guide/* root@<box>:/data/infrastructure/ |
| 103 | # then set LCHAT_HOME_INFRASTRUCTURE_DIR=/data/infrastructure in home's |
| 104 | # environment and restart |
| 105 | ``` |
| 106 | |
| 107 | Re-run the `scp` (it overwrites in place) whenever the guide changes; no |
| 108 | restart is needed for guide-only content updates since it's read from disk on |
| 109 | every request, not cached in the process. A restart is only needed the first |
| 110 | time `LCHAT_HOME_INFRASTRUCTURE_DIR` is set. |
| 111 | |