| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "database/sql" |
| 5 | "fmt" |
| 6 | "sync/atomic" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // A pre-existing DB with the old plaintext invites table has it dropped on open, |
| 11 | // and a token that used to authenticate via it is no longer accepted. |
| 12 | func TestLegacyInvitesRemovedOnOpen(t *testing.T) { |
| 13 | path := t.TempDir() + "/legacy.db" |
| 14 | raw, err := sql.Open("sqlite", path) |
| 15 | if err != nil { |
| 16 | t.Fatalf("open raw: %v", err) |
| 17 | } |
| 18 | if _, err := raw.Exec(`CREATE TABLE invites (token TEXT PRIMARY KEY, label TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, claimed_at INTEGER, revoked INTEGER NOT NULL DEFAULT 0)`); err != nil { |
| 19 | t.Fatalf("create legacy table: %v", err) |
| 20 | } |
| 21 | if _, err := raw.Exec(`INSERT INTO invites(token, label, created_at) VALUES('legacytok', 'x', 0)`); err != nil { |
| 22 | t.Fatalf("seed legacy token: %v", err) |
| 23 | } |
| 24 | raw.Close() |
| 25 | |
| 26 | db, err := openDB(path) |
| 27 | if err != nil { |
| 28 | t.Fatalf("openDB: %v", err) |
| 29 | } |
| 30 | if _, err := db.sql.Exec("SELECT 1 FROM invites LIMIT 1"); err == nil { |
| 31 | t.Fatal("legacy invites table was not dropped on open") |
| 32 | } |
| 33 | srv := &Server{db: db} |
| 34 | if srv.tokenOK("legacytok") { |
| 35 | t.Fatal("a legacy invite token is still accepted for auth") |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | var mintSeq atomic.Int64 |
| 40 | |
| 41 | // mintDeviceToken creates a joined member and returns a valid device (session) |
| 42 | // token via the key-invite claim flow — the standard way to get an authenticated |
| 43 | // caller in tests now that legacy invites are gone. |
| 44 | func mintDeviceToken(t *testing.T, db *DB, label string) string { |
| 45 | t.Helper() |
| 46 | uid, err := db.createUser(label) |
| 47 | if err != nil { |
| 48 | t.Fatalf("createUser: %v", err) |
| 49 | } |
| 50 | id := fmt.Sprintf("mint-%s-%d", label, mintSeq.Add(1)) |
| 51 | if err := db.createKeyInvite(id, uid, "", testClaimHash, 0); err != nil { |
| 52 | t.Fatalf("createKeyInvite: %v", err) |
| 53 | } |
| 54 | tok, ok, err := db.claimKeyInvite(id, label, "", testClaimSecret) |
| 55 | if err != nil || !ok { |
| 56 | t.Fatalf("claimKeyInvite: ok=%v err=%v", ok, err) |
| 57 | } |
| 58 | return tok |
| 59 | } |
| 60 | |
| 61 | // Shared across the invite tests: a claim secret and its stored hash. Creation |
| 62 | // stores testClaimHash; claiming presents testClaimSecret. |
| 63 | const testClaimSecret = "test-claim-secret-vector" |
| 64 | |
| 65 | var testClaimHash = hashToken(testClaimSecret) |
| 66 | |
| 67 | // Cross-implementation vector: the browser's deriveInvite("AAAAAAAAAAAAAAAAAAAAAA") |
| 68 | // (T = 16 zero bytes, base64url) produces this claim secret + hash. The server must |
| 69 | // accept that secret against that hash — i.e. the client's sha256hex and the |
| 70 | // server's hashToken agree, so real browser claims verify. (Regenerate via: |
| 71 | // node --input-type=module -e "import('./web/static/crypto.js').then(...)") |
| 72 | func TestClaimHashCrossImpl(t *testing.T) { |
| 73 | const jsClaimSecret = "zgoEhtdOkw8sCcWC7LhWHMItVCulazloEuXRG7hz_Zw" |
| 74 | const jsClaimHash = "a4553e1d5770adf87427741af0fbcf6e42a223a82403edd6752c0c39fa43a7e3" |
| 75 | if got := hashToken(jsClaimSecret); got != jsClaimHash { |
| 76 | t.Fatalf("hashToken(jsClaimSecret) = %q, want %q — JS and Go hashing disagree", got, jsClaimHash) |
| 77 | } |
| 78 | if !validClaimSecret(jsClaimHash, jsClaimSecret) { |
| 79 | t.Fatal("server would reject a browser-derived claim secret") |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func TestValidClaimSecret(t *testing.T) { |
| 84 | if !validClaimSecret(hashToken("s"), "s") { |
| 85 | t.Error("valid preimage rejected") |
| 86 | } |
| 87 | for _, bad := range [][2]string{{"", ""}, {"", "s"}, {hashToken("s"), ""}, {hashToken("s"), "wrong"}} { |
| 88 | if validClaimSecret(bad[0], bad[1]) { |
| 89 | t.Errorf("validClaimSecret(%q, %q) = true, want false", bad[0], bad[1]) |
| 90 | } |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | // Claiming requires proving knowledge of the invite secret — knowing only the id |
| 95 | // (which the server stores, and a DB thief would have) is not enough. |
| 96 | func TestClaimKeyInviteRequiresSecret(t *testing.T) { |
| 97 | db := newTestDB(t) |
| 98 | if err := db.createKeyInvite("inv1", "u1", "", testClaimHash, 0); err != nil { |
| 99 | t.Fatalf("create: %v", err) |
| 100 | } |
| 101 | // A DB thief who only has the stored id can't produce the preimage. |
| 102 | if _, ok, _ := db.claimKeyInvite("inv1", "dev", "", "wrong-secret"); ok { |
| 103 | t.Fatal("claim succeeded with the wrong secret") |
| 104 | } |
| 105 | if _, ok, _ := db.claimKeyInvite("inv1", "dev", "", ""); ok { |
| 106 | t.Fatal("claim succeeded with an empty secret") |
| 107 | } |
| 108 | // The real invitee (who derived the secret from T in the URL fragment) can. |
| 109 | tok, ok, err := db.claimKeyInvite("inv1", "dev", "", testClaimSecret) |
| 110 | if err != nil || !ok || tok == "" { |
| 111 | t.Fatalf("claim with correct secret: ok=%v err=%v tok=%q", ok, err, tok) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // An invite stored without a claim_hash (e.g. created before this migration) is |
| 116 | // unclaimable — we don't fall back to unauthenticated claiming. |
| 117 | func TestClaimKeyInviteRejectsInviteWithoutHash(t *testing.T) { |
| 118 | db := newTestDB(t) |
| 119 | if err := db.createKeyInvite("inv2", "u2", "", "", 0); err != nil { |
| 120 | t.Fatalf("create: %v", err) |
| 121 | } |
| 122 | if _, ok, _ := db.claimKeyInvite("inv2", "dev", "", testClaimSecret); ok { |
| 123 | t.Fatal("claimed an invite that has no claim_hash") |
| 124 | } |
| 125 | } |
| 126 | |