server: store only a hash of push/APNs session tokens (#95)
push_subscriptions.token and apns_tokens.token stored the raw session token in clear, while device_tokens hashes it — so a stolen DB handed out live member credentials that pass tokenOK (post/edit/delete as that member, register for push, etc.), even with no active server. Store hashToken(token) in a new token_hash column instead, resolve the owning user from the hash (deviceTokenUserByHash), and match the sender by hash. A one-time backfill hashes and scrubs any pre-existing plaintext at migration time. Also fixes removeUser's APNs cascade, which matched on a non-existent device_tokens.token column (a latent bug); it now joins on token_hash.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+207
−31
apns.go+16 −10db.go+62 −4handlers_test.go+3 −1push.go+22 −16push_token_hash_test.go+104 −0
diff --git a/apns.go b/apns.go| index 21b9d2e..1f7141e 100644 |
| --- a/apns.go |
| +++ b/apns.go |
| @@ -38,9 +38,12 @@ import ( |
| // a debug build sets sandbox=1 and is delivered via Apple's sandbox gateway. |
| // APNsToken is one registered iOS device, tied to the session token that made it. |
| +// Only a hash of that token is persisted (it's a live credential); Token is the |
| +// raw value on the way in, TokenHash is what comes back out and is matched on. |
| type APNsToken struct { |
| APNsToken string |
| - Token string |
| + Token string // raw session token — input to addAPNsToken only, never stored |
| + TokenHash string // hashToken(Token); the persisted, matched-on value |
| Sandbox bool |
| } |
| @@ -49,17 +52,18 @@ func (db *DB) addAPNsToken(t APNsToken) error { |
| if t.Sandbox { |
| sandbox = 1 |
| } |
| + // Persist only the token hash; the plaintext `token` column stays empty. |
| _, err := db.sql.Exec( |
| - `INSERT INTO apns_tokens(apns_token, token, sandbox, created_at) |
| - VALUES(?, ?, ?, ?) |
| - ON CONFLICT(apns_token) DO UPDATE SET token=excluded.token, sandbox=excluded.sandbox`, |
| - t.APNsToken, t.Token, sandbox, time.Now().Unix(), |
| + `INSERT INTO apns_tokens(apns_token, token, token_hash, sandbox, created_at) |
| + VALUES(?, '', ?, ?, ?) |
| + ON CONFLICT(apns_token) DO UPDATE SET token='', token_hash=excluded.token_hash, sandbox=excluded.sandbox`, |
| + t.APNsToken, hashToken(t.Token), sandbox, time.Now().Unix(), |
| ) |
| return err |
| } |
| func (db *DB) listAPNsTokens() ([]APNsToken, error) { |
| - rows, err := db.sql.Query("SELECT apns_token, token, sandbox FROM apns_tokens") |
| + rows, err := db.sql.Query("SELECT apns_token, COALESCE(token_hash, ''), sandbox FROM apns_tokens") |
| if err != nil { |
| return nil, err |
| } |
| @@ -68,7 +72,7 @@ func (db *DB) listAPNsTokens() ([]APNsToken, error) { |
| for rows.Next() { |
| var t APNsToken |
| var sandbox int |
| - if err := rows.Scan(&t.APNsToken, &t.Token, &sandbox); err != nil { |
| + if err := rows.Scan(&t.APNsToken, &t.TokenHash, &sandbox); err != nil { |
| return nil, err |
| } |
| t.Sandbox = sandbox == 1 |
| @@ -83,7 +87,7 @@ func (db *DB) deleteAPNsToken(apnsToken string) error { |
| } |
| func (db *DB) deleteAPNsTokensForToken(token string) error { |
| - _, err := db.sql.Exec("DELETE FROM apns_tokens WHERE token = ?", token) |
| + _, err := db.sql.Exec("DELETE FROM apns_tokens WHERE token_hash = ?", hashToken(token)) |
| return err |
| } |
| @@ -222,7 +226,9 @@ func (s *Server) apnsFanOut(threadID, blob, exceptToken string, skipActive bool, |
| return 0 |
| } |
| senderUser := "" |
| + exceptHash := "" |
| if exceptToken != "" { |
| + exceptHash = hashToken(exceptToken) |
| senderUser, _ = s.db.deviceTokenUser(exceptToken) |
| } |
| sem := make(chan struct{}, maxPushConcurrency) |
| @@ -231,10 +237,10 @@ func (s *Server) apnsFanOut(threadID, blob, exceptToken string, skipActive bool, |
| selected := 0 |
| var mu sync.Mutex |
| for _, t := range tokens { |
| - if exceptToken != "" && t.Token == exceptToken { |
| + if exceptHash != "" && t.TokenHash == exceptHash { |
| continue |
| } |
| - uid, known := s.db.deviceTokenUser(t.Token) |
| + uid, known := s.db.deviceTokenUserByHash(t.TokenHash) |
| if senderUser != "" && known && uid == senderUser { |
| continue |
| } |
diff --git a/db.go b/db.go| index 940b0ff..5d8b048 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -225,6 +225,11 @@ CREATE TABLE IF NOT EXISTS bot_configs ( |
| // 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", |
| + // Store only a HASH of the session token in the push/APNs tables — the raw |
| + // token is a live credential (cf. device_tokens.token_hash). Backfilled and |
| + // scrubbed below for rows created before this migration. |
| + "ALTER TABLE push_subscriptions ADD COLUMN token_hash TEXT", |
| + "ALTER TABLE apns_tokens ADD COLUMN token_hash TEXT", |
| // Bot flag: a user driven by an agent (see agent/), not a person. Only |
| // used to badge them in the UI — it grants no privilege. |
| "ALTER TABLE users ADD COLUMN is_agent INTEGER NOT NULL DEFAULT 0", |
| @@ -242,6 +247,53 @@ CREATE TABLE IF NOT EXISTS bot_configs ( |
| return err |
| } |
| } |
| + // Older push/APNs rows stored the session token in clear; replace it with its |
| + // hash and scrub the plaintext so a stolen DB yields no live credentials. |
| + return db.backfillPushTokenHashes() |
| +} |
| + |
| +// backfillPushTokenHashes migrates any legacy plaintext push/APNs token to a hash |
| +// and clears the plaintext column, so the tables never retain a usable credential. |
| +func (db *DB) backfillPushTokenHashes() error { |
| + if err := db.backfillTokenHash("push_subscriptions", "endpoint"); err != nil { |
| + return err |
| + } |
| + return db.backfillTokenHash("apns_tokens", "apns_token") |
| +} |
| + |
| +// backfillTokenHash rewrites rows that still hold a plaintext token. table/keyCol |
| +// are in-code constants, never user input, so the string-built SQL is safe. |
| +func (db *DB) backfillTokenHash(table, keyCol string) error { |
| + rows, err := db.sql.Query( |
| + "SELECT " + keyCol + ", token FROM " + table + |
| + " WHERE (token_hash IS NULL OR token_hash = '') AND token != ''") |
| + if err != nil { |
| + return err |
| + } |
| + type legacy struct{ key, token string } |
| + var pending []legacy |
| + for rows.Next() { |
| + var r legacy |
| + if err := rows.Scan(&r.key, &r.token); err != nil { |
| + rows.Close() |
| + return err |
| + } |
| + pending = append(pending, r) |
| + } |
| + rows.Close() |
| + if err := rows.Err(); err != nil { |
| + return err |
| + } |
| + // Update after the read completes (single-connection SQLite dislikes writing |
| + // mid-iteration). |
| + for _, r := range pending { |
| + if _, err := db.sql.Exec( |
| + "UPDATE "+table+" SET token_hash = ?, token = '' WHERE "+keyCol+" = ?", |
| + hashToken(r.token), r.key, |
| + ); err != nil { |
| + return err |
| + } |
| + } |
| return nil |
| } |
| @@ -692,8 +744,14 @@ func (db *DB) deviceTokenValid(token string) bool { |
| // deviceTokenUser returns the user a device token belongs to (for self-service |
| // "add a device"). ok=false for unknown or legacy (non-device) tokens. |
| func (db *DB) deviceTokenUser(token string) (string, bool) { |
| + return db.deviceTokenUserByHash(hashToken(token)) |
| +} |
| + |
| +// deviceTokenUserByHash resolves the owning user from a token hash, so callers |
| +// that only hold the hash (push/APNs subscriptions) never need the raw token. |
| +func (db *DB) deviceTokenUserByHash(tokenHash string) (string, bool) { |
| var uid string |
| - if db.sql.QueryRow("SELECT user_id FROM device_tokens WHERE token_hash = ?", hashToken(token)).Scan(&uid) != nil { |
| + if db.sql.QueryRow("SELECT user_id FROM device_tokens WHERE token_hash = ?", tokenHash).Scan(&uid) != nil { |
| return "", false |
| } |
| return uid, true |
| @@ -811,9 +869,9 @@ func (db *DB) removeUser(id string) error { |
| }{ |
| {"DELETE FROM thread_keys WHERE user_id = ?", []any{id}}, |
| {"DELETE FROM thread_members WHERE user_id = ?", []any{id}}, |
| - // APNs tokens hang off device-session tokens — resolve them BEFORE the |
| - // device_tokens rows below are gone. |
| - {"DELETE FROM apns_tokens WHERE token IN (SELECT token FROM device_tokens WHERE user_id = ?)", []any{id}}, |
| + // APNs tokens hang off device-session tokens (matched by token hash) — |
| + // resolve them BEFORE the device_tokens rows below are gone. |
| + {"DELETE FROM apns_tokens WHERE token_hash IN (SELECT token_hash FROM device_tokens WHERE user_id = ?)", []any{id}}, |
| {"DELETE FROM device_tokens WHERE user_id = ?", []any{id}}, |
| {"DELETE FROM key_invites WHERE user_id = ?", []any{id}}, |
| {"DELETE FROM instance_keys WHERE user_id = ?", []any{id}}, |
diff --git a/handlers_test.go b/handlers_test.go| index d08cea8..8e0f0cb 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -801,7 +801,9 @@ func TestAPNsSubscribe(t *testing.T) { |
| t.Fatalf("subscribe: %+v", out) |
| } |
| rows, err := s.db.listAPNsTokens() |
| - if err != nil || len(rows) != 1 || rows[0].APNsToken != "abc" || !rows[0].Sandbox || rows[0].Token != claim.Token { |
| + // The session token is stored hashed, never in clear (it's a live credential). |
| + if err != nil || len(rows) != 1 || rows[0].APNsToken != "abc" || !rows[0].Sandbox || |
| + rows[0].Token != "" || rows[0].TokenHash != hashToken(claim.Token) { |
| t.Fatalf("stored: %v %v", rows, err) |
| } |
diff --git a/push.go b/push.go| index 1f7e2c6..29ab691 100644 |
| --- a/push.go |
| +++ b/push.go |
| @@ -64,13 +64,16 @@ func validatePushEndpoint(endpoint string) error { |
| return nil |
| } |
| -// PushSub is a stored Web Push subscription, tied to the invite token that made it. |
| +// PushSub is a stored Web Push subscription, tied to the session token that made |
| +// it. Only a hash of that token is persisted (it's a live credential); Token is |
| +// the raw value on the way in, TokenHash is what comes back out and is matched on. |
| type PushSub struct { |
| - Endpoint string |
| - Token string |
| - P256dh string |
| - Auth string |
| - UserID string // who this belongs to (so removing a user drops their subs) |
| + Endpoint string |
| + Token string // raw session token — input to addSubscription only, never stored |
| + TokenHash string // hashToken(Token); the persisted, matched-on value |
| + P256dh string |
| + Auth string |
| + UserID string // who this belongs to (so removing a user drops their subs) |
| } |
| func (db *DB) addSubscription(s PushSub) error { |
| @@ -78,18 +81,19 @@ func (db *DB) addSubscription(s PushSub) error { |
| if s.UserID != "" { |
| uid = s.UserID |
| } |
| + // Persist only the token hash; the plaintext `token` column stays empty. |
| _, err := db.sql.Exec( |
| - `INSERT INTO push_subscriptions(endpoint, token, p256dh, auth, user_id, created_at) |
| - VALUES(?, ?, ?, ?, ?, ?) |
| + `INSERT INTO push_subscriptions(endpoint, token, token_hash, p256dh, auth, user_id, created_at) |
| + VALUES(?, '', ?, ?, ?, ?, ?) |
| ON CONFLICT(endpoint) DO UPDATE SET |
| - token=excluded.token, p256dh=excluded.p256dh, auth=excluded.auth, user_id=excluded.user_id`, |
| - s.Endpoint, s.Token, s.P256dh, s.Auth, uid, time.Now().Unix(), |
| + token='', token_hash=excluded.token_hash, p256dh=excluded.p256dh, auth=excluded.auth, user_id=excluded.user_id`, |
| + s.Endpoint, hashToken(s.Token), s.P256dh, s.Auth, uid, time.Now().Unix(), |
| ) |
| return err |
| } |
| func (db *DB) listSubscriptions() ([]PushSub, error) { |
| - rows, err := db.sql.Query("SELECT endpoint, token, p256dh, auth FROM push_subscriptions") |
| + rows, err := db.sql.Query("SELECT endpoint, COALESCE(token_hash, ''), p256dh, auth FROM push_subscriptions") |
| if err != nil { |
| return nil, err |
| } |
| @@ -97,7 +101,7 @@ func (db *DB) listSubscriptions() ([]PushSub, error) { |
| out := []PushSub{} |
| for rows.Next() { |
| var s PushSub |
| - if err := rows.Scan(&s.Endpoint, &s.Token, &s.P256dh, &s.Auth); err != nil { |
| + if err := rows.Scan(&s.Endpoint, &s.TokenHash, &s.P256dh, &s.Auth); err != nil { |
| return nil, err |
| } |
| out = append(out, s) |
| @@ -111,7 +115,7 @@ func (db *DB) deleteSubscription(endpoint string) error { |
| } |
| func (db *DB) deleteSubscriptionsForToken(token string) error { |
| - _, err := db.sql.Exec("DELETE FROM push_subscriptions WHERE token = ?", token) |
| + _, err := db.sql.Exec("DELETE FROM push_subscriptions WHERE token_hash = ?", hashToken(token)) |
| return err |
| } |
| @@ -186,20 +190,22 @@ const maxPushConcurrency = 32 |
| // non-nil it keeps only devices whose owner is in that set (thread membership). |
| func (s *Server) selectRecipients(subs []PushSub, exceptToken string, skipActive bool, recipients map[string]bool) []PushSub { |
| senderUser := "" |
| + exceptHash := "" |
| if exceptToken != "" { |
| + exceptHash = hashToken(exceptToken) |
| senderUser, _ = s.db.deviceTokenUser(exceptToken) |
| } |
| out := make([]PushSub, 0, len(subs)) |
| for _, sub := range subs { |
| - if exceptToken != "" && sub.Token == exceptToken { |
| + if exceptHash != "" && sub.TokenHash == exceptHash { |
| continue // the exact device that sent it |
| } |
| // Resolve the owner once — needed for sender-user exclusion, the per-user |
| - // active check, and membership. |
| + // active check, and membership. Resolved from the stored token hash. |
| var uid string |
| var known bool |
| if senderUser != "" || recipients != nil || skipActive { |
| - uid, known = s.db.deviceTokenUser(sub.Token) |
| + uid, known = s.db.deviceTokenUserByHash(sub.TokenHash) |
| } |
| if senderUser != "" && known && uid == senderUser { |
| continue // another of the sender's own devices — don't notify yourself |
diff --git a/push_token_hash_test.go b/push_token_hash_test.go| new file mode 100644 |
| index 0000000..ec1745b |
| --- /dev/null |
| +++ b/push_token_hash_test.go |
| @@ -0,0 +1,104 @@ |
| +package main |
| + |
| +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") |
| + |
| + if err := db.addSubscription(PushSub{Endpoint: "https://e/1", Token: inv.Token, P256dh: "p", Auth: "a"}); err != nil { |
| + t.Fatalf("addSubscription: %v", err) |
| + } |
| + if err := db.addAPNsToken(APNsToken{APNsToken: "apns-1", Token: inv.Token}); err != nil { |
| + t.Fatalf("addAPNsToken: %v", err) |
| + } |
| + |
| + check := func(table, keyCol, keyVal string) { |
| + var tok, th string |
| + if err := db.sql.QueryRow( |
| + "SELECT token, COALESCE(token_hash, '') FROM "+table+" WHERE "+keyCol+" = ?", keyVal, |
| + ).Scan(&tok, &th); err != nil { |
| + t.Fatalf("%s row: %v", table, err) |
| + } |
| + if tok != "" { |
| + t.Errorf("%s retains plaintext token %q, want empty", table, tok) |
| + } |
| + if th != hashToken(inv.Token) { |
| + t.Errorf("%s token_hash = %q, want hash of the token", table, th) |
| + } |
| + } |
| + check("push_subscriptions", "endpoint", "https://e/1") |
| + 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 { |
| + t.Fatalf("deleteSubscriptionsForToken: %v", err) |
| + } |
| + if subs, _ := db.listSubscriptions(); len(subs) != 0 { |
| + t.Fatalf("delete-by-token after hashing left %d sub(s)", len(subs)) |
| + } |
| +} |
| + |
| +// Rows created before this migration stored the token in clear; the backfill |
| +// hashes them and scrubs the plaintext. |
| +func TestBackfillScrubsPlaintextPushTokens(t *testing.T) { |
| + db := newTestDB(t) |
| + if _, err := db.sql.Exec( |
| + "INSERT INTO push_subscriptions(endpoint, token, p256dh, auth, created_at) VALUES('https://e/old','rawtok','p','a',0)", |
| + ); err != nil { |
| + t.Fatalf("seed push: %v", err) |
| + } |
| + if _, err := db.sql.Exec( |
| + "INSERT INTO apns_tokens(apns_token, token, sandbox, created_at) VALUES('apns-old','rawtok',0,0)", |
| + ); err != nil { |
| + t.Fatalf("seed apns: %v", err) |
| + } |
| + |
| + if err := db.backfillPushTokenHashes(); err != nil { |
| + t.Fatalf("backfill: %v", err) |
| + } |
| + |
| + for _, tc := range []struct{ table, keyCol, keyVal string }{ |
| + {"push_subscriptions", "endpoint", "https://e/old"}, |
| + {"apns_tokens", "apns_token", "apns-old"}, |
| + } { |
| + var tok, th string |
| + if err := db.sql.QueryRow( |
| + "SELECT token, COALESCE(token_hash, '') FROM "+tc.table+" WHERE "+tc.keyCol+" = ?", tc.keyVal, |
| + ).Scan(&tok, &th); err != nil { |
| + t.Fatalf("%s: %v", tc.table, err) |
| + } |
| + if tok != "" { |
| + t.Errorf("%s: backfill left plaintext %q", tc.table, tok) |
| + } |
| + if th != hashToken("rawtok") { |
| + t.Errorf("%s: backfill token_hash = %q, want hash of rawtok", tc.table, th) |
| + } |
| + } |
| +} |
| + |
| +// removeUser must drop the user's APNs tokens. This also exercises the token_hash |
| +// join that replaced a query referencing a non-existent device_tokens.token column. |
| +func TestRemoveUserDropsAPNsTokens(t *testing.T) { |
| + db := newTestDB(t) |
| + if err := db.createKeyInvite("k1", "alice", "w", 0); err != nil { |
| + t.Fatalf("createKeyInvite: %v", err) |
| + } |
| + tok, ok, err := db.claimKeyInvite("k1", "phone", "") |
| + if err != nil || !ok { |
| + t.Fatalf("claimKeyInvite: ok=%v err=%v", ok, err) |
| + } |
| + if err := db.addAPNsToken(APNsToken{APNsToken: "apns-alice", Token: tok}); err != nil { |
| + t.Fatalf("addAPNsToken: %v", err) |
| + } |
| + if toks, _ := db.listAPNsTokens(); len(toks) != 1 { |
| + t.Fatalf("precondition: want 1 apns token, got %d", len(toks)) |
| + } |
| + if err := db.removeUser("alice"); err != nil { |
| + t.Fatalf("removeUser: %v", err) |
| + } |
| + if toks, _ := db.listAPNsTokens(); len(toks) != 0 { |
| + t.Fatalf("removeUser left %d apns token(s)", len(toks)) |
| + } |
| +} |