package main import ( "database/sql" "fmt" "sync/atomic" "testing" ) // A pre-existing DB with the old plaintext invites table has it dropped on open, // and a token that used to authenticate via it is no longer accepted. func TestLegacyInvitesRemovedOnOpen(t *testing.T) { path := t.TempDir() + "/legacy.db" raw, err := sql.Open("sqlite", path) if err != nil { t.Fatalf("open raw: %v", err) } 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 { t.Fatalf("create legacy table: %v", err) } if _, err := raw.Exec(`INSERT INTO invites(token, label, created_at) VALUES('legacytok', 'x', 0)`); err != nil { t.Fatalf("seed legacy token: %v", err) } raw.Close() db, err := openDB(path) if err != nil { t.Fatalf("openDB: %v", err) } if _, err := db.sql.Exec("SELECT 1 FROM invites LIMIT 1"); err == nil { t.Fatal("legacy invites table was not dropped on open") } srv := &Server{db: db} if srv.tokenOK("legacytok") { t.Fatal("a legacy invite token is still accepted for auth") } } var mintSeq atomic.Int64 // mintDeviceToken creates a joined member and returns a valid device (session) // token via the key-invite claim flow — the standard way to get an authenticated // caller in tests now that legacy invites are gone. func mintDeviceToken(t *testing.T, db *DB, label string) string { t.Helper() uid, err := db.createUser(label) if err != nil { t.Fatalf("createUser: %v", err) } id := fmt.Sprintf("mint-%s-%d", label, mintSeq.Add(1)) if err := db.createKeyInvite(id, uid, "", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite: %v", err) } tok, ok, err := db.claimKeyInvite(id, label, "", testClaimSecret) if err != nil || !ok { t.Fatalf("claimKeyInvite: ok=%v err=%v", ok, err) } return tok } // Shared across the invite tests: a claim secret and its stored hash. Creation // stores testClaimHash; claiming presents testClaimSecret. const testClaimSecret = "test-claim-secret-vector" var testClaimHash = hashToken(testClaimSecret) // Cross-implementation vector: the browser's deriveInvite("AAAAAAAAAAAAAAAAAAAAAA") // (T = 16 zero bytes, base64url) produces this claim secret + hash. The server must // accept that secret against that hash — i.e. the client's sha256hex and the // server's hashToken agree, so real browser claims verify. (Regenerate via: // node --input-type=module -e "import('./web/static/crypto.js').then(...)") func TestClaimHashCrossImpl(t *testing.T) { const jsClaimSecret = "zgoEhtdOkw8sCcWC7LhWHMItVCulazloEuXRG7hz_Zw" const jsClaimHash = "a4553e1d5770adf87427741af0fbcf6e42a223a82403edd6752c0c39fa43a7e3" if got := hashToken(jsClaimSecret); got != jsClaimHash { t.Fatalf("hashToken(jsClaimSecret) = %q, want %q — JS and Go hashing disagree", got, jsClaimHash) } if !validClaimSecret(jsClaimHash, jsClaimSecret) { t.Fatal("server would reject a browser-derived claim secret") } } func TestValidClaimSecret(t *testing.T) { if !validClaimSecret(hashToken("s"), "s") { t.Error("valid preimage rejected") } for _, bad := range [][2]string{{"", ""}, {"", "s"}, {hashToken("s"), ""}, {hashToken("s"), "wrong"}} { if validClaimSecret(bad[0], bad[1]) { t.Errorf("validClaimSecret(%q, %q) = true, want false", bad[0], bad[1]) } } } // Claiming requires proving knowledge of the invite secret — knowing only the id // (which the server stores, and a DB thief would have) is not enough. func TestClaimKeyInviteRequiresSecret(t *testing.T) { db := newTestDB(t) if err := db.createKeyInvite("inv1", "u1", "", testClaimHash, 0); err != nil { t.Fatalf("create: %v", err) } // A DB thief who only has the stored id can't produce the preimage. if _, ok, _ := db.claimKeyInvite("inv1", "dev", "", "wrong-secret"); ok { t.Fatal("claim succeeded with the wrong secret") } if _, ok, _ := db.claimKeyInvite("inv1", "dev", "", ""); ok { t.Fatal("claim succeeded with an empty secret") } // The real invitee (who derived the secret from T in the URL fragment) can. tok, ok, err := db.claimKeyInvite("inv1", "dev", "", testClaimSecret) if err != nil || !ok || tok == "" { t.Fatalf("claim with correct secret: ok=%v err=%v tok=%q", ok, err, tok) } } // An invite stored without a claim_hash (e.g. created before this migration) is // unclaimable — we don't fall back to unauthenticated claiming. func TestClaimKeyInviteRejectsInviteWithoutHash(t *testing.T) { db := newTestDB(t) if err := db.createKeyInvite("inv2", "u2", "", "", 0); err != nil { t.Fatalf("create: %v", err) } if _, ok, _ := db.claimKeyInvite("inv2", "dev", "", testClaimSecret); ok { t.Fatal("claimed an invite that has no claim_hash") } }