security: encrypt Web Push subscriptions at rest (Closes #1108)
All checks were successful
CI / lint (pull_request) Successful in 2m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m47s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-19 02:34:32 +00:00
parent fb08bc5ce5
commit 67d3242f37
6 changed files with 289 additions and 14 deletions

View File

@ -197,8 +197,11 @@ expose issue bodies, titles, notification metadata, or repository context. Exist
snapshot rows migrate on their first read without changing freshness, revisions, ordering, or claim
filters. Synchronized unfiled Draft collections use a separate AES-256-GCM key and authenticate the
account and revision; existing plaintext rows likewise migrate on first read. Other private stores
are not encrypted at the application layer, so secure host access, encrypted volumes, and private
backups are still required.
are not encrypted at the application layer. Web Push subscriptions use a third, independent
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
single-device enrollment without retaining capability URLs. Existing plaintext subscriptions
migrate atomically at startup without resetting delivery checkpoints or reminder schedules. Secure
host access, encrypted volumes, and private backups are still required.
Set `STACKCHAIN_STATE_DIR` to a
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
@ -303,6 +306,11 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
# Required whenever all three VAPID settings enable Web Push. Keep this key
# independent from the snapshot and Draft keys. Startup authenticates every retained
# subscription and fails closed for a missing, malformed, wrong, or modified key.
# Back up the key separately: losing it makes existing device enrollments unrecoverable.
export STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
# Optional; defaults to a 30-second poll, 10-second endpoint deadline,
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered

View File

@ -57,7 +57,7 @@ from src.push_notifications import (
dispatch_unread_updates,
)
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
from src.push_subscription_store import PushSubscriptionStore
from src.push_subscription_store import build_push_subscription_store
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
from src.unfiled_draft_store import (
@ -353,8 +353,16 @@ _available_issue_snapshot_store = AvailableIssueSnapshotStore(
str(_state_dir / "available-issue-snapshot.sqlite3"),
)
)
_push_subscription_store = PushSubscriptionStore(
os.getenv("STACKCHAIN_PUSH_DB", str(_state_dir / "push-subscriptions.sqlite3"))
_push_subscription_store = build_push_subscription_store(
os.getenv("STACKCHAIN_PUSH_DB", str(_state_dir / "push-subscriptions.sqlite3")),
push_enabled=all(
os.getenv(name, "").strip()
for name in (
"STACKCHAIN_VAPID_PUBLIC_KEY",
"STACKCHAIN_VAPID_PRIVATE_KEY",
"STACKCHAIN_VAPID_SUBJECT",
)
),
)

View File

@ -1,4 +1,8 @@
import json
from __future__ import annotations
import hashlib
import hmac
import os
import sqlite3
import time
from collections.abc import Iterable, Mapping
@ -6,6 +10,10 @@ from dataclasses import dataclass
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import (
PrivateStateCipher,
decode_private_state_encryption_key,
)
@dataclass(frozen=True)
@ -35,6 +43,74 @@ class DeadlineReminderDevice:
snoozed_until: float | None
class DisabledPushSubscriptionStore:
"""No-persistence store used when Web Push is not configured."""
def is_subscribed(self, session_id: str) -> bool:
return False
def deadline_preferences(
self, session_id: str, *, now: float | None = None
) -> dict:
return {
"enabled": False,
"timezone": "UTC",
"reminder_hour": 9,
"reminder_days": 2,
"snoozed_until": None,
}
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
return []
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
return []
def acquire_dispatch_lease(self, *args, **kwargs) -> bool:
return False
def release_dispatch_lease(self, *args, **kwargs) -> bool:
return False
def snooze_deadline_reminder(self, *args, **kwargs) -> bool:
return False
def upsert(self, *args, **kwargs) -> None:
raise RuntimeError("push notifications are not configured")
def delete_session(self, *args, **kwargs) -> None:
return None
def delete_all(self, *args, **kwargs) -> None:
return None
def set_deadline_preferences(self, *args, **kwargs) -> None:
return None
def clear_deadline_snooze(self, *args, **kwargs) -> None:
return None
def mark_deadline_reminder_delivered(self, *args, **kwargs) -> None:
return None
def reconcile_unread(self, *args, **kwargs) -> None:
return None
def mark_digest_pending(self, *args, **kwargs) -> None:
return None
def mark_delivered(self, *args, **kwargs) -> None:
return None
def build_push_subscription_store(
path: str | Path, *, push_enabled: bool
) -> PushSubscriptionStore | DisabledPushSubscriptionStore:
if not push_enabled:
return DisabledPushSubscriptionStore()
return PushSubscriptionStore(path)
def _revisions(
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
) -> tuple[tuple[int, str], ...]:
@ -58,8 +134,15 @@ def _revisions(
class PushSubscriptionStore:
"""Durable, device-bound Web Push subscriptions and delivery deduplication."""
def __init__(self, path: str | Path):
def __init__(self, path: str | Path, *, encryption_key: bytes | None = None):
self.path = Path(path)
key = encryption_key
if key is None:
key = decode_private_state_encryption_key(
os.getenv("STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY", "")
)
self._encryption_key = key
self._cipher = PrivateStateCipher(key, store="push-subscriptions")
with self._connect() as connection:
connection.executescript(
"""
@ -147,6 +230,31 @@ class PushSubscriptionStore:
connection.execute(
"ALTER TABLE push_deadline_preferences ADD COLUMN snoozed_until REAL"
)
rows = connection.execute(
"SELECT session_id, endpoint, subscription_json FROM push_subscriptions"
).fetchall()
for session_id, endpoint, payload in rows:
subscription, plaintext = self._cipher.open(payload, binding=session_id)
if not isinstance(subscription, dict) or not subscription.get("endpoint"):
raise ValueError("push subscription payload is invalid")
endpoint_index = self._endpoint_index(subscription["endpoint"])
if plaintext or endpoint != endpoint_index:
connection.execute(
"UPDATE push_subscriptions SET endpoint = ?, subscription_json = ? WHERE session_id = ?",
(
endpoint_index,
self._cipher.seal(subscription, binding=session_id),
session_id,
),
)
def _endpoint_index(self, endpoint: str) -> str:
return hmac.new(
self._encryption_key,
b"stackchain:push-endpoint:v1\0" + endpoint.encode(),
hashlib.sha256,
).hexdigest()
def _connect(self):
connection = connect_private_sqlite(self.path, timeout=2)
connection.execute("PRAGMA foreign_keys = ON")
@ -182,13 +290,16 @@ class PushSubscriptionStore:
def upsert(self, session_id: str, subscription: dict) -> None:
endpoint = subscription["endpoint"]
encoded = json.dumps(subscription, separators=(",", ":"), sort_keys=True)
endpoint_index = self._endpoint_index(endpoint)
encoded = self._cipher.seal(subscription, binding=session_id)
with self._connect() as connection:
connection.execute("DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,))
connection.execute(
"DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint_index,)
)
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
connection.execute(
"INSERT INTO push_subscriptions(session_id, endpoint, subscription_json) VALUES (?, ?, ?)",
(session_id, endpoint, encoded),
(session_id, endpoint_index, encoded),
)
def delete_session(self, session_id: str) -> None:
@ -251,7 +362,13 @@ class PushSubscriptionStore:
).fetchall()
return [
DeadlineReminderDevice(
row[0], json.loads(row[1]), row[2], row[3], row[4], row[5], row[6]
row[0],
self._open_subscription(row[0], row[1]),
row[2],
row[3],
row[4],
row[5],
row[6],
)
for row in rows
]
@ -346,13 +463,19 @@ class PushSubscriptionStore:
deliveries.append(
PushDelivery(
session_id,
json.loads(encoded),
self._open_subscription(session_id, encoded),
unseen,
tuple(digest_revisions),
)
)
return deliveries
def _open_subscription(self, session_id: str, payload: str) -> dict:
subscription, _plaintext = self._cipher.open(payload, binding=session_id)
if not isinstance(subscription, dict):
raise ValueError("push subscription payload is invalid")
return subscription
def reconcile_unread(self, thread_ids: Iterable[int]) -> None:
"""Prune per-device checkpoints that are absent from a complete snapshot."""
unread_ids = tuple(sorted({int(value) for value in thread_ids if int(value) > 0}))

View File

@ -1,4 +1,4 @@
"""Test-only secret injection for encrypted private snapshot stores."""
"""Test-only secret injection for encrypted private state stores."""
import os
@ -7,3 +7,7 @@ os.environ.setdefault(
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
"c3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3M=",
)
os.environ.setdefault(
"STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY",
"cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHA=",
)

View File

@ -392,7 +392,11 @@ def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
reminder_hour INTEGER NOT NULL DEFAULT 9,
delivered_local_day TEXT
);
INSERT INTO push_subscriptions VALUES ('device-a', 'https://push.example/a', '{}');
INSERT INTO push_subscriptions VALUES (
'device-a',
'https://push.example/a',
'{"endpoint":"https://push.example/a","keys":{"p256dh":"key","auth":"secret"}}'
);
INSERT INTO push_deadline_preferences VALUES ('device-a', 1, 'UTC', 8, NULL);
""")

View File

@ -15,6 +15,7 @@ from src.push_notifications import (
dispatch_unread_updates,
send_web_push,
)
from src import push_subscription_store as push_store_module
from src.push_subscription_store import PushSubscriptionStore
from src.push_endpoint_policy import UnsafePushEndpoint
@ -53,6 +54,133 @@ def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
assert database.stat().st_mode & 0o777 == 0o600
def test_subscription_store_encrypts_device_credentials_and_reopens_with_same_key(tmp_path):
database = tmp_path / "push.sqlite3"
key = b"p" * 32
subscription = {
"endpoint": "https://push.example/private-device-canary",
"keys": {"p256dh": "public-key-canary", "auth": "auth-secret-canary"},
}
PushSubscriptionStore(database, encryption_key=key).upsert("session-a", subscription)
persisted = database.read_bytes()
assert b"private-device-canary" not in persisted
assert b"public-key-canary" not in persisted
assert b"auth-secret-canary" not in persisted
reopened = PushSubscriptionStore(database, encryption_key=key)
assert reopened.claim_unseen({42: "r1"})[0].subscription == subscription
def test_subscription_store_migrates_legacy_plaintext_without_resetting_checkpoints(tmp_path):
database = tmp_path / "push.sqlite3"
subscription = {
"endpoint": "https://push.example/legacy-device-canary",
"keys": {"p256dh": "legacy-public-canary", "auth": "legacy-auth-canary"},
}
with sqlite3.connect(database) as connection:
connection.executescript(
"""
CREATE TABLE push_subscriptions (
session_id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
subscription_json TEXT NOT NULL
);
CREATE TABLE push_deliveries (
session_id TEXT NOT NULL,
thread_id INTEGER NOT NULL,
revision TEXT NOT NULL DEFAULT '',
PRIMARY KEY (session_id, thread_id)
);
"""
)
connection.execute(
"INSERT INTO push_subscriptions VALUES (?, ?, ?)",
("session-a", subscription["endpoint"], json.dumps(subscription)),
)
connection.execute(
"INSERT INTO push_deliveries VALUES (?, ?, ?)",
("session-a", 42, "r1"),
)
store = PushSubscriptionStore(database, encryption_key=b"m" * 32)
assert store.claim_unseen({42: "r1"}) == []
updated = store.claim_unseen({42: "r2"})
assert updated[0].subscription == subscription
persisted = database.read_bytes()
assert b"legacy-device-canary" not in persisted
assert b"legacy-public-canary" not in persisted
assert b"legacy-auth-canary" not in persisted
def test_push_store_factory_only_requires_its_key_when_push_is_enabled(
tmp_path, monkeypatch
):
monkeypatch.delenv("STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY", raising=False)
factory = getattr(push_store_module, "build_push_subscription_store", None)
assert callable(factory), "push store factory is missing"
disabled = factory(tmp_path / "disabled.sqlite3", push_enabled=False)
assert disabled.is_subscribed("session-a") is False
assert disabled.deadline_preferences("session-a")["enabled"] is False
assert not (tmp_path / "disabled.sqlite3").exists()
with pytest.raises(RuntimeError, match="encryption key"):
factory(tmp_path / "enabled.sqlite3", push_enabled=True)
def test_subscription_store_fails_closed_for_wrong_key_or_tampered_payload(tmp_path):
database = tmp_path / "push.sqlite3"
subscription = {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
}
PushSubscriptionStore(database, encryption_key=b"a" * 32).upsert(
"session-a", subscription
)
with pytest.raises(RuntimeError, match="could not be decrypted"):
PushSubscriptionStore(database, encryption_key=b"b" * 32)
with sqlite3.connect(database) as connection:
payload = connection.execute(
"SELECT subscription_json FROM push_subscriptions WHERE session_id = ?",
("session-a",),
).fetchone()[0]
replacement = "A" if payload[-1] != "A" else "B"
connection.execute(
"UPDATE push_subscriptions SET subscription_json = ? WHERE session_id = ?",
(payload[:-1] + replacement, "session-a"),
)
with pytest.raises(RuntimeError, match="could not be decrypted"):
PushSubscriptionStore(database, encryption_key=b"a" * 32)
def test_subscription_ciphertext_cannot_be_substituted_between_sessions(tmp_path):
database = tmp_path / "push.sqlite3"
key = b"s" * 32
store = PushSubscriptionStore(database, encryption_key=key)
for session_id in ("session-a", "session-b"):
store.upsert(
session_id,
{
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": f"key-{session_id}", "auth": f"auth-{session_id}"},
},
)
with sqlite3.connect(database) as connection:
payloads = connection.execute(
"SELECT session_id, subscription_json FROM push_subscriptions ORDER BY session_id"
).fetchall()
connection.executemany(
"UPDATE push_subscriptions SET subscription_json = ? WHERE session_id = ?",
((payloads[1][1], payloads[0][0]), (payloads[0][1], payloads[1][0])),
)
with pytest.raises(RuntimeError, match="could not be decrypted"):
PushSubscriptionStore(database, encryption_key=key)
def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
subscription = {