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) } }