security: encrypt Web Push subscriptions at rest (Closes #1108)
This commit is contained in:
parent
fb08bc5ce5
commit
67d3242f37
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
|
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
|
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
|
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
|
are not encrypted at the application layer. Web Push subscriptions use a third, independent
|
||||||
backups are still required.
|
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
|
Set `STACKCHAIN_STATE_DIR` to a
|
||||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
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
|
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_PUBLIC_KEY='<url-safe-public-key>'
|
||||||
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
|
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
|
||||||
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
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,
|
# Optional; defaults to a 30-second poll, 10-second endpoint deadline,
|
||||||
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
|
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
|
||||||
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
|
# 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,
|
dispatch_unread_updates,
|
||||||
)
|
)
|
||||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
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.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||||
from src.unfiled_draft_store import (
|
from src.unfiled_draft_store import (
|
||||||
|
|
@ -353,8 +353,16 @@ _available_issue_snapshot_store = AvailableIssueSnapshotStore(
|
||||||
str(_state_dir / "available-issue-snapshot.sqlite3"),
|
str(_state_dir / "available-issue-snapshot.sqlite3"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
_push_subscription_store = PushSubscriptionStore(
|
_push_subscription_store = build_push_subscription_store(
|
||||||
os.getenv("STACKCHAIN_PUSH_DB", str(_state_dir / "push-subscriptions.sqlite3"))
|
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 sqlite3
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterable, Mapping
|
from collections.abc import Iterable, Mapping
|
||||||
|
|
@ -6,6 +10,10 @@ from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.private_state import connect_private_sqlite
|
from src.private_state import connect_private_sqlite
|
||||||
|
from src.state_encryption import (
|
||||||
|
PrivateStateCipher,
|
||||||
|
decode_private_state_encryption_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -35,6 +43,74 @@ class DeadlineReminderDevice:
|
||||||
snoozed_until: float | None
|
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(
|
def _revisions(
|
||||||
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
|
||||||
) -> tuple[tuple[int, str], ...]:
|
) -> tuple[tuple[int, str], ...]:
|
||||||
|
|
@ -58,8 +134,15 @@ def _revisions(
|
||||||
class PushSubscriptionStore:
|
class PushSubscriptionStore:
|
||||||
"""Durable, device-bound Web Push subscriptions and delivery deduplication."""
|
"""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)
|
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:
|
with self._connect() as connection:
|
||||||
connection.executescript(
|
connection.executescript(
|
||||||
"""
|
"""
|
||||||
|
|
@ -147,6 +230,31 @@ class PushSubscriptionStore:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"ALTER TABLE push_deadline_preferences ADD COLUMN snoozed_until REAL"
|
"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):
|
def _connect(self):
|
||||||
connection = connect_private_sqlite(self.path, timeout=2)
|
connection = connect_private_sqlite(self.path, timeout=2)
|
||||||
connection.execute("PRAGMA foreign_keys = ON")
|
connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
@ -182,13 +290,16 @@ class PushSubscriptionStore:
|
||||||
|
|
||||||
def upsert(self, session_id: str, subscription: dict) -> None:
|
def upsert(self, session_id: str, subscription: dict) -> None:
|
||||||
endpoint = subscription["endpoint"]
|
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:
|
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("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO push_subscriptions(session_id, endpoint, subscription_json) VALUES (?, ?, ?)",
|
"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:
|
def delete_session(self, session_id: str) -> None:
|
||||||
|
|
@ -251,7 +362,13 @@ class PushSubscriptionStore:
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [
|
return [
|
||||||
DeadlineReminderDevice(
|
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
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
@ -346,13 +463,19 @@ class PushSubscriptionStore:
|
||||||
deliveries.append(
|
deliveries.append(
|
||||||
PushDelivery(
|
PushDelivery(
|
||||||
session_id,
|
session_id,
|
||||||
json.loads(encoded),
|
self._open_subscription(session_id, encoded),
|
||||||
unseen,
|
unseen,
|
||||||
tuple(digest_revisions),
|
tuple(digest_revisions),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return deliveries
|
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:
|
def reconcile_unread(self, thread_ids: Iterable[int]) -> None:
|
||||||
"""Prune per-device checkpoints that are absent from a complete snapshot."""
|
"""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}))
|
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
|
import os
|
||||||
|
|
||||||
|
|
@ -6,4 +6,8 @@ import os
|
||||||
os.environ.setdefault(
|
os.environ.setdefault(
|
||||||
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
|
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
|
||||||
"c3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3M=",
|
"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,
|
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||||
delivered_local_day TEXT
|
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);
|
INSERT INTO push_deadline_preferences VALUES ('device-a', 1, 'UTC', 8, NULL);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from src.push_notifications import (
|
||||||
dispatch_unread_updates,
|
dispatch_unread_updates,
|
||||||
send_web_push,
|
send_web_push,
|
||||||
)
|
)
|
||||||
|
from src import push_subscription_store as push_store_module
|
||||||
from src.push_subscription_store import PushSubscriptionStore
|
from src.push_subscription_store import PushSubscriptionStore
|
||||||
from src.push_endpoint_policy import UnsafePushEndpoint
|
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
|
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):
|
def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path):
|
||||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||||
subscription = {
|
subscription = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user