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>
15 files changed,
+2426
−1
deploy-home.sh+42 −0go.mod+6 −0go.sum+18 −0home/Dockerfile+21 −0home/README.md+90 −0home/app.toml+34 −0home/db.go+364 −0home/home_test.go+408 −0home/main.go+421 −0home/test/crypto.test.mjs+129 −0home/web/pages/index.html+103 −0home/web/static/crypto.js+150 −0home/web/static/home.js+361 −0home/webauthn.go+278 −0package.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" |
| } |
| } |