home: stateless WebAuthn ceremonies — HMAC ceremony proofs + sealed sessions (#550 Phase 0.2) (#613)
Ceremony proofs stop being stored: a proof is now an HMAC-signed token carrying its uid + expiry, verified with a server key — createCeremonyProof/ ceremonyUser leave the vaultStore interface, the sqlite implementation, and the contract suite (the ceremony_proofs table stays declared, retired in place, because migrations are additive-only). In-flight WebAuthn sessions stop being held in memory: the whole session entry is AES-GCM sealed into the ?session= query param the client already round-trips — same param, same endpoints, and the in-memory map is gone. With both gone home holds zero per-request state, removing its only sticky-process constraint. Both keys derive (HMAC-SHA256 with distinct labels) from home's first server-side secret, env LCHAT_HOME_SESSION_KEY — auth plumbing, not a decryption key for any vault. Unset, an ephemeral key is generated at boot with a logged warning, so single-process self-hosters keep working with zero config; ceremonies just don't survive a restart, as before. Tests: round-trip, expiry, per-byte tamper, wrong-key, cross-user splice, nonce freshness, and the HTTP surface (kind mismatch, tampered/expired session params).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+452
−144
home/db.go+3 −32home/home_test.go+9 −14home/main.go+28 −8home/provision_test.go+1 −4home/stateless.go+143 −0home/stateless_test.go+212 −0home/store.go+2 −13home/store_test.go+2 −18home/webauthn.go+52 −55
diff --git a/home/db.go b/home/db.go| index 8cc6178..8308a2e 100644 |
| --- a/home/db.go |
| +++ b/home/db.go |
| @@ -83,9 +83,9 @@ CREATE TABLE IF NOT EXISTS link_tokens ( |
| 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. |
| +-- RETIRED: ceremony proofs became stateless HMAC tokens (stateless.go) — |
| +-- nothing reads or writes this table any more. It stays declared because |
| +-- migrations are additive-only; any leftover rows are expired and inert. |
| CREATE TABLE IF NOT EXISTS ceremony_proofs ( |
| proof_hash TEXT PRIMARY KEY, |
| user_id TEXT NOT NULL, |
| @@ -361,32 +361,3 @@ func (db *sqliteStore) deleteDevice(userID, id string) (bool, error) { |
| 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 *sqliteStore) 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 hash, honouring the freshness window. |
| -func (db *sqliteStore) ceremonyUser(proofHash string) (string, bool) { |
| - var uid string |
| - err := db.sql.QueryRow( |
| - "SELECT user_id FROM ceremony_proofs WHERE proof_hash = ? AND created_at >= ?", |
| - proofHash, time.Now().Unix()-int64(ceremonyWindow.Seconds()), |
| - ).Scan(&uid) |
| - return uid, err == nil |
| -} |
diff --git a/home/home_test.go b/home/home_test.go| index 5e71706..adefaaa 100644 |
| --- a/home/home_test.go |
| +++ b/home/home_test.go |
| @@ -10,6 +10,7 @@ import ( |
| "strconv" |
| "strings" |
| "testing" |
| + "time" |
| "github.com/go-webauthn/webauthn/webauthn" |
| ) |
| @@ -20,7 +21,7 @@ func newTestServer(t *testing.T) (*httptest.Server, *Server) { |
| if err != nil { |
| t.Fatalf("openSQLiteStore: %v", err) |
| } |
| - s, err := newServer(store, "http://localhost:1748") |
| + s, err := newServer(store, "http://localhost:1748", []byte("test-state-secret")) |
| if err != nil { |
| t.Fatalf("newServer: %v", err) |
| } |
| @@ -254,27 +255,21 @@ func TestDestructiveOpsRequireCeremony(t *testing.T) { |
| 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.store.createCeremonyProof(uid) |
| - if err != nil { |
| - t.Fatalf("proof: %v", err) |
| - } |
| - // Backdating needs the raw table — a SQLite-impl detail the interface |
| - // deliberately doesn't expose, hence the type assertion. |
| - s.store.(*sqliteStore).sql.Exec("UPDATE ceremony_proofs SET created_at = created_at - 600 WHERE proof_hash = ?", hashToken(stale)) |
| + // A STALE proof (past its embedded expiry) is refused. |
| + stale := s.keys.mintCeremonyProofAt(uid, time.Now().Add(-time.Minute)) |
| 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.store.createCeremonyProof(other) |
| + otherProof := s.keys.mintCeremonyProof(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.store.createCeremonyProof(uid) |
| + proof := s.keys.mintCeremonyProof(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) |
| } |
| @@ -321,7 +316,7 @@ func TestRegisterBeginOptions(t *testing.T) { |
| // credentials are excluded so the same authenticator can't double-enrol. |
| uid, _ := seedUser(t, s) |
| creds, _ := s.store.credentialsFor(uid) |
| - proof, _ := s.store.createCeremonyProof(uid) |
| + proof := s.keys.mintCeremonyProof(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) |
| @@ -369,12 +364,12 @@ func TestCredentialDeleteRequiresCeremony(t *testing.T) { |
| } |
| // Nor may another user's fresh proof. |
| other, _ := seedUser(t, s) |
| - otherProof, _ := s.store.createCeremonyProof(other) |
| + otherProof := s.keys.mintCeremonyProof(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.store.createCeremonyProof(uid) |
| + proof := s.keys.mintCeremonyProof(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) |
| } |
diff --git a/home/main.go b/home/main.go| index 50fdd55..90f002a 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -40,10 +40,11 @@ type Server struct { |
| // object store later; handlers never know which. |
| store vaultStore |
| 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 |
| + // keys mints/verifies home's stateless auth tokens (stateless.go): HMAC |
| + // ceremony proofs and AES-GCM-sealed in-flight WebAuthn sessions. Nothing |
| + // per-ceremony is held in this process — any replica can finish what |
| + // another began. |
| + keys *stateKeys |
| // limiter rate-limits the ceremony begin endpoints per IP — the abuse |
| // surface of an open, anonymous registration endpoint. |
| limiter *rateLimiter |
| @@ -70,7 +71,17 @@ func main() { |
| if err != nil { |
| log.Fatalf("open db: %v", err) |
| } |
| - s, err := newServer(store, *origin) |
| + // LCHAT_HOME_SESSION_KEY signs/seals home's stateless ceremony proofs and |
| + // in-flight WebAuthn sessions (stateless.go). It is auth plumbing, NOT a |
| + // decryption key for any vault. Any long random string works (e.g. |
| + // `openssl rand -base64 32`); it only needs setting when several replicas |
| + // must honour each other's tokens. Env-only, deliberately not a flag — |
| + // secrets don't belong in process listings. |
| + secret := []byte(os.Getenv("LCHAT_HOME_SESSION_KEY")) |
| + if len(secret) == 0 { |
| + log.Printf("LCHAT_HOME_SESSION_KEY not set; using an ephemeral key — in-flight ceremonies won't survive a restart, and multiple replicas need a shared key") |
| + } |
| + s, err := newServer(store, *origin, secret) |
| if err != nil { |
| log.Fatalf("server: %v", err) |
| } |
| @@ -111,7 +122,10 @@ func serve(addr string, h http.Handler) error { |
| return http.Serve(ln, h) |
| } |
| -func newServer(store vaultStore, origin string) (*Server, error) { |
| +// newServer builds the server. stateSecret keys the stateless ceremony/session |
| +// tokens; empty means "ephemeral random key" (fine single-process — main logs |
| +// the caveat). |
| +func newServer(store vaultStore, origin string, stateSecret []byte) (*Server, error) { |
| u, err := url.Parse(origin) |
| if err != nil { |
| return nil, err |
| @@ -120,6 +134,10 @@ func newServer(store vaultStore, origin string) (*Server, error) { |
| if err != nil { |
| return nil, err |
| } |
| + keys, err := newStateKeys(stateSecret) |
| + if err != nil { |
| + return nil, err |
| + } |
| page, err := webFS.ReadFile("web/pages/index.html") |
| if err != nil { |
| return nil, err |
| @@ -137,7 +155,7 @@ func newServer(store vaultStore, origin string) (*Server, error) { |
| enrollPage: enrollPage, |
| restorePage: restorePage, |
| wa: wa, |
| - sessions: newSessionStore(), |
| + keys: keys, |
| limiter: newRateLimiter(10, 10.0/60), // burst of 10, refills 10/minute |
| page: page, |
| }, nil |
| @@ -305,12 +323,14 @@ func (s *Server) linkUser(r *http.Request) (string, bool) { |
| // 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. |
| +// Verification is stateless: the proof carries its uid + expiry under an HMAC |
| +// (stateless.go), so nothing is looked up. |
| func (s *Server) ceremonyUser(r *http.Request) (string, bool) { |
| proof := r.Header.Get("X-Ceremony-Proof") |
| if proof == "" { |
| return "", false |
| } |
| - return s.store.ceremonyUser(hashToken(proof)) |
| + return s.keys.ceremonyUser(proof) |
| } |
| // ---- blob ------------------------------------------------------------------- |
diff --git a/home/provision_test.go b/home/provision_test.go| index 96b8e67..ef7f8ad 100644 |
| --- a/home/provision_test.go |
| +++ b/home/provision_test.go |
| @@ -247,10 +247,7 @@ func TestDeleteSpaceRequiresFreshCeremony(t *testing.T) { |
| } |
| // A fresh ceremony proof authorises it, and home forwards the requester uid. |
| - proof, err := s.store.createCeremonyProof(uid) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| + proof := s.keys.mintCeremonyProof(uid) |
| if r := del(map[string]string{"X-Ceremony-Proof": proof}); r.StatusCode != http.StatusOK { |
| t.Fatalf("with ceremony: %d, want 200", r.StatusCode) |
| } |
diff --git a/home/stateless.go b/home/stateless.go| new file mode 100644 |
| index 0000000..e3d43fe |
| --- /dev/null |
| +++ b/home/stateless.go |
| @@ -0,0 +1,143 @@ |
| +// Stateless server-side "state" (issue #550, Phase 0): the two short-lived |
| +// artefacts home used to hold between requests — ceremony proofs and in-flight |
| +// WebAuthn sessions — are now self-contained tokens the CLIENT carries, so no |
| +// process ever needs to remember them. Ceremony proofs are HMAC-signed |
| +// (uid + expiry, nothing secret inside); WebAuthn session state is AES-GCM |
| +// sealed into the ?session= query param. With both gone, home holds zero |
| +// per-request state and any replica behind a load balancer can finish what |
| +// another began. |
| +// |
| +// Both keys derive from ONE server secret, env LCHAT_HOME_SESSION_KEY. Like |
| +// the admin token, it is auth plumbing, NOT a decryption key for any vault — |
| +// it can mint/verify short-lived auth artefacts but cannot open a single byte |
| +// of anyone's blob. Unset, an ephemeral random key is generated at boot (with |
| +// a logged warning): single-process deployments — the self-hoster default — |
| +// keep working with zero config, and ceremonies just don't survive a restart, |
| +// which is exactly what the old in-memory map gave them. |
| +package main |
| + |
| +import ( |
| + "crypto/aes" |
| + "crypto/cipher" |
| + "crypto/hmac" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/binary" |
| + "time" |
| +) |
| + |
| +// 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 |
| + |
| +// stateKeys holds the two keys behind home's stateless tokens, both derived |
| +// from the one master secret with distinct labels so a proof can never pass |
| +// as a session (or vice versa) even at the raw-bytes level. |
| +type stateKeys struct { |
| + mac []byte // HMAC-SHA256 key: ceremony proofs |
| + aead cipher.AEAD // AES-256-GCM: sealed WebAuthn sessions |
| +} |
| + |
| +// newStateKeys derives both keys from secret; an empty secret means "mint an |
| +// ephemeral one" (see the package comment for why that's fine). |
| +func newStateKeys(secret []byte) (*stateKeys, error) { |
| + if len(secret) == 0 { |
| + secret = make([]byte, 32) |
| + if _, err := rand.Read(secret); err != nil { |
| + return nil, err |
| + } |
| + } |
| + block, err := aes.NewCipher(deriveKey(secret, "lchat-home session seal v1")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + aead, err := cipher.NewGCM(block) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &stateKeys{mac: deriveKey(secret, "lchat-home ceremony proof v1"), aead: aead}, nil |
| +} |
| + |
| +// deriveKey is HKDF-Expand in miniature: HMAC-SHA256(secret, label). Two fixed |
| +// 32-byte outputs with distinct labels is all we need, so the full extract/ |
| +// expand dance would be ceremony for its own sake. |
| +func deriveKey(secret []byte, label string) []byte { |
| + m := hmac.New(sha256.New, secret) |
| + m.Write([]byte(label)) |
| + return m.Sum(nil) |
| +} |
| + |
| +// ---- ceremony proofs ---------------------------------------------------------- |
| +// |
| +// A proof is base64url( expiry(8, big-endian unix) || uid || HMAC-SHA256(expiry||uid) ). |
| +// The MAC covers both fields, so neither the expiry nor the user can be swapped |
| +// without the server key — a proof minted for user A can never validate as B, |
| +// and extending the window means forging the MAC. Nothing here is stored: the |
| +// old ceremony_proofs table (db.go) is retired in place. |
| + |
| +const proofMACLen = sha256.Size |
| + |
| +// mintCeremonyProof mints the short-lived proof returned by a verified passkey |
| +// ceremony — the "I just did Face ID" credential destructive ops demand. |
| +func (k *stateKeys) mintCeremonyProof(uid string) string { |
| + return k.mintCeremonyProofAt(uid, time.Now().Add(ceremonyWindow)) |
| +} |
| + |
| +// mintCeremonyProofAt is the expiry-injectable body of mintCeremonyProof, so |
| +// tests can mint already-stale proofs without a clock. |
| +func (k *stateKeys) mintCeremonyProofAt(uid string, expiry time.Time) string { |
| + payload := make([]byte, 8+len(uid)) |
| + binary.BigEndian.PutUint64(payload, uint64(expiry.Unix())) |
| + copy(payload[8:], uid) |
| + m := hmac.New(sha256.New, k.mac) |
| + m.Write(payload) |
| + return base64.RawURLEncoding.EncodeToString(m.Sum(payload)) |
| +} |
| + |
| +// ceremonyUser verifies a presented proof: MAC first (constant-time), expiry |
| +// second — nothing in the token is trusted until the MAC holds. |
| +func (k *stateKeys) ceremonyUser(proof string) (uid string, ok bool) { |
| + raw, err := base64.RawURLEncoding.DecodeString(proof) |
| + if err != nil || len(raw) <= 8+proofMACLen { |
| + return "", false |
| + } |
| + payload, tag := raw[:len(raw)-proofMACLen], raw[len(raw)-proofMACLen:] |
| + m := hmac.New(sha256.New, k.mac) |
| + m.Write(payload) |
| + if !hmac.Equal(tag, m.Sum(nil)) { |
| + return "", false |
| + } |
| + if time.Now().Unix() > int64(binary.BigEndian.Uint64(payload)) { |
| + return "", false |
| + } |
| + return string(payload[8:]), true |
| +} |
| + |
| +// ---- sealed byte blobs ---------------------------------------------------------- |
| +// |
| +// seal/open are the generic AES-GCM envelope under the WebAuthn session param |
| +// (webauthn.go wraps them with the JSON + kind + expiry layer). Format is |
| +// base64url( nonce(12) || ciphertext ) with a fresh random nonce per seal; GCM |
| +// authentication is what makes a tampered or wrong-key token fail closed. |
| + |
| +func (k *stateKeys) seal(plaintext []byte) (string, error) { |
| + nonce := make([]byte, k.aead.NonceSize()) |
| + if _, err := rand.Read(nonce); err != nil { |
| + return "", err |
| + } |
| + return base64.RawURLEncoding.EncodeToString(k.aead.Seal(nonce, nonce, plaintext, nil)), nil |
| +} |
| + |
| +func (k *stateKeys) open(token string) ([]byte, bool) { |
| + raw, err := base64.RawURLEncoding.DecodeString(token) |
| + if err != nil || len(raw) < k.aead.NonceSize() { |
| + return nil, false |
| + } |
| + pt, err := k.aead.Open(nil, raw[:k.aead.NonceSize()], raw[k.aead.NonceSize():], nil) |
| + if err != nil { |
| + return nil, false |
| + } |
| + return pt, true |
| +} |
diff --git a/home/stateless_test.go b/home/stateless_test.go| new file mode 100644 |
| index 0000000..e692122 |
| --- /dev/null |
| +++ b/home/stateless_test.go |
| @@ -0,0 +1,212 @@ |
| +package main |
| + |
| +// The stateless-token contract (stateless.go): ceremony proofs and sealed |
| +// WebAuthn sessions must round-trip under the right key and fail closed under |
| +// everything else — expiry, tampering, truncation, a different server key, and |
| +// (for proofs) any attempt to move a token between users. |
| + |
| +import ( |
| + "encoding/base64" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/go-webauthn/webauthn/webauthn" |
| +) |
| + |
| +func testKeys(t *testing.T, secret string) *stateKeys { |
| + t.Helper() |
| + k, err := newStateKeys([]byte(secret)) |
| + if err != nil { |
| + t.Fatalf("newStateKeys: %v", err) |
| + } |
| + return k |
| +} |
| + |
| +func TestCeremonyProofRoundTrip(t *testing.T) { |
| + k := testKeys(t, "secret-a") |
| + proof := k.mintCeremonyProof("user-one") |
| + if proof == "" { |
| + t.Fatalf("empty proof") |
| + } |
| + if uid, ok := k.ceremonyUser(proof); !ok || uid != "user-one" { |
| + t.Fatalf("ceremonyUser: want (user-one, true), got (%q, %v)", uid, ok) |
| + } |
| + // The uid rides inside, MAC'd: a proof for A never validates as B. |
| + if uid, _ := k.ceremonyUser(k.mintCeremonyProof("user-two")); uid != "user-two" { |
| + t.Fatalf("proof carries the wrong uid: %q", uid) |
| + } |
| +} |
| + |
| +func TestCeremonyProofExpiry(t *testing.T) { |
| + k := testKeys(t, "secret-a") |
| + // Expiry is enforced server-side, from the MAC'd timestamp in the token. |
| + if _, ok := k.ceremonyUser(k.mintCeremonyProofAt("u", time.Now().Add(-time.Second))); ok { |
| + t.Fatalf("expired proof accepted") |
| + } |
| + if _, ok := k.ceremonyUser(k.mintCeremonyProofAt("u", time.Now().Add(time.Minute))); !ok { |
| + t.Fatalf("unexpired proof refused") |
| + } |
| +} |
| + |
| +func TestCeremonyProofTamperRejected(t *testing.T) { |
| + k := testKeys(t, "secret-a") |
| + proof := k.mintCeremonyProof("user-one") |
| + raw, err := base64.RawURLEncoding.DecodeString(proof) |
| + if err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + // Flip one byte at every position — expiry, uid, and MAC are all covered; |
| + // no single-byte change may survive. |
| + for i := range raw { |
| + mut := append([]byte(nil), raw...) |
| + mut[i] ^= 0x01 |
| + if _, ok := k.ceremonyUser(base64.RawURLEncoding.EncodeToString(mut)); ok { |
| + t.Fatalf("tampered proof accepted (byte %d)", i) |
| + } |
| + } |
| + // Truncation and garbage fail closed too. |
| + for _, bad := range []string{"", "x", "not!base64url!", proof[:len(proof)/2], |
| + base64.RawURLEncoding.EncodeToString(raw[:8+proofMACLen])} { |
| + if _, ok := k.ceremonyUser(bad); ok { |
| + t.Fatalf("malformed proof %q accepted", bad) |
| + } |
| + } |
| +} |
| + |
| +func TestCeremonyProofWrongKeyRejected(t *testing.T) { |
| + a, b := testKeys(t, "secret-a"), testKeys(t, "secret-b") |
| + if _, ok := b.ceremonyUser(a.mintCeremonyProof("u")); ok { |
| + t.Fatalf("proof minted under one key verified under another") |
| + } |
| + // Two ephemeral keys (empty secret) must not honour each other either. |
| + e1, e2 := testKeys(t, ""), testKeys(t, "") |
| + if _, ok := e2.ceremonyUser(e1.mintCeremonyProof("u")); ok { |
| + t.Fatalf("two ephemeral keys verified each other's proofs") |
| + } |
| +} |
| + |
| +func TestCeremonyProofCrossUserSpliceRejected(t *testing.T) { |
| + k := testKeys(t, "secret-a") |
| + // Same-length uids, so a payload splice is structurally plausible: take |
| + // alice's MAC and staple it onto a payload claiming bob. The MAC covers the |
| + // uid, so it must fail. |
| + alice := k.mintCeremonyProof("alice") |
| + bob := k.mintCeremonyProof("bobby") |
| + ra, _ := base64.RawURLEncoding.DecodeString(alice) |
| + rb, _ := base64.RawURLEncoding.DecodeString(bob) |
| + spliced := append(append([]byte(nil), rb[:len(rb)-proofMACLen]...), ra[len(ra)-proofMACLen:]...) |
| + if uid, ok := k.ceremonyUser(base64.RawURLEncoding.EncodeToString(spliced)); ok { |
| + t.Fatalf("spliced proof accepted as %q", uid) |
| + } |
| +} |
| + |
| +func TestSealOpenRoundTrip(t *testing.T) { |
| + k := testKeys(t, "secret-a") |
| + msg := []byte(`{"hello":"world"}`) |
| + tok1, err := k.seal(msg) |
| + if err != nil { |
| + t.Fatalf("seal: %v", err) |
| + } |
| + if got, ok := k.open(tok1); !ok || string(got) != string(msg) { |
| + t.Fatalf("open: want (%q, true), got (%q, %v)", msg, got, ok) |
| + } |
| + // Fresh random nonce per seal: two seals of one plaintext never collide. |
| + if tok2, _ := k.seal(msg); tok2 == tok1 { |
| + t.Fatalf("two seals produced identical tokens — nonce reuse") |
| + } |
| +} |
| + |
| +func TestSealOpenRejectsTamperAndWrongKey(t *testing.T) { |
| + a, b := testKeys(t, "secret-a"), testKeys(t, "secret-b") |
| + tok, err := a.seal([]byte("payload")) |
| + if err != nil { |
| + t.Fatalf("seal: %v", err) |
| + } |
| + if _, ok := b.open(tok); ok { |
| + t.Fatalf("sealed under one key, opened under another") |
| + } |
| + raw, _ := base64.RawURLEncoding.DecodeString(tok) |
| + for i := range raw { |
| + mut := append([]byte(nil), raw...) |
| + mut[i] ^= 0x01 |
| + if _, ok := a.open(base64.RawURLEncoding.EncodeToString(mut)); ok { |
| + t.Fatalf("tampered sealed token opened (byte %d)", i) |
| + } |
| + } |
| + for _, bad := range []string{"", "!", tok[:10]} { |
| + if _, ok := a.open(bad); ok { |
| + t.Fatalf("malformed sealed token %q opened", bad) |
| + } |
| + } |
| +} |
| + |
| +// The full HTTP surface: a begin's sealed session must be refused on finish |
| +// when tampered with, presented to the other ceremony's finish, or expired — |
| +// and the whole flow must work with NO server-side session state at all. |
| +func TestFinishRejectsBadSessions(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + |
| + begin := func(path string) string { |
| + r := do(t, "POST", ts.URL+path, "", nil, nil) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("%s: want 200, got %d", path, r.StatusCode) |
| + } |
| + var out struct { |
| + Session string `json:"session"` |
| + } |
| + body, _ := io.ReadAll(r.Body) |
| + if err := json.Unmarshal(body, &out); err != nil || out.Session == "" { |
| + t.Fatalf("%s: no session in %s", path, body) |
| + } |
| + return out.Session |
| + } |
| + finish := func(path, session string) int { |
| + r := do(t, "POST", ts.URL+path+"?session="+session, "", nil, []byte("{}")) |
| + return r.StatusCode |
| + } |
| + |
| + reg := begin("/api/register/begin") |
| + login := begin("/api/login/begin") |
| + |
| + // Kind is checked: a register session can't drive login/finish (nor the |
| + // reverse) — 400 before any WebAuthn verification runs. |
| + if got := finish("/api/login/finish", reg); got != http.StatusBadRequest { |
| + t.Fatalf("register session on login/finish: want 400, got %d", got) |
| + } |
| + if got := finish("/api/register/finish", login); got != http.StatusBadRequest { |
| + t.Fatalf("login session on register/finish: want 400, got %d", got) |
| + } |
| + |
| + // Tampering with the sealed param is a 400, never a crash or an accept. |
| + raw, err := base64.RawURLEncoding.DecodeString(reg) |
| + if err != nil { |
| + t.Fatalf("session should be base64url: %v", err) |
| + } |
| + raw[len(raw)-1] ^= 0x01 |
| + if got := finish("/api/register/finish", base64.RawURLEncoding.EncodeToString(raw)); got != http.StatusBadRequest { |
| + t.Fatalf("tampered session: want 400, got %d", got) |
| + } |
| + |
| + // An expired session is refused even though the seal verifies. (Sealed by |
| + // hand: sealSession always stamps a fresh expiry, which is the point.) |
| + expired := sessionEntry{Data: &webauthn.SessionData{}, Kind: "register", |
| + Expires: time.Now().Add(-time.Minute).Unix()} |
| + bts, _ := json.Marshal(expired) |
| + sealed, err := s.keys.seal(bts) |
| + if err != nil { |
| + t.Fatalf("seal: %v", err) |
| + } |
| + if got := finish("/api/register/finish", sealed); got != http.StatusBadRequest { |
| + t.Fatalf("expired session: want 400, got %d", got) |
| + } |
| + |
| + // The session param must never leak ceremony internals in the clear: the |
| + // challenge from the begin options must not be findable in the token. |
| + if strings.Contains(reg, "challenge") { |
| + t.Fatalf("sealed session leaks plaintext") |
| + } |
| +} |
diff --git a/home/store.go b/home/store.go| index 01eb494..20fa367 100644 |
| --- a/home/store.go |
| +++ b/home/store.go |
| @@ -2,7 +2,6 @@ package main |
| import ( |
| "errors" |
| - "time" |
| "github.com/go-webauthn/webauthn/webauthn" |
| ) |
| @@ -12,11 +11,6 @@ import ( |
| // 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 |
| - |
| // lastSeenGranularity is how stale a link token's last_seen must be before a |
| // lookup rewrites it (seconds). Day-coarse keeps the devices list honest while |
| // sparing an object-store backend a write per authenticated read. |
| @@ -104,13 +98,8 @@ type vaultStore interface { |
| // doesn't exist or belongs to someone else. |
| deleteDevice(uid, id string) (ok bool, err error) |
| - // ---- ceremony proofs (short-lived; slated to become stateless HMACs) ---- |
| - |
| - // createCeremonyProof mints the short-lived proof returned by a verified |
| - // passkey ceremony; ceremonyUser resolves a proof HASH, honouring the |
| - // ceremonyWindow freshness cut-off. |
| - createCeremonyProof(uid string) (proof string, err error) |
| - ceremonyUser(proofHash string) (uid string, ok bool) |
| + // Ceremony proofs are deliberately NOT here: they became stateless HMAC |
| + // tokens (stateless.go) — a store that persists nothing needs no methods. |
| } |
| // Device is link-token metadata for the devices list — ids and timestamps |
diff --git a/home/store_test.go b/home/store_test.go| index 18b8591..ff3e0c0 100644 |
| --- a/home/store_test.go |
| +++ b/home/store_test.go |
| @@ -352,24 +352,8 @@ func testVaultStore(t *testing.T, newStore func(t *testing.T) vaultStore) { |
| } |
| }) |
| - t.Run("CeremonyProofs", func(t *testing.T) { |
| - st := newStore(t) |
| - st.createUser("u1") |
| - proof, err := st.createCeremonyProof("u1") |
| - if err != nil || proof == "" { |
| - t.Fatalf("createCeremonyProof: %q %v", proof, err) |
| - } |
| - if uid, ok := st.ceremonyUser(hashToken(proof)); !ok || uid != "u1" { |
| - t.Fatalf("ceremonyUser: want (u1, true), got (%q, %v)", uid, ok) |
| - } |
| - // Hash-keyed, like link tokens. |
| - if _, ok := st.ceremonyUser(proof); ok { |
| - t.Fatalf("plaintext proof resolved — store must be keyed by hash") |
| - } |
| - if _, ok := st.ceremonyUser(hashToken("wrong")); ok { |
| - t.Fatalf("unknown proof hash resolved") |
| - } |
| - }) |
| + // Ceremony proofs deliberately have no contract entry: they left the store |
| + // entirely — stateless HMAC tokens, tested in stateless_test.go. |
| } |
| // b64CredID renders a raw credential id the way stores key it (base64url, |
diff --git a/home/webauthn.go b/home/webauthn.go| index 8dc7fa6..eddbfbd 100644 |
| --- a/home/webauthn.go |
| +++ b/home/webauthn.go |
| @@ -15,9 +15,9 @@ package main |
| import ( |
| "encoding/base64" |
| + "encoding/json" |
| "errors" |
| "net/http" |
| - "sync" |
| "time" |
| "github.com/go-webauthn/webauthn/protocol" |
| @@ -60,53 +60,55 @@ func (u *homeUser) WebAuthnDisplayName() string { return "Sign in |
| func (u *homeUser) WebAuthnCredentials() []webauthn.Credential { return u.creds } |
| // ---- in-flight ceremony sessions ------------------------------------------- |
| +// |
| +// Between begin and finish the only state a ceremony needs is the library's |
| +// SessionData plus a couple of our own fields — and the client already |
| +// round-trips an opaque handle through the ?session= query param. So instead |
| +// of an in-memory map (which pinned a ceremony to one process), the whole |
| +// entry is AES-GCM sealed into that param (stateless.go) and the server keeps |
| +// nothing. Same param, same endpoints — the wire flow is unchanged. |
| +// |
| +// One deliberate trade against the old map: sessions are no longer single-use |
| +// (there is nothing to delete on take). Within sessionTTL a captured finish |
| +// request could be replayed — but its bearer already holds everything that |
| +// finish returned, and the authenticator's signed response is still bound to |
| +// this one challenge, so replay buys nothing a first read didn't. |
| 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 |
| + Data *webauthn.SessionData `json:"d"` |
| + Kind string `json:"k"` // "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 |
| + UserID string `json:"u,omitempty"` |
| + Existing bool `json:"e,omitempty"` |
| + Expires int64 `json:"x"` // unix seconds; enforced server-side on open |
| } |
| -func newSessionStore() *sessionStore { return &sessionStore{m: map[string]sessionEntry{}} } |
| - |
| -func (st *sessionStore) put(e sessionEntry) (string, error) { |
| - id, err := randToken() |
| +// sealSession stamps the expiry and seals the entry into the opaque string the |
| +// client will echo back on finish. |
| +func (s *Server) sealSession(e sessionEntry) (string, error) { |
| + e.Expires = time.Now().Add(sessionTTL).Unix() |
| + b, err := json.Marshal(e) |
| 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 |
| + return s.keys.seal(b) |
| } |
| -// 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) |
| +// openSession unseals a presented session param and checks it's the right |
| +// kind of ceremony and still fresh. Tampering, a wrong key, garbage — all |
| +// fail the GCM open and land here as !ok. |
| +func (s *Server) openSession(token, kind string) (sessionEntry, bool) { |
| + raw, ok := s.keys.open(token) |
| + if !ok { |
| + return sessionEntry{}, false |
| } |
| - if !ok || time.Now().After(e.expires) { |
| + var e sessionEntry |
| + if json.Unmarshal(raw, &e) != nil || e.Kind != kind || e.Data == nil || |
| + time.Now().Unix() > e.Expires { |
| return sessionEntry{}, false |
| } |
| return e, true |
| @@ -171,7 +173,7 @@ func (s *Server) registerBegin(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, "begin registration failed", http.StatusInternalServerError) |
| return |
| } |
| - sid, err := s.sessions.put(sessionEntry{data: session, kind: "register", userID: userID, existing: existing}) |
| + sid, err := s.sealSession(sessionEntry{Data: session, Kind: "register", UserID: userID, Existing: existing}) |
| if err != nil { |
| http.Error(w, "server error", http.StatusInternalServerError) |
| return |
| @@ -180,36 +182,36 @@ func (s *Server) registerBegin(w http.ResponseWriter, r *http.Request) { |
| } |
| 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" { |
| + ent, ok := s.openSession(r.URL.Query().Get("session"), "register") |
| + if !ok { |
| http.Error(w, "unknown or expired session", http.StatusBadRequest) |
| return |
| } |
| - u := &homeUser{id: ent.userID} |
| - if ent.existing { |
| - creds, err := s.store.credentialsFor(ent.userID) |
| + u := &homeUser{id: ent.UserID} |
| + if ent.Existing { |
| + creds, err := s.store.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) |
| + 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.store.createUser(ent.userID); err != nil { |
| + if !ent.Existing { |
| + if err := s.store.createUser(ent.UserID); err != nil { |
| http.Error(w, "db error", http.StatusInternalServerError) |
| return |
| } |
| } |
| - if err := s.store.insertCredential(ent.userID, cred); err != nil { |
| + if err := s.store.insertCredential(ent.UserID, cred); err != nil { |
| http.Error(w, "db error", http.StatusInternalServerError) |
| return |
| } |
| - s.finishCeremony(w, ent.userID, cred) |
| + s.finishCeremony(w, ent.UserID, cred) |
| } |
| // ---- login -------------------------------------------------------------------- |
| @@ -224,7 +226,7 @@ func (s *Server) loginBegin(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, "begin login failed", http.StatusInternalServerError) |
| return |
| } |
| - sid, err := s.sessions.put(sessionEntry{data: session, kind: "login"}) |
| + sid, err := s.sealSession(sessionEntry{Data: session, Kind: "login"}) |
| if err != nil { |
| http.Error(w, "server error", http.StatusInternalServerError) |
| return |
| @@ -233,8 +235,8 @@ func (s *Server) loginBegin(w http.ResponseWriter, r *http.Request) { |
| } |
| 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" { |
| + ent, ok := s.openSession(r.URL.Query().Get("session"), "login") |
| + if !ok { |
| http.Error(w, "unknown or expired session", http.StatusBadRequest) |
| return |
| } |
| @@ -247,7 +249,7 @@ func (s *Server) loginFinish(w http.ResponseWriter, r *http.Request) { |
| } |
| userID = uid |
| return &homeUser{id: uid, creds: creds}, nil |
| - }, *ent.data, r) |
| + }, *ent.Data, r) |
| if err != nil { |
| http.Error(w, "login failed", http.StatusUnauthorized) |
| return |
| @@ -270,14 +272,9 @@ func (s *Server) finishCeremony(w http.ResponseWriter, userID string, cred *weba |
| http.Error(w, "db error", http.StatusInternalServerError) |
| return |
| } |
| - proof, err := s.store.createCeremonyProof(userID) |
| - if err != nil { |
| - http.Error(w, "db error", http.StatusInternalServerError) |
| - return |
| - } |
| writeJSON(w, http.StatusOK, map[string]any{ |
| "token": tok, |
| - "proof": proof, |
| + "proof": s.keys.mintCeremonyProof(userID), |
| "credential_id": base64.RawURLEncoding.EncodeToString(cred.ID), |
| }) |
| } |