Encrypt Web Push subscriptions at rest #1109
12
README.md
12
README.md
|
|
@ -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
|
||||
|
|
|
|||
14
src/main.py
14
src/main.py
|
|
@ -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",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Test-only secret injection for encrypted private snapshot stores."""
|
||||
"""Test-only secret injection for encrypted private state stores."""
|
||||
|
||||
import os
|
||||
|
||||
|
|
@ -6,4 +6,8 @@ import os
|
|||
os.environ.setdefault(
|
||||
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
|
||||
"c3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3M=",
|
||||
)
|
||||
os.environ.setdefault(
|
||||
"STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY",
|
||||
"cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHA=",
|
||||
)
|
||||
|
|
@ -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);
|
||||
""")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user