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>
8 files changed,
+778
−34
home/anchors.go+105 −0home/anchors_test.go+235 −0home/db.go+90 −4home/home_test.go+5 −2home/main.go+13 −4home/s3store.go+186 −18home/store.go+41 −6home/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. |
| } |