eleven / messenger Public

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>
Paul Campbell a3d8e5351906295bd40d6d17f0eafe494a38841f parent e5f8792
5 files changed, +207 −31
  • apns.go +16 −10
  • db.go +62 −4
  • handlers_test.go +3 −1
  • push.go +22 −16
  • push_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))
+ }
+}