security: encrypt Web Push subscriptions at rest (Closes #1108)
This commit is contained in:
1 parent
fb08bc5ce5
commit
67d3242f37
6 files changed
+289
-14
No files matched your search
+5
-1
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user