eleven / messenger Public

home: anchor management β€” list and remove sign-in methods (#1089) (#1125)

GET /api/anchors (link token) lists every way into the account β€” passkeys,
SiwA anchors, email anchors β€” as {kind, id, created_at}; the id is the
removal handle (base64url credential id; the HMAC-peppered hash for
siwa/email, which reverses to nothing). DELETE /api/anchors/{kind}/{id}
(ceremony proof) removes one, refused with a 409 when it would leave ZERO
ways in: the guard evolved from last-passkey to last-METHOD, so an anchored
account may now shed its final passkey. The guard lives in the stores
(transaction/CAS-atomic), enforced identically by the legacy
/api/credentials/{id} door. Store additions (listing + deletion) land in
both SQLite and S3; the S3 side also sweeps stale anchor pointers so a
removed identifier can re-anchor a different account. #1098's settings UI
builds on this.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 39d66aa206c16624f7a6b66ffea96d06554d4950 parent e307414
8 files changed, +778 −34
  • home/anchors.go +105 −0
  • home/anchors_test.go +235 −0
  • home/db.go +90 −4
  • home/home_test.go +5 −2
  • home/main.go +13 −4
  • home/s3store.go +186 −18
  • home/store.go +41 −6
  • home/store_test.go +103 −0
diff --git a/home/anchors.go b/home/anchors.go
new file mode 100644
index 0000000..5e5816a
--- /dev/null
+++ b/home/anchors.go
@@ -0,0 +1,105 @@
+package main
+
+// πŸ€– Anchor management β€” the account's sign-in methods as one manageable list
+// (issue #1089; the settings surface that consumes this is #1098). Two
+// endpoints:
+//
+// GET /api/anchors list every way into the account
+// DELETE /api/anchors/{kind}/{id} remove one (ceremony-gated)
+//
+// The listing is metadata only: {kind, id, created_at}. The id is the removal
+// handle β€” the base64url credential id for passkeys; for siwa/email THE
+// STORED HASH itself. Handing the account's own client that hash is
+// deliberate: it is HMAC-peppered server-side data (reverses to nothing
+// without the pepper), already opaque and stable, and it's exactly the key
+// removal needs β€” a surrogate id would mean more store state for zero privacy
+// gain. What must never appear is anything reversible: no Apple subs, no
+// addresses (the server never holds those anyway), no public keys.
+//
+// Removal rule (the last-method guard): the LAST sign-in method overall β€”
+// passkeys, SiwA anchors, and email anchors counted together β€” can never be
+// removed; an account must not be brickable from its own settings screen.
+// This REPLACES the old last-PASSKEY-only rule: the anchor model reversed the
+// passkey-only invariant (docs/accounts-and-recovery.md), so an account
+// holding a live email or Apple anchor may shed its final passkey. The guard
+// itself lives in the store (transaction/CAS-atomic with the delete it
+// protects); handlers only translate last=true into a 409.
+
+import "net/http"
+
+// anchorEntry is one listing row: an Anchor plus its kind, so the client can
+// group and route removals without a second lookup.
+type anchorEntry struct {
+ Kind string `json:"kind"`
+ ID string `json:"id"`
+ CreatedAt int64 `json:"created_at"`
+}
+
+// listAnchorsHandler is GET /api/anchors β€” link-token gated like the devices
+// list: which ways in exist reveals nothing to a holder of the account's own
+// token, and the settings screen must render without demanding a ceremony.
+func (s *Server) listAnchorsHandler(w http.ResponseWriter, r *http.Request) {
+ uid, ok := s.linkUser(r)
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ anchors := []anchorEntry{}
+ for _, part := range []struct {
+ kind string
+ list func(string) ([]Anchor, error)
+ }{
+ {"passkey", s.store.listCredentialMeta},
+ {"siwa", s.store.listSiwaAnchors},
+ {"email", s.store.listEmailAnchors},
+ } {
+ rows, err := part.list(uid)
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ for _, a := range rows {
+ anchors = append(anchors, anchorEntry{Kind: part.kind, ID: a.ID, CreatedAt: a.CreatedAt})
+ }
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"anchors": anchors})
+}
+
+// deleteAnchorHandler is DELETE /api/anchors/{kind}/{id} β€” ceremony-gated
+// like credential deletion (removing a way in reduces access, so a stolen
+// link token must never be able to do it). The passkey kind routes to the
+// same store call as DELETE /api/credentials/{id}, so both doors enforce the
+// one last-method rule.
+func (s *Server) deleteAnchorHandler(w http.ResponseWriter, r *http.Request) {
+ uid, ok := s.ceremonyUser(r)
+ if !ok {
+ http.Error(w, "confirm it's you first", http.StatusUnauthorized)
+ return
+ }
+ var deleted, last bool
+ var err error
+ switch kind, id := r.PathValue("kind"), r.PathValue("id"); kind {
+ case "passkey":
+ deleted, last, err = s.store.deleteCredential(uid, id)
+ case "siwa":
+ deleted, last, err = s.store.deleteSiwaAnchor(uid, id)
+ case "email":
+ deleted, last, err = s.store.deleteEmailAnchor(uid, id)
+ default:
+ http.Error(w, "unknown sign-in method kind", http.StatusBadRequest)
+ return
+ }
+ if err != nil {
+ http.Error(w, "db error", http.StatusInternalServerError)
+ return
+ }
+ if last {
+ http.Error(w, "that's the last way into this account β€” add another way to sign in before removing it, or delete the backup instead", http.StatusConflict)
+ return
+ }
+ if !deleted {
+ http.Error(w, "no such sign-in method", http.StatusNotFound)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
diff --git a/home/anchors_test.go b/home/anchors_test.go
new file mode 100644
index 0000000..6a67592
--- /dev/null
+++ b/home/anchors_test.go
@@ -0,0 +1,235 @@
+package main
+
+// πŸ€– Anchor-management tests (anchors.go, issues #1089/#1098): the listing
+// shape, per-kind removal, the last-method refusal matrix, and auth gating.
+// Anchors are seeded directly through the store β€” the fake-Apple / fake-SES
+// rigs (siwa_test.go, email_test.go) already cover how anchors ARRIVE; these
+// cover managing them once they're there.
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+type anchorRow struct {
+ Kind string `json:"kind"`
+ ID string `json:"id"`
+ CreatedAt int64 `json:"created_at"`
+}
+
+func listAnchors(t *testing.T, url, token string) []anchorRow {
+ t.Helper()
+ r := do(t, "GET", url+"/api/anchors", token, nil, nil)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("GET /api/anchors: want 200, got %d", r.StatusCode)
+ }
+ var out struct {
+ Anchors []anchorRow `json:"anchors"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&out); err != nil {
+ t.Fatalf("bad json: %v", err)
+ }
+ return out.Anchors
+}
+
+func TestAnchorsListing(t *testing.T) {
+ ts, s := newTestServer(t)
+ uid, tok := seedUser(t, s)
+ if _, err := s.store.insertSiwaAnchor(uid, "siwa-hash-1"); err != nil {
+ t.Fatalf("insertSiwaAnchor: %v", err)
+ }
+ if _, err := s.store.insertEmailAnchor(uid, "email-hash-1"); err != nil {
+ t.Fatalf("insertEmailAnchor: %v", err)
+ }
+
+ // Link-token gated, like the devices list.
+ if r := do(t, "GET", ts.URL+"/api/anchors", "", nil, nil); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no token: want 401, got %d", r.StatusCode)
+ }
+ if r := do(t, "GET", ts.URL+"/api/anchors", "wrong", nil, nil); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("bad token: want 401, got %d", r.StatusCode)
+ }
+
+ // The listing is {kind, id, created_at} only β€” the raw body must carry no
+ // credential material (the passkey's public key stays server-side).
+ r := do(t, "GET", ts.URL+"/api/anchors", tok, nil, nil)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("list: want 200, got %d", r.StatusCode)
+ }
+ raw, _ := io.ReadAll(r.Body)
+ if strings.Contains(string(raw), "publicKey") || strings.Contains(string(raw), base64.StdEncoding.EncodeToString([]byte("test-cose-public-key"))) {
+ t.Fatalf("anchors listing leaks credential material: %s", raw)
+ }
+ var out struct {
+ Anchors []anchorRow `json:"anchors"`
+ }
+ if err := json.Unmarshal(raw, &out); err != nil {
+ t.Fatalf("bad json: %v", err)
+ }
+ if len(out.Anchors) != 3 {
+ t.Fatalf("want 3 anchors, got %d: %+v", len(out.Anchors), out.Anchors)
+ }
+ creds, _ := s.store.credentialsFor(uid)
+ wantIDs := map[string]string{
+ "passkey": base64.RawURLEncoding.EncodeToString(creds[0].ID),
+ "siwa": "siwa-hash-1",
+ "email": "email-hash-1",
+ }
+ for _, a := range out.Anchors {
+ want, ok := wantIDs[a.Kind]
+ if !ok {
+ t.Fatalf("unexpected/duplicate kind %q: %+v", a.Kind, out.Anchors)
+ }
+ delete(wantIDs, a.Kind)
+ if a.ID != want {
+ t.Fatalf("%s id: want %q, got %q", a.Kind, want, a.ID)
+ }
+ if a.CreatedAt == 0 {
+ t.Fatalf("%s created_at missing: %+v", a.Kind, a)
+ }
+ }
+}
+
+func TestAnchorRemovalHappyPaths(t *testing.T) {
+ ts, s := newTestServer(t)
+ uid, tok := seedUser(t, s)
+ second := &webauthn.Credential{ID: []byte("cred-second"), PublicKey: []byte("pk2")}
+ if err := s.store.insertCredential(uid, second); err != nil {
+ t.Fatalf("insertCredential: %v", err)
+ }
+ s.store.insertSiwaAnchor(uid, "siwa-hash-1")
+ s.store.insertEmailAnchor(uid, "email-hash-1")
+ proof := s.keys.mintCeremonyProof(uid)
+
+ // One removal per kind β€” each leaves other methods, so each succeeds.
+ for _, path := range []string{
+ "/api/anchors/siwa/siwa-hash-1",
+ "/api/anchors/email/email-hash-1",
+ "/api/anchors/passkey/" + base64.RawURLEncoding.EncodeToString(second.ID),
+ } {
+ r := do(t, "DELETE", ts.URL+path, "", map[string]string{"X-Ceremony-Proof": proof}, nil)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("DELETE %s: want 200, got %d", path, r.StatusCode)
+ }
+ }
+ // The listing reflects it: one passkey left, nothing else.
+ anchors := listAnchors(t, ts.URL, tok)
+ if len(anchors) != 1 || anchors[0].Kind != "passkey" {
+ t.Fatalf("post-removal listing: %+v", anchors)
+ }
+ // The removed identifiers no longer sign in.
+ if _, ok := s.store.siwaUser("siwa-hash-1"); ok {
+ t.Fatalf("removed siwa anchor still resolves")
+ }
+ if _, ok := s.store.emailUser("email-hash-1"); ok {
+ t.Fatalf("removed email anchor still resolves")
+ }
+}
+
+// The refusal matrix (#1089): removal is refused exactly when it would leave
+// ZERO ways into the account, and allowed whenever ANOTHER method remains β€”
+// whatever its kind. This is the rule evolution from the old last-passkey-only
+// guard: an anchored account may shed its final passkey.
+func TestAnchorLastMethodMatrix(t *testing.T) {
+ ts, s := newTestServer(t)
+
+ credID := func(uid string) string {
+ creds, _ := s.store.credentialsFor(uid)
+ return base64.RawURLEncoding.EncodeToString(creds[0].ID)
+ }
+ del := func(proof, path string) *http.Response {
+ return do(t, "DELETE", ts.URL+path, "", map[string]string{"X-Ceremony-Proof": proof}, nil)
+ }
+
+ // A passkey-only account keeps its passkey (pre-anchor behaviour holds).
+ uid1, _ := seedUser(t, s)
+ proof1 := s.keys.mintCeremonyProof(uid1)
+ r := del(proof1, "/api/anchors/passkey/"+credID(uid1))
+ if r.StatusCode != http.StatusConflict {
+ t.Fatalf("last passkey, no anchors: want 409, got %d", r.StatusCode)
+ }
+ // The refusal reads as plain English, not a code.
+ if body, _ := io.ReadAll(r.Body); !strings.Contains(string(body), "last way into this account") {
+ t.Fatalf("refusal message unhelpful: %s", body)
+ }
+ // With an email anchor beside it, the same passkey MAY go…
+ s.store.insertEmailAnchor(uid1, "matrix-email-1")
+ if r := del(proof1, "/api/anchors/passkey/"+credID(uid1)); r.StatusCode != http.StatusOK {
+ t.Fatalf("last passkey with email anchor: want 200, got %d", r.StatusCode)
+ }
+ // …and the email anchor is now the last method: refused.
+ if r := del(proof1, "/api/anchors/email/matrix-email-1"); r.StatusCode != http.StatusConflict {
+ t.Fatalf("last email anchor: want 409, got %d", r.StatusCode)
+ }
+
+ // Same shape with a SiwA anchor carrying the account.
+ uid2, _ := seedUser(t, s)
+ proof2 := s.keys.mintCeremonyProof(uid2)
+ s.store.insertSiwaAnchor(uid2, "matrix-siwa-2")
+ if r := del(proof2, "/api/anchors/passkey/"+credID(uid2)); r.StatusCode != http.StatusOK {
+ t.Fatalf("last passkey with siwa anchor: want 200, got %d", r.StatusCode)
+ }
+ if r := del(proof2, "/api/anchors/siwa/matrix-siwa-2"); r.StatusCode != http.StatusConflict {
+ t.Fatalf("last siwa anchor: want 409, got %d", r.StatusCode)
+ }
+
+ // An anchor may go freely while a passkey remains.
+ uid3, _ := seedUser(t, s)
+ proof3 := s.keys.mintCeremonyProof(uid3)
+ s.store.insertEmailAnchor(uid3, "matrix-email-3")
+ if r := del(proof3, "/api/anchors/email/matrix-email-3"); r.StatusCode != http.StatusOK {
+ t.Fatalf("email anchor beside a passkey: want 200, got %d", r.StatusCode)
+ }
+
+ // The legacy credentials endpoint enforces the SAME rule β€” one guard (in
+ // the store), two doors.
+ uid4, _ := seedUser(t, s)
+ proof4 := s.keys.mintCeremonyProof(uid4)
+ if r := del(proof4, "/api/credentials/"+credID(uid4)); r.StatusCode != http.StatusConflict {
+ t.Fatalf("legacy delete, last passkey, no anchors: want 409, got %d", r.StatusCode)
+ }
+ s.store.insertEmailAnchor(uid4, "matrix-email-4")
+ if r := del(proof4, "/api/credentials/"+credID(uid4)); r.StatusCode != http.StatusOK {
+ t.Fatalf("legacy delete, last passkey with email anchor: want 200, got %d", r.StatusCode)
+ }
+}
+
+func TestAnchorDeleteAuthGating(t *testing.T) {
+ ts, s := newTestServer(t)
+ uid, tok := seedUser(t, s)
+ s.store.insertEmailAnchor(uid, "email-hash-1")
+
+ // A link token must never remove a sign-in method (it reduces access) β€”
+ // same asymmetry as devices/credentials.
+ if r := do(t, "DELETE", ts.URL+"/api/anchors/email/email-hash-1", tok, nil, nil); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("delete with link token: want 401, got %d", r.StatusCode)
+ }
+ if r := do(t, "DELETE", ts.URL+"/api/anchors/email/email-hash-1", "", nil, nil); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("delete with no auth: want 401, got %d", r.StatusCode)
+ }
+ // Another user's fresh proof can't touch it.
+ other, _ := seedUser(t, s)
+ otherProof := s.keys.mintCeremonyProof(other)
+ if r := do(t, "DELETE", ts.URL+"/api/anchors/email/email-hash-1", "", map[string]string{"X-Ceremony-Proof": otherProof}, nil); r.StatusCode != http.StatusNotFound {
+ t.Fatalf("cross-user proof: want 404, got %d", r.StatusCode)
+ }
+ proof := s.keys.mintCeremonyProof(uid)
+ // An unknown kind is a 400 with a message, not a fall-through.
+ if r := do(t, "DELETE", ts.URL+"/api/anchors/carrier-pigeon/x", "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusBadRequest {
+ t.Fatalf("unknown kind: want 400, got %d", r.StatusCode)
+ }
+ // An unknown id is a 404.
+ if r := do(t, "DELETE", ts.URL+"/api/anchors/email/nope", "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusNotFound {
+ t.Fatalf("unknown id: want 404, got %d", r.StatusCode)
+ }
+ // The owner's fresh proof works.
+ if r := do(t, "DELETE", ts.URL+"/api/anchors/email/email-hash-1", "", 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/db.go b/home/db.go
index 6d0f8aa..d10674b 100644
--- a/home/db.go
+++ b/home/db.go
@@ -135,6 +135,14 @@ func (s *sqliteStore) insertSiwaAnchor(uid, subHash string) (bool, error) {
return owner != uid, nil
}
+func (s *sqliteStore) listSiwaAnchors(userID string) ([]Anchor, error) {
+ return s.listAnchorRows("SELECT sub_hash, created_at FROM siwa_anchors WHERE user_id = ? ORDER BY created_at", userID)
+}
+
+func (s *sqliteStore) deleteSiwaAnchor(userID, subHash string) (deleted, last bool, err error) {
+ return s.deleteAnchorRow("siwa_anchors", "sub_hash", userID, subHash)
+}
+
func (s *sqliteStore) emailUser(index string) (string, bool) {
var uid string
err := s.sql.QueryRow(`SELECT user_id FROM email_anchors WHERE email_hash = ?`, index).Scan(&uid)
@@ -159,6 +167,76 @@ func (s *sqliteStore) insertEmailAnchor(uid, index string) (bool, error) {
return owner != uid, nil
}
+func (s *sqliteStore) listEmailAnchors(userID string) ([]Anchor, error) {
+ return s.listAnchorRows("SELECT email_hash, created_at FROM email_anchors WHERE user_id = ? ORDER BY created_at", userID)
+}
+
+func (s *sqliteStore) deleteEmailAnchor(userID, index string) (deleted, last bool, err error) {
+ return s.deleteAnchorRow("email_anchors", "email_hash", userID, index)
+}
+
+// listAnchorRows runs one of the fixed (id, created_at) listing queries above
+// β€” the three anchor listings differ only in table and key column.
+func (s *sqliteStore) listAnchorRows(query, userID string) ([]Anchor, error) {
+ rows, err := s.sql.Query(query, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []Anchor{}
+ for rows.Next() {
+ var a Anchor
+ if err := rows.Scan(&a.ID, &a.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
+
+// deleteAnchorRow removes one identity anchor (siwa or email β€” table and key
+// column are in-code constants from the two callers above, never input) under
+// the same transaction-atomic last-method guard as deleteCredential.
+func (s *sqliteStore) deleteAnchorRow(table, keyCol, userID, hash string) (deleted, last bool, err error) {
+ tx, err := s.sql.Begin()
+ if err != nil {
+ return false, false, err
+ }
+ defer tx.Rollback()
+ var owned int
+ e := tx.QueryRow("SELECT 1 FROM "+table+" WHERE user_id = ? AND "+keyCol+" = ?", userID, hash).Scan(&owned)
+ if e == sql.ErrNoRows {
+ return false, false, nil
+ }
+ if e != nil {
+ return false, false, e
+ }
+ n, err := signInMethods(tx, userID)
+ if err != nil {
+ return false, false, err
+ }
+ if n <= 1 {
+ return false, true, nil
+ }
+ if _, err := tx.Exec("DELETE FROM "+table+" WHERE user_id = ? AND "+keyCol+" = ?", userID, hash); err != nil {
+ return false, false, err
+ }
+ return true, false, tx.Commit()
+}
+
+// signInMethods counts every way into the account β€” passkeys plus SiwA and
+// email anchors β€” inside the caller's transaction, so the last-method guard
+// is atomic with the delete it protects.
+func signInMethods(tx *sql.Tx, userID string) (int, error) {
+ var n int
+ err := tx.QueryRow(`SELECT
+ (SELECT COUNT(*) FROM credentials WHERE user_id = ?)
+ + (SELECT COUNT(*) FROM siwa_anchors WHERE user_id = ?)
+ + (SELECT COUNT(*) FROM email_anchors WHERE user_id = ?)`,
+ userID, userID, userID).Scan(&n)
+ return n, err
+}
+
func hashToken(t string) string {
h := sha256.Sum256([]byte(t))
return hex.EncodeToString(h[:])
@@ -243,8 +321,10 @@ func (db *sqliteStore) credentialUser(credID string) (string, bool) {
}
// 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 β€”
+// It refuses to remove the account's LAST sign-in method β€” passkeys plus
+// siwa/email anchors (#1089 evolved the guard from last-passkey to
+// last-method): an account keeping an anchor may shed its final passkey, but
+// no removal may leave zero ways in while the blob stays 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 *sqliteStore) deleteCredential(userID, credID string) (deleted, last bool, err error) {
@@ -261,8 +341,8 @@ func (db *sqliteStore) deleteCredential(userID, credID string) (deleted, last bo
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 {
+ n, err := signInMethods(tx, userID)
+ if err != nil {
return false, false, err
}
if n <= 1 {
@@ -274,6 +354,12 @@ func (db *sqliteStore) deleteCredential(userID, credID string) (deleted, last bo
return true, false, tx.Commit()
}
+// listCredentialMeta returns passkey listing metadata (base64url id +
+// enrolment time), enrolment order β€” the anchors list needs no public keys.
+func (db *sqliteStore) listCredentialMeta(userID string) ([]Anchor, error) {
+ return db.listAnchorRows("SELECT cred_id, created_at FROM credentials WHERE user_id = ? ORDER BY created_at", userID)
+}
+
// ---- the blob ---------------------------------------------------------------
// getBlob returns the latest version of a user's blob.
diff --git a/home/home_test.go b/home/home_test.go
index 77396de..2f16b38 100644
--- a/home/home_test.go
+++ b/home/home_test.go
@@ -374,8 +374,11 @@ func TestCredentialDeleteRequiresCeremony(t *testing.T) {
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.)
+ // The LAST credential is refused β€” this user has no SiwA/email anchors, so
+ // the passkey is the last sign-in method overall. (The guard evolved from
+ // last-passkey to last-method with #1089: an ANCHORED account may now shed
+ // its final passkey β€” that side of the matrix lives in anchors_test.go.
+ // Blob delete remains 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)
}
diff --git a/home/main.go b/home/main.go
index 6963cc0..110fcc4 100644
--- a/home/main.go
+++ b/home/main.go
@@ -453,6 +453,12 @@ func (s *Server) handler() http.Handler {
// it's ceremony-gated like the other destructive ops.
mux.HandleFunc("DELETE /api/credentials/{id}", s.deleteCredentialHandler)
+ // Sign-in anchors (anchors.go): every way into the account as one list β€”
+ // read with a link token, remove with a fresh ceremony. The settings
+ // surface (#1098) builds on these.
+ mux.HandleFunc("GET /api/anchors", s.listAnchorsHandler)
+ mux.HandleFunc("DELETE /api/anchors/{kind}/{id}", s.deleteAnchorHandler)
+
return securityHeaders(mux)
}
@@ -724,10 +730,13 @@ func (s *Server) deleteCredentialHandler(w http.ResponseWriter, r *http.Request)
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)
+ // Removing the account's LAST sign-in method would brick it: the blob
+ // stays live with no way to ever get in again. This used to be a
+ // last-PASSKEY guard; under the anchor model (#1089) the store counts
+ // SiwA and email anchors too, so an account holding another anchor CAN
+ // shed its final passkey. Winding an account down is the
+ // (ceremony-gated) blob delete, explicitly.
+ http.Error(w, "cannot remove your last way to sign in β€” delete the backup instead", http.StatusConflict)
return
}
if !deleted {
diff --git a/home/s3store.go b/home/s3store.go
index e12d193..24ec44b 100644
--- a/home/s3store.go
+++ b/home/s3store.go
@@ -361,8 +361,8 @@ func (s *s3Store) listVersions(prefix string) ([]s3Version, error) {
// s3UserDoc is the one metadata JSON per user. Array order is enrolment /
// creation order β€” the contract's ordering guarantee.
type s3UserDoc struct {
- Credentials []webauthn.Credential `json:"credentials"`
- Devices []s3Device `json:"devices"`
+ Credentials []s3Credential `json:"credentials"`
+ Devices []s3Device `json:"devices"`
// SiwaAnchors lists the account's Sign in with Apple sub hashes β€” the
// doc-side half of the siwa/<subHash> pointers (pointers never decide;
// see the vaultStore contract).
@@ -371,6 +371,16 @@ type s3UserDoc struct {
EmailAnchors []s3EmailAnchor `json:"email_anchors,omitempty"`
}
+// s3Credential wraps webauthn.Credential with its enrolment time β€” the
+// listing metadata the anchors surface shows (#1089). The embedded
+// credential's fields inline in the JSON, so this is additive on the wire:
+// docs written before this field unmarshal with CreatedAt 0 (the client
+// renders "unknown"), and older readers ignore the extra key.
+type s3Credential struct {
+ webauthn.Credential
+ CreatedAt int64 `json:"created_at,omitempty"`
+}
+
type s3SiwaAnchor struct {
SubHash string `json:"sub_hash"`
CreatedAt int64 `json:"created_at"`
@@ -454,7 +464,7 @@ func (s *s3Store) updateUser(uid string, mutate func(doc *s3UserDoc) (write bool
// success: uids are long random strings, so a duplicate create can only be a
// retry of our own request β€” erroring would just make registration flaky.
func (s *s3Store) createUser(uid string) error {
- body, err := json.Marshal(s3UserDoc{Credentials: []webauthn.Credential{}, Devices: []s3Device{}})
+ body, err := json.Marshal(s3UserDoc{Credentials: []s3Credential{}, Devices: []s3Device{}})
if err != nil {
return err
}
@@ -514,6 +524,23 @@ func (s *s3Store) getPointer(key string) (string, bool, error) {
}
}
+// deletePointer removes a pointer object; 404 counts as success (a retried
+// delete, or one never written). On a versioned bucket this lays a delete
+// marker, which is all that's needed: GETs answer 404 and a later write-once
+// claim of the same key succeeds again.
+func (s *s3Store) deletePointer(key string) error {
+ res, err := s.c.do("DELETE", key, nil, nil, nil)
+ if err != nil {
+ return err
+ }
+ switch res.status {
+ case http.StatusNoContent, http.StatusOK, http.StatusNotFound:
+ return nil
+ default:
+ return fmt.Errorf("s3 delete pointer: HTTP %d: %s", res.status, truncate(res.body))
+ }
+}
+
// ---- credentials ------------------------------------------------------------------
func b64CredIDOf(cred *webauthn.Credential) string {
@@ -527,8 +554,9 @@ func (s *s3Store) insertCredential(uid string, cred *webauthn.Credential) error
if err := s.putPointer(s.credKey(b64CredIDOf(cred)), uid); err != nil {
return err
}
+ now := time.Now().Unix()
return s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
- doc.Credentials = append(doc.Credentials, *cred)
+ doc.Credentials = append(doc.Credentials, s3Credential{Credential: *cred, CreatedAt: now})
return true, nil
})
}
@@ -545,7 +573,7 @@ func (s *s3Store) updateCredential(cred *webauthn.Credential) error {
return s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
for i := range doc.Credentials {
if bytes.Equal(doc.Credentials[i].ID, cred.ID) {
- doc.Credentials[i] = *cred
+ doc.Credentials[i].Credential = *cred // CreatedAt stays put
return true, nil
}
}
@@ -558,10 +586,26 @@ func (s *s3Store) credentialsFor(uid string) ([]webauthn.Credential, error) {
if err != nil {
return nil, err
}
- if doc.Credentials == nil {
- return []webauthn.Credential{}, nil
+ out := []webauthn.Credential{}
+ for _, c := range doc.Credentials {
+ out = append(out, c.Credential)
}
- return doc.Credentials, nil
+ return out, nil
+}
+
+// listCredentialMeta returns passkey listing metadata from the user doc β€”
+// base64url id + enrolment time, enrolment order (like credentialsFor, but
+// without hauling public keys anywhere).
+func (s *s3Store) listCredentialMeta(uid string) ([]Anchor, error) {
+ doc, _, _, err := s.loadUser(uid)
+ if err != nil {
+ return nil, err
+ }
+ out := []Anchor{}
+ for _, c := range doc.Credentials {
+ out = append(out, Anchor{ID: b64CredIDOf(&c.Credential), CreatedAt: c.CreatedAt})
+ }
+ return out, nil
}
// credentialUser resolves the creds/<credId> pointer and then VERIFIES the
@@ -586,8 +630,9 @@ func (s *s3Store) credentialUser(credID string) (string, bool) {
// deleteCredential removes the credential from the user doc only; the stale
// creds/ pointer stays behind, harmless because resolution re-checks the doc.
-// The last-passkey guard is atomic here because the whole doc updates under
-// one CAS β€” same guarantee SQLite gets from its transaction.
+// The last-METHOD guard (passkeys + siwa + email anchors, #1089) is atomic
+// here because the whole doc updates under one CAS β€” same guarantee SQLite
+// gets from its transaction.
func (s *s3Store) deleteCredential(uid, credID string) (deleted, last bool, err error) {
err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
deleted, last = false, false
@@ -601,7 +646,7 @@ func (s *s3Store) deleteCredential(uid, credID string) (deleted, last bool, err
if idx < 0 {
return false, nil
}
- if len(doc.Credentials) == 1 {
+ if signInMethodsDoc(doc) == 1 {
last = true
return false, nil
}
@@ -612,6 +657,12 @@ func (s *s3Store) deleteCredential(uid, credID string) (deleted, last bool, err
return deleted, last, err
}
+// signInMethodsDoc counts every way into the account within one loaded user
+// doc β€” the doc-CAS twin of SQLite's in-transaction signInMethods.
+func signInMethodsDoc(doc *s3UserDoc) int {
+ return len(doc.Credentials) + len(doc.SiwaAnchors) + len(doc.EmailAnchors)
+}
+
// ---- device link tokens --------------------------------------------------------
// createLinkToken mints the token, then pointer-first like insertCredential:
@@ -663,12 +714,17 @@ func (s *s3Store) siwaUser(subHash string) (string, bool) {
// insertSiwaAnchor writes pointer first, doc entry second (same ordering
// rationale as createLinkToken: an orphaned pointer denies; a doc entry
-// without its pointer would be an unusable anchor).
+// without its pointer would be an unusable anchor). The conflict check is
+// DOC-verified (siwaUser), not pointer-only: an unlink (deleteSiwaAnchor)
+// leaves a stale pointer behind when its cleanup half fails, and that must
+// not stop the same Apple ID anchoring a different account later β€” a stale
+// pointer is swept here so the write-once claim below can land.
func (s *s3Store) insertSiwaAnchor(uid, subHash string) (bool, error) {
- if owner, found, err := s.getPointer(s.siwaKey(subHash)); err != nil {
+ if err := s.sweepAnchorPointer(s.siwaKey(subHash), uid, s.siwaUser, subHash); err != nil {
+ if err == errAnchorTaken {
+ return true, nil
+ }
return false, err
- } else if found && owner != uid {
- return true, nil
}
if err := s.putPointer(s.siwaKey(subHash), uid); err != nil {
return false, err
@@ -689,6 +745,76 @@ func (s *s3Store) insertSiwaAnchor(uid, subHash string) (bool, error) {
return false, nil
}
+// errAnchorTaken is sweepAnchorPointer's "anchored to a DIFFERENT user"
+// signal β€” the callers' conflict=true.
+var errAnchorTaken = errors.New("anchor taken")
+
+// sweepAnchorPointer prepares an anchor key for a write-once claim by uid:
+// errAnchorTaken when the identifier is live on another account (doc-verified
+// via resolve β€” the pointer alone never decides). A leftover pointer that no
+// doc lists any more is deleted so the claim can land; a live pointer that
+// already names uid is left alone (putPointer treats a same-owner re-claim as
+// success, and deleting it would open a needless deny window).
+func (s *s3Store) sweepAnchorPointer(key, uid string, resolve func(string) (string, bool), hash string) error {
+ if owner, ok := resolve(hash); ok {
+ if owner != uid {
+ return errAnchorTaken
+ }
+ return nil
+ }
+ if _, found, err := s.getPointer(key); err != nil {
+ return err
+ } else if found {
+ return s.deletePointer(key)
+ }
+ return nil
+}
+
+func (s *s3Store) listSiwaAnchors(uid string) ([]Anchor, error) {
+ doc, _, _, err := s.loadUser(uid)
+ if err != nil {
+ return nil, err
+ }
+ out := []Anchor{}
+ for _, a := range doc.SiwaAnchors {
+ out = append(out, Anchor{ID: a.SubHash, CreatedAt: a.CreatedAt})
+ }
+ return out, nil
+}
+
+// deleteSiwaAnchor removes the anchor from the user doc β€” under the same
+// one-CAS last-method guard as deleteCredential β€” then clears the siwa/
+// pointer best-effort. A pointer a failed cleanup leaves behind still denies
+// at resolution (siwaUser re-checks the doc), and the next link of the same
+// sub sweeps it (sweepAnchorPointer), so the miss costs nothing but a
+// dangling object.
+func (s *s3Store) deleteSiwaAnchor(uid, subHash string) (deleted, last bool, err error) {
+ err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
+ deleted, last = false, false
+ idx := -1
+ for i, a := range doc.SiwaAnchors {
+ if a.SubHash == subHash {
+ idx = i
+ break
+ }
+ }
+ if idx < 0 {
+ return false, nil
+ }
+ if signInMethodsDoc(doc) == 1 {
+ last = true
+ return false, nil
+ }
+ doc.SiwaAnchors = append(doc.SiwaAnchors[:idx], doc.SiwaAnchors[idx+1:]...)
+ deleted = true
+ return true, nil
+ })
+ if err == nil && deleted {
+ _ = s.deletePointer(s.siwaKey(subHash)) // best-effort; see doc comment
+ }
+ return deleted, last, err
+}
+
// emailUser / insertEmailAnchor mirror the siwa pair exactly β€” pointer plus
// doc entry, pointer never decides.
func (s *s3Store) emailUser(index string) (string, bool) {
@@ -709,10 +835,11 @@ func (s *s3Store) emailUser(index string) (string, bool) {
}
func (s *s3Store) insertEmailAnchor(uid, index string) (bool, error) {
- if owner, found, err := s.getPointer(s.emailKey(index)); err != nil {
+ if err := s.sweepAnchorPointer(s.emailKey(index), uid, s.emailUser, index); err != nil {
+ if err == errAnchorTaken {
+ return true, nil
+ }
return false, err
- } else if found && owner != uid {
- return true, nil
}
if err := s.putPointer(s.emailKey(index), uid); err != nil {
return false, err
@@ -733,6 +860,47 @@ func (s *s3Store) insertEmailAnchor(uid, index string) (bool, error) {
return false, nil
}
+func (s *s3Store) listEmailAnchors(uid string) ([]Anchor, error) {
+ doc, _, _, err := s.loadUser(uid)
+ if err != nil {
+ return nil, err
+ }
+ out := []Anchor{}
+ for _, a := range doc.EmailAnchors {
+ out = append(out, Anchor{ID: a.EmailHash, CreatedAt: a.CreatedAt})
+ }
+ return out, nil
+}
+
+// deleteEmailAnchor mirrors deleteSiwaAnchor exactly β€” one-CAS last-method
+// guard, then best-effort pointer cleanup.
+func (s *s3Store) deleteEmailAnchor(uid, index string) (deleted, last bool, err error) {
+ err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) {
+ deleted, last = false, false
+ idx := -1
+ for i, a := range doc.EmailAnchors {
+ if a.EmailHash == index {
+ idx = i
+ break
+ }
+ }
+ if idx < 0 {
+ return false, nil
+ }
+ if signInMethodsDoc(doc) == 1 {
+ last = true
+ return false, nil
+ }
+ doc.EmailAnchors = append(doc.EmailAnchors[:idx], doc.EmailAnchors[idx+1:]...)
+ deleted = true
+ return true, nil
+ })
+ if err == nil && deleted {
+ _ = s.deletePointer(s.emailKey(index)) // best-effort; see deleteSiwaAnchor
+ }
+ return deleted, last, err
+}
+
// linkTokenUser resolves tokens/<hash> and verifies the device is still in
// the user doc (revocation = removal from the doc; the pointer never decides).
// last_seen bumps only when more than lastSeenGranularity stale β€” that
diff --git a/home/store.go b/home/store.go
index a629b1a..cfd42b3 100644
--- a/home/store.go
+++ b/home/store.go
@@ -70,11 +70,19 @@ type vaultStore interface {
// updateCredential persists post-ceremony state (sign count, backup flags),
// located by credential id.
updateCredential(cred *webauthn.Credential) error
- // deleteCredential removes one passkey. It refuses to remove the LAST one
- // (last=true, nothing deleted): the blob would be orphaned while staying
- // live β€” the explicit wind-down path is the ceremony-gated blob delete.
- // deleted=false with last=false means the id is unknown (or someone else's).
+ // deleteCredential removes one passkey. It refuses to remove the account's
+ // LAST sign-in method (last=true, nothing deleted) β€” counting passkeys
+ // PLUS siwa/email anchors, so an account holding another anchor may shed
+ // its final passkey (#1089 reversed the passkey-only invariant), but no
+ // removal may ever leave zero ways in: the blob would be orphaned while
+ // staying live β€” the explicit wind-down path is the ceremony-gated blob
+ // delete. deleted=false with last=false means the id is unknown (or
+ // someone else's).
deleteCredential(uid, credID string) (deleted, last bool, err error)
+ // listCredentialMeta returns the user's passkeys as listing metadata β€”
+ // base64url credential id + enrolment time, enrolment order β€” what the
+ // anchors list (#1089) shows without hauling public keys to the client.
+ listCredentialMeta(uid string) ([]Anchor, error)
// credentialUser resolves the creds/<credId> pointer: which user enrolled
// this credential. ok=false for an unknown (or revoked) id.
credentialUser(credID string) (uid string, ok bool)
@@ -105,8 +113,18 @@ type vaultStore interface {
siwaUser(subHash string) (uid string, ok bool)
// insertSiwaAnchor stores the siwa/<subHash> pointer. Idempotent for the
// same user; conflict=true (nothing written) when the sub is already
- // anchored to a DIFFERENT user β€” an Apple ID signs into one account.
+ // anchored to a DIFFERENT user β€” an Apple ID signs into one account. An
+ // anchor removed by deleteSiwaAnchor is genuinely gone: the same sub may
+ // anchor a different account afterwards.
insertSiwaAnchor(uid, subHash string) (conflict bool, err error)
+ // listSiwaAnchors returns the user's SiwA anchors (removal id + creation
+ // time), creation order. The id IS the stored sub hash β€” see the Anchor
+ // doc for why handing it to the account's own client is fine.
+ listSiwaAnchors(uid string) ([]Anchor, error)
+ // deleteSiwaAnchor removes one SiwA anchor, under the same last-method
+ // guard as deleteCredential (last=true, nothing deleted). deleted=false
+ // with last=false means the hash is unknown (or someone else's).
+ deleteSiwaAnchor(uid, subHash string) (deleted, last bool, err error)
// ---- email anchors (emails/<blind index> pointers) ----
@@ -115,13 +133,30 @@ type vaultStore interface {
emailUser(index string) (uid string, ok bool)
// insertEmailAnchor stores the emails/<index> pointer. Idempotent for the
// same user; conflict=true (nothing written) when the address is already
- // anchored to a DIFFERENT user.
+ // anchored to a DIFFERENT user. Like siwa, a removed anchor's index may
+ // re-anchor a different account later.
insertEmailAnchor(uid, index string) (conflict bool, err error)
+ // listEmailAnchors / deleteEmailAnchor mirror the siwa pair for the email
+ // blind indexes.
+ listEmailAnchors(uid string) ([]Anchor, error)
+ deleteEmailAnchor(uid, index string) (deleted, last bool, err error)
// Ceremony proofs are deliberately NOT here: they became stateless HMAC
// tokens (stateless.go) β€” a store that persists nothing needs no methods.
}
+// Anchor is one sign-in method's listing metadata β€” an opaque, stable removal
+// id plus creation time, nothing else (cf. Device). For siwa/email the id is
+// the stored HMAC-peppered hash ITSELF: it reverses to nothing without the
+// pepper, it never changes, and it's exactly the key removal needs β€” minting
+// a surrogate id per anchor would add store state for zero privacy gain.
+// What must never appear here is anything reversible (an Apple sub, an email
+// address β€” the server never holds those anyway).
+type Anchor struct {
+ ID string `json:"id"`
+ CreatedAt int64 `json:"created_at"`
+}
+
// Device is link-token metadata for the devices list β€” ids and timestamps
// only, never token values (or anything derived from them).
type Device struct {
diff --git a/home/store_test.go b/home/store_test.go
index ff3e0c0..348dcb3 100644
--- a/home/store_test.go
+++ b/home/store_test.go
@@ -352,6 +352,109 @@ func testVaultStore(t *testing.T, newStore func(t *testing.T) vaultStore) {
}
})
+ t.Run("AnchorListing", func(t *testing.T) {
+ st := newStore(t)
+ st.createUser("u1")
+ st.insertCredential("u1", mkCred("cred-one"))
+ if conflict, err := st.insertSiwaAnchor("u1", "subhash-1"); conflict || err != nil {
+ t.Fatalf("insertSiwaAnchor: conflict=%v err=%v", conflict, err)
+ }
+ if conflict, err := st.insertEmailAnchor("u1", "emailhash-1"); conflict || err != nil {
+ t.Fatalf("insertEmailAnchor: conflict=%v err=%v", conflict, err)
+ }
+
+ meta, err := st.listCredentialMeta("u1")
+ if err != nil || len(meta) != 1 {
+ t.Fatalf("listCredentialMeta: want 1, got %d err=%v", len(meta), err)
+ }
+ if meta[0].ID != b64CredID("cred-one") || meta[0].CreatedAt == 0 {
+ t.Fatalf("credential meta: %+v", meta[0])
+ }
+ siwa, err := st.listSiwaAnchors("u1")
+ if err != nil || len(siwa) != 1 || siwa[0].ID != "subhash-1" || siwa[0].CreatedAt == 0 {
+ t.Fatalf("listSiwaAnchors: %+v err=%v", siwa, err)
+ }
+ email, err := st.listEmailAnchors("u1")
+ if err != nil || len(email) != 1 || email[0].ID != "emailhash-1" || email[0].CreatedAt == 0 {
+ t.Fatalf("listEmailAnchors: %+v err=%v", email, err)
+ }
+ // Listings are per-user, empty (not an error) for an unknown one.
+ if rows, err := st.listSiwaAnchors("u2"); err != nil || len(rows) != 0 {
+ t.Fatalf("other user's siwa anchors: %+v err=%v", rows, err)
+ }
+ })
+
+ t.Run("DeleteAnchorLastMethodGuard", func(t *testing.T) {
+ st := newStore(t)
+ st.createUser("u1")
+ st.insertCredential("u1", mkCred("cred-one"))
+ st.insertSiwaAnchor("u1", "subhash-1")
+ st.insertEmailAnchor("u1", "emailhash-1")
+
+ // Unknown β€” and someone else's β€” hash is (false, false).
+ if deleted, last, err := st.deleteSiwaAnchor("u1", "nope"); deleted || last || err != nil {
+ t.Fatalf("delete unknown siwa: want (false,false), got (%v,%v,%v)", deleted, last, err)
+ }
+ if deleted, last, err := st.deleteSiwaAnchor("u2", "subhash-1"); deleted || last || err != nil {
+ t.Fatalf("delete cross-user siwa: want (false,false), got (%v,%v,%v)", deleted, last, err)
+ }
+ // Three methods: the siwa anchor may go…
+ if deleted, last, err := st.deleteSiwaAnchor("u1", "subhash-1"); !deleted || last || err != nil {
+ t.Fatalf("delete siwa: want (true,false), got (%v,%v,%v)", deleted, last, err)
+ }
+ // …and stops resolving (a stale pointer must deny, never resolve).
+ if _, ok := st.siwaUser("subhash-1"); ok {
+ t.Fatalf("removed siwa anchor still resolves")
+ }
+ // A removed identifier is genuinely free: the same sub may anchor a
+ // DIFFERENT account afterwards (unlink-then-relink-elsewhere).
+ st.createUser("u2")
+ if conflict, err := st.insertSiwaAnchor("u2", "subhash-1"); conflict || err != nil {
+ t.Fatalf("re-anchor removed sub: conflict=%v err=%v", conflict, err)
+ }
+ if uid, ok := st.siwaUser("subhash-1"); !ok || uid != "u2" {
+ t.Fatalf("re-anchored sub: want (u2, true), got (%q, %v)", uid, ok)
+ }
+
+ // Two methods left on u1 (passkey + email): the email anchor may go…
+ if deleted, last, err := st.deleteEmailAnchor("u1", "emailhash-1"); !deleted || last || err != nil {
+ t.Fatalf("delete email: want (true,false), got (%v,%v,%v)", deleted, last, err)
+ }
+ if _, ok := st.emailUser("emailhash-1"); ok {
+ t.Fatalf("removed email anchor still resolves")
+ }
+ // …leaving the passkey as the LAST method, which is refused.
+ if deleted, last, err := st.deleteCredential("u1", b64CredID("cred-one")); deleted || !last || err != nil {
+ t.Fatalf("delete last method: want (false,true), got (%v,%v,%v)", deleted, last, err)
+ }
+ })
+
+ t.Run("LastPasskeyMayGoWhenAnchored", func(t *testing.T) {
+ // The rule evolution (#1089): last-PASSKEY became last-METHOD β€” an
+ // account holding an email (or SiwA) anchor may shed its final
+ // passkey; only a removal that would leave ZERO ways in is refused.
+ st := newStore(t)
+ st.createUser("u1")
+ st.insertCredential("u1", mkCred("cred-one"))
+ st.insertEmailAnchor("u1", "emailhash-1")
+ if deleted, last, err := st.deleteCredential("u1", b64CredID("cred-one")); !deleted || last || err != nil {
+ t.Fatalf("delete last passkey with email anchor: want (true,false), got (%v,%v,%v)", deleted, last, err)
+ }
+ if _, ok := st.credentialUser(b64CredID("cred-one")); ok {
+ t.Fatalf("removed credential still resolves")
+ }
+ // The email anchor is now the last method: refused.
+ if deleted, last, err := st.deleteEmailAnchor("u1", "emailhash-1"); deleted || !last || err != nil {
+ t.Fatalf("delete last email anchor: want (false,true), got (%v,%v,%v)", deleted, last, err)
+ }
+ // A siwa anchor as the only method is refused the same way.
+ st.createUser("u2")
+ st.insertSiwaAnchor("u2", "subhash-2")
+ if deleted, last, err := st.deleteSiwaAnchor("u2", "subhash-2"); deleted || !last || err != nil {
+ t.Fatalf("delete last siwa anchor: want (false,true), got (%v,%v,%v)", deleted, last, err)
+ }
+ })
+
// Ceremony proofs deliberately have no contract entry: they left the store
// entirely β€” stateless HMAC tokens, tested in stateless_test.go.
}