eleven / messenger Public

Home server MVP: anonymous passkey-gated backup store (#112)

* home: anonymous passkey-gated blob store (server, web UI, tests)

The home-server MVP from docs/onboarding-and-home-server.md: a separate
static Go binary (go build ./home) that stores, per anonymous user, WebAuthn
credential public keys, ONE opaque client-encrypted blob (plaintext padded to
a fixed 16 KiB before encryption; last 5 versions kept), and hashed device
link tokens. No email/username/display name anywhere.

Ceremonies via github.com/go-webauthn/webauthn (discoverable, resident-key +
user-verification required). The PRF extension is requested and evaluated
client-side only: PRF -> HKDF -> KEK, envelope encryption (random DEK wrapped
per-passkey), and the serializer strips PRF results so the output never
reaches the server. Destructive ops (revoke device, delete blob) demand a
fresh ceremony proof (5-min window, hashed) — a stolen link token can read
and update but never reduce access. begin endpoints are per-IP rate-limited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: deploy script, container config, README

deploy-home.sh mirrors deploy-instance.sh (templated per-instance app.toml,
data-safe redeploys, no admin token — there is none). The RP origin is the
one per-instance knob; changing the hostname later orphans passkeys, so the
script says to pick it once. README records the additive-only invariant and
the who-sees-what table from the design doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: security review fixes — link tokens can't enrol passkeys; no dormant postMessage receiver; crypto tests

1. CRITICAL: register/begin no longer accepts a link token to attach a new
   credential to an existing account — that escalated a stolen read/write
   token into permanent takeover (enrol attacker passkey -> login -> fresh
   ceremony proof -> destroy blob, revoke devices). Adding a passkey now
   requires a FRESH ceremony proof; a presented link token is refused (403)
   rather than ignored, so a client meaning "add to my account" can't
   silently enrol a second, empty account. Link tokens authorise exactly
   blob read/write + the devices listing. Also adds ceremony-gated
   DELETE /api/credentials/{id} so a rogue/lost passkey can be removed,
   refusing to delete the last credential (that would orphan the blob —
   blob delete is the wind-down path).

2. IMPORTANT: the postMessage receiver stub is removed from home.js — it
   accepted eleven-home-add entries from ANY origin, letting any site that
   window.open'd the page inject a spoofed server entry into the synced
   backup. No receiver ships until the sender does; the comment records
   that it must land with a strict e.origin allowlist + user confirmation.

3. IMPORTANT: home/web/static/crypto.js now has Node test coverage
   (home/test/crypto.test.mjs, wired into npm test): pad/unpad round-trips
   incl. the BLOB_PAD-4 boundary + over-limit rejection, fixed-size padding,
   seal/open round-trip, wrong-KEK unwrap failure, and the two-passkey
   envelope where each KEK opens the blob.

Tests updated/added: TestRegisterBeginOptions (proof-gated exclusions),
TestRegisterBeginRefusesLinkToken, TestCredentialDeleteRequiresCeremony.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 2c546154caf13326720db59d1df7a3afbe0694d5 parent 6489be5
15 files changed, +2426 −1
  • deploy-home.sh +42 −0
  • go.mod +6 −0
  • go.sum +18 −0
  • home/Dockerfile +21 −0
  • home/README.md +90 −0
  • home/app.toml +34 −0
  • home/db.go +364 −0
  • home/home_test.go +408 −0
  • home/main.go +421 −0
  • home/test/crypto.test.mjs +129 −0
  • home/web/pages/index.html +103 −0
  • home/web/static/crypto.js +150 −0
  • home/web/static/home.js +361 −0
  • home/webauthn.go +278 −0
  • package.json +1 −1
diff --git a/deploy-home.sh b/deploy-home.sh
new file mode 100755
index 0000000..0e356b4
--- /dev/null
+++ b/deploy-home.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+# Deploy the home server (home/ — the anonymous passkey-gated backup store) as
+# its own Miren app. Mirrors deploy-instance.sh: templates a throwaway app.toml
+# in a temp dir so git stays clean; a plain redeploy PRESERVES the disk (never
+# delete the app to update it — that wipes users' backups).
+#
+# Usage: ./deploy-home.sh <instance> <hostname>
+# ./deploy-home.sh home home.elevenmessenger.com
+#
+# There is no admin token: the home server has no admin surface. The only
+# per-instance knob is LCHAT_HOME_ORIGIN (the WebAuthn Relying Party origin) —
+# derived from <hostname> here. NOTE: changing the hostname later orphans
+# existing passkeys (they're bound to the RP ID), so pick it once.
+set -euo pipefail
+
+INSTANCE="${1:?usage: deploy-home.sh <instance> <hostname>}"
+HOST="${2:?usage: deploy-home.sh <instance> <hostname>}"
+CLUSTER="${MIREN_CLUSTER:-luanchat}"
+
+REPO="$(cd "$(dirname "$0")" && pwd)"
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+
+# Build context = the source, with instance-specific app name + disk name.
+# The Dockerfile and app.toml live under home/ (keeping the messenger's
+# defaults untouched); stage them where Miren expects them.
+cp -R "$REPO"/. "$TMP"/
+rm -rf "$TMP/.git"
+cp "$REPO/home/Dockerfile" "$TMP/Dockerfile.home"
+sed -e "s/^name = \"home\"\$/name = \"$INSTANCE\"/" \
+ -e "s/^name = \"home-data\"\$/name = \"${INSTANCE}-data\"/" \
+ "$REPO/home/app.toml" > "$TMP/.miren/app.toml"
+
+echo "Deploying '$INSTANCE' (disk: ${INSTANCE}-data, host: $HOST, cluster: $CLUSTER)"
+miren deploy -d "$TMP" -C "$CLUSTER" -f \
+ -e LCHAT_HOME_DB=/data/home.db -e "LCHAT_HOME_ORIGIN=https://$HOST"
+miren route set "$HOST" "$INSTANCE" -C "$CLUSTER"
+
+echo
+echo "✅ '$INSTANCE' deployed and routed to $HOST"
+echo " No admin token exists (nothing to store). Verify at https://$HOST/"
+echo " Last step (if needed): add DNS $HOST A -> <server IP> (TLS auto-provisions after)."
diff --git a/go.mod b/go.mod
index 67190ed..ac49e79 100644
--- a/go.mod
+++ b/go.mod
@@ -5,17 +5,23 @@ go 1.23
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/coder/websocket v1.8.12
+ github.com/go-webauthn/webauthn v0.11.2
modernc.org/sqlite v1.34.4
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/fxamacker/cbor/v2 v2.7.0 // indirect
+ github.com/go-webauthn/x v0.1.14 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
+ github.com/google/go-tpm v0.9.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sys v0.28.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
diff --git a/go.sum b/go.sum
index 19381e0..4fa1263 100644
--- a/go.sum
+++ b/go.sum
@@ -2,11 +2,21 @@ github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
+github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
+github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
+github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
+github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
+github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
+github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -15,12 +25,18 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@@ -90,6 +106,8 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
diff --git a/home/Dockerfile b/home/Dockerfile
new file mode 100644
index 0000000..b26a0ac
--- /dev/null
+++ b/home/Dockerfile
@@ -0,0 +1,21 @@
+# The home server: single static Go binary, built from the repo root so the
+# shared go.mod resolves. deploy-home.sh copies this file to the build context
+# root as "Dockerfile.home".
+
+# ---- build ----
+FROM golang:1.23-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+# Pure-Go SQLite (modernc) means we can build a fully static binary with no cgo.
+RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /home-server ./home
+
+# ---- run ----
+# distroless/static: ~2MB, no shell, runs as root so it can write to the
+# mounted data volume. The frontend and SQLite engine are embedded.
+FROM gcr.io/distroless/static-debian12
+COPY --from=build /home-server /home-server
+# Runtime config comes from env: LCHAT_HOME_DB, LCHAT_HOME_ORIGIN, ADDR.
+EXPOSE 1748
+ENTRYPOINT ["/home-server"]
diff --git a/home/README.md b/home/README.md
new file mode 100644
index 0000000..fac2ba8
--- /dev/null
+++ b/home/README.md
@@ -0,0 +1,90 @@
+# 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
+
+```sh
+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. Deploy with
+`./deploy-home.sh <instance> <hostname>` (data-safe redeploys, same rules as
+`deploy-instance.sh`: never delete the app to update it).
diff --git a/home/app.toml b/home/app.toml
new file mode 100644
index 0000000..273bf03
--- /dev/null
+++ b/home/app.toml
@@ -0,0 +1,34 @@
+# Miren deployment config template for the home server (see deploy-home.sh,
+# which copies this to .miren/app.toml with the instance name substituted —
+# mirrors how deploy-instance.sh templates the messenger's app.toml).
+
+name = "home"
+
+[build]
+# deploy-home.sh places home/Dockerfile at the context root under this name.
+dockerfile = "Dockerfile.home"
+
+[services.web]
+# Miren terminates TLS and routes external HTTPS to this container port.
+# The app listens on :1748 by default (or $ADDR), so these must match.
+port = 1748
+
+# A persistent disk requires fixed concurrency; single instance also suits
+# SQLite's single-writer model.
+[services.web.concurrency]
+mode = "fixed"
+num_instances = 1
+
+[[services.web.disks]]
+name = "home-data"
+mount_path = "/data"
+size_gb = 1
+
+[[env]]
+key = "LCHAT_HOME_DB"
+value = "/data/home.db"
+
+# LCHAT_HOME_ORIGIN (the public https origin — the WebAuthn RP identity) is set
+# at deploy time by deploy-home.sh via `miren deploy -e`, since it depends on
+# the hostname being routed. There is deliberately NO admin token: the server
+# has no admin surface, and no key material to protect.
diff --git a/home/db.go b/home/db.go
new file mode 100644
index 0000000..ac4802e
--- /dev/null
+++ b/home/db.go
@@ -0,0 +1,364 @@
+package main
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "time"
+
+ "github.com/go-webauthn/webauthn/webauthn"
+ _ "modernc.org/sqlite"
+)
+
+// blobKeepVersions is how many blob versions the server retains per user —
+// overwrite protection against a stolen link token trashing the backup (the
+// token can write, but the previous versions survive until pushed out).
+const blobKeepVersions = 5
+
+// ceremonyWindow is how long a ceremony proof stays "fresh". Destructive ops
+// (revoking devices, deleting the blob) demand a passkey ceremony completed
+// within this window, so a stolen link token can never reduce access.
+const ceremonyWindow = 5 * time.Minute
+
+// DB wraps the SQLite connection. The home server is content-blind: the one
+// blob per user is opaque, client-encrypted bytes; credentials are WebAuthn
+// public keys (useless without the authenticator); tokens are stored hashed.
+// There is deliberately no email, username, or display name anywhere.
+type DB struct{ sql *sql.DB }
+
+func openDB(path string) (*DB, error) {
+ d, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ // One writer at a time keeps WAL happy and avoids "database is locked".
+ d.SetMaxOpenConns(1)
+ for _, p := range []string{
+ "PRAGMA journal_mode=WAL",
+ "PRAGMA busy_timeout=5000",
+ "PRAGMA synchronous=NORMAL",
+ "PRAGMA foreign_keys=ON",
+ } {
+ if _, err := d.Exec(p); err != nil {
+ return nil, err
+ }
+ }
+ db := &DB{sql: d}
+ if err := db.migrate(); err != nil {
+ return nil, err
+ }
+ return db, nil
+}
+
+func (db *DB) migrate() error {
+ _, err := db.sql.Exec(`
+CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY, -- random; the only identity there is
+ created_at INTEGER NOT NULL
+);
+CREATE TABLE IF NOT EXISTS credentials (
+ cred_id TEXT PRIMARY KEY, -- base64url WebAuthn credential id
+ user_id TEXT NOT NULL,
+ credential TEXT NOT NULL, -- webauthn.Credential JSON (public key, sign count, flags)
+ created_at INTEGER NOT NULL
+);
+-- One logical blob per user, kept as its last few versions (overwrite
+-- protection). The latest version is the blob; older rows are history.
+CREATE TABLE IF NOT EXISTS blobs (
+ user_id TEXT NOT NULL,
+ version INTEGER NOT NULL,
+ data BLOB NOT NULL,
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY (user_id, version)
+);
+-- Device link tokens: bearer credentials minted after a verified ceremony.
+-- Only the hash is stored (cf. device_tokens in the messenger) — a stolen DB
+-- yields no live credential. id is a separate random handle so the devices
+-- list can name a token without revealing anything derived from it.
+CREATE TABLE IF NOT EXISTS link_tokens (
+ id TEXT PRIMARY KEY,
+ token_hash TEXT NOT NULL UNIQUE,
+ user_id TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ last_seen INTEGER NOT NULL
+);
+-- Ceremony proofs: short-lived, hashed bearer values minted by login/finish.
+-- Presenting one proves a passkey ceremony happened within ceremonyWindow —
+-- the gate for destructive ops. Expired rows are pruned opportunistically.
+CREATE TABLE IF NOT EXISTS ceremony_proofs (
+ proof_hash TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+);`)
+ return err
+}
+
+func hashToken(t string) string {
+ h := sha256.Sum256([]byte(t))
+ return hex.EncodeToString(h[:])
+}
+
+func randToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// ---- users + credentials --------------------------------------------------
+
+func (db *DB) createUser(id string) error {
+ _, err := db.sql.Exec("INSERT INTO users(id, created_at) VALUES(?, ?)", id, time.Now().Unix())
+ return err
+}
+
+func (db *DB) userExists(id string) bool {
+ var x int
+ return db.sql.QueryRow("SELECT 1 FROM users WHERE id = ?", id).Scan(&x) == nil
+}
+
+// insertCredential stores a verified WebAuthn credential as the library's own
+// JSON — public key, sign count, and flags travel together, so upgrades that
+// add fields stay additive.
+func (db *DB) insertCredential(userID string, cred *webauthn.Credential) error {
+ blob, err := json.Marshal(cred)
+ if err != nil {
+ return err
+ }
+ _, err = db.sql.Exec(
+ "INSERT INTO credentials(cred_id, user_id, credential, created_at) VALUES(?, ?, ?, ?)",
+ base64.RawURLEncoding.EncodeToString(cred.ID), userID, string(blob), time.Now().Unix(),
+ )
+ return err
+}
+
+// updateCredential persists post-ceremony state (sign count, backup flags).
+func (db *DB) updateCredential(cred *webauthn.Credential) error {
+ blob, err := json.Marshal(cred)
+ if err != nil {
+ return err
+ }
+ _, err = db.sql.Exec(
+ "UPDATE credentials SET credential = ? WHERE cred_id = ?",
+ string(blob), base64.RawURLEncoding.EncodeToString(cred.ID),
+ )
+ return err
+}
+
+func (db *DB) credentialsFor(userID string) ([]webauthn.Credential, error) {
+ rows, err := db.sql.Query("SELECT credential FROM credentials WHERE user_id = ? ORDER BY created_at", userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []webauthn.Credential{}
+ for rows.Next() {
+ var blob string
+ if err := rows.Scan(&blob); err != nil {
+ return nil, err
+ }
+ var c webauthn.Credential
+ if err := json.Unmarshal([]byte(blob), &c); err != nil {
+ return nil, err
+ }
+ out = append(out, c)
+ }
+ return out, rows.Err()
+}
+
+// deleteCredential removes one of a user's passkeys (a rogue or lost one).
+// It refuses to remove the LAST credential: the blob would be orphaned (no
+// passkey could ever unwrap its DEK again) while staying live on the server —
+// the explicit path for "I'm done" is the ceremony-gated blob delete.
+// deleted=false with last=false means the id is unknown (or someone else's).
+func (db *DB) deleteCredential(userID, credID string) (deleted, last bool, err error) {
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return false, false, err
+ }
+ defer tx.Rollback()
+ var owned int
+ e := tx.QueryRow("SELECT 1 FROM credentials WHERE user_id = ? AND cred_id = ?", userID, credID).Scan(&owned)
+ if e == sql.ErrNoRows {
+ return false, false, nil
+ }
+ if e != nil {
+ return false, false, e
+ }
+ var n int
+ if err := tx.QueryRow("SELECT COUNT(*) FROM credentials WHERE user_id = ?", userID).Scan(&n); err != nil {
+ return false, false, err
+ }
+ if n <= 1 {
+ return false, true, nil
+ }
+ if _, err := tx.Exec("DELETE FROM credentials WHERE user_id = ? AND cred_id = ?", userID, credID); err != nil {
+ return false, false, err
+ }
+ return true, false, tx.Commit()
+}
+
+// ---- the blob ---------------------------------------------------------------
+
+// getBlob returns the latest version of a user's blob.
+func (db *DB) getBlob(userID string) (data []byte, version int64, ok bool, err error) {
+ err = db.sql.QueryRow(
+ "SELECT data, version FROM blobs WHERE user_id = ? ORDER BY version DESC LIMIT 1", userID,
+ ).Scan(&data, &version)
+ if err == sql.ErrNoRows {
+ return nil, 0, false, nil
+ }
+ if err != nil {
+ return nil, 0, false, err
+ }
+ return data, version, true, nil
+}
+
+// putBlob writes the next version if ifMatch equals the current one (0 = no
+// blob yet). On mismatch it reports conflict=true with the current version so
+// the caller can 409 + ETag; the client merges and retries. Old versions past
+// blobKeepVersions are pruned.
+func (db *DB) putBlob(userID string, data []byte, ifMatch int64) (newVersion int64, conflict bool, err error) {
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return 0, false, err
+ }
+ defer tx.Rollback()
+ var cur int64
+ if err := tx.QueryRow("SELECT COALESCE(MAX(version), 0) FROM blobs WHERE user_id = ?", userID).Scan(&cur); err != nil {
+ return 0, false, err
+ }
+ if cur != ifMatch {
+ return cur, true, nil
+ }
+ newVersion = cur + 1
+ if _, err := tx.Exec(
+ "INSERT INTO blobs(user_id, version, data, updated_at) VALUES(?, ?, ?, ?)",
+ userID, newVersion, data, time.Now().Unix(),
+ ); err != nil {
+ return 0, false, err
+ }
+ if _, err := tx.Exec(
+ "DELETE FROM blobs WHERE user_id = ? AND version <= ?", userID, newVersion-blobKeepVersions,
+ ); err != nil {
+ return 0, false, err
+ }
+ return newVersion, false, tx.Commit()
+}
+
+// deleteBlob removes every version — history included, that's the point of the
+// ceremony gate in front of it.
+func (db *DB) deleteBlob(userID string) error {
+ _, err := db.sql.Exec("DELETE FROM blobs WHERE user_id = ?", userID)
+ return err
+}
+
+// ---- device link tokens -----------------------------------------------------
+
+// createLinkToken mints a bearer token for a user and returns the plaintext
+// (shown/stored client-side once; only the hash persists here).
+func (db *DB) createLinkToken(userID string) (string, error) {
+ tok, err := randToken()
+ if err != nil {
+ return "", err
+ }
+ id, err := randToken()
+ if err != nil {
+ return "", err
+ }
+ now := time.Now().Unix()
+ _, err = db.sql.Exec(
+ "INSERT INTO link_tokens(id, token_hash, user_id, created_at, last_seen) VALUES(?, ?, ?, ?, ?)",
+ id, hashToken(tok), userID, now, now,
+ )
+ return tok, err
+}
+
+// linkTokenUser resolves a presented token to its user and bumps last_seen.
+func (db *DB) linkTokenUser(token string) (string, bool) {
+ var uid string
+ if db.sql.QueryRow("SELECT user_id FROM link_tokens WHERE token_hash = ?", hashToken(token)).Scan(&uid) != nil {
+ return "", false
+ }
+ db.sql.Exec("UPDATE link_tokens SET last_seen = ? WHERE token_hash = ?", time.Now().Unix(), hashToken(token))
+ return uid, true
+}
+
+// Device is link-token metadata for the devices list — ids and timestamps
+// only, never token values (or anything derived from them).
+type Device struct {
+ ID string `json:"id"`
+ CreatedAt int64 `json:"created_at"`
+ LastSeen int64 `json:"last_seen"`
+ Current bool `json:"current,omitempty"`
+}
+
+func (db *DB) listDevices(userID string) ([]Device, error) {
+ rows, err := db.sql.Query(
+ "SELECT id, created_at, last_seen FROM link_tokens WHERE user_id = ? ORDER BY created_at", userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []Device{}
+ for rows.Next() {
+ var d Device
+ if err := rows.Scan(&d.ID, &d.CreatedAt, &d.LastSeen); err != nil {
+ return nil, err
+ }
+ out = append(out, d)
+ }
+ return out, rows.Err()
+}
+
+// linkTokenID returns the device id of a presented token, so the devices list
+// can mark "this device".
+func (db *DB) linkTokenID(token string) string {
+ var id string
+ db.sql.QueryRow("SELECT id FROM link_tokens WHERE token_hash = ?", hashToken(token)).Scan(&id)
+ return id
+}
+
+// deleteDevice revokes one of the user's link tokens. ok=false when the id
+// doesn't exist or belongs to someone else.
+func (db *DB) deleteDevice(userID, id string) (bool, error) {
+ res, err := db.sql.Exec("DELETE FROM link_tokens WHERE user_id = ? AND id = ?", userID, id)
+ if err != nil {
+ return false, err
+ }
+ n, _ := res.RowsAffected()
+ return n > 0, nil
+}
+
+// ---- ceremony proofs ----------------------------------------------------------
+
+// createCeremonyProof mints the short-lived proof returned by a verified
+// passkey ceremony (login/register finish). Expired rows are swept here so the
+// table never needs external housekeeping.
+func (db *DB) createCeremonyProof(userID string) (string, error) {
+ proof, err := randToken()
+ if err != nil {
+ return "", err
+ }
+ now := time.Now().Unix()
+ db.sql.Exec("DELETE FROM ceremony_proofs WHERE created_at < ?", now-int64(ceremonyWindow.Seconds()))
+ _, err = db.sql.Exec(
+ "INSERT INTO ceremony_proofs(proof_hash, user_id, created_at) VALUES(?, ?, ?)",
+ hashToken(proof), userID, now,
+ )
+ return proof, err
+}
+
+// ceremonyUser resolves a presented proof, honouring the freshness window.
+func (db *DB) ceremonyUser(proof string) (string, bool) {
+ var uid string
+ err := db.sql.QueryRow(
+ "SELECT user_id FROM ceremony_proofs WHERE proof_hash = ? AND created_at >= ?",
+ hashToken(proof), time.Now().Unix()-int64(ceremonyWindow.Seconds()),
+ ).Scan(&uid)
+ return uid, err == nil
+}
diff --git a/home/home_test.go b/home/home_test.go
new file mode 100644
index 0000000..509fb9b
--- /dev/null
+++ b/home/home_test.go
@@ -0,0 +1,408 @@
+package main
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+func newTestServer(t *testing.T) (*httptest.Server, *Server) {
+ t.Helper()
+ db, err := openDB(t.TempDir() + "/home.db")
+ if err != nil {
+ t.Fatalf("openDB: %v", err)
+ }
+ s, err := newServer(db, "http://localhost:1748")
+ if err != nil {
+ t.Fatalf("newServer: %v", err)
+ }
+ ts := httptest.NewServer(s.handler())
+ t.Cleanup(ts.Close)
+ return ts, s
+}
+
+// seedUser creates a user with one credential (a real webauthn.Credential, as
+// a verified ceremony would store) and a device link token — the state a
+// register/finish leaves behind, without simulating an authenticator.
+func seedUser(t *testing.T, s *Server) (userID, token string) {
+ t.Helper()
+ userID, err := randToken()
+ if err != nil {
+ t.Fatalf("randToken: %v", err)
+ }
+ if err := s.db.createUser(userID); err != nil {
+ t.Fatalf("createUser: %v", err)
+ }
+ cred := &webauthn.Credential{
+ ID: []byte("cred-" + userID[:8]),
+ PublicKey: []byte("test-cose-public-key"),
+ Authenticator: webauthn.Authenticator{
+ AAGUID: make([]byte, 16),
+ SignCount: 1,
+ },
+ }
+ if err := s.db.insertCredential(userID, cred); err != nil {
+ t.Fatalf("insertCredential: %v", err)
+ }
+ token, err = s.db.createLinkToken(userID)
+ if err != nil {
+ t.Fatalf("createLinkToken: %v", err)
+ }
+ return userID, token
+}
+
+func do(t *testing.T, method, url, token string, headers map[string]string, body []byte) *http.Response {
+ t.Helper()
+ req, _ := http.NewRequest(method, url, bytes.NewReader(body))
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ return resp
+}
+
+func TestBlobAuthGating(t *testing.T) {
+ ts, _ := newTestServer(t)
+ if r := do(t, "GET", ts.URL+"/api/blob", "", nil, nil); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("GET no token: want 401, got %d", r.StatusCode)
+ }
+ if r := do(t, "PUT", ts.URL+"/api/blob", "", map[string]string{"If-Match": `"0"`}, []byte("x")); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("PUT no token: want 401, got %d", r.StatusCode)
+ }
+ if r := do(t, "GET", ts.URL+"/api/blob", "wrong", nil, nil); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("GET bad token: want 401, got %d", r.StatusCode)
+ }
+}
+
+func TestBlobPutGet304(t *testing.T) {
+ ts, s := newTestServer(t)
+ _, tok := seedUser(t, s)
+
+ // No blob yet.
+ if r := do(t, "GET", ts.URL+"/api/blob", tok, nil, nil); r.StatusCode != http.StatusNotFound {
+ t.Fatalf("empty GET: want 404, got %d", r.StatusCode)
+ }
+
+ // First write claims version 0 -> 1.
+ blob := []byte("opaque-ciphertext")
+ r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, blob)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("PUT: want 200, got %d", r.StatusCode)
+ }
+ if got := r.Header.Get("ETag"); got != `"1"` {
+ t.Fatalf("PUT ETag: want %q, got %q", `"1"`, got)
+ }
+
+ r = do(t, "GET", ts.URL+"/api/blob", tok, nil, nil)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("GET: want 200, got %d", r.StatusCode)
+ }
+ if got := r.Header.Get("ETag"); got != `"1"` {
+ t.Fatalf("GET ETag: want %q, got %q", `"1"`, got)
+ }
+ body, _ := io.ReadAll(r.Body)
+ if !bytes.Equal(body, blob) {
+ t.Fatalf("GET body mismatch")
+ }
+
+ // Conditional read: matching version returns 304 with no body.
+ r = do(t, "GET", ts.URL+"/api/blob", tok, map[string]string{"If-None-Match": `"1"`}, nil)
+ if r.StatusCode != http.StatusNotModified {
+ t.Fatalf("If-None-Match: want 304, got %d", r.StatusCode)
+ }
+ // A stale version still gets the full blob.
+ r = do(t, "GET", ts.URL+"/api/blob", tok, map[string]string{"If-None-Match": `"0"`}, nil)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("stale If-None-Match: want 200, got %d", r.StatusCode)
+ }
+}
+
+func TestBlobVersionConflict(t *testing.T) {
+ ts, s := newTestServer(t)
+ _, tok := seedUser(t, s)
+
+ if r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, []byte("v1")); r.StatusCode != http.StatusOK {
+ t.Fatalf("first PUT: want 200, got %d", r.StatusCode)
+ }
+ // A second writer that still believes version 0 must lose.
+ r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, []byte("v1-race"))
+ if r.StatusCode != http.StatusConflict {
+ t.Fatalf("stale PUT: want 409, got %d", r.StatusCode)
+ }
+ if got := r.Header.Get("ETag"); got != `"1"` {
+ t.Fatalf("conflict ETag should name the current version: want %q, got %q", `"1"`, got)
+ }
+ // If-Match is mandatory: an unconditional overwrite is not a thing.
+ if r := do(t, "PUT", ts.URL+"/api/blob", tok, nil, []byte("x")); r.StatusCode != http.StatusPreconditionRequired {
+ t.Fatalf("missing If-Match: want 428, got %d", r.StatusCode)
+ }
+}
+
+func TestBlobSizeCap(t *testing.T) {
+ ts, s := newTestServer(t)
+ _, tok := seedUser(t, s)
+ big := make([]byte, maxBlobBytes+1)
+ r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, big)
+ if r.StatusCode != http.StatusRequestEntityTooLarge {
+ t.Fatalf("oversize PUT: want 413, got %d", r.StatusCode)
+ }
+ // At the cap is fine.
+ ok := make([]byte, maxBlobBytes)
+ if r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, ok); r.StatusCode != http.StatusOK {
+ t.Fatalf("cap-size PUT: want 200, got %d", r.StatusCode)
+ }
+}
+
+func TestBlobVersionHistory(t *testing.T) {
+ ts, s := newTestServer(t)
+ uid, tok := seedUser(t, s)
+ for i := 0; i < blobKeepVersions+2; i++ {
+ r := do(t, "PUT", ts.URL+"/api/blob", tok,
+ map[string]string{"If-Match": `"` + itoa(i) + `"`}, []byte("v"+itoa(i+1)))
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("PUT %d: want 200, got %d", i+1, r.StatusCode)
+ }
+ }
+ var n, min int
+ s.db.sql.QueryRow("SELECT COUNT(*), MIN(version) FROM blobs WHERE user_id = ?", uid).Scan(&n, &min)
+ if n != blobKeepVersions {
+ t.Fatalf("history: want %d versions kept, got %d", blobKeepVersions, n)
+ }
+ if min != 3 { // 7 writes, keep 5 -> versions 3..7
+ t.Fatalf("history: want oldest kept = 3, got %d", min)
+ }
+}
+
+func itoa(i int) string { return strconv.Itoa(i) }
+
+func TestDevicesListing(t *testing.T) {
+ ts, s := newTestServer(t)
+ uid, tok := seedUser(t, s)
+ tok2, _ := s.db.createLinkToken(uid) // a second device
+
+ if r := do(t, "GET", ts.URL+"/api/devices", "", nil, nil); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no token: want 401, got %d", r.StatusCode)
+ }
+ r := do(t, "GET", ts.URL+"/api/devices", tok, nil, nil)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("list: want 200, got %d", r.StatusCode)
+ }
+ raw, _ := io.ReadAll(r.Body)
+ // Token values (or hashes) must never appear in the listing.
+ for _, secret := range []string{tok, tok2, hashToken(tok), hashToken(tok2)} {
+ if strings.Contains(string(raw), secret) {
+ t.Fatalf("devices listing leaks a token value")
+ }
+ }
+ var out struct{ Devices []Device }
+ if err := json.Unmarshal(raw, &out); err != nil {
+ t.Fatalf("bad json: %v", err)
+ }
+ if len(out.Devices) != 2 {
+ t.Fatalf("want 2 devices, got %d", len(out.Devices))
+ }
+ currents := 0
+ for _, d := range out.Devices {
+ if d.ID == "" || d.CreatedAt == 0 || d.LastSeen == 0 {
+ t.Fatalf("device metadata incomplete: %+v", d)
+ }
+ if d.Current {
+ currents++
+ }
+ }
+ if currents != 1 {
+ t.Fatalf("exactly one device should be marked current, got %d", currents)
+ }
+}
+
+func TestDestructiveOpsRequireCeremony(t *testing.T) {
+ ts, s := newTestServer(t)
+ uid, tok := seedUser(t, s)
+ do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, []byte("precious"))
+
+ devices, _ := s.db.listDevices(uid)
+ devID := devices[0].ID
+
+ // A link token alone must never authorise a destructive op — that's the
+ // whole point of the proof mechanism (a stolen token can't lock you out).
+ if r := do(t, "DELETE", ts.URL+"/api/blob", tok, nil, nil); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("blob delete with link token: want 401, got %d", r.StatusCode)
+ }
+ if r := do(t, "DELETE", ts.URL+"/api/devices/"+devID, tok, nil, nil); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("device delete with link token: want 401, got %d", r.StatusCode)
+ }
+
+ // A STALE proof (older than the freshness window) is refused.
+ stale, err := s.db.createCeremonyProof(uid)
+ if err != nil {
+ t.Fatalf("proof: %v", err)
+ }
+ s.db.sql.Exec("UPDATE ceremony_proofs SET created_at = created_at - 600 WHERE proof_hash = ?", hashToken(stale))
+ if r := do(t, "DELETE", ts.URL+"/api/blob", "", map[string]string{"X-Ceremony-Proof": stale}, nil); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("stale proof: want 401, got %d", r.StatusCode)
+ }
+
+ // A fresh proof from ANOTHER user can't touch this user's devices.
+ other, _ := seedUser(t, s)
+ otherProof, _ := s.db.createCeremonyProof(other)
+ if r := do(t, "DELETE", ts.URL+"/api/devices/"+devID, "", map[string]string{"X-Ceremony-Proof": otherProof}, nil); r.StatusCode != http.StatusNotFound {
+ t.Fatalf("cross-user proof: want 404, got %d", r.StatusCode)
+ }
+
+ // A fresh proof for the right user works.
+ proof, _ := s.db.createCeremonyProof(uid)
+ if r := do(t, "DELETE", ts.URL+"/api/devices/"+devID, "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK {
+ t.Fatalf("device delete with fresh proof: want 200, got %d", r.StatusCode)
+ }
+ if r := do(t, "DELETE", ts.URL+"/api/blob", "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK {
+ t.Fatalf("blob delete with fresh proof: want 200, got %d", r.StatusCode)
+ }
+ if _, _, found, _ := s.db.getBlob(uid); found {
+ t.Fatalf("blob (and history) should be gone after ceremony-gated delete")
+ }
+}
+
+func TestRegisterBeginOptions(t *testing.T) {
+ ts, s := newTestServer(t)
+ r := do(t, "POST", ts.URL+"/api/register/begin", "", nil, nil)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("begin: want 200, got %d", r.StatusCode)
+ }
+ var out struct {
+ Session string `json:"session"`
+ Options struct {
+ PublicKey struct {
+ Challenge string `json:"challenge"`
+ AuthenticatorSelection struct {
+ ResidentKey string `json:"residentKey"`
+ } `json:"authenticatorSelection"`
+ ExcludeCredentials []struct {
+ ID string `json:"id"`
+ } `json:"excludeCredentials"`
+ } `json:"publicKey"`
+ } `json:"options"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&out); err != nil {
+ t.Fatalf("bad json: %v", err)
+ }
+ if out.Session == "" || out.Options.PublicKey.Challenge == "" {
+ t.Fatalf("begin response missing session/challenge")
+ }
+ // Login is discoverable-only, so registration must demand a resident key.
+ if out.Options.PublicKey.AuthenticatorSelection.ResidentKey != "required" {
+ t.Fatalf("residentKey: want required, got %q", out.Options.PublicKey.AuthenticatorSelection.ResidentKey)
+ }
+
+ // With a FRESH ceremony proof, begin targets the existing account: its
+ // credentials are excluded so the same authenticator can't double-enrol.
+ uid, _ := seedUser(t, s)
+ creds, _ := s.db.credentialsFor(uid)
+ proof, _ := s.db.createCeremonyProof(uid)
+ r = do(t, "POST", ts.URL+"/api/register/begin", "", map[string]string{"X-Ceremony-Proof": proof}, nil)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("begin with proof: want 200, got %d", r.StatusCode)
+ }
+ if err := json.NewDecoder(r.Body).Decode(&out); err != nil {
+ t.Fatalf("bad json: %v", err)
+ }
+ want := base64.RawURLEncoding.EncodeToString(creds[0].ID)
+ if len(out.Options.PublicKey.ExcludeCredentials) != 1 || out.Options.PublicKey.ExcludeCredentials[0].ID != want {
+ t.Fatalf("expected the existing credential to be excluded, got %+v", out.Options.PublicKey.ExcludeCredentials)
+ }
+}
+
+// A link token must never open an add-a-passkey ceremony: an enrolled
+// credential can mint fresh ceremony proofs, so accepting one here would
+// escalate a stolen read/write token into full account takeover.
+func TestRegisterBeginRefusesLinkToken(t *testing.T) {
+ ts, s := newTestServer(t)
+ _, tok := seedUser(t, s)
+ if r := do(t, "POST", ts.URL+"/api/register/begin", tok, nil, nil); r.StatusCode != http.StatusForbidden {
+ t.Fatalf("begin with link token: want 403, got %d", r.StatusCode)
+ }
+ // An unknown/garbage bearer token is refused the same way, not treated as
+ // an anonymous new enrolment.
+ if r := do(t, "POST", ts.URL+"/api/register/begin", "stolen-or-stale", nil, nil); r.StatusCode != http.StatusForbidden {
+ t.Fatalf("begin with bogus token: want 403, got %d", r.StatusCode)
+ }
+}
+
+func TestCredentialDeleteRequiresCeremony(t *testing.T) {
+ ts, s := newTestServer(t)
+ uid, tok := seedUser(t, s)
+ // A second credential, as register/finish with a fresh proof would store it.
+ second := &webauthn.Credential{ID: []byte("cred-second"), PublicKey: []byte("pk2")}
+ if err := s.db.insertCredential(uid, second); err != nil {
+ t.Fatalf("insertCredential: %v", err)
+ }
+ creds, _ := s.db.credentialsFor(uid)
+ firstID := base64.RawURLEncoding.EncodeToString(creds[0].ID)
+ secondID := base64.RawURLEncoding.EncodeToString(second.ID)
+
+ // A link token must not remove a passkey (it reduces access).
+ if r := do(t, "DELETE", ts.URL+"/api/credentials/"+secondID, tok, nil, nil); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("delete with link token: want 401, got %d", r.StatusCode)
+ }
+ // Nor may another user's fresh proof.
+ other, _ := seedUser(t, s)
+ otherProof, _ := s.db.createCeremonyProof(other)
+ if r := do(t, "DELETE", ts.URL+"/api/credentials/"+secondID, "", map[string]string{"X-Ceremony-Proof": otherProof}, nil); r.StatusCode != http.StatusNotFound {
+ t.Fatalf("cross-user proof: want 404, got %d", r.StatusCode)
+ }
+ // A fresh proof for the owner works.
+ proof, _ := s.db.createCeremonyProof(uid)
+ if r := do(t, "DELETE", ts.URL+"/api/credentials/"+secondID, "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK {
+ t.Fatalf("delete with fresh proof: want 200, got %d", r.StatusCode)
+ }
+ // The LAST credential is refused — deleting it would orphan the blob while
+ // leaving it live. (Blob delete is the wind-down path.)
+ if r := do(t, "DELETE", ts.URL+"/api/credentials/"+firstID, "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusConflict {
+ t.Fatalf("delete last credential: want 409, got %d", r.StatusCode)
+ }
+ if left, _ := s.db.credentialsFor(uid); len(left) != 1 {
+ t.Fatalf("want exactly 1 credential left, got %d", len(left))
+ }
+}
+
+func TestFinishRejectsUnknownSession(t *testing.T) {
+ ts, _ := newTestServer(t)
+ for _, path := range []string{"/api/register/finish", "/api/login/finish"} {
+ r := do(t, "POST", ts.URL+path+"?session=nope", "", nil, []byte("{}"))
+ if r.StatusCode != http.StatusBadRequest {
+ t.Fatalf("%s: want 400, got %d", path, r.StatusCode)
+ }
+ }
+}
+
+func TestBeginEndpointsRateLimit(t *testing.T) {
+ ts, _ := newTestServer(t)
+ limited := false
+ for i := 0; i < 30; i++ {
+ r := do(t, "POST", ts.URL+"/api/register/begin", "", nil, nil)
+ if r.StatusCode == http.StatusTooManyRequests {
+ limited = true
+ break
+ }
+ }
+ if !limited {
+ t.Fatalf("register/begin never rate-limited after 30 rapid requests")
+ }
+ // The bucket is shared per IP across both ceremony begin endpoints.
+ if r := do(t, "POST", ts.URL+"/api/login/begin", "", nil, nil); r.StatusCode != http.StatusTooManyRequests {
+ t.Fatalf("login/begin should share the exhausted bucket: want 429, got %d", r.StatusCode)
+ }
+}
diff --git a/home/main.go b/home/main.go
new file mode 100644
index 0000000..852d2bf
--- /dev/null
+++ b/home/main.go
@@ -0,0 +1,421 @@
+// The Eleven home server: a tiny, anonymous, content-blind, passkey-gated blob
+// store (docs/onboarding-and-home-server.md). It holds, per user: WebAuthn
+// credential public keys, ONE opaque client-encrypted blob (padded client-side
+// so length leaks nothing), and hashed device link tokens. No email, no
+// username, no display name — the credential is the identity. It is strictly
+// additive to the messenger: clients with no enrolment never contact it.
+package main
+
+import (
+ "embed"
+ "encoding/json"
+ "flag"
+ "io"
+ "io/fs"
+ "log"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// maxBlobBytes caps a blob upload. The client pads the plaintext to a fixed
+// 16 KiB before encrypting (see web/static/crypto.js), so every blob's
+// ciphertext is the same size; the extra headroom here is the envelope header
+// (one wrapped DEK per enrolled passkey) plus AES-GCM overhead. The cap is a
+// hard abuse control, not a privacy boundary — sizes below it are already
+// uniform by construction.
+const maxBlobBytes = 18 << 10
+
+//go:embed web/pages/*.html web/static/*
+var webFS embed.FS
+
+type Server struct {
+ db *DB
+ wa *waRelyingParty
+ // sessions holds in-flight WebAuthn ceremony state (challenge -> finish).
+ // In-memory on purpose: ceremonies live seconds, and losing them on a
+ // redeploy just means re-tapping the button.
+ sessions *sessionStore
+ // limiter rate-limits the ceremony begin endpoints per IP — the abuse
+ // surface of an open, anonymous registration endpoint.
+ limiter *rateLimiter
+ page []byte
+}
+
+func main() {
+ addr := flag.String("addr", envOr("ADDR", ":1748"), "listen address (env: ADDR)")
+ dbPath := flag.String("db", envOr("LCHAT_HOME_DB", "home.db"), "path to SQLite database file (env: LCHAT_HOME_DB)")
+ origin := flag.String("origin", envOr("LCHAT_HOME_ORIGIN", "http://localhost:1748"),
+ "public origin, e.g. https://home.elevenmessenger.com (env: LCHAT_HOME_ORIGIN) — WebAuthn RP identity")
+ flag.Parse()
+
+ db, err := openDB(*dbPath)
+ if err != nil {
+ log.Fatalf("open db: %v", err)
+ }
+ s, err := newServer(db, *origin)
+ if err != nil {
+ log.Fatalf("server: %v", err)
+ }
+ log.Printf("home listening on %s (db: %s, origin: %s)", *addr, *dbPath, *origin)
+ if err := http.ListenAndServe(*addr, s.handler()); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func newServer(db *DB, origin string) (*Server, error) {
+ u, err := url.Parse(origin)
+ if err != nil {
+ return nil, err
+ }
+ wa, err := newRelyingParty(u.Hostname(), origin)
+ if err != nil {
+ return nil, err
+ }
+ page, err := webFS.ReadFile("web/pages/index.html")
+ if err != nil {
+ return nil, err
+ }
+ return &Server{
+ db: db,
+ wa: wa,
+ sessions: newSessionStore(),
+ limiter: newRateLimiter(10, 10.0/60), // burst of 10, refills 10/minute
+ page: page,
+ }, nil
+}
+
+// handler wires up all routes. It's a method so tests can exercise the full
+// router via httptest without starting a real listener.
+func (s *Server) handler() http.Handler {
+ staticFS, err := fs.Sub(webFS, "web/static")
+ if err != nil {
+ log.Fatalf("static fs: %v", err)
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", s.servePage)
+ mux.Handle("GET /static/", noCache(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))))
+
+ // WebAuthn ceremonies. begin endpoints are rate-limited: they're the open,
+ // anonymous surface (finish requires a signed response, which is its own gate).
+ mux.HandleFunc("POST /api/register/begin", s.rateLimited(s.registerBegin))
+ mux.HandleFunc("POST /api/register/finish", s.registerFinish)
+ mux.HandleFunc("POST /api/login/begin", s.rateLimited(s.loginBegin))
+ mux.HandleFunc("POST /api/login/finish", s.loginFinish)
+
+ // The blob: read/write with a link token; delete only with a fresh ceremony.
+ mux.HandleFunc("GET /api/blob", s.getBlobHandler)
+ mux.HandleFunc("PUT /api/blob", s.putBlobHandler)
+ mux.HandleFunc("DELETE /api/blob", s.deleteBlobHandler)
+
+ // Devices: list with a link token; revoke only with a fresh ceremony. The
+ // asymmetry is deliberate — a stolen link token must never reduce access.
+ mux.HandleFunc("GET /api/devices", s.listDevicesHandler)
+ mux.HandleFunc("DELETE /api/devices/{id}", s.deleteDeviceHandler)
+
+ // Credentials: removing a passkey reduces who can ever unlock the blob, so
+ // it's ceremony-gated like the other destructive ops.
+ mux.HandleFunc("DELETE /api/credentials/{id}", s.deleteCredentialHandler)
+
+ return securityHeaders(mux)
+}
+
+// securityHeaders adds defense-in-depth headers to every response (mirrors the
+// messenger's). The blob download gets nosniff like any other response here.
+func securityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ h := w.Header()
+ h.Set("X-Content-Type-Options", "nosniff")
+ h.Set("X-Frame-Options", "DENY")
+ h.Set("Referrer-Policy", "no-referrer")
+ h.Set("Strict-Transport-Security", "max-age=31536000")
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (s *Server) servePage(w http.ResponseWriter, r *http.Request) {
+ // "GET /" matches everything; only the exact root serves the page.
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ // No inline scripts on this page, so no nonce needed — everything the CSP
+ // allows is our own embedded modules and styles.
+ w.Header().Set("Content-Security-Policy",
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
+ "img-src 'self' data:; connect-src 'self'; "+
+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write(s.page)
+}
+
+func noCache(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-cache")
+ next.ServeHTTP(w, r)
+ })
+}
+
+// ---- auth helpers ---------------------------------------------------------
+
+// bearerToken pulls the token out of "Authorization: Bearer <t>".
+func bearerToken(r *http.Request) string {
+ h := r.Header.Get("Authorization")
+ if strings.HasPrefix(h, "Bearer ") {
+ return strings.TrimPrefix(h, "Bearer ")
+ }
+ return ""
+}
+
+// linkUser authenticates a request by device link token; ""/false when absent
+// or unknown.
+func (s *Server) linkUser(r *http.Request) (string, bool) {
+ tok := bearerToken(r)
+ if tok == "" {
+ return "", false
+ }
+ return s.db.linkTokenUser(tok)
+}
+
+// ceremonyUser authenticates a request by fresh ceremony proof (the
+// X-Ceremony-Proof header, minted by login/register finish within
+// ceremonyWindow). Required for destructive ops — never accept a link token.
+func (s *Server) ceremonyUser(r *http.Request) (string, bool) {
+ proof := r.Header.Get("X-Ceremony-Proof")
+ if proof == "" {
+ return "", false
+ }
+ return s.db.ceremonyUser(proof)
+}
+
+// ---- blob -------------------------------------------------------------------
+
+func (s *Server) getBlobHandler(w http.ResponseWriter, r *http.Request) {
+ uid, ok := s.linkUser(r)
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ data, version, found, err := s.db.getBlob(uid)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ if !found {
+ http.Error(w, "no blob", http.StatusNotFound)
+ return
+ }
+ etag := `"` + strconv.FormatInt(version, 10) + `"`
+ w.Header().Set("ETag", etag)
+ if r.Header.Get("If-None-Match") == etag {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Write(data)
+}
+
+func (s *Server) putBlobHandler(w http.ResponseWriter, r *http.Request) {
+ uid, ok := s.linkUser(r)
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ // If-Match is mandatory: `"0"` claims "no blob yet", anything else names the
+ // version being replaced. A mismatch means another device wrote first — the
+ // client re-fetches, merges its (small) list, and retries.
+ ifMatch := strings.Trim(r.Header.Get("If-Match"), `"`)
+ if ifMatch == "" {
+ http.Error(w, "If-Match required", http.StatusPreconditionRequired)
+ return
+ }
+ want, err := strconv.ParseInt(ifMatch, 10, 64)
+ if err != nil || want < 0 {
+ http.Error(w, "bad If-Match", http.StatusBadRequest)
+ return
+ }
+ data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBlobBytes))
+ if err != nil {
+ http.Error(w, "blob too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+ if len(data) == 0 {
+ http.Error(w, "empty blob", http.StatusBadRequest)
+ return
+ }
+ newVersion, conflict, err := s.db.putBlob(uid, data, want)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ if conflict {
+ w.Header().Set("ETag", `"`+strconv.FormatInt(newVersion, 10)+`"`)
+ http.Error(w, "version conflict", http.StatusConflict)
+ return
+ }
+ w.Header().Set("ETag", `"`+strconv.FormatInt(newVersion, 10)+`"`)
+ writeJSON(w, http.StatusOK, map[string]any{"version": newVersion})
+}
+
+func (s *Server) deleteBlobHandler(w http.ResponseWriter, r *http.Request) {
+ uid, ok := s.ceremonyUser(r)
+ if !ok {
+ http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized)
+ return
+ }
+ if err := s.db.deleteBlob(uid); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// ---- devices ----------------------------------------------------------------
+
+func (s *Server) listDevicesHandler(w http.ResponseWriter, r *http.Request) {
+ uid, ok := s.linkUser(r)
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ devices, err := s.db.listDevices(uid)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ current := s.db.linkTokenID(bearerToken(r))
+ for i := range devices {
+ devices[i].Current = devices[i].ID == current
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"devices": devices})
+}
+
+func (s *Server) deleteDeviceHandler(w http.ResponseWriter, r *http.Request) {
+ uid, ok := s.ceremonyUser(r)
+ if !ok {
+ http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized)
+ return
+ }
+ deleted, err := s.db.deleteDevice(uid, r.PathValue("id"))
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ if !deleted {
+ http.Error(w, "no such device", http.StatusNotFound)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// ---- credentials -----------------------------------------------------------------
+
+func (s *Server) deleteCredentialHandler(w http.ResponseWriter, r *http.Request) {
+ uid, ok := s.ceremonyUser(r)
+ if !ok {
+ http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized)
+ return
+ }
+ deleted, last, err := s.db.deleteCredential(uid, r.PathValue("id"))
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ if last {
+ // Removing the last passkey would orphan the blob — nothing left could
+ // ever unwrap its DEK — while leaving it live on the server. Winding an
+ // account down is the (ceremony-gated) blob delete, explicitly.
+ http.Error(w, "cannot remove the last passkey — delete the backup instead", http.StatusConflict)
+ return
+ }
+ if !deleted {
+ http.Error(w, "no such credential", http.StatusNotFound)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// ---- rate limiting ------------------------------------------------------------
+
+// rateLimiter is a per-key (IP) token bucket. Buckets refill continuously and
+// idle ones are swept when the map grows, so memory stays bounded.
+type rateLimiter struct {
+ mu sync.Mutex
+ buckets map[string]*bucket
+ burst float64
+ perSec float64
+}
+
+type bucket struct {
+ tokens float64
+ last time.Time
+}
+
+func newRateLimiter(burst, perSec float64) *rateLimiter {
+ return &rateLimiter{buckets: map[string]*bucket{}, burst: burst, perSec: perSec}
+}
+
+func (rl *rateLimiter) allow(key string) bool {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ now := time.Now()
+ if len(rl.buckets) > 10000 {
+ for k, b := range rl.buckets {
+ if b.tokens >= rl.burst || now.Sub(b.last) > time.Hour {
+ delete(rl.buckets, k)
+ }
+ }
+ }
+ b, ok := rl.buckets[key]
+ if !ok {
+ b = &bucket{tokens: rl.burst, last: now}
+ rl.buckets[key] = b
+ }
+ b.tokens += now.Sub(b.last).Seconds() * rl.perSec
+ if b.tokens > rl.burst {
+ b.tokens = rl.burst
+ }
+ b.last = now
+ if b.tokens < 1 {
+ return false
+ }
+ b.tokens--
+ return true
+}
+
+func (s *Server) rateLimited(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ip, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ ip = r.RemoteAddr
+ }
+ if !s.limiter.allow(ip) {
+ http.Error(w, "rate limited", http.StatusTooManyRequests)
+ return
+ }
+ next(w, r)
+ }
+}
+
+// ---- small helpers -------------------------------------------------------------
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ if err := json.NewEncoder(w).Encode(v); err != nil {
+ log.Printf("write json: %v", err)
+ }
+}
+
+func envOr(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
diff --git a/home/test/crypto.test.mjs b/home/test/crypto.test.mjs
new file mode 100644
index 0000000..f8aec1a
--- /dev/null
+++ b/home/test/crypto.test.mjs
@@ -0,0 +1,129 @@
+// Tests for the home server's browser crypto module, run in Node (Node 22 has
+// Web Crypto). Same harness style as test/crypto.test.mjs.
+// node home/test/crypto.test.mjs
+import assert from "node:assert/strict";
+import {
+ b64urlToBytes,
+ bytesToB64url,
+ kekFromPRF,
+ generateDEK,
+ exportDEK,
+ importDEK,
+ wrapDEK,
+ unwrapDEK,
+ pad,
+ unpad,
+ BLOB_PAD,
+ sealBlob,
+ unpackBlob,
+ openBlob,
+} from "../web/static/crypto.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+// A stand-in for the PRF extension output (32 random bytes from the
+// authenticator; only ever seen client-side).
+const fakePRF = () => crypto.getRandomValues(new Uint8Array(32));
+
+await test("pad produces a fixed-size buffer and unpad round-trips", async () => {
+ const msg = new TextEncoder().encode("héllo 🌍");
+ const padded = pad(msg);
+ assert.equal(padded.length, BLOB_PAD, "padded size must always be BLOB_PAD");
+ assert.deepEqual(unpad(padded), msg);
+});
+
+await test("pad: empty and boundary (BLOB_PAD-4) payloads round-trip", async () => {
+ for (const n of [0, 1, BLOB_PAD - 4]) {
+ const msg = crypto.getRandomValues(new Uint8Array(n));
+ const padded = pad(msg);
+ assert.equal(padded.length, BLOB_PAD);
+ assert.deepEqual(unpad(padded), msg);
+ }
+});
+
+await test("pad rejects payloads over the fixed size", async () => {
+ assert.throws(() => pad(new Uint8Array(BLOB_PAD - 3)), /too large/);
+ assert.throws(() => pad(new Uint8Array(BLOB_PAD + 1)), /too large/);
+});
+
+await test("padded size is identical regardless of contents (length leaks nothing)", async () => {
+ const short = pad(new TextEncoder().encode("{}"));
+ const long = pad(crypto.getRandomValues(new Uint8Array(8000)));
+ assert.equal(short.length, long.length);
+});
+
+await test("sealBlob → openBlob round-trips via a PRF-derived KEK", async () => {
+ const prf = fakePRF();
+ const kek = await kekFromPRF(prf);
+ const dek = await generateDEK();
+ const obj = { v: 1, servers: [{ url: "https://family.luan.chat", name: "Family" }] };
+ const sealed = await sealBlob(obj, dek, { "cred-1": await wrapDEK(dek, kek) });
+
+ const { keys } = unpackBlob(sealed);
+ const dek2 = await unwrapDEK(keys["cred-1"], await kekFromPRF(prf));
+ const { obj: out } = await openBlob(sealed, dek2);
+ assert.deepEqual(out, obj);
+});
+
+await test("unwrapDEK with a KEK from the wrong PRF output fails", async () => {
+ const kek = await kekFromPRF(fakePRF());
+ const dek = await generateDEK();
+ const wrapped = await wrapDEK(dek, kek);
+ const wrongKek = await kekFromPRF(fakePRF());
+ await assert.rejects(() => unwrapDEK(wrapped, wrongKek));
+});
+
+await test("envelope wraps the DEK per credential: each of 2 KEKs can open", async () => {
+ const prfA = fakePRF();
+ const prfB = fakePRF();
+ const dek = await generateDEK();
+ const obj = { v: 1, servers: [{ url: "https://test.luan.chat" }] };
+ const sealed = await sealBlob(obj, dek, {
+ "cred-a": await wrapDEK(dek, await kekFromPRF(prfA)),
+ "cred-b": await wrapDEK(dek, await kekFromPRF(prfB)),
+ });
+
+ const { keys } = unpackBlob(sealed);
+ for (const [credId, prf] of [["cred-a", prfA], ["cred-b", prfB]]) {
+ const unwrapped = await unwrapDEK(keys[credId], await kekFromPRF(prf));
+ const { obj: out } = await openBlob(sealed, unwrapped);
+ assert.deepEqual(out, obj, credId + " must open the blob");
+ }
+ // ...and neither KEK unwraps the OTHER credential's blob copy directly.
+ const kekB = await kekFromPRF(prfB);
+ await assert.rejects(() => unwrapDEK(keys["cred-a"], kekB));
+});
+
+await test("ciphertext hides the plaintext and the DEK survives export/import", async () => {
+ const dek = await generateDEK();
+ const obj = { v: 1, servers: [{ url: "https://secret-instance.example" }] };
+ const sealed = await sealBlob(obj, dek, {});
+ assert.ok(!new TextDecoder("utf-8", { fatal: false }).decode(sealed).includes("secret-instance"));
+
+ const dek2 = await importDEK(await exportDEK(dek)); // the silent-sync cache path
+ const { obj: out } = await openBlob(sealed, dek2);
+ assert.deepEqual(out, obj);
+});
+
+await test("unpackBlob rejects non-envelope bytes", async () => {
+ assert.throws(() => unpackBlob(new TextEncoder().encode("NOPE-not-an-envelope")), /not a home blob/);
+});
+
+await test("b64url is URL-safe and round-trips", async () => {
+ const bytes = crypto.getRandomValues(new Uint8Array(64));
+ const s = bytesToB64url(bytes);
+ assert.ok(!/[+/=]/.test(s));
+ assert.deepEqual(b64urlToBytes(s), bytes);
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
new file mode 100644
index 0000000..caa1b6f
--- /dev/null
+++ b/home/web/pages/index.html
@@ -0,0 +1,103 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Eleven backup</title>
+<style>
+ :root { color-scheme: light dark; }
+ * { box-sizing: border-box; }
+ body {
+ margin: 0; padding: 24px 16px 48px;
+ font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: Canvas; color: CanvasText;
+ display: flex; justify-content: center;
+ }
+ main { width: 100%; max-width: 30rem; }
+ h1 { font-size: 1.3rem; margin: 0 0 .25rem; }
+ .sub { margin: 0 0 1.5rem; opacity: .7; font-size: .95rem; }
+ section {
+ border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
+ border-radius: 12px; padding: 16px; margin-bottom: 16px;
+ }
+ section h2 { font-size: 1.05rem; margin: 0 0 .35rem; }
+ section p { margin: .25rem 0 .75rem; font-size: .9rem; opacity: .75; }
+ button {
+ font: inherit; padding: .5rem 1rem; border-radius: 8px; cursor: pointer;
+ border: 1px solid color-mix(in srgb, CanvasText 25%, transparent);
+ background: color-mix(in srgb, CanvasText 6%, Canvas); color: inherit;
+ }
+ button.primary { background: #4457ff; border-color: #4457ff; color: #fff; }
+ button:disabled { opacity: .5; cursor: default; }
+ /* All status/errors are inline, occupying layout space — never a floating
+ toast (product-wide rule). */
+ .status { margin: .75rem 0 0; font-size: .9rem; min-height: 0; }
+ .status.error { color: #d33; }
+ .status.ok { color: #2a8a4a; }
+ .status:empty { display: none; }
+ ul.servers { list-style: none; margin: .75rem 0 0; padding: 0; }
+ ul.servers li {
+ display: flex; align-items: baseline; gap: .5rem; padding: .4rem 0;
+ border-top: 1px solid color-mix(in srgb, CanvasText 12%, transparent);
+ }
+ ul.servers li .name { font-weight: 600; }
+ ul.servers li .url { opacity: .65; font-size: .85rem; overflow-wrap: anywhere; }
+ ul.devices { list-style: none; margin: .75rem 0 0; padding: 0; }
+ ul.devices li {
+ display: flex; align-items: center; gap: .75rem; padding: .4rem 0;
+ border-top: 1px solid color-mix(in srgb, CanvasText 12%, transparent);
+ font-size: .9rem;
+ }
+ ul.devices li .meta { flex: 1; }
+ ul.devices li .meta small { display: block; opacity: .6; }
+ .addform { display: flex; gap: .5rem; margin-top: .75rem; flex-wrap: wrap; }
+ .addform input {
+ font: inherit; flex: 1; min-width: 8rem; padding: .45rem .6rem;
+ border-radius: 8px; border: 1px solid color-mix(in srgb, CanvasText 25%, transparent);
+ background: Canvas; color: inherit;
+ }
+ .hidden { display: none; }
+</style>
+</head>
+<body>
+<main>
+ <h1>Eleven backup</h1>
+ <p class="sub">A passkey-protected, encrypted backup of your chat servers.
+ This server can't read it — only your passkeys can unlock it.</p>
+
+ <section id="create-section">
+ <h2>Create a backup passkey</h2>
+ <p>Saves a passkey on this device and starts an empty, encrypted backup.
+ Use it later to restore your chats on a new device.</p>
+ <button id="create" class="primary">Create a backup passkey</button>
+ <p class="status" id="create-status"></p>
+ </section>
+
+ <section id="restore-section">
+ <h2>Restore</h2>
+ <p>Unlock your backup with a passkey and see the chat servers in it.</p>
+ <button id="restore" class="primary">Restore with passkey</button>
+ <p class="status" id="restore-status"></p>
+ <div id="vault" class="hidden">
+ <ul class="servers" id="server-list"></ul>
+ <form class="addform" id="add-form">
+ <input id="add-name" placeholder="Name" autocomplete="off">
+ <input id="add-url" placeholder="https://family.luan.chat" autocomplete="off" required>
+ <button type="submit">Add</button>
+ </form>
+ <p class="status" id="vault-status"></p>
+ </div>
+ </section>
+
+ <section id="devices-section">
+ <h2>Devices</h2>
+ <p>Devices that can read and update this backup. Revoking one requires a
+ fresh passkey check.</p>
+ <button id="devices">Show devices</button>
+ <ul class="devices hidden" id="device-list"></ul>
+ <p class="status" id="devices-status"></p>
+ </section>
+</main>
+<script type="module" src="/static/home.js"></script>
+</body>
+</html>
diff --git a/home/web/static/crypto.js b/home/web/static/crypto.js
new file mode 100644
index 0000000..0d631fe
--- /dev/null
+++ b/home/web/static/crypto.js
@@ -0,0 +1,150 @@
+// All blob encryption happens in the browser. The passkey's PRF output (only
+// ever visible client-side) derives a per-credential KEK; a random long-lived
+// DEK encrypts the blob; the DEK is wrapped once per enrolled passkey's KEK
+// (envelope encryption — adding a second passkey = wrapping the DEK again).
+// The plaintext is padded to a fixed size BEFORE encryption, so the ciphertext
+// length leaks nothing about the contents. The server only ever stores/relays
+// the opaque envelope produced here.
+
+export function b64urlToBytes(s) {
+ s = s.replace(/-/g, "+").replace(/_/g, "/");
+ while (s.length % 4) s += "=";
+ const bin = atob(s);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+}
+
+export function bytesToB64url(bytes) {
+ let bin = "";
+ for (const b of bytes) bin += String.fromCharCode(b);
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+// encryptBytes/decryptBytes: raw iv(12)||ciphertext, same format as the
+// messenger's crypto.js.
+export async function encryptBytes(key, bytes) {
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, bytes));
+ const out = new Uint8Array(iv.length + ct.length);
+ out.set(iv, 0);
+ out.set(ct, iv.length);
+ return out;
+}
+
+export async function decryptBytes(key, bytes) {
+ const iv = bytes.slice(0, 12);
+ const ct = bytes.slice(12);
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
+ return new Uint8Array(pt);
+}
+
+// ---- PRF -> KEK -----------------------------------------------------------
+
+// The PRF eval input is a fixed, public constant: the secrecy lives entirely in
+// the passkey (the authenticator mixes the input with a per-credential secret).
+// Versioned so a future KDF change can request both and migrate.
+export function prfEvalInput() {
+ return new TextEncoder().encode("eleven-home-kek-v1");
+}
+
+// kekFromPRF derives the AES-GCM key-encryption key from a PRF output
+// (ArrayBuffer/Uint8Array, from getClientExtensionResults().prf.results.first).
+export async function kekFromPRF(prfOutput) {
+ const ikm = await crypto.subtle.importKey("raw", prfOutput, "HKDF", false, ["deriveKey"]);
+ return crypto.subtle.deriveKey(
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("eleven-home-kek") },
+ ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
+ );
+}
+
+// ---- DEK (the long-lived blob key) ----------------------------------------
+
+// The DEK is extractable so it can be wrapped to each passkey's KEK and cached
+// (session-only by default) for silent sync later.
+export async function generateDEK() {
+ return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
+}
+
+export async function exportDEK(dek) {
+ return bytesToB64url(new Uint8Array(await crypto.subtle.exportKey("raw", dek)));
+}
+
+export async function importDEK(b64) {
+ return crypto.subtle.importKey("raw", b64urlToBytes(b64), "AES-GCM", true, ["encrypt", "decrypt"]);
+}
+
+// wrapDEK/unwrapDEK: the DEK's raw bytes under a KEK, as base64url(iv||ct).
+export async function wrapDEK(dek, kek) {
+ const raw = new Uint8Array(await crypto.subtle.exportKey("raw", dek));
+ return bytesToB64url(await encryptBytes(kek, raw));
+}
+
+export async function unwrapDEK(wrappedB64, kek) {
+ const raw = await decryptBytes(kek, b64urlToBytes(wrappedB64));
+ return crypto.subtle.importKey("raw", raw, "AES-GCM", true, ["encrypt", "decrypt"]);
+}
+
+// ---- fixed-size padding -----------------------------------------------------
+
+// Every blob's plaintext is padded to exactly this many bytes before
+// encryption, so the (server-visible) ciphertext length reveals nothing about
+// how many servers are in the list or how big their entries are.
+export const BLOB_PAD = 16384;
+
+// pad: 4-byte big-endian length prefix, then the bytes, then zeros to BLOB_PAD.
+export function pad(bytes) {
+ if (bytes.length > BLOB_PAD - 4) throw new Error("blob contents too large");
+ const out = new Uint8Array(BLOB_PAD);
+ new DataView(out.buffer).setUint32(0, bytes.length);
+ out.set(bytes, 4);
+ return out;
+}
+
+export function unpad(bytes) {
+ const len = new DataView(bytes.buffer, bytes.byteOffset).getUint32(0);
+ return bytes.slice(4, 4 + len);
+}
+
+// ---- the envelope ------------------------------------------------------------
+//
+// Wire format (what PUT /api/blob stores, opaque to the server):
+// "EHB1" (4) || u32 BE headerLen || header JSON || iv(12) || ciphertext
+// header = { keys: { <credentialId>: <wrapped DEK b64url> } } — one entry per
+// enrolled passkey. The header is in clear, but holds nothing the server
+// doesn't already know (it stores the credentials) and nothing usable without
+// an authenticator: wrapped DEKs are ciphertext under per-passkey KEKs.
+
+const MAGIC = new TextEncoder().encode("EHB1");
+
+// sealBlob encrypts obj (padded) under dek and packs it with the wrapped-key
+// header. keys is { credentialId: wrappedDEK }.
+export async function sealBlob(obj, dek, keys) {
+ const pt = pad(new TextEncoder().encode(JSON.stringify(obj)));
+ const body = await encryptBytes(dek, pt); // iv||ct
+ const header = new TextEncoder().encode(JSON.stringify({ keys }));
+ const out = new Uint8Array(4 + 4 + header.length + body.length);
+ out.set(MAGIC, 0);
+ new DataView(out.buffer).setUint32(4, header.length);
+ out.set(header, 8);
+ out.set(body, 8 + header.length);
+ return out;
+}
+
+// unpackBlob splits an envelope into { keys, body } without decrypting — used
+// both to find our wrapped DEK and to re-seal preserving other passkeys' keys.
+export function unpackBlob(bytes) {
+ for (let i = 0; i < 4; i++) {
+ if (bytes[i] !== MAGIC[i]) throw new Error("not a home blob");
+ }
+ const headerLen = new DataView(bytes.buffer, bytes.byteOffset).getUint32(4);
+ const header = JSON.parse(new TextDecoder().decode(bytes.slice(8, 8 + headerLen)));
+ return { keys: header.keys || {}, body: bytes.slice(8 + headerLen) };
+}
+
+// openBlob decrypts an envelope with an already-unwrapped DEK.
+export async function openBlob(bytes, dek) {
+ const { keys, body } = unpackBlob(bytes);
+ const pt = unpad(await decryptBytes(dek, body));
+ return { obj: JSON.parse(new TextDecoder().decode(pt)), keys };
+}
diff --git a/home/web/static/home.js b/home/web/static/home.js
new file mode 100644
index 0000000..a3077ae
--- /dev/null
+++ b/home/web/static/home.js
@@ -0,0 +1,361 @@
+// The home page: create a backup passkey, restore, manage devices. All crypto
+// is client-side (see crypto.js); this file owns the WebAuthn ceremonies and
+// the (deliberately minimal) DOM. Status and errors always render inline in
+// each section — never a floating toast.
+
+import {
+ b64urlToBytes, bytesToB64url, prfEvalInput, kekFromPRF,
+ generateDEK, exportDEK, importDEK, wrapDEK, unwrapDEK,
+ sealBlob, unpackBlob, openBlob,
+} from "/static/crypto.js";
+
+// ---- session state ----------------------------------------------------------
+// Session-only on purpose (the design doc's web default): closing the tab
+// forgets the DEK and link token, so signing in on a shared computer doesn't
+// durably enrol it. A "trust this browser" persistence opt-in comes later.
+const state = {
+ get token() { return sessionStorage.getItem("home_token") || ""; },
+ set token(v) { sessionStorage.setItem("home_token", v); },
+ get dek() { return sessionStorage.getItem("home_dek") || ""; },
+ set dek(v) { sessionStorage.setItem("home_dek", v); },
+ get credId() { return sessionStorage.getItem("home_cred") || ""; },
+ set credId(v) { sessionStorage.setItem("home_cred", v); },
+};
+let vault = null; // { obj, keys, version } once unlocked
+let dekKey = null; // the imported CryptoKey
+
+// ---- tiny DOM helpers ---------------------------------------------------------
+
+const $ = (id) => document.getElementById(id);
+
+function setStatus(id, msg, kind) {
+ const el = $(id);
+ el.textContent = msg || "";
+ el.className = "status" + (kind ? " " + kind : "");
+}
+
+// ---- WebAuthn plumbing ---------------------------------------------------------
+
+async function api(path, opts = {}) {
+ const res = await fetch(path, opts);
+ if (!res.ok) throw new Error((await res.text().catch(() => "")).trim() || `HTTP ${res.status}`);
+ return res;
+}
+
+async function apiJSON(path, opts) {
+ return (await api(path, opts)).json();
+}
+
+// The server speaks the WebAuthn JSON wire format (base64url strings); the
+// browser API wants ArrayBuffers. Decode just the fields we use.
+function decodePublicKeyOptions(publicKey) {
+ const out = { ...publicKey, challenge: b64urlToBytes(publicKey.challenge) };
+ if (publicKey.user) out.user = { ...publicKey.user, id: b64urlToBytes(publicKey.user.id) };
+ for (const k of ["excludeCredentials", "allowCredentials"]) {
+ if (publicKey[k]) out[k] = publicKey[k].map((c) => ({ ...c, id: b64urlToBytes(c.id) }));
+ }
+ return out;
+}
+
+// Serialize a credential for the server, by hand: it guarantees the PRF
+// results (secret key material!) never ride along — only `enabled` survives.
+function credentialToJSON(cred) {
+ const r = cred.response;
+ const ext = cred.getClientExtensionResults();
+ const out = {
+ id: cred.id,
+ rawId: bytesToB64url(new Uint8Array(cred.rawId)),
+ type: cred.type,
+ clientExtensionResults: ext.prf ? { prf: { enabled: !!ext.prf.enabled } } : {},
+ response: { clientDataJSON: bytesToB64url(new Uint8Array(r.clientDataJSON)) },
+ };
+ if (r.attestationObject) out.response.attestationObject = bytesToB64url(new Uint8Array(r.attestationObject));
+ if (r.authenticatorData) out.response.authenticatorData = bytesToB64url(new Uint8Array(r.authenticatorData));
+ if (r.signature) out.response.signature = bytesToB64url(new Uint8Array(r.signature));
+ if (r.userHandle) out.response.userHandle = bytesToB64url(new Uint8Array(r.userHandle));
+ return out;
+}
+
+function prfOutputOf(cred) {
+ const first = cred.getClientExtensionResults().prf?.results?.first;
+ return first ? new Uint8Array(first) : null;
+}
+
+// login runs a full assertion ceremony (with PRF eval) and stores the
+// resulting link token. Returns { prf, proof, credId }.
+async function login() {
+ const { session, options } = await apiJSON("/api/login/begin", { method: "POST" });
+ const publicKey = decodePublicKeyOptions(options.publicKey);
+ // PRF eval is a client<->authenticator matter; the server never sees the output.
+ publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: prfEvalInput() } } };
+ const cred = await navigator.credentials.get({ publicKey });
+ const fin = await apiJSON("/api/login/finish?session=" + encodeURIComponent(session), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(credentialToJSON(cred)),
+ });
+ state.token = fin.token;
+ state.credId = fin.credential_id;
+ return { prf: prfOutputOf(cred), proof: fin.proof, credId: fin.credential_id };
+}
+
+// ---- blob I/O -------------------------------------------------------------------
+
+async function fetchBlob() {
+ const res = await fetch("/api/blob", { headers: { Authorization: "Bearer " + state.token } });
+ if (res.status === 404) return null;
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return {
+ bytes: new Uint8Array(await res.arrayBuffer()),
+ version: (res.headers.get("ETag") || '"0"').replace(/"/g, ""),
+ };
+}
+
+async function putBlob(bytes, ifMatchVersion) {
+ const res = await fetch("/api/blob", {
+ method: "PUT",
+ headers: {
+ Authorization: "Bearer " + state.token,
+ "If-Match": `"${ifMatchVersion}"`,
+ "Content-Type": "application/octet-stream",
+ },
+ body: bytes,
+ });
+ if (res.status === 409) return null; // caller re-fetches and retries
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return String((await res.json()).version);
+}
+
+// saveVault seals the current vault and PUTs it, retrying once on a version
+// race (re-fetch, re-apply our object over the merge base, re-seal). The list
+// is tiny, so last-writer-wins per entry is fine at this stage.
+async function saveVault() {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const sealed = await sealBlob(vault.obj, dekKey, vault.keys);
+ const v = await putBlob(sealed, vault.version);
+ if (v !== null) {
+ vault.version = v;
+ return;
+ }
+ const cur = await fetchBlob();
+ if (!cur) throw new Error("backup disappeared");
+ const opened = await openBlob(cur.bytes, dekKey);
+ const merged = new Map(opened.obj.servers.map((s) => [s.url, s]));
+ for (const s of vault.obj.servers) merged.set(s.url, s);
+ vault = { obj: { ...opened.obj, servers: [...merged.values()] }, keys: opened.keys, version: cur.version };
+ }
+ throw new Error("could not save after retry");
+}
+
+// ---- create ---------------------------------------------------------------------
+
+async function createBackup() {
+ setStatus("create-status", "Waiting for your passkey…");
+ const { session, options } = await apiJSON("/api/register/begin", { method: "POST" });
+ const publicKey = decodePublicKeyOptions(options.publicKey);
+ // Ask for PRF at creation. Some authenticators evaluate it right here; the
+ // rest only during an assertion (handled below).
+ publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: prfEvalInput() } } };
+ const cred = await navigator.credentials.create({ publicKey });
+ const ext = cred.getClientExtensionResults();
+ if (!ext.prf?.enabled && !ext.prf?.results) {
+ setStatus("create-status", "This passkey manager doesn't support the PRF extension, so it can't protect a backup here. Add-device links remain the fallback.", "error");
+ return;
+ }
+ const fin = await apiJSON("/api/register/finish?session=" + encodeURIComponent(session), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(credentialToJSON(cred)),
+ });
+ state.token = fin.token;
+ state.credId = fin.credential_id;
+
+ let prf = prfOutputOf(cred);
+ if (!prf) {
+ // The authenticator enabled PRF but didn't evaluate at create time — one
+ // assertion gets the output. The challenge is local: PRF eval happens on
+ // the authenticator and needs no server verification.
+ setStatus("create-status", "One more passkey check to derive your encryption key…");
+ const assertion = await navigator.credentials.get({
+ publicKey: {
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
+ allowCredentials: [{ type: "public-key", id: cred.rawId }],
+ userVerification: "required",
+ extensions: { prf: { eval: { first: prfEvalInput() } } },
+ },
+ });
+ prf = prfOutputOf(assertion);
+ }
+ if (!prf) throw new Error("no PRF output from this passkey");
+
+ const kek = await kekFromPRF(prf);
+ dekKey = await generateDEK();
+ state.dek = await exportDEK(dekKey);
+ vault = {
+ obj: { v: 1, servers: [] },
+ keys: { [fin.credential_id]: await wrapDEK(dekKey, kek) },
+ version: "0",
+ };
+ await saveVault();
+ setStatus("create-status", "Backup created and protected by your new passkey.", "ok");
+ renderVault();
+}
+
+// ---- restore ---------------------------------------------------------------------
+
+async function restore() {
+ setStatus("restore-status", "Waiting for your passkey…");
+ const { prf, credId } = await login();
+ const got = await fetchBlob();
+ if (!got) {
+ setStatus("restore-status", "Signed in, but there's no backup stored yet.", "error");
+ return;
+ }
+ if (!prf) {
+ setStatus("restore-status", "Signed in, but this browser didn't return a PRF result, so the backup can't be decrypted here.", "error");
+ return;
+ }
+ const kek = await kekFromPRF(prf);
+ const { keys } = unpackBlob(got.bytes);
+ const wrapped = keys[credId];
+ if (!wrapped) {
+ setStatus("restore-status", "This passkey isn't enrolled in the backup's keys.", "error");
+ return;
+ }
+ dekKey = await unwrapDEK(wrapped, kek);
+ state.dek = await exportDEK(dekKey);
+ const opened = await openBlob(got.bytes, dekKey);
+ vault = { obj: opened.obj, keys: opened.keys, version: got.version };
+ setStatus("restore-status", "Backup unlocked.", "ok");
+ renderVault();
+}
+
+function renderVault() {
+ $("vault").classList.remove("hidden");
+ const list = $("server-list");
+ list.textContent = "";
+ const servers = vault.obj.servers || [];
+ if (!servers.length) {
+ const li = document.createElement("li");
+ li.textContent = "No servers in this backup yet.";
+ list.append(li);
+ }
+ for (const s of servers) {
+ const li = document.createElement("li");
+ const name = document.createElement("span");
+ name.className = "name";
+ name.textContent = s.name || new URL(s.url).host;
+ const url = document.createElement("span");
+ url.className = "url";
+ url.textContent = s.url;
+ li.append(name, url);
+ list.append(li);
+ }
+}
+
+// ---- devices ----------------------------------------------------------------------
+
+async function showDevices() {
+ if (!state.token) {
+ setStatus("devices-status", "Restore (or create) first — the device list needs a signed-in session.", "error");
+ return;
+ }
+ const { devices } = await apiJSON("/api/devices", {
+ headers: { Authorization: "Bearer " + state.token },
+ });
+ const list = $("device-list");
+ list.classList.remove("hidden");
+ list.textContent = "";
+ for (const d of devices) {
+ const li = document.createElement("li");
+ const meta = document.createElement("div");
+ meta.className = "meta";
+ meta.textContent = "Device " + d.id.slice(0, 8) + (d.current ? " (this session)" : "");
+ const small = document.createElement("small");
+ small.textContent = "added " + new Date(d.created_at * 1000).toLocaleDateString() +
+ " · last seen " + new Date(d.last_seen * 1000).toLocaleDateString();
+ meta.append(small);
+ const btn = document.createElement("button");
+ btn.textContent = "Revoke";
+ btn.addEventListener("click", () => revokeDevice(d.id).catch(fail("devices-status")));
+ li.append(meta, btn);
+ list.append(li);
+ }
+ setStatus("devices-status", devices.length ? "" : "No devices.");
+}
+
+async function revokeDevice(id) {
+ // Destructive: needs a FRESH ceremony, not the link token — a stolen token
+ // must never be able to lock you out.
+ setStatus("devices-status", "Confirm with your passkey to revoke…");
+ const { proof } = await login();
+ await api("/api/devices/" + encodeURIComponent(id), {
+ method: "DELETE",
+ headers: { "X-Ceremony-Proof": proof },
+ });
+ setStatus("devices-status", "Device revoked.", "ok");
+ await showDevices();
+}
+
+// ---- add-server form -----------------------------------------------------------------
+
+async function addServer(e) {
+ e.preventDefault();
+ const url = $("add-url").value.trim();
+ const name = $("add-name").value.trim();
+ if (!url || !vault) return;
+ try { new URL(url); } catch { setStatus("vault-status", "That doesn't look like a URL.", "error"); return; }
+ vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url);
+ vault.obj.servers.push(name ? { url, name } : { url });
+ setStatus("vault-status", "Saving…");
+ await saveVault();
+ $("add-url").value = "";
+ $("add-name").value = "";
+ setStatus("vault-status", "Saved.", "ok");
+ renderVault();
+}
+
+// ---- postMessage handshake (not yet implemented) -----------------------------------------
+//
+// Later, instance welcome pages open this page as a popup and hand over a new
+// server entry ({ url, credentials }) via postMessage — client to client, never
+// through a server in the clear (the design doc's "auto-add flow"). NO receiver
+// ships until the sender does: a message listener that merges entries into the
+// synced backup is an injection point (any site can window.open this page), so
+// when it lands it must enforce a strict e.origin allowlist AND show the user
+// the entry for confirmation before anything is merged into the blob.
+
+// ---- wiring ---------------------------------------------------------------------------
+
+const fail = (id) => (err) => {
+ // A dismissed passkey sheet is a user choice, not an error worth shouting about.
+ if (err.name === "NotAllowedError") setStatus(id, "Passkey prompt was dismissed.");
+ else setStatus(id, err.message || String(err), "error");
+};
+
+if (!window.PublicKeyCredential) {
+ setStatus("create-status", "This browser doesn't support passkeys.", "error");
+ $("create").disabled = true;
+ $("restore").disabled = true;
+} else {
+ $("create").addEventListener("click", () => createBackup().catch(fail("create-status")));
+ $("restore").addEventListener("click", () => restore().catch(fail("restore-status")));
+}
+$("devices").addEventListener("click", () => showDevices().catch(fail("devices-status")));
+$("add-form").addEventListener("submit", (e) => addServer(e).catch(fail("vault-status")));
+
+// Rehydrate a same-tab session (e.g. a reload): the DEK and token are still in
+// sessionStorage, so the vault can reopen without a ceremony.
+if (state.token && state.dek) {
+ (async () => {
+ try {
+ const got = await fetchBlob();
+ if (!got) return;
+ dekKey = await importDEK(state.dek);
+ const opened = await openBlob(got.bytes, dekKey);
+ vault = { obj: opened.obj, keys: opened.keys, version: got.version };
+ setStatus("restore-status", "Backup unlocked (session).", "ok");
+ renderVault();
+ } catch { /* token or DEK went stale; the buttons still work */ }
+ })();
+}
diff --git a/home/webauthn.go b/home/webauthn.go
new file mode 100644
index 0000000..a03ea12
--- /dev/null
+++ b/home/webauthn.go
@@ -0,0 +1,278 @@
+// WebAuthn ceremonies. Two of them:
+//
+// - register (attestation): creates a user + credential. Adding a credential
+// to an EXISTING user (a second passkey = recovery redundancy) requires a
+// FRESH ceremony proof — never a link token; otherwise a brand-new
+// anonymous user is minted.
+// - login (discoverable assertion): proves possession of an enrolled
+// passkey; success mints a device link token + a short-lived ceremony proof.
+//
+// The PRF extension is requested and evaluated entirely CLIENT-side (its
+// output derives the KEK — see web/static/crypto.js). The server never asks
+// for it and never receives its output; it merely tolerates whatever
+// clientExtensionResults arrive (the library parses them as an opaque map).
+package main
+
+import (
+ "encoding/base64"
+ "errors"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/go-webauthn/webauthn/protocol"
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+// waRelyingParty wraps the library handle so main.go stays import-clean.
+type waRelyingParty struct{ *webauthn.WebAuthn }
+
+func newRelyingParty(rpID, origin string) (*waRelyingParty, error) {
+ wa, err := webauthn.New(&webauthn.Config{
+ // The display name is deliberately constant and generic: passkey managers
+ // show it, and it must not carry anything user-identifying.
+ RPDisplayName: "Eleven backup",
+ RPID: rpID,
+ RPOrigins: []string{origin},
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &waRelyingParty{wa}, nil
+}
+
+// homeUser adapts our anonymous user row to the library's User interface. The
+// name/display-name are the same fixed string for everyone — an account here
+// has no name, and the passkey entry in the user's manager shouldn't invent one.
+type homeUser struct {
+ id string
+ creds []webauthn.Credential
+}
+
+func (u *homeUser) WebAuthnID() []byte { return []byte(u.id) }
+func (u *homeUser) WebAuthnName() string { return "Eleven backup" }
+func (u *homeUser) WebAuthnDisplayName() string { return "Eleven backup" }
+func (u *homeUser) WebAuthnCredentials() []webauthn.Credential { return u.creds }
+
+// ---- in-flight ceremony sessions -------------------------------------------
+
+const sessionTTL = 5 * time.Minute
+
+type sessionEntry struct {
+ data *webauthn.SessionData
+ kind string // "register" | "login"
+ // userID + existing carry the register flow's decision from begin to
+ // finish: which user the new credential attaches to, and whether that user
+ // already exists (vs. is created on finish).
+ userID string
+ existing bool
+ expires time.Time
+}
+
+type sessionStore struct {
+ mu sync.Mutex
+ m map[string]sessionEntry
+}
+
+func newSessionStore() *sessionStore { return &sessionStore{m: map[string]sessionEntry{}} }
+
+func (st *sessionStore) put(e sessionEntry) (string, error) {
+ id, err := randToken()
+ if err != nil {
+ return "", err
+ }
+ e.expires = time.Now().Add(sessionTTL)
+ st.mu.Lock()
+ defer st.mu.Unlock()
+ for k, v := range st.m {
+ if time.Now().After(v.expires) {
+ delete(st.m, k)
+ }
+ }
+ st.m[id] = e
+ return id, nil
+}
+
+// take removes and returns a session — one finish attempt per begin.
+func (st *sessionStore) take(id string) (sessionEntry, bool) {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+ e, ok := st.m[id]
+ if ok {
+ delete(st.m, id)
+ }
+ if !ok || time.Now().After(e.expires) {
+ return sessionEntry{}, false
+ }
+ return e, true
+}
+
+// ---- register ---------------------------------------------------------------
+
+func (s *Server) registerBegin(w http.ResponseWriter, r *http.Request) {
+ // A fresh ceremony proof means "add a passkey to MY account"; with none,
+ // this is a brand-new enrolment. A link token is deliberately NOT accepted
+ // here: an enrolled credential can log in and mint fresh ceremony proofs,
+ // so accepting one would escalate a stolen read/write token into permanent
+ // account takeover (enrol attacker passkey -> ceremony -> destroy blob,
+ // revoke devices). Link tokens authorise exactly blob read/write and the
+ // devices listing — nothing else. A presented token is refused outright
+ // rather than ignored, so a client that meant "add to my account" can't
+ // silently enrol a second, empty account instead.
+ userID, existing := s.ceremonyUser(r)
+ if !existing {
+ if bearerToken(r) != "" {
+ http.Error(w, "fresh passkey ceremony required to add a passkey", http.StatusForbidden)
+ return
+ }
+ id, err := randToken()
+ if err != nil {
+ http.Error(w, "server error", http.StatusInternalServerError)
+ return
+ }
+ // The new user id is minted now but only written to the DB when the
+ // ceremony verifies, so abandoned ceremonies leave no rows behind.
+ userID = id
+ }
+ u := &homeUser{id: userID}
+ // Discoverable (resident) credentials are required: login is by credential,
+ // not by naming an account — there is nothing to name an account by. User
+ // verification is required too — a ceremony proof means "the user just did
+ // Face ID / PIN", not merely "the authenticator was present".
+ opts := []webauthn.RegistrationOption{
+ webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
+ ResidentKey: protocol.ResidentKeyRequirementRequired,
+ UserVerification: protocol.VerificationRequired,
+ }),
+ // Applied after WithAuthenticatorSelection: also sets the legacy
+ // requireResidentKey bool for older clients.
+ webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
+ }
+ if existing {
+ creds, err := s.db.credentialsFor(userID)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ u.creds = creds
+ var excl []protocol.CredentialDescriptor
+ for _, c := range creds {
+ excl = append(excl, c.Descriptor())
+ }
+ opts = append(opts, webauthn.WithExclusions(excl))
+ }
+ creation, session, err := s.wa.BeginRegistration(u, opts...)
+ if err != nil {
+ http.Error(w, "begin registration failed", http.StatusInternalServerError)
+ return
+ }
+ sid, err := s.sessions.put(sessionEntry{data: session, kind: "register", userID: userID, existing: existing})
+ if err != nil {
+ http.Error(w, "server error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"session": sid, "options": creation})
+}
+
+func (s *Server) registerFinish(w http.ResponseWriter, r *http.Request) {
+ ent, ok := s.sessions.take(r.URL.Query().Get("session"))
+ if !ok || ent.kind != "register" {
+ http.Error(w, "unknown or expired session", http.StatusBadRequest)
+ return
+ }
+ u := &homeUser{id: ent.userID}
+ if ent.existing {
+ creds, err := s.db.credentialsFor(ent.userID)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ u.creds = creds
+ }
+ cred, err := s.wa.FinishRegistration(u, *ent.data, r)
+ if err != nil {
+ http.Error(w, "registration failed", http.StatusBadRequest)
+ return
+ }
+ if !ent.existing {
+ if err := s.db.createUser(ent.userID); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ }
+ if err := s.db.insertCredential(ent.userID, cred); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ s.finishCeremony(w, ent.userID, cred)
+}
+
+// ---- login --------------------------------------------------------------------
+
+func (s *Server) loginBegin(w http.ResponseWriter, r *http.Request) {
+ // Discoverable login: no allowCredentials, the authenticator offers whatever
+ // passkeys it holds for this RP. The client adds the PRF eval extension to
+ // these options itself — the server has no business in that exchange.
+ assertion, session, err := s.wa.BeginDiscoverableLogin(
+ webauthn.WithUserVerification(protocol.VerificationRequired))
+ if err != nil {
+ http.Error(w, "begin login failed", http.StatusInternalServerError)
+ return
+ }
+ sid, err := s.sessions.put(sessionEntry{data: session, kind: "login"})
+ if err != nil {
+ http.Error(w, "server error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"session": sid, "options": assertion})
+}
+
+func (s *Server) loginFinish(w http.ResponseWriter, r *http.Request) {
+ ent, ok := s.sessions.take(r.URL.Query().Get("session"))
+ if !ok || ent.kind != "login" {
+ http.Error(w, "unknown or expired session", http.StatusBadRequest)
+ return
+ }
+ var userID string
+ cred, err := s.wa.FinishDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
+ uid := string(userHandle)
+ creds, err := s.db.credentialsFor(uid)
+ if err != nil || len(creds) == 0 {
+ return nil, errors.New("unknown user")
+ }
+ userID = uid
+ return &homeUser{id: uid, creds: creds}, nil
+ }, *ent.data, r)
+ if err != nil {
+ http.Error(w, "login failed", http.StatusUnauthorized)
+ return
+ }
+ // Persist the updated sign count / backup flags for clone detection.
+ if err := s.db.updateCredential(cred); err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ s.finishCeremony(w, userID, cred)
+}
+
+// finishCeremony is the shared tail of both verified ceremonies: mint a device
+// link token (ongoing bearer auth) and a ceremony proof (the short-lived
+// "I just did Face ID" credential destructive ops demand), and tell the client
+// which credential id it used, so it can pick its wrapped DEK out of the blob.
+func (s *Server) finishCeremony(w http.ResponseWriter, userID string, cred *webauthn.Credential) {
+ tok, err := s.db.createLinkToken(userID)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ proof, err := s.db.createCeremonyProof(userID)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "token": tok,
+ "proof": proof,
+ "credential_id": base64.RawURLEncoding.EncodeToString(cred.ID),
+ })
+}
diff --git a/package.json b/package.json
index bd1d115..dc36286 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/actions.test.mjs && node test/invite.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/actions.test.mjs && node test/invite.test.mjs && node home/test/crypto.test.mjs"
}
}