eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

πŸ€– The home server

A tiny, anonymous, content-blind, passkey-gated blob store β€” the optional backup half of the onboarding design (docs/onboarding-and-home-server.md in the design repo). It lets a member keep an encrypted list of their chat servers (and, later, credentials) restorable on a fresh device with one passkey ceremony.

It is not a Matrix-style homeserver: it holds no messages, no roster, no identity any instance recognises. It disappearing inconveniences you exactly once β€” you lose the backup, not the chats.

The additive-only invariant (non-negotiable)

A client with no enrolment record makes no requests to any home server, ever β€” no passive pings, no "check for a backup" lookups. Enrolment is created only by an explicit user action on this page. A web-only user who declines never appears in any home-server log. Instances are not involved at all; the only relationship is client ↔ home.

Who sees what

| Party | Sees | |---|---| | An instance | your per-instance public key + ciphertext (unchanged β€” instances don't know this server exists) | | The home server (if enrolled) | one WebAuthn credential public key per passkey, one opaque padded blob, device link tokens (hashed), IPs/timing | | A stolen device link token | ciphertext reads + overwrites (the server keeps the last 5 versions; destructive ops need a fresh ceremony) | | A compromised enrolled device | the blob β€” but it already holds live sessions to every instance in it, so nothing new | | Nobody | the relationship between your identities on different instances, except your own devices |

What it stores (per user)

  • users β€” a random id. No email, no username, no display name; the credential is the identity.
  • credentials β€” WebAuthn credential public keys (+ sign count/flags).
  • one blob β€” opaque client-encrypted bytes. The plaintext is padded to a fixed 16 KiB before encryption so length leaks nothing; the last 5 versions are retained as overwrite protection.
  • device link tokens β€” bearer tokens minted after a verified ceremony, stored hashed (like the messenger's device tokens).
  • ceremony proofs β€” 5-minute hashed proofs minted by a verified ceremony; the only credential destructive ops accept.

Crypto (all client-side, web/static/crypto.js)

Passkey PRF output β†’ HKDF β†’ per-credential KEK. A random long-lived DEK encrypts the blob (AES-GCM); the DEK is wrapped once per enrolled passkey's KEK (envelope encryption β€” a second passkey is recovery redundancy). The PRF output never leaves the client; the serializer strips PRF results before anything is sent. No PRF support = no enrolment on that device; add-device links remain the universal fallback.

API

POST /api/register/begin      WebAuthn attestation options (rate-limited).
POST /api/register/finish     verify; create user + credential; mint link token
                              + ceremony proof. With a FRESH ceremony proof on
                              begin, adds a passkey to that account instead
                              (a link token is refused β€” see below).
POST /api/login/begin         discoverable assertion options (rate-limited).
POST /api/login/finish        verify; mint link token + ceremony proof.
GET  /api/blob                bearer link token; If-None-Match: "<version>" β†’ 304.
PUT  /api/blob                bearer link token; If-Match required, 409 on race;
                              hard size cap.
GET  /api/devices             bearer link token; ids + timestamps only.
DELETE /api/devices/<id>      X-Ceremony-Proof (fresh ceremony) β€” NOT a link token.
DELETE /api/credentials/<id>  X-Ceremony-Proof; refuses to remove the last
                              passkey (that would orphan the blob).
DELETE /api/blob              X-Ceremony-Proof (fresh ceremony).

The asymmetry is deliberate: a link token authorises exactly blob read/write and the devices listing β€” nothing else. Anything that changes who can get in (adding a passkey, removing one, revoking devices, deleting the blob) requires a live passkey ceremony β€” a stolen token can't lock you out, and it can't enrol an attacker's passkey to escalate itself into ceremony power either.

Build, run, deploy

go build ./home                    # single static binary, UI embedded
LCHAT_HOME_ORIGIN=http://localhost:1748 ./home &   # env: ADDR, LCHAT_HOME_DB
go test ./home

LCHAT_HOME_ORIGIN is the public origin (the WebAuthn RP identity) β€” pick the hostname once; changing it orphans existing passkeys. In production it runs as the lchat-home systemd service on the router box (unix socket, fronted by the router): build the home binary, copy it up, systemctl restart lchat-home β€” data-safe; the DB at /data/home.db is never touched by a redeploy.

Optional: serving the planet-scale guide at /infrastructure/

LCHAT_HOME_INFRASTRUCTURE_DIR=<path> serves the operator guide (docs/planet-scale-guide/ in this repo) at /infrastructure/, read straight from that directory on disk β€” unset (the default) means the route 404s. The guide's source stays in the repo; only a deploy step copies its built files onto the box:

scp -r docs/planet-scale-guide/* root@<box>:/data/infrastructure/
# then set LCHAT_HOME_INFRASTRUCTURE_DIR=/data/infrastructure in home's
# environment and restart

Re-run the scp (it overwrites in place) whenever the guide changes; no restart is needed for guide-only content updates since it's read from disk on every request, not cached in the process. A restart is only needed the first time LCHAT_HOME_INFRASTRUCTURE_DIR is set.