eleven / ocho Public

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>
Paul Campbell 0a77fde56b7e14e8df9001bdc072c180622d7802 parent a0f8e89
9 files changed, +452 −144
  • home/db.go +3 −32
  • home/home_test.go +9 −14
  • home/main.go +28 −8
  • home/provision_test.go +1 −4
  • home/stateless.go +143 −0
  • home/stateless_test.go +212 −0
  • home/store.go +2 −13
  • home/store_test.go +2 −18
  • home/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),
})
}