eleven / messenger Public

server: remove the legacy invite path (#99)

Legacy invites (the pre-key-invite `invites` table) stored their tokens in
plaintext and were still accepted by tokenOK, so a stolen DB yielded usable
member credentials. The current client no longer creates or claims them —
key-invites superseded them — so remove the whole path:

- Delete the createInvite / listInvites / revokeInvite admin endpoints and the
  getInvite / claimInvite legacy-join endpoints, plus their handlers and DB funcs.
- tokenOK now accepts device tokens only (no legacy invite tokens).
- A migration DROPs the invites table so existing DBs retain no plaintext.

BREAKING: any member still authenticating with an old #k= legacy invite token is
logged out and must be re-invited (accepted decision — the current flow issues
device tokens). Tests migrate from createInvite to a mintDeviceToken helper.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 79e6bba21712a28a8ec164278702891575d94172 parent efbf8b5
9 files changed, +95 −280
  • authz_hardening_test.go +5 −5
  • db.go +4 −93
  • db_test.go +5 −37
  • handlers_test.go +10 −37
  • invite_claim_test.go +57 −1
  • main.go +0 −86
  • push_ssrf_test.go +2 −5
  • push_token_hash_test.go +9 −9
  • unfurl_test.go +3 −7
diff --git a/authz_hardening_test.go b/authz_hardening_test.go
index bc69347..2a925bb 100644
--- a/authz_hardening_test.go
+++ b/authz_hardening_test.go
@@ -11,9 +11,9 @@ import (
// happens to know the endpoint.
func TestUnsubscribePushIsOwnerScoped(t *testing.T) {
ts, srv := newTestServer(t)
- a, _ := srv.db.createInvite("A")
- b, _ := srv.db.createInvite("B")
- if err := srv.db.addSubscription(PushSub{Endpoint: "https://e/1", Token: a.Token, P256dh: "p", Auth: "a"}); err != nil {
+ aTok := mintDeviceToken(t, srv.db, "A")
+ bTok := mintDeviceToken(t, srv.db, "B")
+ if err := srv.db.addSubscription(PushSub{Endpoint: "https://e/1", Token: aTok, P256dh: "p", Auth: "a"}); err != nil {
t.Fatalf("addSubscription: %v", err)
}
@@ -35,14 +35,14 @@ func TestUnsubscribePushIsOwnerScoped(t *testing.T) {
t.Fatalf("no-token request deleted a sub")
}
// Valid but non-owning token → accepted, but the other user's sub survives.
- if got := post(fmt.Sprintf(`{"token":%q,"endpoint":"https://e/1"}`, b.Token)); got != http.StatusOK {
+ if got := post(fmt.Sprintf(`{"token":%q,"endpoint":"https://e/1"}`, bTok)); got != http.StatusOK {
t.Fatalf("non-owner: want 200, got %d", got)
}
if subCount() != 1 {
t.Fatalf("non-owner deleted someone else's subscription")
}
// Owner token → removed.
- if got := post(fmt.Sprintf(`{"token":%q,"endpoint":"https://e/1"}`, a.Token)); got != http.StatusOK {
+ if got := post(fmt.Sprintf(`{"token":%q,"endpoint":"https://e/1"}`, aTok)); got != http.StatusOK {
t.Fatalf("owner: want 200, got %d", got)
}
if subCount() != 0 {
diff --git a/db.go b/db.go
index 2183212..03a02ef 100644
--- a/db.go
+++ b/db.go
@@ -81,13 +81,6 @@ CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-CREATE TABLE IF NOT EXISTS invites (
- token TEXT PRIMARY KEY,
- label TEXT NOT NULL DEFAULT '',
- created_at INTEGER NOT NULL,
- claimed_at INTEGER,
- revoked INTEGER NOT NULL DEFAULT 0
-);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at INTEGER NOT NULL,
@@ -227,6 +220,10 @@ CREATE TABLE IF NOT EXISTS bot_configs (
// preimage, so reading the DB (which stores only the id + this hash) is not
// enough to claim a pending invite. See deriveInvite in crypto.js.
"ALTER TABLE key_invites ADD COLUMN claim_hash TEXT",
+ // Legacy invites are removed (superseded by key-invites). Their tokens were
+ // stored in clear and are no longer accepted; drop the table so a stolen DB
+ // retains no plaintext credentials. Members re-authenticate via device tokens.
+ "DROP TABLE IF EXISTS invites",
// Which user a push subscription belongs to, so removing a user can drop their
// subscriptions (sign-out clears them by token; removal has only the user id).
"ALTER TABLE push_subscriptions ADD COLUMN user_id TEXT",
@@ -420,92 +417,6 @@ func (db *DB) checkAdmin(token string) bool {
return subtle.ConstantTimeCompare([]byte(hashToken(token)), []byte(want)) == 1
}
-// ---- invites ------------------------------------------------------------
-
-type Invite struct {
- Token string `json:"token"`
- Label string `json:"label"`
- CreatedAt int64 `json:"created_at"`
- ClaimedAt *int64 `json:"claimed_at"`
- Revoked bool `json:"revoked"`
-}
-
-func (db *DB) createInvite(label string) (Invite, error) {
- tok, err := randToken()
- if err != nil {
- return Invite{}, err
- }
- now := time.Now().Unix()
- _, err = db.sql.Exec(
- "INSERT INTO invites(token, label, created_at) VALUES(?, ?, ?)",
- tok, label, now,
- )
- if err != nil {
- return Invite{}, err
- }
- return Invite{Token: tok, Label: label, CreatedAt: now}, nil
-}
-
-func (db *DB) listInvites() ([]Invite, error) {
- rows, err := db.sql.Query(
- "SELECT token, label, created_at, claimed_at, revoked FROM invites ORDER BY created_at DESC",
- )
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- out := []Invite{}
- for rows.Next() {
- var inv Invite
- var revoked int
- if err := rows.Scan(&inv.Token, &inv.Label, &inv.CreatedAt, &inv.ClaimedAt, &revoked); err != nil {
- return nil, err
- }
- inv.Revoked = revoked != 0
- out = append(out, inv)
- }
- return out, rows.Err()
-}
-
-// inviteStatus reports whether a token exists and whether it is revoked.
-func (db *DB) inviteStatus(token string) (exists, revoked bool, err error) {
- var r int
- err = db.sql.QueryRow("SELECT revoked FROM invites WHERE token = ?", token).Scan(&r)
- if err == sql.ErrNoRows {
- return false, false, nil
- }
- if err != nil {
- return false, false, err
- }
- return true, r != 0, nil
-}
-
-// inviteLabel returns the admin's label for an invite (used to pre-fill the
-// screen name). Empty string if the invite doesn't exist or has no label.
-func (db *DB) inviteLabel(token string) (string, error) {
- var label string
- err := db.sql.QueryRow("SELECT label FROM invites WHERE token = ?", token).Scan(&label)
- if err == sql.ErrNoRows {
- return "", nil
- }
- return label, err
-}
-
-// claimInvite marks the first use of an invite. Reusable afterwards (so a kid
-// can reopen the link on another device) unless revoked.
-func (db *DB) claimInvite(token string) error {
- _, err := db.sql.Exec(
- "UPDATE invites SET claimed_at = COALESCE(claimed_at, ?) WHERE token = ? AND revoked = 0",
- time.Now().Unix(), token,
- )
- return err
-}
-
-func (db *DB) revokeInvite(token string) error {
- _, err := db.sql.Exec("UPDATE invites SET revoked = 1 WHERE token = ?", token)
- return err
-}
-
// ---- users, key-invites, device tokens (invite redesign) ----------------
//
// The server stores a client-WRAPPED group key per invite, keyed by an id the
diff --git a/db_test.go b/db_test.go
index 57643c0..ae7712f 100644
--- a/db_test.go
+++ b/db_test.go
@@ -15,38 +15,6 @@ func newTestDB(t *testing.T) *DB {
return db
}
-func TestInviteLifecycle(t *testing.T) {
- db := newTestDB(t)
-
- inv, err := db.createInvite("Sophie")
- if err != nil || inv.Token == "" {
- t.Fatalf("createInvite: %v token=%q", err, inv.Token)
- }
-
- exists, revoked, err := db.inviteStatus(inv.Token)
- if err != nil || !exists || revoked {
- t.Fatalf("fresh invite: exists=%v revoked=%v err=%v", exists, revoked, err)
- }
-
- if label, _ := db.inviteLabel(inv.Token); label != "Sophie" {
- t.Fatalf("inviteLabel = %q, want Sophie", label)
- }
-
- if err := db.claimInvite(inv.Token); err != nil {
- t.Fatalf("claimInvite: %v", err)
- }
-
- if err := db.revokeInvite(inv.Token); err != nil {
- t.Fatalf("revokeInvite: %v", err)
- }
- if _, revoked, _ := db.inviteStatus(inv.Token); !revoked {
- t.Fatal("invite should be revoked")
- }
-
- if exists, _, _ := db.inviteStatus("does-not-exist"); exists {
- t.Fatal("unknown token should not exist")
- }
-}
func TestAdminToken(t *testing.T) {
db := newTestDB(t)
@@ -165,16 +133,16 @@ func TestEditMessage(t *testing.T) {
func TestPushSubscriptions(t *testing.T) {
db := newTestDB(t)
- inv, _ := db.createInvite("x")
+ tok := mintDeviceToken(t, db, "x")
- db.addSubscription(PushSub{Endpoint: "https://e/1", Token: inv.Token, P256dh: "p", Auth: "a"})
- db.addSubscription(PushSub{Endpoint: "https://e/2", Token: inv.Token, P256dh: "p", Auth: "a"})
+ db.addSubscription(PushSub{Endpoint: "https://e/1", Token: tok, P256dh: "p", Auth: "a"})
+ db.addSubscription(PushSub{Endpoint: "https://e/2", Token: tok, P256dh: "p", Auth: "a"})
if subs, _ := db.listSubscriptions(); len(subs) != 2 {
t.Fatalf("expected 2 subs, got %d", len(subs))
}
// Same endpoint upserts, not duplicates.
- db.addSubscription(PushSub{Endpoint: "https://e/1", Token: inv.Token, P256dh: "p2", Auth: "a2"})
+ db.addSubscription(PushSub{Endpoint: "https://e/1", Token: tok, P256dh: "p2", Auth: "a2"})
if subs, _ := db.listSubscriptions(); len(subs) != 2 {
t.Fatalf("upsert duplicated: %d", len(subs))
}
@@ -183,7 +151,7 @@ func TestPushSubscriptions(t *testing.T) {
if subs, _ := db.listSubscriptions(); len(subs) != 1 {
t.Fatalf("after delete: %d", len(subs))
}
- db.deleteSubscriptionsForToken(inv.Token)
+ db.deleteSubscriptionsForToken(tok)
if subs, _ := db.listSubscriptions(); len(subs) != 0 {
t.Fatalf("after token delete: %d", len(subs))
}
diff --git a/handlers_test.go b/handlers_test.go
index b7dad75..44de3f0 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -47,12 +47,12 @@ func adminPost(t *testing.T, url, body string) *http.Response {
func TestAdminAuthGating(t *testing.T) {
ts, _ := newTestServer(t)
- resp, _ := http.Post(ts.URL+"/api/admin/invites", "application/json", strings.NewReader(`{"label":"x"}`))
+ resp, _ := http.Post(ts.URL+"/api/admin/key-invites", "application/json", strings.NewReader(`{"label":"x"}`))
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("no auth: want 401, got %d", resp.StatusCode)
}
- req, _ := http.NewRequest("POST", ts.URL+"/api/admin/invites", strings.NewReader(`{}`))
+ req, _ := http.NewRequest("POST", ts.URL+"/api/admin/key-invites", strings.NewReader(`{}`))
req.Header.Set("Authorization", "Bearer wrong")
resp, _ = http.DefaultClient.Do(req)
if resp.StatusCode != http.StatusUnauthorized {
@@ -85,35 +85,12 @@ func TestRenameUser(t *testing.T) {
}
}
-func TestInviteAndMessagesFlow(t *testing.T) {
+func TestMessagesThreadScoping(t *testing.T) {
ts, srv := newTestServer(t)
- resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"Sophie"}`)
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("create invite: %d", resp.StatusCode)
- }
- var inv struct {
- Token string `json:"token"`
- }
- json.NewDecoder(resp.Body).Decode(&inv)
- if inv.Token == "" {
- t.Fatal("no token returned")
- }
-
- // Invite info exposes the label (for screen-name pre-fill).
- resp, _ = http.Get(ts.URL + "/api/invite/" + inv.Token)
- var info struct {
- Ok bool `json:"ok"`
- Label string `json:"label"`
- }
- json.NewDecoder(resp.Body).Decode(&info)
- if !info.Ok || info.Label != "Sophie" {
- t.Fatalf("invite info: %+v", info)
- }
-
// Messages are thread-scoped: a bad token is rejected; a real member reading
// their own thread is allowed; a member reading a thread they're not in is not.
- resp, _ = http.Get(ts.URL + "/api/messages?token=bogus&thread=x")
+ resp, _ := http.Get(ts.URL + "/api/messages?token=bogus&thread=x")
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("messages bad token: want 403, got %d", resp.StatusCode)
}
@@ -401,22 +378,18 @@ func TestPostMessageAPI(t *testing.T) {
}
func TestBlobUploadDownload(t *testing.T) {
- ts, _ := newTestServer(t)
- resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"x"}`)
- var inv struct {
- Token string `json:"token"`
- }
- json.NewDecoder(resp.Body).Decode(&inv)
+ ts, srv := newTestServer(t)
+ tok := mintDeviceToken(t, srv.db, "x")
payload := []byte("pretend-this-is-encrypted-bytes\x00\x01\x02")
// Upload requires a valid token.
- resp, _ = http.Post(ts.URL+"/api/upload?token=bogus", "application/octet-stream", strings.NewReader("x"))
+ resp, _ := http.Post(ts.URL+"/api/upload?token=bogus", "application/octet-stream", strings.NewReader("x"))
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("upload bad token: want 403, got %d", resp.StatusCode)
}
- resp, err := http.Post(ts.URL+"/api/upload?token="+inv.Token, "application/octet-stream", strings.NewReader(string(payload)))
+ resp, err := http.Post(ts.URL+"/api/upload?token="+tok, "application/octet-stream", strings.NewReader(string(payload)))
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("upload: err=%v status=%d", err, resp.StatusCode)
}
@@ -435,7 +408,7 @@ func TestBlobUploadDownload(t *testing.T) {
}
// Round-trips exactly.
- resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=" + inv.Token)
+ resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=" + tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("download: %d", resp.StatusCode)
}
@@ -445,7 +418,7 @@ func TestBlobUploadDownload(t *testing.T) {
}
// Unknown id is a 404.
- resp, _ = http.Get(ts.URL + "/api/blob/nope?token=" + inv.Token)
+ resp, _ = http.Get(ts.URL + "/api/blob/nope?token=" + tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("missing blob: want 404, got %d", resp.StatusCode)
}
diff --git a/invite_claim_test.go b/invite_claim_test.go
index 890a5cc..4d497ff 100644
--- a/invite_claim_test.go
+++ b/invite_claim_test.go
@@ -1,6 +1,62 @@
package main
-import "testing"
+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.
diff --git a/main.go b/main.go
index 36cb0f4..886002f 100644
--- a/main.go
+++ b/main.go
@@ -162,10 +162,6 @@ func (s *Server) handler() http.Handler {
// caches it, and serves cached results immediately (refreshing stale ones).
mux.HandleFunc("GET /api/unfurl", s.unfurl)
- // Invite lifecycle (legacy join page)
- mux.HandleFunc("GET /api/invite/{token}", s.getInvite)
- mux.HandleFunc("POST /api/invite/{token}/claim", s.claimInvite)
-
// Key-invites (invite redesign): server stores a client-wrapped key it can't
// open; claiming mints a device token. See docs/invite-redesign.md.
mux.HandleFunc("GET /api/key-invite/{id}", s.getKeyInvite)
@@ -191,9 +187,6 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/admin/group", s.setGroup)
// Admin API (requires admin token)
- mux.HandleFunc("POST /api/admin/invites", s.createInvite)
- mux.HandleFunc("GET /api/admin/invites", s.listInvites)
- mux.HandleFunc("POST /api/admin/invites/{token}/revoke", s.revokeInvite)
mux.HandleFunc("GET /api/admin/settings", s.getSettings)
mux.HandleFunc("POST /api/admin/settings", s.setSettings)
@@ -461,9 +454,6 @@ func (s *Server) tokenOK(token string) bool {
if token == "" {
return false
}
- if exists, revoked, err := s.db.inviteStatus(token); err == nil && exists && !revoked {
- return true
- }
return s.db.deviceTokenValid(token)
}
@@ -1140,40 +1130,6 @@ func (s *Server) fetchAndCachePreview(norm string) LinkPreview {
return pv
}
-// ---- invite (join flow) -------------------------------------------------
-
-func (s *Server) getInvite(w http.ResponseWriter, r *http.Request) {
- token := r.PathValue("token")
- exists, revoked, err := s.db.inviteStatus(token)
- if err != nil {
- httpError(w, http.StatusInternalServerError, "db error")
- return
- }
- label := ""
- if exists {
- label, _ = s.db.inviteLabel(token)
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": exists && !revoked, "revoked": revoked, "label": label})
-}
-
-func (s *Server) claimInvite(w http.ResponseWriter, r *http.Request) {
- token := r.PathValue("token")
- exists, revoked, err := s.db.inviteStatus(token)
- if err != nil {
- httpError(w, http.StatusInternalServerError, "db error")
- return
- }
- if !exists || revoked {
- httpError(w, http.StatusForbidden, "invalid invite")
- return
- }
- if err := s.db.claimInvite(token); err != nil {
- httpError(w, http.StatusInternalServerError, "db error")
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
-}
-
// ---- key-invites (invite redesign) --------------------------------------
// createKeyInvite stores a client-wrapped group key (the admin's browser did the
@@ -1638,48 +1594,6 @@ func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
return false
}
-func (s *Server) createInvite(w http.ResponseWriter, r *http.Request) {
- if !s.requireAdmin(w, r) {
- return
- }
- var body struct {
- Label string `json:"label"`
- }
- json.NewDecoder(r.Body).Decode(&body)
- inv, err := s.db.createInvite(strings.TrimSpace(body.Label))
- if err != nil {
- httpError(w, http.StatusInternalServerError, "db error")
- return
- }
- writeJSON(w, http.StatusOK, inv)
-}
-
-func (s *Server) listInvites(w http.ResponseWriter, r *http.Request) {
- if !s.requireAdmin(w, r) {
- return
- }
- invites, err := s.db.listInvites()
- if err != nil {
- httpError(w, http.StatusInternalServerError, "db error")
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{"invites": invites})
-}
-
-func (s *Server) revokeInvite(w http.ResponseWriter, r *http.Request) {
- if !s.requireAdmin(w, r) {
- return
- }
- token := r.PathValue("token")
- if err := s.db.revokeInvite(token); err != nil {
- httpError(w, http.StatusInternalServerError, "db error")
- return
- }
- // A revoked participant should also stop receiving push notifications.
- _ = s.db.deleteSubscriptionsForToken(token)
- _ = s.db.deleteAPNsTokensForToken(token)
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
-}
// ---- push notifications -------------------------------------------------
diff --git a/push_ssrf_test.go b/push_ssrf_test.go
index d7b072a..5700805 100644
--- a/push_ssrf_test.go
+++ b/push_ssrf_test.go
@@ -66,12 +66,9 @@ func TestGuardDialControlBlocksNonPublic(t *testing.T) {
// register an internal/non-https push endpoint (proves the validator is wired in).
func TestSubscribePushRejectsBadEndpoint(t *testing.T) {
ts, srv := newTestServer(t)
- inv, err := srv.db.createInvite("push-test")
- if err != nil {
- t.Fatalf("createInvite: %v", err)
- }
+ tok := mintDeviceToken(t, srv.db, "push-test")
post := func(endpoint string) int {
- body := fmt.Sprintf(`{"token":%q,"subscription":{"endpoint":%q,"keys":{"p256dh":"p","auth":"a"}}}`, inv.Token, endpoint)
+ body := fmt.Sprintf(`{"token":%q,"subscription":{"endpoint":%q,"keys":{"p256dh":"p","auth":"a"}}}`, tok, endpoint)
resp, err := http.Post(ts.URL+"/api/push/subscribe", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("post: %v", err)
diff --git a/push_token_hash_test.go b/push_token_hash_test.go
index 6bb6c10..6d63ae5 100644
--- a/push_token_hash_test.go
+++ b/push_token_hash_test.go
@@ -5,26 +5,26 @@ import "testing"
// The push/APNs tables must never retain a usable session token — only its hash.
func TestPushTokensHashedAtRest(t *testing.T) {
db := newTestDB(t)
- inv, _ := db.createInvite("x")
+ tok := mintDeviceToken(t, db, "x")
- if err := db.addSubscription(PushSub{Endpoint: "https://e/1", Token: inv.Token, P256dh: "p", Auth: "a"}); err != nil {
+ if err := db.addSubscription(PushSub{Endpoint: "https://e/1", Token: tok, P256dh: "p", Auth: "a"}); err != nil {
t.Fatalf("addSubscription: %v", err)
}
- if err := db.addAPNsToken(APNsToken{APNsToken: "apns-1", Token: inv.Token}); err != nil {
+ if err := db.addAPNsToken(APNsToken{APNsToken: "apns-1", Token: tok}); err != nil {
t.Fatalf("addAPNsToken: %v", err)
}
check := func(table, keyCol, keyVal string) {
- var tok, th string
+ var storedTok, th string
if err := db.sql.QueryRow(
"SELECT token, COALESCE(token_hash, '') FROM "+table+" WHERE "+keyCol+" = ?", keyVal,
- ).Scan(&tok, &th); err != nil {
+ ).Scan(&storedTok, &th); err != nil {
t.Fatalf("%s row: %v", table, err)
}
- if tok != "" {
- t.Errorf("%s retains plaintext token %q, want empty", table, tok)
+ if storedTok != "" {
+ t.Errorf("%s retains plaintext token %q, want empty", table, storedTok)
}
- if th != hashToken(inv.Token) {
+ if th != hashToken(tok) {
t.Errorf("%s token_hash = %q, want hash of the token", table, th)
}
}
@@ -32,7 +32,7 @@ func TestPushTokensHashedAtRest(t *testing.T) {
check("apns_tokens", "apns_token", "apns-1")
// Delete-by-raw-token still resolves via the stored hash.
- if err := db.deleteSubscriptionsForToken(inv.Token); err != nil {
+ if err := db.deleteSubscriptionsForToken(tok); err != nil {
t.Fatalf("deleteSubscriptionsForToken: %v", err)
}
if subs, _ := db.listSubscriptions(); len(subs) != 0 {
diff --git a/unfurl_test.go b/unfurl_test.go
index 432c58d..eaa43d4 100644
--- a/unfurl_test.go
+++ b/unfurl_test.go
@@ -65,11 +65,7 @@ func TestUnfurlEndpoint(t *testing.T) {
}))
t.Cleanup(page.Close)
- resp := adminPost(t, ts.URL+"/api/admin/invites", `{"label":"x"}`)
- var inv struct {
- Token string `json:"token"`
- }
- json.NewDecoder(resp.Body).Decode(&inv)
+ tok := mintDeviceToken(t, srv.db, "x")
// No token → 403.
r, _ := http.Get(ts.URL + "/api/unfurl?url=" + page.URL)
@@ -78,13 +74,13 @@ func TestUnfurlEndpoint(t *testing.T) {
}
// Bad URL → 400.
- r, _ = http.Get(ts.URL + "/api/unfurl?token=" + inv.Token + "&url=notaurl")
+ r, _ = http.Get(ts.URL + "/api/unfurl?token=" + tok + "&url=notaurl")
if r.StatusCode != http.StatusBadRequest {
t.Fatalf("unfurl bad url: want 400, got %d", r.StatusCode)
}
// Good fetch → preview returned and cached.
- r, _ = http.Get(ts.URL + "/api/unfurl?token=" + inv.Token + "&url=" + page.URL)
+ r, _ = http.Get(ts.URL + "/api/unfurl?token=" + tok + "&url=" + page.URL)
if r.StatusCode != http.StatusOK {
t.Fatalf("unfurl: %d", r.StatusCode)
}