eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// πŸ€– Anchor management β€” the account's sign-in methods as one manageable list
4// (issue #1089; the settings surface that consumes this is #1098). Two
5// endpoints:
6//
7// GET /api/anchors list every way into the account
8// DELETE /api/anchors/{kind}/{id} remove one (ceremony-gated)
9//
10// The listing is metadata only: {kind, id, created_at}. The id is the removal
11// handle β€” the base64url credential id for passkeys; for siwa/email/oidc THE
12// STORED HASH itself. Handing the account's own client that hash is
13// deliberate: it is HMAC-peppered server-side data (reverses to nothing
14// without the pepper), already opaque and stable, and it's exactly the key
15// removal needs β€” a surrogate id would mean more store state for zero privacy
16// gain. What must never appear is anything reversible: no Apple subs, no
17// addresses (the server never holds those anyway), no public keys. The OIDC
18// provider doors (oidc.go) list under their provider name ("google",
19// "microsoft") β€” one kind per door, exactly as siwa is Apple's.
20//
21// Removal rule (the last-method guard): the LAST sign-in method overall β€”
22// passkeys, SiwA anchors, email anchors, and OIDC provider anchors counted
23// together β€” can never be
24// removed; an account must not be brickable from its own settings screen.
25// This REPLACES the old last-PASSKEY-only rule: the anchor model reversed the
26// passkey-only invariant (docs/accounts-and-recovery.md), so an account
27// holding a live email or Apple anchor may shed its final passkey. The guard
28// itself lives in the store (transaction/CAS-atomic with the delete it
29// protects); handlers only translate last=true into a 409.
30
31import "net/http"
32
33// anchorEntry is one listing row: an Anchor plus its kind, so the client can
34// group and route removals without a second lookup.
35type anchorEntry struct {
36 Kind string `json:"kind"`
37 ID string `json:"id"`
38 CreatedAt int64 `json:"created_at"`
39}
40
41// listAnchorsHandler is GET /api/anchors β€” link-token gated like the devices
42// list: which ways in exist reveals nothing to a holder of the account's own
43// token, and the settings screen must render without demanding a ceremony.
44func (s *Server) listAnchorsHandler(w http.ResponseWriter, r *http.Request) {
45 uid, ok := s.linkUser(r)
46 if !ok {
47 http.Error(w, "unauthorized", http.StatusUnauthorized)
48 return
49 }
50 type anchorSource struct {
51 kind string
52 list func(string) ([]Anchor, error)
53 }
54 parts := []anchorSource{
55 {"passkey", s.store.listCredentialMeta},
56 {"siwa", s.store.listSiwaAnchors},
57 {"email", s.store.listEmailAnchors},
58 }
59 // The OIDC-table kinds come from the fixed set (oidcAnchorKinds β€” the
60 // verifier providers plus github), not the enabled doors: anchors of a
61 // currently-disabled door still list (and remove below), exactly as
62 // siwa anchors do with SiwA off.
63 for _, name := range oidcAnchorKinds {
64 parts = append(parts, anchorSource{name, func(uid string) ([]Anchor, error) {
65 return s.store.listOidcAnchors(uid, name)
66 }})
67 }
68 anchors := []anchorEntry{}
69 for _, part := range parts {
70 rows, err := part.list(uid)
71 if err != nil {
72 http.Error(w, "db error", http.StatusInternalServerError)
73 return
74 }
75 for _, a := range rows {
76 anchors = append(anchors, anchorEntry{Kind: part.kind, ID: a.ID, CreatedAt: a.CreatedAt})
77 }
78 }
79 writeJSON(w, http.StatusOK, map[string]any{"anchors": anchors})
80}
81
82// deleteAnchorHandler is DELETE /api/anchors/{kind}/{id} β€” ceremony-gated
83// like credential deletion (removing a way in reduces access, so a stolen
84// link token must never be able to do it). The passkey kind routes to the
85// same store call as DELETE /api/credentials/{id}, so both doors enforce the
86// one last-method rule.
87func (s *Server) deleteAnchorHandler(w http.ResponseWriter, r *http.Request) {
88 uid, ok := s.ceremonyUser(r)
89 if !ok {
90 http.Error(w, "Confirm it's you first", http.StatusUnauthorized)
91 return
92 }
93 var deleted, last bool
94 var err error
95 switch kind, id := r.PathValue("kind"), r.PathValue("id"); kind {
96 case "passkey":
97 deleted, last, err = s.store.deleteCredential(uid, id)
98 case "siwa":
99 deleted, last, err = s.store.deleteSiwaAnchor(uid, id)
100 case "email":
101 deleted, last, err = s.store.deleteEmailAnchor(uid, id)
102 case "google", "microsoft", "github": // the OIDC-table kinds (oidcAnchorKinds)
103 deleted, last, err = s.store.deleteOidcAnchor(uid, kind, id)
104 default:
105 http.Error(w, "unknown sign-in method kind", http.StatusBadRequest)
106 return
107 }
108 if err != nil {
109 http.Error(w, "db error", http.StatusInternalServerError)
110 return
111 }
112 if last {
113 http.Error(w, "that's the last way into this account β€” add another way to sign in before removing it", http.StatusConflict)
114 return
115 }
116 if !deleted {
117 http.Error(w, "no such sign-in method", http.StatusNotFound)
118 return
119 }
120 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
121}
122