package linkstore import ( "bytes" "context" "database/sql" "encoding/base64" "errors" "net/url" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" _ "modernc.org/sqlite" ) var ctx = context.Background() var testNow = time.Unix(1788865200, 0) const origin = "https://test.docs.oficina.works" const issuer = "https://id.oficina.works" func fixture(t *testing.T) (*Store, string) { t.Helper() path := filepath.Join(t.TempDir(), "docs.db") d, err := sql.Open("sqlite", (&url.URL{Scheme: "file", Path: path}).String()+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)") if err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) _, err = d.Exec(`CREATE TABLE people (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE); INSERT INTO people VALUES (1,'one@example.com'),(2,'two@example.com'); CREATE TABLE old_content (owner_id INTEGER, bytes BLOB); INSERT INTO old_content VALUES (1,x'00010203'),(2,x'04050607')`) if err != nil { t.Fatal(err) } for _, name := range []string{"0001_links.sql", "0002_person_binding.sql"} { migration, err := migrations.ReadFile("migrations/" + name) if err != nil { t.Fatal(err) } if _, err := d.Exec(string(migration)); err != nil { t.Fatal(err) } } s, err := New(d, bytes.Repeat([]byte{42}, 32), origin, issuer, "oficina/docs", testLookup) if err != nil { t.Fatal(err) } if err := s.Sweep(ctx, testNow); err != nil { t.Fatal(err) } return s, path } func local(id int64) Local { return Local{PersonID: id, SessionToken: strings.Repeat(string(rune('a'+id)), 43), AuthTime: testNow.Add(-time.Minute)} } func nonce(n byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{n}, 16)) } func start(t *testing.T, s *Store, l Local, n string) string { t.Helper() secret, err := s.Start(ctx, l, "/d/private-document?view=reading", testNow) if err != nil { t.Fatal(err) } if err := s.BindNonce(ctx, secret, l, n, testNow); err != nil { t.Fatal(err) } return secret } func complete(t *testing.T, s *Store, l Local, subject string, n byte) string { t.Helper() secret := start(t, s, l, nonce(n)) pending, err := s.Complete(ctx, secret, l, Home{Issuer: issuer, Subject: subject, Nonce: nonce(n), AuthTime: testNow.Add(-30 * time.Second)}, testNow) if err != nil { t.Fatal(err) } return pending } func refused(t *testing.T, err error) { t.Helper() if !errors.Is(err, ErrRefused) { t.Fatalf("error = %v, want generic refusal", err) } } func flowCount(t *testing.T, s *Store) int { t.Helper() var count int if err := s.db.QueryRow("SELECT COUNT(*) FROM home_link_flows").Scan(&count); err != nil { t.Fatal(err) } return count } func TestExplicitLinkPreservesPeopleAndContent(t *testing.T) { s, _ := fixture(t) for _, id := range []int64{1, 2} { l := local(id) subject := "opaque-home-" + string(rune('a'+id)) cookie := complete(t, s, l, subject, byte(id)) p, err := s.Pending(ctx, cookie, l, testNow) if err != nil || p.PersonID != id || p.ReturnPath != "/d/private-document?view=reading" { t.Fatalf("pending=%+v error=%v", p, err) } _, _, err = s.Resolve(ctx, issuer, subject) refused(t, err) got, err := s.Confirm(ctx, cookie, l, testNow) if err != nil || got.PersonID != id { t.Fatalf("confirm=%+v error=%v", got, err) } linked, address, err := s.Resolve(ctx, issuer, subject) if err != nil || linked != id || address == "" { t.Fatalf("resolve=%d,%q,%v", linked, address, err) } _, err = s.Confirm(ctx, cookie, l, testNow) refused(t, err) } var count int if err := s.db.QueryRow("SELECT COUNT(*) FROM people").Scan(&count); err != nil || count != 2 { t.Fatalf("people changed: %d %v", count, err) } var content string if err := s.db.QueryRow("SELECT group_concat(hex(bytes), ',') FROM old_content ORDER BY owner_id").Scan(&content); err != nil || content != "00010203,04050607" { t.Fatalf("legacy content changed: %q %v", content, err) } if _, err := s.db.Exec("UPDATE people SET email = 'new@example.com' WHERE id = 1"); err != nil { t.Fatal(err) } _, _, err := s.Resolve(ctx, issuer, "opaque-home-b") refused(t, err) _, _, err = s.Resolve(ctx, "https://other.example", "opaque-home-b") refused(t, err) if flowCount(t, s) != 0 { t.Fatal("consumed rows remain") } } func TestInitiationAndCompletionBindingsBurnFailures(t *testing.T) { for _, which := range []string{"missing-init", "unbound-nonce", "nonce", "person", "session", "local-auth", "issuer", "home-stale", "home-future", "local-stale", "local-future"} { t.Run(which, func(t *testing.T) { s, _ := fixture(t) l := local(1) n := nonce(1) secret, err := s.Start(ctx, l, "/", testNow) if err != nil { t.Fatal(err) } if which != "unbound-nonce" { if err := s.BindNonce(ctx, secret, l, n, testNow); err != nil { t.Fatal(err) } } home := Home{Issuer: issuer, Subject: "opaque", Nonce: n, AuthTime: testNow} switch which { case "missing-init": secret = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{80}, 32)) case "nonce": home.Nonce = nonce(2) case "person": l.PersonID = 2 case "session": l.SessionToken = "different-actual-cookie" case "local-auth": l.AuthTime = testNow case "issuer": home.Issuer = "https://evil.example" case "home-stale": home.AuthTime = testNow.Add(-301 * time.Second) case "home-future": home.AuthTime = testNow.Add(time.Second) case "local-stale": l.AuthTime = testNow.Add(-301 * time.Second) case "local-future": l.AuthTime = testNow.Add(time.Second) } got, err := s.Complete(ctx, secret, l, home, testNow) refused(t, err) if got != "" { t.Fatal("rejected completion returned cookie") } _, err = s.Complete(ctx, secret, local(1), Home{Issuer: issuer, Subject: "opaque", Nonce: n, AuthTime: testNow}, testNow) refused(t, err) if which != "missing-init" && flowCount(t, s) != 0 { t.Fatal("failed initiation was reusable") } }) } } func TestBindNonceOnceAndExactLocalSession(t *testing.T) { for _, which := range []string{"duplicate", "person", "session", "invalid-nonce"} { t.Run(which, func(t *testing.T) { s, _ := fixture(t) l := local(1) secret, err := s.Start(ctx, l, "/", testNow) if err != nil { t.Fatal(err) } n := nonce(1) if which == "duplicate" { if err := s.BindNonce(ctx, secret, l, n, testNow); err != nil { t.Fatal(err) } } if which == "person" { l.PersonID = 2 } if which == "session" { l.SessionToken = "changed" } if which == "invalid-nonce" { n += "=" } refused(t, s.BindNonce(ctx, secret, l, n, testNow)) refused(t, s.BindNonce(ctx, secret, local(1), nonce(2), testNow)) }) } } func TestConfirmationFreshnessAndLocalBinding(t *testing.T) { for _, which := range []string{"person", "session", "auth-time", "expired", "future-after-rollback"} { t.Run(which, func(t *testing.T) { s, _ := fixture(t) l := local(1) cookie := complete(t, s, l, "opaque", 1) now := testNow switch which { case "person": l.PersonID = 2 case "session": l.SessionToken = "new-cookie" case "auth-time": l.AuthTime = testNow case "expired": now = now.Add(241 * time.Second) case "future-after-rollback": // Complete a proof with a genuinely current Home gesture, then roll back // within the accepted clock tolerance. It must not earn future credit. init := start(t, s, l, nonce(2)) var err error cookie, err = s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "opaque", Nonce: nonce(2), AuthTime: testNow}, testNow) if err != nil { t.Fatal(err) } now = now.Add(-time.Second) } _, err := s.Confirm(ctx, cookie, l, now) refused(t, err) _, err = s.Confirm(ctx, cookie, local(1), now) refused(t, err) _, _, err = s.Resolve(ctx, issuer, "opaque") refused(t, err) }) } } func TestConflictConsumedWithoutReplacingEitherMapping(t *testing.T) { s, _ := fixture(t) l1, l2 := local(1), local(2) first := complete(t, s, l1, "subject-one", 1) if _, err := s.Confirm(ctx, first, l1, testNow); err != nil { t.Fatal(err) } identical := complete(t, s, l1, "subject-one", 2) if _, err := s.Confirm(ctx, identical, l1, testNow); err != nil { t.Fatal("separate identical proof should be idempotent", err) } for _, tc := range []struct { local Local sub string }{{l2, "subject-one"}, {l1, "subject-two"}} { cookie := complete(t, s, tc.local, tc.sub, 3) _, err := s.Confirm(ctx, cookie, tc.local, testNow) refused(t, err) // Remove the competing mapping only in the fixture: an unconsumed failed // proof would now succeed, making this a direct replay regression test. if _, err := s.db.Exec("DELETE FROM home_identity_links"); err != nil { t.Fatal(err) } _, err = s.Confirm(ctx, cookie, tc.local, testNow) refused(t, err) if _, err := s.db.Exec("INSERT INTO home_identity_links VALUES (?,?,?,?)", issuer, "subject-one", 1, s.personBinding(1, "one@example.com")); err != nil { t.Fatal(err) } } id, _, err := s.Resolve(ctx, issuer, "subject-one") if err != nil || id != 1 { t.Fatalf("mapping replaced: %d %v", id, err) } } func TestConcurrentConfirmationAndCapacity(t *testing.T) { s, _ := fixture(t) l := local(1) cookie := complete(t, s, l, "subject", 1) var winners atomic.Int32 var wg sync.WaitGroup for i := 0; i < 12; i++ { wg.Add(1) go func() { defer wg.Done() _, err := s.Confirm(ctx, cookie, l, testNow) if err == nil { winners.Add(1) } else if !errors.Is(err, ErrRefused) { t.Errorf("unexpected concurrent failure: %v", err) } }() } wg.Wait() if winners.Load() != 1 { t.Fatalf("winners=%d", winners.Load()) } _, err := s.db.Exec(`WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x < 9999) INSERT INTO home_link_flows SELECT printf('%064d',x),'init',?,x'00' FROM n`, testNow.Unix()+300) if err != nil { t.Fatal(err) } winners.Store(0) for i := 0; i < 8; i++ { wg.Add(1) go func() { defer wg.Done() _, err := s.Start(ctx, l, "/", testNow) if err == nil { winners.Add(1) } else if !errors.Is(err, ErrRefused) { t.Errorf("unexpected capacity failure: %v", err) } }() } wg.Wait() if winners.Load() != 1 || flowCount(t, s) != 10000 { t.Fatalf("capacity winners=%d count=%d", winners.Load(), flowCount(t, s)) } if err := s.Sweep(ctx, testNow.Add(301*time.Second)); err != nil { t.Fatal(err) } if flowCount(t, s) != 0 { t.Fatal("expired rows survived sweep") } } func TestSealedPendingAndTamperRefusal(t *testing.T) { for _, which := range []string{"ciphertext", "row", "origin", "expiry", "key"} { t.Run(which, func(t *testing.T) { s, _ := fixture(t) l := local(1) cookie := complete(t, s, l, "private-subject", 1) switch which { case "ciphertext": if _, err := s.db.Exec("UPDATE home_link_flows SET payload = zeroblob(length(payload))"); err != nil { t.Fatal(err) } case "row": replacement, _ := newSecret() if _, err := s.db.Exec("UPDATE home_link_flows SET id_hash = ?", hash(replacement)); err != nil { t.Fatal(err) } cookie = replacement case "origin": s.origin = "https://other.docs.oficina.works" case "expiry": if _, err := s.db.Exec("UPDATE home_link_flows SET expires_at = expires_at + 300"); err != nil { t.Fatal(err) } case "key": var err error s, err = New(s.db, bytes.Repeat([]byte{43}, 32), origin, issuer, "oficina/docs", testLookup) if err != nil { t.Fatal(err) } } _, err := s.Confirm(ctx, cookie, l, testNow) refused(t, err) if flowCount(t, s) != 0 { t.Fatal("tampered row remains reusable") } }) } } func TestNoPendingSecretsInSQLiteOrWAL(t *testing.T) { s, path := fixture(t) l := local(1) n := nonce(8) init := start(t, s, l, n) cookie, err := s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "private-subject-should-be-sealed", Nonce: n, AuthTime: testNow}, testNow) if err != nil { t.Fatal(err) } for _, file := range []string{path, path + "-wal"} { raw, err := os.ReadFile(file) if err != nil { t.Fatal(err) } for _, secret := range []string{init, cookie, l.SessionToken, n, "private-subject-should-be-sealed", "/d/private-document?view=reading"} { if bytes.Contains(raw, []byte(secret)) { t.Fatalf("plaintext in %s", filepath.Base(file)) } } } reopened, err := New(s.db, bytes.Repeat([]byte{42}, 32), origin, issuer, "oficina/docs", testLookup) if err != nil { t.Fatal(err) } if _, err := reopened.Confirm(ctx, cookie, l, testNow); err != nil { t.Fatal("sealed pending must survive store restart", err) } } func TestClockHighWaterPreventsRevivedAuthentication(t *testing.T) { s, _ := fixture(t) l := local(1) cookie := complete(t, s, l, "subject", 1) if err := s.Sweep(ctx, testNow.Add(241*time.Second)); err != nil { t.Fatal(err) } _, err := s.Confirm(ctx, cookie, l, testNow.Add(220*time.Second)) refused(t, err) if err := s.Sweep(ctx, testNow); !errors.Is(err, ErrRefused) { t.Fatalf("large clock rollback=%v", err) } } func TestSafePathsAndInvalidInputs(t *testing.T) { for _, path := range []string{"", "https://evil.example", "//evil.example", "/auth/finish", "/%2fhost", "/d/../auth/finish", "/d\\evil", "/d/x\n"} { t.Run(path, func(t *testing.T) { s, _ := fixture(t) l := local(1) init, err := s.Start(ctx, l, path, testNow) if err != nil { t.Fatal(err) } if err := s.BindNonce(ctx, init, l, nonce(1), testNow); err != nil { t.Fatal(err) } cookie, err := s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "subject", Nonce: nonce(1), AuthTime: testNow}, testNow) if err != nil { t.Fatal(err) } p, err := s.Pending(ctx, cookie, l, testNow) if err != nil || p.ReturnPath != "/" { t.Fatalf("unsafe return=%q %v", p.ReturnPath, err) } }) } s, _ := fixture(t) for _, l := range []Local{{}, {PersonID: 99, SessionToken: "actual", AuthTime: testNow}, {PersonID: 1, SessionToken: "actual", AuthTime: testNow.Add(time.Second)}, {PersonID: 1, SessionToken: "actual", AuthTime: testNow.Add(-301 * time.Second)}} { _, err := s.Start(ctx, l, "/", testNow) refused(t, err) } for _, cookie := range []string{"", "short", strings.Repeat("A", 43) + "=", strings.Repeat("A", 42) + "B"} { _, err := s.Confirm(ctx, cookie, local(1), testNow) refused(t, err) } } func TestMappingStorageFailureRollsBackProofConsumption(t *testing.T) { s, _ := fixture(t) l := local(1) cookie := complete(t, s, l, "subject", 1) if _, err := s.db.Exec(`CREATE TRIGGER block_link BEFORE INSERT ON home_identity_links BEGIN SELECT RAISE(ABORT, 'private internal storage detail'); END`); err != nil { t.Fatal(err) } _, err := s.Confirm(ctx, cookie, l, testNow) if !errors.Is(err, ErrStorage) || strings.Contains(err.Error(), "private") { t.Fatalf("storage error = %v", err) } if flowCount(t, s) != 1 { t.Fatal("storage failure consumed proof without mapping") } if _, err := s.db.Exec("DROP TRIGGER block_link"); err != nil { t.Fatal(err) } if _, err := s.Confirm(ctx, cookie, l, testNow); err != nil { t.Fatal("retry after storage recovery", err) } } func TestConcurrentConflictingSubjectsCannotReplacePersonLink(t *testing.T) { s, _ := fixture(t) l := local(1) a, b := complete(t, s, l, "subject-a", 1), complete(t, s, l, "subject-b", 2) var winners atomic.Int32 var wg sync.WaitGroup for _, cookie := range []string{a, b} { wg.Add(1) go func() { defer wg.Done() _, err := s.Confirm(ctx, cookie, l, testNow) if err == nil { winners.Add(1) } else if !errors.Is(err, ErrRefused) { t.Errorf("conflicting confirmation: %v", err) } }() } wg.Wait() if winners.Load() != 1 || flowCount(t, s) != 0 { t.Fatalf("conflict winners=%d remaining=%d", winners.Load(), flowCount(t, s)) } for _, cookie := range []string{a, b} { _, err := s.Confirm(ctx, cookie, l, testNow) refused(t, err) } } func TestPendingReadConsumesChangedSession(t *testing.T) { s, _ := fixture(t) l := local(1) cookie := complete(t, s, l, "subject", 1) changed := l changed.SessionToken = "rotated-cookie" _, err := s.Pending(ctx, cookie, changed, testNow) refused(t, err) _, err = s.Confirm(ctx, cookie, l, testNow) refused(t, err) } func TestFreshnessBoundaryProducesNoExpiredNewRows(t *testing.T) { s, _ := fixture(t) l := local(1) l.AuthTime = testNow.Add(-300 * time.Second) _, err := s.Start(ctx, l, "/", testNow) refused(t, err) l = local(1) init := start(t, s, l, nonce(1)) _, err = s.Complete(ctx, init, l, Home{Issuer: issuer, Subject: "subject", Nonce: nonce(1), AuthTime: testNow.Add(-300 * time.Second)}, testNow) refused(t, err) if flowCount(t, s) != 0 { t.Fatal("expired state minted at exact five-minute boundary") } } func testLookup(ctx context.Context, q Queryer, id int64) (string, error) { var label string err := q.QueryRowContext(ctx, "SELECT email FROM people WHERE id=? AND email<>''", id).Scan(&label) return label, err } func TestLookupUsesLinkTransactionAndRechecksAdmission(t *testing.T) { s, _ := fixture(t) calls := 0 allowed := true s.lookup = func(ctx context.Context, q Queryer, id int64) (string, error) { if _, ok := q.(*sql.Tx); !ok { t.Fatal("link proof lookup escaped transaction") } calls++ if !allowed { return "", sql.ErrNoRows } return testLookup(ctx, q, id) } l := local(1) proof := complete(t, s, l, "subject-one", 41) allowed = false _, err := s.Confirm(ctx, proof, l, testNow) refused(t, err) allowed = true _, err = s.Confirm(ctx, proof, l, testNow) refused(t, err) var count int if err = s.db.QueryRow("SELECT COUNT(*) FROM home_identity_links").Scan(&count); err != nil || count != 0 { t.Fatal("revoked local account linked", count, err) } if calls < 3 { t.Fatal("missing current account rechecks", calls) } } func TestResolveRechecksAppAdmissionAndDoesNotRevealLookupErrors(t *testing.T) { s, _ := fixture(t) l := local(1) proof := complete(t, s, l, "subject-one", 42) if _, err := s.Confirm(ctx, proof, l, testNow); err != nil { t.Fatal(err) } s.lookup = func(context.Context, Queryer, int64) (string, error) { return "", sql.ErrNoRows } _, _, err := s.Resolve(ctx, issuer, "subject-one") refused(t, err) s.lookup = func(context.Context, Queryer, int64) (string, error) { return "", errors.New("private-address@example.invalid") } _, _, err = s.Resolve(ctx, issuer, "subject-one") if err != ErrStorage || strings.Contains(err.Error(), "private-address") { t.Fatal("lookup error escaped", err) } } func TestPersonBindingRefusesReusedIDAtEveryStage(t *testing.T) { for _, stage := range []string{"bind", "complete", "pending", "confirm", "resolve"} { t.Run(stage, func(t *testing.T) { s, _ := fixture(t) l := local(1) secret, err := s.Start(ctx, l, "/", testNow) if err != nil { t.Fatal(err) } nonce := nonce(42) if stage != "bind" { if err := s.BindNonce(ctx, secret, l, nonce, testNow); err != nil { t.Fatal(err) } } if stage != "bind" && stage != "complete" { secret, err = s.Complete(ctx, secret, l, Home{Issuer: issuer, Subject: "person", Nonce: nonce, AuthTime: testNow}, testNow) if err != nil { t.Fatal(err) } } if stage == "resolve" { if _, err := s.Confirm(ctx, secret, l, testNow); err != nil { t.Fatal(err) } } if _, err := s.db.Exec("DELETE FROM people WHERE id=1; INSERT INTO people VALUES(1,'replacement@example.com')"); err != nil { t.Fatal(err) } switch stage { case "bind": err = s.BindNonce(ctx, secret, l, nonce, testNow) case "complete": _, err = s.Complete(ctx, secret, l, Home{Issuer: issuer, Subject: "person", Nonce: nonce, AuthTime: testNow}, testNow) case "pending": _, err = s.Pending(ctx, secret, l, testNow) case "confirm": _, err = s.Confirm(ctx, secret, l, testNow) case "resolve": _, _, err = s.Resolve(ctx, issuer, "person") } refused(t, err) if stage != "resolve" && flowCount(t, s) != 0 { t.Fatal("rejected proof was not consumed") } }) } } func TestUnboundLegacyMappingFailsClosed(t *testing.T) { s, _ := fixture(t) if _, err := s.db.Exec("INSERT INTO home_identity_links(issuer,subject,person_id) VALUES(?,?,?)", issuer, "legacy", 1); err != nil { t.Fatal(err) } _, _, err := s.Resolve(ctx, issuer, "legacy") refused(t, err) }