eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// The vaultStore contract, tested against the interface — not any one backend.
4// A future s3Store passes by adding one TestXxxVaultStore entry point that
5// calls testVaultStore with its own constructor; nothing here may reach into
6// backend internals (versions are asserted opaque: only "0" and round-tripped
7// values are ever compared).
8
9import (
10 "bytes"
11 "encoding/base64"
12 "errors"
13 "testing"
14
15 "github.com/go-webauthn/webauthn/webauthn"
16)
17
18func TestSQLiteVaultStore(t *testing.T) {
19 testVaultStore(t, func(t *testing.T) vaultStore {
20 st, err := openSQLiteStore(t.TempDir() + "/store.db")
21 if err != nil {
22 t.Fatalf("openSQLiteStore: %v", err)
23 }
24 return st
25 })
26}
27
28// SQLite-specific: a version string the backend can't interpret at all (never
29// one of ours) is errBadVersion, which the handler maps to 400 — distinct from
30// a mismatch, which is a conflict. Other backends may treat garbage as a plain
31// mismatch, so this isn't part of the shared contract.
32func TestSQLitePutBlobBadVersion(t *testing.T) {
33 st, err := openSQLiteStore(t.TempDir() + "/store.db")
34 if err != nil {
35 t.Fatalf("openSQLiteStore: %v", err)
36 }
37 for _, bad := range []string{"abc", "-1", ""} {
38 if _, _, err := st.putBlob("u", []byte("x"), bad); !errors.Is(err, errBadVersion) {
39 t.Fatalf("putBlob(ifMatch=%q): want errBadVersion, got %v", bad, err)
40 }
41 }
42}
43
44// SQLite-specific (backdating needs SQL): last_seen bumps at most once per
45// lastSeenGranularity — an hour-stale value stays put, a two-day-stale one
46// refreshes. Guards the no-write-per-read property an object store depends on.
47func TestSQLiteLastSeenCoarse(t *testing.T) {
48 st, err := openSQLiteStore(t.TempDir() + "/store.db")
49 if err != nil {
50 t.Fatalf("openSQLiteStore: %v", err)
51 }
52 if err := st.createUser("u1"); err != nil {
53 t.Fatalf("createUser: %v", err)
54 }
55 tok, err := st.createLinkToken("u1")
56 if err != nil {
57 t.Fatalf("createLinkToken: %v", err)
58 }
59 lastSeen := func() int64 {
60 var v int64
61 if err := st.sql.QueryRow("SELECT last_seen FROM link_tokens WHERE token_hash = ?",
62 hashToken(tok)).Scan(&v); err != nil {
63 t.Fatalf("read last_seen: %v", err)
64 }
65 return v
66 }
67 backdate := func(by int64) int64 {
68 v := lastSeen() - by
69 if _, err := st.sql.Exec("UPDATE link_tokens SET last_seen = ? WHERE token_hash = ?",
70 v, hashToken(tok)); err != nil {
71 t.Fatalf("backdate: %v", err)
72 }
73 return v
74 }
75
76 fresh := backdate(3600) // an hour stale: within granularity, must not rewrite
77 if _, ok := st.linkTokenUser(hashToken(tok)); !ok {
78 t.Fatalf("linkTokenUser: want ok")
79 }
80 if got := lastSeen(); got != fresh {
81 t.Fatalf("hour-stale last_seen rewritten: want %d, got %d", fresh, got)
82 }
83
84 stale := backdate(2 * lastSeenGranularity) // two days stale: must refresh
85 if _, ok := st.linkTokenUser(hashToken(tok)); !ok {
86 t.Fatalf("linkTokenUser: want ok")
87 }
88 if got := lastSeen(); got <= stale {
89 t.Fatalf("two-day-stale last_seen not refreshed: still %d", got)
90 }
91}
92
93// testVaultStore runs the shared contract suite against one backend.
94func testVaultStore(t *testing.T, newStore func(t *testing.T) vaultStore) {
95 mkCred := func(id string) *webauthn.Credential {
96 return &webauthn.Credential{
97 ID: []byte(id),
98 PublicKey: []byte("pk-" + id),
99 Authenticator: webauthn.Authenticator{
100 AAGUID: make([]byte, 16),
101 SignCount: 1,
102 },
103 }
104 }
105
106 t.Run("BlobCAS", func(t *testing.T) {
107 st := newStore(t)
108 if _, _, found, err := st.getBlob("u1"); err != nil || found {
109 t.Fatalf("getBlob empty: want !found, got found=%v err=%v", found, err)
110 }
111
112 // A CAS write against a version that doesn't exist yet must lose, and
113 // report the "0" sentinel as the current version.
114 if cur, conflict, err := st.putBlob("u1", []byte("x"), "5"); err != nil || !conflict || cur != "0" {
115 t.Fatalf("putBlob on empty with stale version: want conflict + current \"0\", got (%q, %v, %v)", cur, conflict, err)
116 }
117
118 // "0" is create-only: it claims "no blob yet".
119 v1, conflict, err := st.putBlob("u1", []byte("one"), "0")
120 if err != nil || conflict {
121 t.Fatalf("create putBlob: %v conflict=%v", err, conflict)
122 }
123 if v1 == "" || v1 == "0" {
124 t.Fatalf("new version must be a non-sentinel opaque string, got %q", v1)
125 }
126 data, got, found, err := st.getBlob("u1")
127 if err != nil || !found || !bytes.Equal(data, []byte("one")) || got != v1 {
128 t.Fatalf("getBlob after create: data=%q version=%q found=%v err=%v (want version %q)", data, got, found, err, v1)
129 }
130
131 // Once a blob exists, "0" must lose (create-only), naming the current version.
132 if cur, conflict, _ := st.putBlob("u1", []byte("race"), "0"); !conflict || cur != v1 {
133 t.Fatalf("second create: want conflict + current %q, got (%q, %v)", v1, cur, conflict)
134 }
135 // So must any stale non-current version.
136 v2, conflict, err := st.putBlob("u1", []byte("two"), v1)
137 if err != nil || conflict {
138 t.Fatalf("CAS update: %v conflict=%v", err, conflict)
139 }
140 if cur, conflict, _ := st.putBlob("u1", []byte("stale"), v1); !conflict || cur != v2 {
141 t.Fatalf("stale CAS: want conflict + current %q, got (%q, %v)", v2, cur, conflict)
142 }
143 // The loser's write never landed.
144 if data, _, _, _ := st.getBlob("u1"); !bytes.Equal(data, []byte("two")) {
145 t.Fatalf("conflicted write must not land, got %q", data)
146 }
147
148 // Users are isolated: u1's blob says nothing about u2's.
149 if _, _, found, _ := st.getBlob("u2"); found {
150 t.Fatalf("getBlob other user: want !found")
151 }
152 })
153
154 t.Run("BlobKeepLastN", func(t *testing.T) {
155 st := newStore(t)
156 version := "0"
157 var wrote []string // every version handed out, oldest first
158 for i := 0; i < blobKeepVersions+2; i++ {
159 v, conflict, err := st.putBlob("u1", []byte{byte(i)}, version)
160 if err != nil || conflict {
161 t.Fatalf("put %d: %v conflict=%v", i, err, conflict)
162 }
163 version = v
164 wrote = append(wrote, v)
165 }
166 versions, err := st.listBlobVersions("u1")
167 if err != nil {
168 t.Fatalf("listBlobVersions: %v", err)
169 }
170 if len(versions) != blobKeepVersions {
171 t.Fatalf("want %d versions retained, got %d", blobKeepVersions, len(versions))
172 }
173 // Newest first, and exactly the last blobKeepVersions writes survive.
174 for i, v := range versions {
175 if want := wrote[len(wrote)-1-i]; v != want {
176 t.Fatalf("versions[%d]: want %q, got %q", i, want, v)
177 }
178 }
179 })
180
181 t.Run("BlobDeleteIsPrefixDelete", func(t *testing.T) {
182 st := newStore(t)
183 version := "0"
184 for i := 0; i < 3; i++ {
185 v, _, err := st.putBlob("u1", []byte("v"), version)
186 if err != nil {
187 t.Fatalf("put: %v", err)
188 }
189 version = v
190 }
191 if err := st.deleteBlob("u1"); err != nil {
192 t.Fatalf("deleteBlob: %v", err)
193 }
194 // Every version is gone — history included.
195 if _, _, found, _ := st.getBlob("u1"); found {
196 t.Fatalf("blob still readable after delete")
197 }
198 if versions, _ := st.listBlobVersions("u1"); len(versions) != 0 {
199 t.Fatalf("history survives delete: %v", versions)
200 }
201 // And the user is back at the "0" sentinel: create-only works again.
202 if _, conflict, err := st.putBlob("u1", []byte("fresh"), "0"); err != nil || conflict {
203 t.Fatalf("re-create after delete: %v conflict=%v", err, conflict)
204 }
205 })
206
207 t.Run("Credentials", func(t *testing.T) {
208 st := newStore(t)
209 if err := st.createUser("u1"); err != nil {
210 t.Fatalf("createUser: %v", err)
211 }
212 if creds, err := st.credentialsFor("u1"); err != nil || len(creds) != 0 {
213 t.Fatalf("fresh user credentials: want none, got %d err=%v", len(creds), err)
214 }
215 c1, c2 := mkCred("cred-one"), mkCred("cred-two")
216 if err := st.insertCredential("u1", c1); err != nil {
217 t.Fatalf("insertCredential: %v", err)
218 }
219 if err := st.insertCredential("u1", c2); err != nil {
220 t.Fatalf("insertCredential: %v", err)
221 }
222 creds, err := st.credentialsFor("u1")
223 if err != nil || len(creds) != 2 {
224 t.Fatalf("credentialsFor: want 2, got %d err=%v", len(creds), err)
225 }
226 // Enrolment order, with public keys intact.
227 if !bytes.Equal(creds[0].ID, c1.ID) || !bytes.Equal(creds[1].ID, c2.ID) ||
228 !bytes.Equal(creds[0].PublicKey, c1.PublicKey) {
229 t.Fatalf("credentials round-trip mismatch: %+v", creds)
230 }
231
232 // updateCredential persists post-ceremony state, located by credential id.
233 c1.Authenticator.SignCount = 42
234 if err := st.updateCredential(c1); err != nil {
235 t.Fatalf("updateCredential: %v", err)
236 }
237 creds, _ = st.credentialsFor("u1")
238 if creds[0].Authenticator.SignCount != 42 {
239 t.Fatalf("sign count not persisted: %d", creds[0].Authenticator.SignCount)
240 }
241 })
242
243 t.Run("CredentialPointer", func(t *testing.T) {
244 st := newStore(t)
245 st.createUser("u1")
246 st.insertCredential("u1", mkCred("cred-one"))
247 credID := b64CredID("cred-one")
248 if uid, ok := st.credentialUser(credID); !ok || uid != "u1" {
249 t.Fatalf("credentialUser: want (u1, true), got (%q, %v)", uid, ok)
250 }
251 if _, ok := st.credentialUser("nope"); ok {
252 t.Fatalf("credentialUser unknown id: want !ok")
253 }
254 })
255
256 t.Run("DeleteCredentialLastGuard", func(t *testing.T) {
257 st := newStore(t)
258 st.createUser("u1")
259 st.insertCredential("u1", mkCred("cred-one"))
260 st.insertCredential("u1", mkCred("cred-two"))
261 one, two := b64CredID("cred-one"), b64CredID("cred-two")
262
263 // Unknown id — and someone else's id — is (false, false).
264 if deleted, last, err := st.deleteCredential("u1", "nope"); deleted || last || err != nil {
265 t.Fatalf("delete unknown: want (false,false), got (%v,%v,%v)", deleted, last, err)
266 }
267 if deleted, last, err := st.deleteCredential("u2", one); deleted || last || err != nil {
268 t.Fatalf("delete cross-user: want (false,false), got (%v,%v,%v)", deleted, last, err)
269 }
270 // With two enrolled, one may go…
271 if deleted, last, err := st.deleteCredential("u1", two); !deleted || last || err != nil {
272 t.Fatalf("delete second: want (true,false), got (%v,%v,%v)", deleted, last, err)
273 }
274 // …and the pointer must stop resolving (never authoritative).
275 if _, ok := st.credentialUser(two); ok {
276 t.Fatalf("revoked credential pointer still resolves")
277 }
278 // The LAST credential is refused, and nothing is deleted.
279 if deleted, last, err := st.deleteCredential("u1", one); deleted || !last || err != nil {
280 t.Fatalf("delete last: want (false,true), got (%v,%v,%v)", deleted, last, err)
281 }
282 if creds, _ := st.credentialsFor("u1"); len(creds) != 1 {
283 t.Fatalf("last-guard must leave the credential in place, got %d", len(creds))
284 }
285 })
286
287 t.Run("LinkTokens", func(t *testing.T) {
288 st := newStore(t)
289 st.createUser("u1")
290 tok1, err := st.createLinkToken("u1")
291 if err != nil || tok1 == "" {
292 t.Fatalf("createLinkToken: %q %v", tok1, err)
293 }
294 tok2, _ := st.createLinkToken("u1")
295
296 // Resolution is by HASH — the tokens/<sha256> pointer.
297 if uid, ok := st.linkTokenUser(hashToken(tok1)); !ok || uid != "u1" {
298 t.Fatalf("linkTokenUser: want (u1, true), got (%q, %v)", uid, ok)
299 }
300 // The plaintext itself must NOT be a key (the store holds only hashes).
301 if _, ok := st.linkTokenUser(tok1); ok {
302 t.Fatalf("plaintext token resolved — store must be keyed by hash")
303 }
304 if _, ok := st.linkTokenUser(hashToken("wrong")); ok {
305 t.Fatalf("unknown token hash resolved")
306 }
307
308 devices, err := st.listDevices("u1")
309 if err != nil || len(devices) != 2 {
310 t.Fatalf("listDevices: want 2, got %d err=%v", len(devices), err)
311 }
312 for _, d := range devices {
313 if d.ID == "" || d.CreatedAt == 0 || d.LastSeen == 0 {
314 t.Fatalf("device metadata incomplete: %+v", d)
315 }
316 // A device id names a token without deriving from it.
317 if d.ID == tok1 || d.ID == tok2 || d.ID == hashToken(tok1) || d.ID == hashToken(tok2) {
318 t.Fatalf("device id derived from token value")
319 }
320 }
321 if id := st.linkTokenID(hashToken(tok1)); id != devices[0].ID && id != devices[1].ID {
322 t.Fatalf("linkTokenID: %q not among device ids", id)
323 }
324 if st.linkTokenID(hashToken("wrong")) != "" {
325 t.Fatalf("linkTokenID unknown hash: want \"\"")
326 }
327 })
328
329 t.Run("DeleteDeviceRevokes", func(t *testing.T) {
330 st := newStore(t)
331 st.createUser("u1")
332 tok, _ := st.createLinkToken("u1")
333 id := st.linkTokenID(hashToken(tok))
334
335 // Someone else can't revoke it.
336 if ok, err := st.deleteDevice("u2", id); ok || err != nil {
337 t.Fatalf("cross-user deleteDevice: want false, got %v err=%v", ok, err)
338 }
339 if ok, err := st.deleteDevice("u1", "nope"); ok || err != nil {
340 t.Fatalf("unknown deleteDevice: want false, got %v err=%v", ok, err)
341 }
342 if ok, err := st.deleteDevice("u1", id); !ok || err != nil {
343 t.Fatalf("deleteDevice: want true, got %v err=%v", ok, err)
344 }
345 // Revocation must be effective at resolution time — a stale pointer left
346 // behind by a backend must deny, not resolve.
347 if _, ok := st.linkTokenUser(hashToken(tok)); ok {
348 t.Fatalf("revoked token still resolves")
349 }
350 if devices, _ := st.listDevices("u1"); len(devices) != 0 {
351 t.Fatalf("revoked device still listed")
352 }
353 })
354
355 t.Run("AnchorListing", func(t *testing.T) {
356 st := newStore(t)
357 st.createUser("u1")
358 st.insertCredential("u1", mkCred("cred-one"))
359 if conflict, err := st.insertSiwaAnchor("u1", "subhash-1"); conflict || err != nil {
360 t.Fatalf("insertSiwaAnchor: conflict=%v err=%v", conflict, err)
361 }
362 if conflict, err := st.insertEmailAnchor("u1", "emailhash-1"); conflict || err != nil {
363 t.Fatalf("insertEmailAnchor: conflict=%v err=%v", conflict, err)
364 }
365
366 meta, err := st.listCredentialMeta("u1")
367 if err != nil || len(meta) != 1 {
368 t.Fatalf("listCredentialMeta: want 1, got %d err=%v", len(meta), err)
369 }
370 if meta[0].ID != b64CredID("cred-one") || meta[0].CreatedAt == 0 {
371 t.Fatalf("credential meta: %+v", meta[0])
372 }
373 siwa, err := st.listSiwaAnchors("u1")
374 if err != nil || len(siwa) != 1 || siwa[0].ID != "subhash-1" || siwa[0].CreatedAt == 0 {
375 t.Fatalf("listSiwaAnchors: %+v err=%v", siwa, err)
376 }
377 email, err := st.listEmailAnchors("u1")
378 if err != nil || len(email) != 1 || email[0].ID != "emailhash-1" || email[0].CreatedAt == 0 {
379 t.Fatalf("listEmailAnchors: %+v err=%v", email, err)
380 }
381 // Listings are per-user, empty (not an error) for an unknown one.
382 if rows, err := st.listSiwaAnchors("u2"); err != nil || len(rows) != 0 {
383 t.Fatalf("other user's siwa anchors: %+v err=%v", rows, err)
384 }
385 })
386
387 t.Run("DeleteAnchorLastMethodGuard", func(t *testing.T) {
388 st := newStore(t)
389 st.createUser("u1")
390 st.insertCredential("u1", mkCred("cred-one"))
391 st.insertSiwaAnchor("u1", "subhash-1")
392 st.insertEmailAnchor("u1", "emailhash-1")
393
394 // Unknown — and someone else's — hash is (false, false).
395 if deleted, last, err := st.deleteSiwaAnchor("u1", "nope"); deleted || last || err != nil {
396 t.Fatalf("delete unknown siwa: want (false,false), got (%v,%v,%v)", deleted, last, err)
397 }
398 if deleted, last, err := st.deleteSiwaAnchor("u2", "subhash-1"); deleted || last || err != nil {
399 t.Fatalf("delete cross-user siwa: want (false,false), got (%v,%v,%v)", deleted, last, err)
400 }
401 // Three methods: the siwa anchor may go…
402 if deleted, last, err := st.deleteSiwaAnchor("u1", "subhash-1"); !deleted || last || err != nil {
403 t.Fatalf("delete siwa: want (true,false), got (%v,%v,%v)", deleted, last, err)
404 }
405 // …and stops resolving (a stale pointer must deny, never resolve).
406 if _, ok := st.siwaUser("subhash-1"); ok {
407 t.Fatalf("removed siwa anchor still resolves")
408 }
409 // A removed identifier is genuinely free: the same sub may anchor a
410 // DIFFERENT account afterwards (unlink-then-relink-elsewhere).
411 st.createUser("u2")
412 if conflict, err := st.insertSiwaAnchor("u2", "subhash-1"); conflict || err != nil {
413 t.Fatalf("re-anchor removed sub: conflict=%v err=%v", conflict, err)
414 }
415 if uid, ok := st.siwaUser("subhash-1"); !ok || uid != "u2" {
416 t.Fatalf("re-anchored sub: want (u2, true), got (%q, %v)", uid, ok)
417 }
418
419 // Two methods left on u1 (passkey + email): the email anchor may go…
420 if deleted, last, err := st.deleteEmailAnchor("u1", "emailhash-1"); !deleted || last || err != nil {
421 t.Fatalf("delete email: want (true,false), got (%v,%v,%v)", deleted, last, err)
422 }
423 if _, ok := st.emailUser("emailhash-1"); ok {
424 t.Fatalf("removed email anchor still resolves")
425 }
426 // …leaving the passkey as the LAST method, which is refused.
427 if deleted, last, err := st.deleteCredential("u1", b64CredID("cred-one")); deleted || !last || err != nil {
428 t.Fatalf("delete last method: want (false,true), got (%v,%v,%v)", deleted, last, err)
429 }
430 })
431
432 t.Run("LastPasskeyMayGoWhenAnchored", func(t *testing.T) {
433 // The rule evolution (#1089): last-PASSKEY became last-METHOD — an
434 // account holding an email (or SiwA) anchor may shed its final
435 // passkey; only a removal that would leave ZERO ways in is refused.
436 st := newStore(t)
437 st.createUser("u1")
438 st.insertCredential("u1", mkCred("cred-one"))
439 st.insertEmailAnchor("u1", "emailhash-1")
440 if deleted, last, err := st.deleteCredential("u1", b64CredID("cred-one")); !deleted || last || err != nil {
441 t.Fatalf("delete last passkey with email anchor: want (true,false), got (%v,%v,%v)", deleted, last, err)
442 }
443 if _, ok := st.credentialUser(b64CredID("cred-one")); ok {
444 t.Fatalf("removed credential still resolves")
445 }
446 // The email anchor is now the last method: refused.
447 if deleted, last, err := st.deleteEmailAnchor("u1", "emailhash-1"); deleted || !last || err != nil {
448 t.Fatalf("delete last email anchor: want (false,true), got (%v,%v,%v)", deleted, last, err)
449 }
450 // A siwa anchor as the only method is refused the same way.
451 st.createUser("u2")
452 st.insertSiwaAnchor("u2", "subhash-2")
453 if deleted, last, err := st.deleteSiwaAnchor("u2", "subhash-2"); deleted || !last || err != nil {
454 t.Fatalf("delete last siwa anchor: want (false,true), got (%v,%v,%v)", deleted, last, err)
455 }
456 })
457
458 t.Run("OidcAnchors", func(t *testing.T) {
459 // The provider-table anchors (#1112): the siwa semantics with the
460 // provider name beside the hash — provider-scoped resolution and
461 // listing, the same idempotence/conflict rules, the same last-method
462 // guard, the same re-anchor-after-removal freedom.
463 st := newStore(t)
464 st.createUser("u1")
465 st.insertCredential("u1", mkCred("cred-one"))
466 if conflict, err := st.insertOidcAnchor("u1", "google", "ghash-1"); conflict || err != nil {
467 t.Fatalf("insertOidcAnchor: conflict=%v err=%v", conflict, err)
468 }
469 if uid, ok := st.oidcUser("google", "ghash-1"); !ok || uid != "u1" {
470 t.Fatalf("oidcUser: want (u1, true), got (%q, %v)", uid, ok)
471 }
472 // The SAME hash under the other provider is a different anchor
473 // entirely — nothing resolves, and claiming it conflicts with no one.
474 if _, ok := st.oidcUser("microsoft", "ghash-1"); ok {
475 t.Fatalf("hash resolves across providers")
476 }
477 // Idempotent for the same user; conflict for a different one.
478 if conflict, err := st.insertOidcAnchor("u1", "google", "ghash-1"); conflict || err != nil {
479 t.Fatalf("re-insert: conflict=%v err=%v", conflict, err)
480 }
481 st.createUser("u2")
482 if conflict, err := st.insertOidcAnchor("u2", "google", "ghash-1"); !conflict || err != nil {
483 t.Fatalf("cross-user insert: want conflict, got conflict=%v err=%v", conflict, err)
484 }
485
486 // Listing is provider-scoped, (id, created_at) shaped.
487 st.insertOidcAnchor("u1", "microsoft", "mhash-1")
488 g, err := st.listOidcAnchors("u1", "google")
489 if err != nil || len(g) != 1 || g[0].ID != "ghash-1" || g[0].CreatedAt == 0 {
490 t.Fatalf("listOidcAnchors google: %+v err=%v", g, err)
491 }
492 if m, err := st.listOidcAnchors("u1", "microsoft"); err != nil || len(m) != 1 || m[0].ID != "mhash-1" {
493 t.Fatalf("listOidcAnchors microsoft: %+v err=%v", m, err)
494 }
495 if rows, err := st.listOidcAnchors("u2", "google"); err != nil || len(rows) != 0 {
496 t.Fatalf("other user's oidc anchors: %+v err=%v", rows, err)
497 }
498
499 // Unknown and cross-user (and cross-provider) deletes are (false,false).
500 if deleted, last, err := st.deleteOidcAnchor("u1", "google", "nope"); deleted || last || err != nil {
501 t.Fatalf("delete unknown: got (%v,%v,%v)", deleted, last, err)
502 }
503 if deleted, last, err := st.deleteOidcAnchor("u2", "google", "ghash-1"); deleted || last || err != nil {
504 t.Fatalf("delete cross-user: got (%v,%v,%v)", deleted, last, err)
505 }
506 if deleted, last, err := st.deleteOidcAnchor("u1", "microsoft", "ghash-1"); deleted || last || err != nil {
507 t.Fatalf("delete cross-provider: got (%v,%v,%v)", deleted, last, err)
508 }
509
510 // Three methods on u1 (passkey + google + microsoft): the google
511 // anchor may go, stops resolving, and its identifier is genuinely
512 // free — the same (provider, sub) may anchor a different account.
513 if deleted, last, err := st.deleteOidcAnchor("u1", "google", "ghash-1"); !deleted || last || err != nil {
514 t.Fatalf("delete google anchor: got (%v,%v,%v)", deleted, last, err)
515 }
516 if _, ok := st.oidcUser("google", "ghash-1"); ok {
517 t.Fatalf("removed oidc anchor still resolves")
518 }
519 if conflict, err := st.insertOidcAnchor("u2", "google", "ghash-1"); conflict || err != nil {
520 t.Fatalf("re-anchor removed hash: conflict=%v err=%v", conflict, err)
521 }
522 if uid, ok := st.oidcUser("google", "ghash-1"); !ok || uid != "u2" {
523 t.Fatalf("re-anchored hash: want (u2, true), got (%q, %v)", uid, ok)
524 }
525
526 // The guard counts oidc anchors: u1's passkey may go while the
527 // microsoft anchor holds the account, and then that anchor — the
528 // last way in — is refused.
529 if deleted, last, err := st.deleteCredential("u1", b64CredID("cred-one")); !deleted || last || err != nil {
530 t.Fatalf("shed passkey beside oidc anchor: got (%v,%v,%v)", deleted, last, err)
531 }
532 if deleted, last, err := st.deleteOidcAnchor("u1", "microsoft", "mhash-1"); deleted || !last || err != nil {
533 t.Fatalf("delete last oidc anchor: want (false,true), got (%v,%v,%v)", deleted, last, err)
534 }
535 })
536
537 t.Run("MergeAccounts", func(t *testing.T) {
538 // The account merge (#1098): everything that signs in moves from the
539 // absorbed account to the survivor, resolution follows, and the
540 // absorbed account's listings empty out. The blob belt, the
541 // same-account refusal, and the zero-count retry are contract too.
542 st := newStore(t)
543 st.createUser("u1")
544 st.insertCredential("u1", mkCred("cred-surv"))
545 survTok, err := st.createLinkToken("u1")
546 if err != nil {
547 t.Fatalf("createLinkToken survivor: %v", err)
548 }
549 if _, conflict, err := st.putBlob("u1", []byte("survivor-vault"), "0"); err != nil || conflict {
550 t.Fatalf("survivor putBlob: %v conflict=%v", err, conflict)
551 }
552 st.createUser("u2")
553 st.insertCredential("u2", mkCred("cred-abs"))
554 absTok1, _ := st.createLinkToken("u2")
555 absTok2, _ := st.createLinkToken("u2")
556 st.insertSiwaAnchor("u2", "merge-siwa")
557 st.insertEmailAnchor("u2", "merge-email")
558 st.insertOidcAnchor("u2", "google", "merge-google")
559
560 // Belt: an absorbed account still holding a blob refuses, moving nothing.
561 if _, conflict, err := st.putBlob("u2", []byte("not-yet-moved"), "0"); err != nil || conflict {
562 t.Fatalf("absorbed putBlob: %v conflict=%v", err, conflict)
563 }
564 if _, err := st.mergeAccounts("u1", "u2"); !errors.Is(err, errAbsorbedHasBlob) {
565 t.Fatalf("merge with blob present: want errAbsorbedHasBlob, got %v", err)
566 }
567 if uid, ok := st.siwaUser("merge-siwa"); !ok || uid != "u2" {
568 t.Fatalf("refused merge moved an anchor: (%q, %v)", uid, ok)
569 }
570 // Same-account merge is a hard error (it would retire the account itself).
571 if _, err := st.mergeAccounts("u1", "u1"); err == nil {
572 t.Fatalf("same-account merge: want error")
573 }
574
575 // The client-first ordering: absorbed blob deleted, then merge.
576 if err := st.deleteBlob("u2"); err != nil {
577 t.Fatalf("deleteBlob: %v", err)
578 }
579 moved, err := st.mergeAccounts("u1", "u2")
580 if err != nil {
581 t.Fatalf("mergeAccounts: %v", err)
582 }
583 if moved.Anchors != 3 || moved.Passkeys != 1 || moved.Devices != 2 {
584 t.Fatalf("moved counts: want {3 1 2}, got %+v", moved)
585 }
586
587 // Every identifier now resolves to the survivor…
588 if uid, ok := st.siwaUser("merge-siwa"); !ok || uid != "u1" {
589 t.Fatalf("merged siwa anchor: want (u1, true), got (%q, %v)", uid, ok)
590 }
591 if uid, ok := st.emailUser("merge-email"); !ok || uid != "u1" {
592 t.Fatalf("merged email anchor: want (u1, true), got (%q, %v)", uid, ok)
593 }
594 if uid, ok := st.oidcUser("google", "merge-google"); !ok || uid != "u1" {
595 t.Fatalf("merged oidc anchor: want (u1, true), got (%q, %v)", uid, ok)
596 }
597 if uid, ok := st.credentialUser(b64CredID("cred-abs")); !ok || uid != "u1" {
598 t.Fatalf("merged credential: want (u1, true), got (%q, %v)", uid, ok)
599 }
600 // …including the absorbed devices' link tokens (they keep working,
601 // now signed into the survivor).
602 for _, tok := range []string{absTok1, absTok2} {
603 if uid, ok := st.linkTokenUser(hashToken(tok)); !ok || uid != "u1" {
604 t.Fatalf("merged link token: want (u1, true), got (%q, %v)", uid, ok)
605 }
606 }
607 // The survivor's own token and blob are untouched.
608 if uid, ok := st.linkTokenUser(hashToken(survTok)); !ok || uid != "u1" {
609 t.Fatalf("survivor link token: want (u1, true), got (%q, %v)", uid, ok)
610 }
611 if data, _, found, _ := st.getBlob("u1"); !found || !bytes.Equal(data, []byte("survivor-vault")) {
612 t.Fatalf("survivor blob touched by merge: found=%v data=%q", found, data)
613 }
614 // The survivor lists everything; creation times ride along.
615 if creds, _ := st.credentialsFor("u1"); len(creds) != 2 {
616 t.Fatalf("survivor credentials: want 2, got %d", len(creds))
617 }
618 if devices, _ := st.listDevices("u1"); len(devices) != 3 {
619 t.Fatalf("survivor devices: want 3, got %d", len(devices))
620 }
621 if siwa, _ := st.listSiwaAnchors("u1"); len(siwa) != 1 || siwa[0].ID != "merge-siwa" || siwa[0].CreatedAt == 0 {
622 t.Fatalf("survivor siwa listing: %+v", siwa)
623 }
624 // The absorbed account's listings are empty.
625 if creds, _ := st.credentialsFor("u2"); len(creds) != 0 {
626 t.Fatalf("absorbed credentials remain: %d", len(creds))
627 }
628 if devices, _ := st.listDevices("u2"); len(devices) != 0 {
629 t.Fatalf("absorbed devices remain: %d", len(devices))
630 }
631 if rows, _ := st.listEmailAnchors("u2"); len(rows) != 0 {
632 t.Fatalf("absorbed email anchors remain: %+v", rows)
633 }
634
635 // A retry (the absorb proof outlives the deleted account) is a
636 // zero-count success, never an error.
637 if again, err := st.mergeAccounts("u1", "u2"); err != nil || again != (mergeMoved{}) {
638 t.Fatalf("retried merge: want zero counts, got %+v err=%v", again, err)
639 }
640 })
641
642 // Ceremony proofs deliberately have no contract entry: they left the store
643 // entirely — stateless HMAC tokens, tested in stateless_test.go.
644}
645
646// b64CredID renders a raw credential id the way stores key it (base64url,
647// matching webauthn.Credential JSON round-trips).
648func b64CredID(raw string) string {
649 return base64.RawURLEncoding.EncodeToString([]byte(raw))
650}
651