diff --git a/README.md b/README.md index 4424516..b860904 100644 --- a/README.md +++ b/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='' export STACKCHAIN_VAPID_PRIVATE_KEY='' 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='' # 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 diff --git a/src/main.py b/src/main.py index 2b92202..fc8e3f0 100644 --- a/src/main.py +++ b/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", + ) + ), ) diff --git a/src/push_subscription_store.py b/src/push_subscription_store.py index c273baf..9913f95 100644 --- a/src/push_subscription_store.py +++ b/src/push_subscription_store.py @@ -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})) diff --git a/tests/conftest.py b/tests/conftest.py index 3a00516..afce251 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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=", ) \ No newline at end of file diff --git a/tests/test_deadline_reminders.py b/tests/test_deadline_reminders.py index 4dadb65..9ac339b 100644 --- a/tests/test_deadline_reminders.py +++ b/tests/test_deadline_reminders.py @@ -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); """) diff --git a/tests/test_push_notifications.py b/tests/test_push_notifications.py index 20728dd..d8ea486 100644 --- a/tests/test_push_notifications.py +++ b/tests/test_push_notifications.py @@ -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 = {