eleven / messenger Public

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>
Paul Campbell 9001e44662beedc5e112bae8a1f3b5abf202c390 parent db6a134
7 files changed, +681 −0
  • home/db.go +56 −0
  • home/main.go +5 −0
  • home/merge.go +93 −0
  • home/merge_test.go +230 −0
  • home/s3store.go +160 −0
  • home/store.go +32 −0
  • home/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.
}