home: account merge — POST /api/account/merge (#1098) (#1130)
* home: anchor management — list and remove sign-in methods (#1089)
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>
* home: anchor management — list and remove sign-in methods (#1089)
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>
* home: the OIDC provider table — dark Google and Microsoft doors (#1112)
Generalizes the Sign in with Apple verifier into a provider table
(home/oidc.go). What differs per door lives in one oidcProvider row —
name, display, issuer check, client ids, pepper, JWKS cache — and
everything else is shared: the jwksCache (moved verbatim from siwa.go),
the hand-rolled RS256 verification (verifyIDToken, same check order),
resolve-or-create, link mode, and the form_post trampoline. Apple keeps
its exact routes, env vars, and siwa_anchors rows; verifySiwaToken is
now a thin wrapper over the shared core, so there is still one verifier
and /api/siwa/* is unchanged on the wire (its whole suite passes
untouched).
New per-provider surface: POST /api/oidc/{provider}/verify (the same
request/response contract as /api/siwa/verify) and POST /oidc/return
(the same web-state trampoline, provider-blind, fragment key #oidc=).
Gating rides the SiwA pattern per door: LCHAT_HOME_GOOGLE_CLIENT_IDS +
LCHAT_HOME_GOOGLE_PEPPER and LCHAT_HOME_MICROSOFT_CLIENT_IDS +
LCHAT_HOME_MICROSOFT_PEPPER, both-or-neither each, off = 404. Peppers
are per provider and durable — account keys are HMAC(provider pepper,
sub), and the store carries the provider name beside the hash
(oidc_anchors rows; oidc/<provider>/<hash> pointers on S3), so hashes
can never collide across providers.
Issuer decisions: Google accepts both documented spellings
(https://accounts.google.com and the legacy bare form — same account
either way). Microsoft uses the v2.0 common endpoint, so the issuer is
the per-tenant template https://login.microsoftonline.com/{tenantid}/v2.0
(consumer + work accounts); we accept the pattern and conservatively
require the token's tid claim to equal the issuer's tenant segment.
The anchors surface (#1089) gains the new kinds: GET /api/anchors lists
google/microsoft anchors (from the fixed table, so a disabled door's
anchors still list), DELETE /api/anchors/{google|microsoft}/{id}
removes them, and the last-method guard counts them in both stores.
Both doors ship DARK: no UI, and each stays 404 until its console
registration exists and the env pair is set (Paul's #1112 checklist).
No changelog entry for the same reason.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* home: the account-merge operation — POST /api/account/merge (#1098)
Merging two accounts is client-first by design: the client decrypts both
vaults locally, unions the saved spaces, re-encrypts under the survivor's
DEK, PUTs to the survivor, and deletes the absorbed blob — only then does
the server move anything. This adds the server slice: a both-sides-proofs-
gated endpoint (survivor link token + survivor fresh ceremony proof +
absorb_proof for the account being absorbed) that moves every anchor,
passkey credential, and device link token onto the survivor and retires
the emptied account. An absorbed account still holding a blob is a 409
("move the spaces over first") — both in the handler and as a store-side
belt (errAbsorbedHasBlob) — so merge itself never destroys anything but
an empty shell, and a retried merge is a zero-count success.
Store seam: vaultStore.mergeAccounts(survivor, absorbed) (mergeMoved, err).
SQLite does it in one transaction (user_id repoints can't collide — every
key column is globally unique). S3 sequences for crash safety like
createLinkToken documents: survivor doc gains entries first (deduped CAS),
pointers repoint second (unconditional overwrite — pointers never decide),
absorbed doc deleted last, so a crash anywhere resolves via the
pointer-verify contract and a retry completes the walk.
Tests: the contract suite gains a MergeAccounts subtest (runs against
SQLite and, env-gated, live S3); merge_test.go covers the HTTP contract —
happy path incl. the absorbed device's token now seeing the merged vault,
blob-present 409 then success after the ceremony-gated blob delete,
same-account 400, the auth-gating matrix (missing/mismatched/stale
proofs), and the last-method guard counting merged-in methods.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+681
−0
home/db.go+56 −0home/main.go+5 −0home/merge.go+93 −0home/merge_test.go+230 −0home/s3store.go+160 −0home/store.go+32 −0home/store_test.go+105 −0
diff --git a/home/db.go b/home/db.go| index 2597085..4b6ae51 100644 |
| --- a/home/db.go |
| +++ b/home/db.go |
| @@ -297,6 +297,62 @@ func (s *sqliteStore) deleteAnchorRow(table, keyCol, userID, hash string) (delet |
| return true, false, tx.Commit() |
| } |
| +// mergeAccounts (#1098) folds the absorbed account into the survivor in ONE |
| +// transaction: every credential, link token, and anchor row repoints its |
| +// user_id to the survivor, then the absorbed user row goes. Repointing can't |
| +// collide — each table's key column (cred_id, token_hash, the anchor hashes) |
| +// is unique across ALL accounts, and the absorbed account by definition holds |
| +// keys the survivor doesn't. The blob check inside the transaction is the |
| +// store-side belt for the handler's precondition: the client empties the |
| +// absorbed vault first, so merge itself destroys nothing but the empty shell. |
| +func (db *sqliteStore) mergeAccounts(survivor, absorbed string) (mergeMoved, error) { |
| + var moved mergeMoved |
| + if survivor == absorbed { |
| + // Belt: the handler 400s this, but a same-account "merge" reaching the |
| + // store would delete the account's own user row below. |
| + return moved, errors.New("merge: survivor and absorbed are the same account") |
| + } |
| + tx, err := db.sql.Begin() |
| + if err != nil { |
| + return moved, err |
| + } |
| + defer tx.Rollback() |
| + var blobs int |
| + if err := tx.QueryRow("SELECT COUNT(*) FROM blobs WHERE user_id = ?", absorbed).Scan(&blobs); err != nil { |
| + return moved, err |
| + } |
| + if blobs > 0 { |
| + return moved, errAbsorbedHasBlob |
| + } |
| + // created_at rides along untouched everywhere — a merged anchor keeps its |
| + // original enrolment time. |
| + move := func(table string) (int, error) { |
| + res, err := tx.Exec("UPDATE "+table+" SET user_id = ? WHERE user_id = ?", survivor, absorbed) |
| + if err != nil { |
| + return 0, err |
| + } |
| + n, err := res.RowsAffected() |
| + return int(n), err |
| + } |
| + for _, table := range []string{"siwa_anchors", "email_anchors", "oidc_anchors"} { |
| + n, err := move(table) |
| + if err != nil { |
| + return mergeMoved{}, err |
| + } |
| + moved.Anchors += n |
| + } |
| + if moved.Passkeys, err = move("credentials"); err != nil { |
| + return mergeMoved{}, err |
| + } |
| + if moved.Devices, err = move("link_tokens"); err != nil { |
| + return mergeMoved{}, err |
| + } |
| + if _, err := tx.Exec("DELETE FROM users WHERE id = ?", absorbed); err != nil { |
| + return mergeMoved{}, err |
| + } |
| + return moved, tx.Commit() |
| +} |
| + |
| // signInMethods counts every way into the account — passkeys plus SiwA, |
| // email, and OIDC-provider anchors — inside the caller's transaction, so the |
| // last-method guard is atomic with the delete it protects. |
diff --git a/home/main.go b/home/main.go| index a03ca6f..88128e4 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -493,6 +493,11 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("GET /api/anchors", s.listAnchorsHandler) |
| mux.HandleFunc("DELETE /api/anchors/{kind}/{id}", s.deleteAnchorHandler) |
| + // Account merge (merge.go, #1098): both-sides-proofs-gated. The client |
| + // unions the vaults and empties the absorbed one FIRST; this then moves |
| + // anchors/passkeys/devices and retires the emptied account. |
| + mux.HandleFunc("POST /api/account/merge", s.mergeAccountHandler) |
| + |
| return securityHeaders(mux) |
| } |
diff --git a/home/merge.go b/home/merge.go| new file mode 100644 |
| index 0000000..4da7e8b |
| --- /dev/null |
| +++ b/home/merge.go |
| @@ -0,0 +1,93 @@ |
| +package main |
| + |
| +// 🤖 The account-merge operation (issue #1098): "looks like you have two |
| +// accounts — combine them?". One endpoint: |
| +// |
| +// POST /api/account/merge {absorb_proof: "<ceremony proof for the account being absorbed>"} |
| +// |
| +// The DIVISION OF LABOUR is the design: the CLIENT merges the vaults first — |
| +// decrypt both locally, union the saved spaces, re-encrypt under the |
| +// survivor's DEK, PUT to the survivor, then delete the now-redundant absorbed |
| +// blob via the existing ceremony-gated DELETE /api/blob. Only then does it |
| +// call this endpoint, which moves the SERVER-side account furniture — every |
| +// anchor, passkey credential, and device link token — onto the survivor and |
| +// retires the emptied absorbed account. The server never sees vault contents |
| +// (the one rule), and the client-first ordering means merge itself destroys |
| +// nothing but an empty shell: if the absorbed account still holds a blob, |
| +// this refuses with a 409 and the plain instruction to move the spaces over |
| +// first — which also makes the whole op idempotent-safe (a retried merge |
| +// finds nothing left to lose). |
| +// |
| +// Auth is BOTH-SIDES-PROOFS. The survivor side: Authorization carries its |
| +// link token (names the account) AND X-Ceremony-Proof carries its own fresh |
| +// ceremony proof — merging moves sign-in methods, so like every |
| +// access-changing op a stolen link token alone must never suffice. The |
| +// absorbed side: the body's absorb_proof, a fresh proof minted by verifying |
| +// any of THAT account's anchors (or a passkey ceremony). Both proofs must be |
| +// live; proving you hold two accounts is exactly two fresh ceremonies. |
| +// |
| +// Device link tokens MOVE rather than die, so the absorbed account's other |
| +// devices keep working — same tokens, now signed into the survivor and seeing |
| +// the merged vault. |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| +) |
| + |
| +// mergeAccountHandler is POST /api/account/merge — see the file comment for |
| +// the contract. The settings surface (#1098's kebab) and the 409 "already |
| +// signs in somewhere else" flow both land here once the client-side vault |
| +// union is done. |
| +func (s *Server) mergeAccountHandler(w http.ResponseWriter, r *http.Request) { |
| + survivor, ok := s.linkUser(r) |
| + if !ok { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + // The survivor's OWN fresh proof — and it must be for the bearer's |
| + // account, or it's someone else's ceremony riding this session. |
| + if proofUID, ok := s.ceremonyUser(r); !ok || proofUID != survivor { |
| + http.Error(w, "confirm it's you first", http.StatusUnauthorized) |
| + return |
| + } |
| + var body struct { |
| + AbsorbProof string `json:"absorb_proof"` |
| + } |
| + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.AbsorbProof == "" { |
| + http.Error(w, "absorb_proof required", http.StatusBadRequest) |
| + return |
| + } |
| + absorbed, ok := s.keys.ceremonyUser(body.AbsorbProof) |
| + if !ok { |
| + http.Error(w, "confirm the other account too", http.StatusUnauthorized) |
| + return |
| + } |
| + if absorbed == survivor { |
| + http.Error(w, "those are the same account — nothing to combine", http.StatusBadRequest) |
| + return |
| + } |
| + // Precondition: the absorbed vault must already be empty (client-first |
| + // ordering, above). The store re-checks under its own atomicity — this |
| + // early read just gives the common case its answer without side effects. |
| + if _, _, found, err := s.store.getBlob(absorbed); err != nil { |
| + http.Error(w, "db error", http.StatusInternalServerError) |
| + return |
| + } else if found { |
| + http.Error(w, "that account still holds a backup — move the spaces over first", http.StatusConflict) |
| + return |
| + } |
| + moved, err := s.store.mergeAccounts(survivor, absorbed) |
| + if errors.Is(err, errAbsorbedHasBlob) { |
| + // The store's belt caught a blob written between the check above and |
| + // the merge. |
| + http.Error(w, "that account still holds a backup — move the spaces over first", http.StatusConflict) |
| + return |
| + } |
| + if err != nil { |
| + http.Error(w, "db error", http.StatusInternalServerError) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"merged": true, "moved": moved}) |
| +} |
diff --git a/home/merge_test.go b/home/merge_test.go| new file mode 100644 |
| index 0000000..f129ece |
| --- /dev/null |
| +++ b/home/merge_test.go |
| @@ -0,0 +1,230 @@ |
| +package main |
| + |
| +// 🤖 Account-merge tests (merge.go, issue #1098): the both-sides-proofs gate, |
| +// the empty-absorbed-vault precondition, and the move itself — everything the |
| +// absorbed account could sign in with ends up on the survivor, including the |
| +// absorbed devices' link tokens. Store-level move semantics live in the |
| +// vaultStore contract suite (store_test.go MergeAccounts); these tests cover |
| +// the HTTP contract. |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func mergeBody(t *testing.T, absorbProof string) []byte { |
| + t.Helper() |
| + b, err := json.Marshal(map[string]string{"absorb_proof": absorbProof}) |
| + if err != nil { |
| + t.Fatalf("marshal: %v", err) |
| + } |
| + return b |
| +} |
| + |
| +func TestAccountMergeHappyPath(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + survivor, survTok := seedUser(t, s) |
| + absorbed, absTok := seedUser(t, s) |
| + s.store.insertSiwaAnchor(absorbed, "merge-siwa") |
| + s.store.insertEmailAnchor(absorbed, "merge-email") |
| + s.store.insertOidcAnchor(absorbed, "google", "merge-google") |
| + // The survivor's vault already holds the client-side union (the client |
| + // merged and PUT before calling this endpoint). |
| + if r := do(t, "PUT", ts.URL+"/api/blob", survTok, map[string]string{"If-Match": `"0"`}, []byte("merged-vault")); r.StatusCode != http.StatusOK { |
| + t.Fatalf("survivor PUT blob: want 200, got %d", r.StatusCode) |
| + } |
| + |
| + survProof := s.keys.mintCeremonyProof(survivor) |
| + absProof := s.keys.mintCeremonyProof(absorbed) |
| + r := do(t, "POST", ts.URL+"/api/account/merge", survTok, |
| + map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("merge: want 200, got %d", r.StatusCode) |
| + } |
| + var out struct { |
| + Merged bool `json:"merged"` |
| + Moved mergeMoved `json:"moved"` |
| + } |
| + if err := json.NewDecoder(r.Body).Decode(&out); err != nil { |
| + t.Fatalf("bad json: %v", err) |
| + } |
| + if !out.Merged || out.Moved.Anchors != 3 || out.Moved.Passkeys != 1 || out.Moved.Devices != 1 { |
| + t.Fatalf("merge response: %+v", out) |
| + } |
| + |
| + // The anchors listing shows every way in under the survivor: two |
| + // passkeys plus the three moved anchors. |
| + kinds := map[string]int{} |
| + for _, a := range listAnchors(t, ts.URL, survTok) { |
| + kinds[a.Kind]++ |
| + } |
| + if kinds["passkey"] != 2 || kinds["siwa"] != 1 || kinds["email"] != 1 || kinds["google"] != 1 { |
| + t.Fatalf("post-merge anchors listing: %+v", kinds) |
| + } |
| + // The absorbed device's link token now signs into the survivor — it sees |
| + // the merged vault. |
| + r = do(t, "GET", ts.URL+"/api/blob", absTok, nil, nil) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("absorbed token GET blob: want 200, got %d", r.StatusCode) |
| + } |
| + if uid, ok := s.store.linkTokenUser(hashToken(absTok)); !ok || uid != survivor { |
| + t.Fatalf("absorbed token resolves to %q (ok=%v), want survivor", uid, ok) |
| + } |
| + // The absorbed account is gone (SQLite backs the test server). |
| + if s.store.(*sqliteStore).userExists(absorbed) { |
| + t.Fatalf("absorbed user row survives the merge") |
| + } |
| + // A retry within the proofs' lifetime is a zero-count success. |
| + r = do(t, "POST", ts.URL+"/api/account/merge", survTok, |
| + map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("retried merge: want 200, got %d", r.StatusCode) |
| + } |
| + if err := json.NewDecoder(r.Body).Decode(&out); err != nil { |
| + t.Fatalf("bad json: %v", err) |
| + } |
| + if !out.Merged || out.Moved != (mergeMoved{}) { |
| + t.Fatalf("retried merge response: %+v", out) |
| + } |
| +} |
| + |
| +// The precondition: the client moves the vault contents over (and deletes the |
| +// absorbed blob) BEFORE the merge — an absorbed account still holding a blob |
| +// is refused with a plain message, and nothing moves. |
| +func TestAccountMergeAbsorbedBlobStillPresent(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + survivor, survTok := seedUser(t, s) |
| + absorbed, absTok := seedUser(t, s) |
| + s.store.insertEmailAnchor(absorbed, "still-here") |
| + if r := do(t, "PUT", ts.URL+"/api/blob", absTok, map[string]string{"If-Match": `"0"`}, []byte("unmoved-vault")); r.StatusCode != http.StatusOK { |
| + t.Fatalf("absorbed PUT blob: want 200, got %d", r.StatusCode) |
| + } |
| + |
| + survProof := s.keys.mintCeremonyProof(survivor) |
| + absProof := s.keys.mintCeremonyProof(absorbed) |
| + r := do(t, "POST", ts.URL+"/api/account/merge", survTok, |
| + map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)) |
| + if r.StatusCode != http.StatusConflict { |
| + t.Fatalf("merge with absorbed blob: want 409, got %d", r.StatusCode) |
| + } |
| + if uid, ok := s.store.emailUser("still-here"); !ok || uid != absorbed { |
| + t.Fatalf("refused merge moved an anchor: (%q, %v)", uid, ok) |
| + } |
| + // Once the client deletes the absorbed blob (the ceremony-gated path), |
| + // the same call goes through. |
| + if r := do(t, "DELETE", ts.URL+"/api/blob", "", map[string]string{"X-Ceremony-Proof": absProof}, nil); r.StatusCode != http.StatusOK { |
| + t.Fatalf("absorbed blob delete: want 200, got %d", r.StatusCode) |
| + } |
| + r = do(t, "POST", ts.URL+"/api/account/merge", survTok, |
| + map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("merge after blob delete: want 200, got %d", r.StatusCode) |
| + } |
| +} |
| + |
| +func TestAccountMergeSameAccount(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + survivor, survTok := seedUser(t, s) |
| + proof := s.keys.mintCeremonyProof(survivor) |
| + // An absorb proof for the bearer's own account is a 400, not a merge. |
| + r := do(t, "POST", ts.URL+"/api/account/merge", survTok, |
| + map[string]string{"X-Ceremony-Proof": proof}, mergeBody(t, proof)) |
| + if r.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("same-account merge: want 400, got %d", r.StatusCode) |
| + } |
| + if !s.store.(*sqliteStore).userExists(survivor) { |
| + t.Fatalf("same-account merge deleted the account") |
| + } |
| +} |
| + |
| +func TestAccountMergeAuthGating(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + survivor, survTok := seedUser(t, s) |
| + absorbed, _ := seedUser(t, s) |
| + survProof := s.keys.mintCeremonyProof(survivor) |
| + absProof := s.keys.mintCeremonyProof(absorbed) |
| + |
| + // Both survivor-side headers are required: the link token names the |
| + // account, the fresh proof authorises the access change. |
| + if r := do(t, "POST", ts.URL+"/api/account/merge", "", |
| + map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)); r.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("no bearer: want 401, got %d", r.StatusCode) |
| + } |
| + if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, nil, mergeBody(t, absProof)); r.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("no ceremony proof: want 401, got %d", r.StatusCode) |
| + } |
| + // A proof for a DIFFERENT account than the bearer's is refused — it isn't |
| + // the survivor's ceremony. |
| + if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, |
| + map[string]string{"X-Ceremony-Proof": absProof}, mergeBody(t, absProof)); r.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("mismatched proof: want 401, got %d", r.StatusCode) |
| + } |
| + // Stale proofs — either side — are refused. |
| + staleSurv := s.keys.mintCeremonyProofAt(survivor, time.Now().Add(-time.Minute)) |
| + if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, |
| + map[string]string{"X-Ceremony-Proof": staleSurv}, mergeBody(t, absProof)); r.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("stale survivor proof: want 401, got %d", r.StatusCode) |
| + } |
| + staleAbs := s.keys.mintCeremonyProofAt(absorbed, time.Now().Add(-time.Minute)) |
| + if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, |
| + map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, staleAbs)); r.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("stale absorb proof: want 401, got %d", r.StatusCode) |
| + } |
| + if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, |
| + map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, "garbage")); r.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("garbage absorb proof: want 401, got %d", r.StatusCode) |
| + } |
| + // A missing/empty absorb_proof is a 400 (bad request, not bad auth). |
| + if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, |
| + map[string]string{"X-Ceremony-Proof": survProof}, []byte(`{}`)); r.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("missing absorb_proof: want 400, got %d", r.StatusCode) |
| + } |
| + // Nothing above moved anything. |
| + if creds, _ := s.store.credentialsFor(absorbed); len(creds) != 1 { |
| + t.Fatalf("refused merges moved credentials: %d", len(creds)) |
| + } |
| +} |
| + |
| +// The last-method guard interacts naturally with a merge: the survivor gains |
| +// the absorbed account's methods, so a passkey it couldn't shed before (its |
| +// only way in) may go afterwards — and the merged-in anchor then becomes the |
| +// last method itself. |
| +func TestAccountMergeLastMethodAfterMerge(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + survivor, survTok := seedUser(t, s) |
| + absorbed, _ := seedUser(t, s) |
| + s.store.insertEmailAnchor(absorbed, "merged-email") |
| + survProof := s.keys.mintCeremonyProof(survivor) |
| + |
| + creds, _ := s.store.credentialsFor(survivor) |
| + survCredID := b64CredID(string(creds[0].ID)) |
| + // Pre-merge: the survivor's only method can't be removed. |
| + if r := do(t, "DELETE", ts.URL+"/api/anchors/passkey/"+survCredID, "", |
| + map[string]string{"X-Ceremony-Proof": survProof}, nil); r.StatusCode != http.StatusConflict { |
| + t.Fatalf("pre-merge last passkey: want 409, got %d", r.StatusCode) |
| + } |
| + |
| + absProof := s.keys.mintCeremonyProof(absorbed) |
| + if r := do(t, "POST", ts.URL+"/api/account/merge", survTok, |
| + map[string]string{"X-Ceremony-Proof": survProof}, mergeBody(t, absProof)); r.StatusCode != http.StatusOK { |
| + t.Fatalf("merge: want 200, got %d", r.StatusCode) |
| + } |
| + |
| + // Post-merge the account holds 2 passkeys + 1 email anchor: both passkeys |
| + // may go now… |
| + absCreds, _ := s.store.credentialsFor(survivor) |
| + for _, c := range absCreds { |
| + id := b64CredID(string(c.ID)) |
| + if r := do(t, "DELETE", ts.URL+"/api/anchors/passkey/"+id, "", |
| + map[string]string{"X-Ceremony-Proof": survProof}, nil); r.StatusCode != http.StatusOK { |
| + t.Fatalf("post-merge passkey delete: want 200, got %d", r.StatusCode) |
| + } |
| + } |
| + // …until the merged-in email anchor is the last way in. |
| + if r := do(t, "DELETE", ts.URL+"/api/anchors/email/merged-email", "", |
| + map[string]string{"X-Ceremony-Proof": survProof}, nil); r.StatusCode != http.StatusConflict { |
| + t.Fatalf("merged anchor as last method: want 409, got %d", r.StatusCode) |
| + } |
| +} |
diff --git a/home/s3store.go b/home/s3store.go| index b7c47f2..a2d245e 100644 |
| --- a/home/s3store.go |
| +++ b/home/s3store.go |
| @@ -542,6 +542,23 @@ func (s *s3Store) getPointer(key string) (string, bool, error) { |
| } |
| } |
| +// repointPointer unconditionally overwrites a pointer object's body with a new |
| +// uid — the account-merge move (#1098). Unlike putPointer there is no |
| +// write-once condition: the pointer is KNOWN to exist and name the absorbed |
| +// account, and pointers are never authoritative anyway — the doc-verify at |
| +// resolution is what decides, so an overwrite can never grant access the docs |
| +// don't back. |
| +func (s *s3Store) repointPointer(key, uid string) error { |
| + res, err := s.c.do("PUT", key, nil, nil, []byte(uid)) |
| + if err != nil { |
| + return err |
| + } |
| + if res.status != http.StatusOK { |
| + return fmt.Errorf("s3 repoint pointer: HTTP %d: %s", res.status, truncate(res.body)) |
| + } |
| + return nil |
| +} |
| + |
| // 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 |
| @@ -1009,6 +1026,149 @@ func (s *s3Store) deleteOidcAnchor(uid, provider, subHash string) (deleted, last |
| return deleted, last, err |
| } |
| +// mergeAccounts (#1098) folds the absorbed account into the survivor. There is |
| +// no cross-doc transaction on S3, so the write ORDER is what makes a crash at |
| +// any step resolve safely (same discipline as createLinkToken's pointer-first |
| +// rule; every step is idempotent, so a retried merge re-walks the sequence): |
| +// |
| +// 1. The SURVIVOR doc gains every absorbed entry first (one CAS update, |
| +// deduped by each entry's key so a retry appends nothing twice). Crash |
| +// after this: pointers still name the absorbed account and its doc still |
| +// lists everything, so every anchor/token resolves exactly as before — |
| +// the survivor's extra entries are inert (nothing points at them yet). |
| +// 2. Every pointer repoints to the survivor (unconditional overwrite — |
| +// repointPointer). Crash mid-way: a repointed key resolves via |
| +// pointer→survivor-doc (which lists it, thanks to step 1); an untouched |
| +// key still resolves via absorbed. Either way the doc-verify contract |
| +// holds and nothing is denied or misgranted. |
| +// 3. The ABSORBED doc is deleted LAST. Deleting it any earlier would kill |
| +// resolution for every not-yet-repointed key (pointer→absorbed→gone = |
| +// deny); last, it only retires listings nothing points at any more. A |
| +// lingering absorbed doc after a crash is harmless for the same reason — |
| +// pointers all name the survivor — and the retry deletes it. |
| +// |
| +// The blob check up front is the store-side belt for the handler's |
| +// precondition (the client empties the absorbed vault first); an absorbed |
| +// account with no doc is a zero-count success — that's the post-completion |
| +// retry, which must not error while the absorb proof is still live. |
| +func (s *s3Store) mergeAccounts(survivor, absorbed string) (mergeMoved, error) { |
| + var moved mergeMoved |
| + if survivor == absorbed { |
| + // Belt: the handler 400s this; reaching step 3 here would delete the |
| + // account's own doc. |
| + return moved, errors.New("merge: survivor and absorbed are the same account") |
| + } |
| + if _, _, found, err := s.getBlob(absorbed); err != nil { |
| + return moved, err |
| + } else if found { |
| + return moved, errAbsorbedHasBlob |
| + } |
| + absDoc, _, found, err := s.loadUser(absorbed) |
| + if err != nil { |
| + return moved, err |
| + } |
| + if !found { |
| + return moved, nil // already merged — a retry |
| + } |
| + moved = mergeMoved{ |
| + Anchors: len(absDoc.SiwaAnchors) + len(absDoc.EmailAnchors) + len(absDoc.OidcAnchors), |
| + Passkeys: len(absDoc.Credentials), |
| + Devices: len(absDoc.Devices), |
| + } |
| + |
| + // Step 1: the survivor doc gains everything. CreatedAt/LastSeen ride along |
| + // untouched — a merged anchor keeps its original enrolment time. |
| + err = s.updateUser(survivor, func(doc *s3UserDoc) (bool, error) { |
| + changed := false |
| + has := map[string]bool{} |
| + for _, c := range doc.Credentials { |
| + has["cred/"+b64CredIDOf(&c.Credential)] = true |
| + } |
| + for _, d := range doc.Devices { |
| + has["tok/"+d.TokenHash] = true |
| + } |
| + for _, a := range doc.SiwaAnchors { |
| + has["siwa/"+a.SubHash] = true |
| + } |
| + for _, a := range doc.EmailAnchors { |
| + has["email/"+a.EmailHash] = true |
| + } |
| + for _, a := range doc.OidcAnchors { |
| + has["oidc/"+a.Provider+"/"+a.SubHash] = true |
| + } |
| + for _, c := range absDoc.Credentials { |
| + if !has["cred/"+b64CredIDOf(&c.Credential)] { |
| + doc.Credentials = append(doc.Credentials, c) |
| + changed = true |
| + } |
| + } |
| + for _, d := range absDoc.Devices { |
| + if !has["tok/"+d.TokenHash] { |
| + doc.Devices = append(doc.Devices, d) |
| + changed = true |
| + } |
| + } |
| + for _, a := range absDoc.SiwaAnchors { |
| + if !has["siwa/"+a.SubHash] { |
| + doc.SiwaAnchors = append(doc.SiwaAnchors, a) |
| + changed = true |
| + } |
| + } |
| + for _, a := range absDoc.EmailAnchors { |
| + if !has["email/"+a.EmailHash] { |
| + doc.EmailAnchors = append(doc.EmailAnchors, a) |
| + changed = true |
| + } |
| + } |
| + for _, a := range absDoc.OidcAnchors { |
| + if !has["oidc/"+a.Provider+"/"+a.SubHash] { |
| + doc.OidcAnchors = append(doc.OidcAnchors, a) |
| + changed = true |
| + } |
| + } |
| + return changed, nil |
| + }) |
| + if err != nil { |
| + return mergeMoved{}, err |
| + } |
| + |
| + // Step 2: repoint every pointer at the survivor. |
| + for _, c := range absDoc.Credentials { |
| + if err := s.repointPointer(s.credKey(b64CredIDOf(&c.Credential)), survivor); err != nil { |
| + return mergeMoved{}, err |
| + } |
| + } |
| + for _, d := range absDoc.Devices { |
| + if err := s.repointPointer(s.tokenKey(d.TokenHash), survivor); err != nil { |
| + return mergeMoved{}, err |
| + } |
| + } |
| + for _, a := range absDoc.SiwaAnchors { |
| + if err := s.repointPointer(s.siwaKey(a.SubHash), survivor); err != nil { |
| + return mergeMoved{}, err |
| + } |
| + } |
| + for _, a := range absDoc.EmailAnchors { |
| + if err := s.repointPointer(s.emailKey(a.EmailHash), survivor); err != nil { |
| + return mergeMoved{}, err |
| + } |
| + } |
| + for _, a := range absDoc.OidcAnchors { |
| + if err := s.repointPointer(s.oidcKey(a.Provider, a.SubHash), survivor); err != nil { |
| + return mergeMoved{}, err |
| + } |
| + } |
| + |
| + // Step 3: retire the emptied absorbed doc. deletePointer is the generic |
| + // delete-with-404-as-success this needs (a delete marker suffices — uids |
| + // are long random strings, never reused, so create-only never re-claims |
| + // this key). |
| + if err := s.deletePointer(s.userKey(absorbed)); err != nil { |
| + return mergeMoved{}, err |
| + } |
| + return moved, nil |
| +} |
| + |
| // 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 06d3a4e..84b6c34 100644 |
| --- a/home/store.go |
| +++ b/home/store.go |
| @@ -21,6 +21,12 @@ const lastSeenGranularity = int64(24 * 60 * 60) |
| // The handler maps it to 400; a mismatch maps to 409. |
| var errBadVersion = errors.New("bad version") |
| +// errAbsorbedHasBlob is mergeAccounts refusing to touch an account that still |
| +// holds a vault blob — the belt under the merge handler's own precondition |
| +// check (the client must move the vault contents over and delete the absorbed |
| +// blob FIRST; see merge.go). The handler maps it to 409. |
| +var errAbsorbedHasBlob = errors.New("absorbed account still has a blob") |
| + |
| // vaultStore is home's persistence seam. Its method shapes deliberately mirror |
| // an object store — point reads by key, conditional writes (CAS on an opaque |
| // version), keep-last-N versioning, prefix deletes, and write-once pointers — |
| @@ -157,10 +163,36 @@ type vaultStore interface { |
| listOidcAnchors(uid, provider string) ([]Anchor, error) |
| deleteOidcAnchor(uid, provider, subHash string) (deleted, last bool, err error) |
| + // ---- account merge (#1098) ---- |
| + |
| + // mergeAccounts folds the absorbed account into the survivor: EVERY |
| + // sign-in anchor (siwa, email, oidc), passkey credential, and device link |
| + // token moves from absorbed to survivor, then the absorbed user record is |
| + // deleted. Identifiers cannot collide across accounts by construction — |
| + // each anchor hash / credential id / token hash is a globally-unique |
| + // pointer to exactly one account — so a move never conflicts. Link tokens |
| + // move (rather than die) so the absorbed account's other devices keep |
| + // working, now signed into the survivor. The op is atomic per backend |
| + // (one transaction / a crash-safe write ordering); it refuses with |
| + // errAbsorbedHasBlob while the absorbed account still holds a blob — the |
| + // belt under the handler's own check, so a merge can never orphan vault |
| + // data. An absorbed account that no longer exists is a zero-count |
| + // success, which is what makes a retried merge (the ceremony proof |
| + // outlives the deleted account by a few minutes) harmless. |
| + mergeAccounts(survivor, absorbed string) (mergeMoved, error) |
| + |
| // Ceremony proofs are deliberately NOT here: they became stateless HMAC |
| // tokens (stateless.go) — a store that persists nothing needs no methods. |
| } |
| +// mergeMoved reports what mergeAccounts relocated — the counts the merge |
| +// endpoint answers with, so the client can say what happened in plain words. |
| +type mergeMoved struct { |
| + Anchors int `json:"anchors"` |
| + Passkeys int `json:"passkeys"` |
| + Devices int `json:"devices"` |
| +} |
| + |
| // 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 |
diff --git a/home/store_test.go b/home/store_test.go| index b454f73..0134a9e 100644 |
| --- a/home/store_test.go |
| +++ b/home/store_test.go |
| @@ -534,6 +534,111 @@ func testVaultStore(t *testing.T, newStore func(t *testing.T) vaultStore) { |
| } |
| }) |
| + t.Run("MergeAccounts", func(t *testing.T) { |
| + // The account merge (#1098): everything that signs in moves from the |
| + // absorbed account to the survivor, resolution follows, and the |
| + // absorbed account's listings empty out. The blob belt, the |
| + // same-account refusal, and the zero-count retry are contract too. |
| + st := newStore(t) |
| + st.createUser("u1") |
| + st.insertCredential("u1", mkCred("cred-surv")) |
| + survTok, err := st.createLinkToken("u1") |
| + if err != nil { |
| + t.Fatalf("createLinkToken survivor: %v", err) |
| + } |
| + if _, conflict, err := st.putBlob("u1", []byte("survivor-vault"), "0"); err != nil || conflict { |
| + t.Fatalf("survivor putBlob: %v conflict=%v", err, conflict) |
| + } |
| + st.createUser("u2") |
| + st.insertCredential("u2", mkCred("cred-abs")) |
| + absTok1, _ := st.createLinkToken("u2") |
| + absTok2, _ := st.createLinkToken("u2") |
| + st.insertSiwaAnchor("u2", "merge-siwa") |
| + st.insertEmailAnchor("u2", "merge-email") |
| + st.insertOidcAnchor("u2", "google", "merge-google") |
| + |
| + // Belt: an absorbed account still holding a blob refuses, moving nothing. |
| + if _, conflict, err := st.putBlob("u2", []byte("not-yet-moved"), "0"); err != nil || conflict { |
| + t.Fatalf("absorbed putBlob: %v conflict=%v", err, conflict) |
| + } |
| + if _, err := st.mergeAccounts("u1", "u2"); !errors.Is(err, errAbsorbedHasBlob) { |
| + t.Fatalf("merge with blob present: want errAbsorbedHasBlob, got %v", err) |
| + } |
| + if uid, ok := st.siwaUser("merge-siwa"); !ok || uid != "u2" { |
| + t.Fatalf("refused merge moved an anchor: (%q, %v)", uid, ok) |
| + } |
| + // Same-account merge is a hard error (it would retire the account itself). |
| + if _, err := st.mergeAccounts("u1", "u1"); err == nil { |
| + t.Fatalf("same-account merge: want error") |
| + } |
| + |
| + // The client-first ordering: absorbed blob deleted, then merge. |
| + if err := st.deleteBlob("u2"); err != nil { |
| + t.Fatalf("deleteBlob: %v", err) |
| + } |
| + moved, err := st.mergeAccounts("u1", "u2") |
| + if err != nil { |
| + t.Fatalf("mergeAccounts: %v", err) |
| + } |
| + if moved.Anchors != 3 || moved.Passkeys != 1 || moved.Devices != 2 { |
| + t.Fatalf("moved counts: want {3 1 2}, got %+v", moved) |
| + } |
| + |
| + // Every identifier now resolves to the survivor… |
| + if uid, ok := st.siwaUser("merge-siwa"); !ok || uid != "u1" { |
| + t.Fatalf("merged siwa anchor: want (u1, true), got (%q, %v)", uid, ok) |
| + } |
| + if uid, ok := st.emailUser("merge-email"); !ok || uid != "u1" { |
| + t.Fatalf("merged email anchor: want (u1, true), got (%q, %v)", uid, ok) |
| + } |
| + if uid, ok := st.oidcUser("google", "merge-google"); !ok || uid != "u1" { |
| + t.Fatalf("merged oidc anchor: want (u1, true), got (%q, %v)", uid, ok) |
| + } |
| + if uid, ok := st.credentialUser(b64CredID("cred-abs")); !ok || uid != "u1" { |
| + t.Fatalf("merged credential: want (u1, true), got (%q, %v)", uid, ok) |
| + } |
| + // …including the absorbed devices' link tokens (they keep working, |
| + // now signed into the survivor). |
| + for _, tok := range []string{absTok1, absTok2} { |
| + if uid, ok := st.linkTokenUser(hashToken(tok)); !ok || uid != "u1" { |
| + t.Fatalf("merged link token: want (u1, true), got (%q, %v)", uid, ok) |
| + } |
| + } |
| + // The survivor's own token and blob are untouched. |
| + if uid, ok := st.linkTokenUser(hashToken(survTok)); !ok || uid != "u1" { |
| + t.Fatalf("survivor link token: want (u1, true), got (%q, %v)", uid, ok) |
| + } |
| + if data, _, found, _ := st.getBlob("u1"); !found || !bytes.Equal(data, []byte("survivor-vault")) { |
| + t.Fatalf("survivor blob touched by merge: found=%v data=%q", found, data) |
| + } |
| + // The survivor lists everything; creation times ride along. |
| + if creds, _ := st.credentialsFor("u1"); len(creds) != 2 { |
| + t.Fatalf("survivor credentials: want 2, got %d", len(creds)) |
| + } |
| + if devices, _ := st.listDevices("u1"); len(devices) != 3 { |
| + t.Fatalf("survivor devices: want 3, got %d", len(devices)) |
| + } |
| + if siwa, _ := st.listSiwaAnchors("u1"); len(siwa) != 1 || siwa[0].ID != "merge-siwa" || siwa[0].CreatedAt == 0 { |
| + t.Fatalf("survivor siwa listing: %+v", siwa) |
| + } |
| + // The absorbed account's listings are empty. |
| + if creds, _ := st.credentialsFor("u2"); len(creds) != 0 { |
| + t.Fatalf("absorbed credentials remain: %d", len(creds)) |
| + } |
| + if devices, _ := st.listDevices("u2"); len(devices) != 0 { |
| + t.Fatalf("absorbed devices remain: %d", len(devices)) |
| + } |
| + if rows, _ := st.listEmailAnchors("u2"); len(rows) != 0 { |
| + t.Fatalf("absorbed email anchors remain: %+v", rows) |
| + } |
| + |
| + // A retry (the absorb proof outlives the deleted account) is a |
| + // zero-count success, never an error. |
| + if again, err := st.mergeAccounts("u1", "u2"); err != nil || again != (mergeMoved{}) { |
| + t.Fatalf("retried merge: want zero counts, got %+v err=%v", again, err) |
| + } |
| + }) |
| + |
| // Ceremony proofs deliberately have no contract entry: they left the store |
| // entirely — stateless HMAC tokens, tested in stateless_test.go. |
| } |