security: encrypt Web Push subscriptions at rest (Closes #1108)
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 committed 2026-08-19 02:34:32 +00:00
1 parent fb08bc5ce5
commit 67d3242f37
6 files changed
+289 -14

No files matched your search

+5 -1
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
@@ -6,4 +6,8 @@ import os
os.environ.setdefault(
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
"c3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3M=",
)
os.environ.setdefault(
"STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY",
"cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHA=",
)
+5 -1
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);
""")
+128
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 = {