security: encrypt worker-shared snapshots (Closes #1106)
This commit is contained in:
parent
8ad02933c9
commit
9d9b28af8e
17
README.md
17
README.md
|
|
@ -191,10 +191,14 @@ cannot alter a newer crash-recovery claim. Device purge cancels an active drain
|
||||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. All private SQLite stores enforce a
|
private outbox storage. Results are coordinated through a bounded SQLite ledger. All private SQLite stores enforce a
|
||||||
filesystem boundary independently of the service umask: the database directory is repaired to
|
filesystem boundary independently of the service umask: the database directory is repaired to
|
||||||
owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database
|
owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database
|
||||||
paths are rejected before access. Synchronized unfiled Draft collections add AES-256-GCM payload
|
paths are rejected before access. Worker-shared live and Find Work snapshots add AES-256-GCM
|
||||||
encryption with account and revision authentication; existing plaintext rows migrate on their first
|
envelopes authenticated to their store identity (and live generation), so copied databases do not
|
||||||
read without changing revision or order. Other private stores are not encrypted at the application
|
expose issue bodies, titles, notification metadata, or repository context. Existing plaintext
|
||||||
layer, so secure host access, encrypted volumes, and private backups are still required.
|
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.
|
||||||
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
|
||||||
|
|
@ -260,6 +264,11 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
|
||||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||||
|
# Required for worker-shared live and Find Work snapshots. Keep this key
|
||||||
|
# independent from the Draft key and inject the base64 encoding of exactly 32
|
||||||
|
# random bytes from a secret manager. Never commit it. Missing, malformed,
|
||||||
|
# wrong-key, or modified snapshot state fails closed without returning content.
|
||||||
|
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||||
# Required for cross-device unfiled Draft sync. The single-key setting remains
|
# Required for cross-device unfiled Draft sync. The single-key setting remains
|
||||||
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
|
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
|
||||||
# Inject the base64 encoding of exactly 32 random bytes from a secret manager.
|
# Inject the base64 encoding of exactly 32 random bytes from a secret manager.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
|
@ -10,6 +9,11 @@ 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,
|
||||||
|
PrivateStateEncryptionError,
|
||||||
|
private_state_encryption_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RefreshLeaseLost(RuntimeError):
|
class RefreshLeaseLost(RuntimeError):
|
||||||
|
|
@ -26,9 +30,13 @@ class AvailableIssueSnapshotState:
|
||||||
|
|
||||||
|
|
||||||
class AvailableIssueSnapshotStore:
|
class AvailableIssueSnapshotStore:
|
||||||
def __init__(self, path, *, clock=None):
|
def __init__(self, path, *, clock=None, encryption_key=None):
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
self.clock = clock or time.time
|
self.clock = clock or time.time
|
||||||
|
self._cipher = PrivateStateCipher(
|
||||||
|
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||||
|
store="available-issue-snapshot",
|
||||||
|
)
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.executescript(
|
connection.executescript(
|
||||||
"""
|
"""
|
||||||
|
|
@ -91,8 +99,21 @@ class AvailableIssueSnapshotStore:
|
||||||
"SELECT expires_at FROM available_issue_refresh_lease "
|
"SELECT expires_at FROM available_issue_refresh_lease "
|
||||||
"WHERE singleton = 1 AND expires_at > ?", (now,)
|
"WHERE singleton = 1 AND expires_at > ?", (now,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
items = None
|
||||||
|
if row["items_json"]:
|
||||||
|
items, legacy = self._cipher.open(row["items_json"])
|
||||||
|
if not isinstance(items, list):
|
||||||
|
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||||
|
if legacy:
|
||||||
|
migrated = self._cipher.seal(items)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE available_issue_snapshot SET items_json = ? "
|
||||||
|
"WHERE singleton = 1 AND items_json = ?",
|
||||||
|
(migrated, row["items_json"]),
|
||||||
|
)
|
||||||
return AvailableIssueSnapshotState(
|
return AvailableIssueSnapshotState(
|
||||||
items=json.loads(row["items_json"]) if row["items_json"] else None,
|
items=items,
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
retry_at=row["retry_at"],
|
retry_at=row["retry_at"],
|
||||||
refreshing=lease is not None,
|
refreshing=lease is not None,
|
||||||
|
|
@ -127,7 +148,7 @@ class AvailableIssueSnapshotStore:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, "
|
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, "
|
||||||
"retry_at = NULL WHERE singleton = 1",
|
"retry_at = NULL WHERE singleton = 1",
|
||||||
(json.dumps(items, separators=(",", ":")), now),
|
(self._cipher.seal(items), now),
|
||||||
)
|
)
|
||||||
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
|
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
@ -143,7 +164,7 @@ class AvailableIssueSnapshotStore:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
items = json.loads(row["items_json"]) if row["items_json"] else None
|
items = self._cipher.open(row["items_json"])[0] if row["items_json"] else None
|
||||||
if items is not None:
|
if items is not None:
|
||||||
items = [
|
items = [
|
||||||
item for item in items
|
item for item in items
|
||||||
|
|
@ -151,7 +172,7 @@ class AvailableIssueSnapshotStore:
|
||||||
]
|
]
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE available_issue_snapshot SET items_json = ? WHERE singleton = 1",
|
"UPDATE available_issue_snapshot SET items_json = ? WHERE singleton = 1",
|
||||||
(json.dumps(items, separators=(",", ":")),),
|
(self._cipher.seal(items),),
|
||||||
)
|
)
|
||||||
connection.commit()
|
connection.commit()
|
||||||
return self.load()
|
return self.load()
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ from pathlib import Path
|
||||||
from typing import Callable, Iterable
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
from src.private_state import connect_private_sqlite
|
from src.private_state import connect_private_sqlite
|
||||||
|
from src.state_encryption import (
|
||||||
|
PrivateStateCipher,
|
||||||
|
PrivateStateEncryptionError,
|
||||||
|
private_state_encryption_key,
|
||||||
|
)
|
||||||
|
|
||||||
SECTIONS = ("context", "events", "notifications")
|
SECTIONS = ("context", "events", "notifications")
|
||||||
|
|
||||||
|
|
@ -53,9 +58,14 @@ class LiveSnapshotStore:
|
||||||
path: str | os.PathLike[str],
|
path: str | os.PathLike[str],
|
||||||
*,
|
*,
|
||||||
clock: Callable[[], float] | None = None,
|
clock: Callable[[], float] | None = None,
|
||||||
|
encryption_key: bytes | None = None,
|
||||||
):
|
):
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
self.clock = clock or time.time
|
self.clock = clock or time.time
|
||||||
|
self._cipher = PrivateStateCipher(
|
||||||
|
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||||
|
store="live-snapshot",
|
||||||
|
)
|
||||||
self._initialize()
|
self._initialize()
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
|
@ -138,8 +148,23 @@ class LiveSnapshotStore:
|
||||||
(now,),
|
(now,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert row is not None
|
assert row is not None
|
||||||
|
value = None
|
||||||
|
if row["value_json"] is not None:
|
||||||
|
value, legacy = self._cipher.open(
|
||||||
|
row["value_json"], binding=row["generation"]
|
||||||
|
)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||||
|
if legacy:
|
||||||
|
migrated = self._cipher.seal(value, binding=row["generation"])
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE live_snapshot SET value_json = ? "
|
||||||
|
"WHERE singleton = 1 AND value_json = ?",
|
||||||
|
(migrated, row["value_json"]),
|
||||||
|
)
|
||||||
return LiveSnapshotState(
|
return LiveSnapshotState(
|
||||||
value=json.loads(row["value_json"]) if row["value_json"] is not None else None,
|
value=value,
|
||||||
created_at=json.loads(row["created_at_json"]),
|
created_at=json.loads(row["created_at_json"]),
|
||||||
failure_count=json.loads(row["failure_count_json"]),
|
failure_count=json.loads(row["failure_count_json"]),
|
||||||
retry_at=json.loads(row["retry_at_json"]),
|
retry_at=json.loads(row["retry_at_json"]),
|
||||||
|
|
@ -198,7 +223,7 @@ class LiveSnapshotStore:
|
||||||
connection.rollback()
|
connection.rollback()
|
||||||
raise RefreshLeaseLost("live refresh lease expired or changed owner")
|
raise RefreshLeaseLost("live refresh lease expired or changed owner")
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT revisions_json FROM live_snapshot WHERE singleton = 1"
|
"SELECT revisions_json, generation FROM live_snapshot WHERE singleton = 1"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
revisions = json.loads(row["revisions_json"])
|
revisions = json.loads(row["revisions_json"])
|
||||||
notifications = value.get("notifications")
|
notifications = value.get("notifications")
|
||||||
|
|
@ -232,7 +257,7 @@ class LiveSnapshotStore:
|
||||||
failure_count_json = ?, retry_at_json = ?, revisions_json = ?
|
failure_count_json = ?, retry_at_json = ?, revisions_json = ?
|
||||||
WHERE singleton = 1""",
|
WHERE singleton = 1""",
|
||||||
(
|
(
|
||||||
json.dumps(value, separators=(",", ":")),
|
self._cipher.seal(value, binding=row["generation"]),
|
||||||
json.dumps(created_at, separators=(",", ":")),
|
json.dumps(created_at, separators=(",", ":")),
|
||||||
json.dumps(failure_count, separators=(",", ":")),
|
json.dumps(failure_count, separators=(",", ":")),
|
||||||
json.dumps(retry_at, separators=(",", ":")),
|
json.dumps(retry_at, separators=(",", ":")),
|
||||||
|
|
@ -257,9 +282,11 @@ class LiveSnapshotStore:
|
||||||
((notification_id,) for notification_id in read_ids),
|
((notification_id,) for notification_id in read_ids),
|
||||||
)
|
)
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT value_json, revisions_json FROM live_snapshot WHERE singleton = 1"
|
"SELECT value_json, revisions_json, generation FROM live_snapshot WHERE singleton = 1"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
value = json.loads(row["value_json"]) if row["value_json"] else None
|
value = self._cipher.open(
|
||||||
|
row["value_json"], binding=row["generation"]
|
||||||
|
)[0] if row["value_json"] else None
|
||||||
revisions = json.loads(row["revisions_json"])
|
revisions = json.loads(row["revisions_json"])
|
||||||
if value is not None and isinstance(value.get("notifications"), list):
|
if value is not None and isinstance(value.get("notifications"), list):
|
||||||
previous = value["notifications"]
|
previous = value["notifications"]
|
||||||
|
|
@ -274,7 +301,7 @@ class LiveSnapshotStore:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
|
"UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
|
||||||
(
|
(
|
||||||
json.dumps(value, separators=(",", ":")),
|
self._cipher.seal(value, binding=row["generation"]),
|
||||||
json.dumps(revisions, separators=(",", ":")),
|
json.dumps(revisions, separators=(",", ":")),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
89
src/state_encryption.py
Normal file
89
src/state_encryption.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""Authenticated envelopes for retained private dashboard state."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateStateEncryptionError(RuntimeError):
|
||||||
|
"""Private retained state could not be authenticated or decrypted."""
|
||||||
|
|
||||||
|
|
||||||
|
def decode_private_state_encryption_key(encoded: str) -> bytes:
|
||||||
|
"""Decode the independently injected 256-bit private-state key."""
|
||||||
|
try:
|
||||||
|
key = base64.b64decode(encoded, validate=True)
|
||||||
|
except (binascii.Error, ValueError) as error:
|
||||||
|
raise PrivateStateEncryptionError(
|
||||||
|
"private state encryption key is invalid"
|
||||||
|
) from error
|
||||||
|
if len(key) != 32:
|
||||||
|
raise PrivateStateEncryptionError(
|
||||||
|
"private state encryption key must decode to exactly 32 bytes"
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def private_state_encryption_key() -> bytes:
|
||||||
|
return decode_private_state_encryption_key(
|
||||||
|
os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateStateCipher:
|
||||||
|
"""Seal JSON values with store-specific authenticated context."""
|
||||||
|
|
||||||
|
def __init__(self, key: bytes, *, store: str):
|
||||||
|
if not isinstance(key, bytes) or len(key) != 32:
|
||||||
|
raise PrivateStateEncryptionError(
|
||||||
|
"private state encryption requires exactly 32 key bytes"
|
||||||
|
)
|
||||||
|
if not store or "\0" in store:
|
||||||
|
raise ValueError("private state store identity is invalid")
|
||||||
|
self._cipher = AESGCM(key)
|
||||||
|
self._store = store
|
||||||
|
|
||||||
|
def _aad(self, binding: str) -> bytes:
|
||||||
|
return f"stackchain:private-state:v1\0{self._store}\0{binding}".encode()
|
||||||
|
|
||||||
|
def seal(self, value: object, *, binding: str = "singleton") -> str:
|
||||||
|
plaintext = json.dumps(value, separators=(",", ":")).encode()
|
||||||
|
nonce = os.urandom(12)
|
||||||
|
sealed = nonce + self._cipher.encrypt(nonce, plaintext, self._aad(binding))
|
||||||
|
return "v1:" + base64.urlsafe_b64encode(sealed).decode()
|
||||||
|
|
||||||
|
def open(self, payload: str, *, binding: str = "singleton") -> tuple[object, bool]:
|
||||||
|
"""Return the decoded value and whether plaintext migration is required."""
|
||||||
|
if not isinstance(payload, str):
|
||||||
|
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||||
|
if not payload.startswith("v1:"):
|
||||||
|
try:
|
||||||
|
return json.loads(payload), True
|
||||||
|
except (TypeError, json.JSONDecodeError) as error:
|
||||||
|
raise PrivateStateEncryptionError(
|
||||||
|
"private state could not be decrypted"
|
||||||
|
) from error
|
||||||
|
try:
|
||||||
|
sealed = base64.b64decode(payload[3:], altchars=b"-_", validate=True)
|
||||||
|
if len(sealed) < 28:
|
||||||
|
raise ValueError("encrypted payload is too short")
|
||||||
|
plaintext = self._cipher.decrypt(
|
||||||
|
sealed[:12], sealed[12:], self._aad(binding)
|
||||||
|
)
|
||||||
|
return json.loads(plaintext), False
|
||||||
|
except (
|
||||||
|
binascii.Error,
|
||||||
|
InvalidTag,
|
||||||
|
UnicodeDecodeError,
|
||||||
|
ValueError,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
) as error:
|
||||||
|
raise PrivateStateEncryptionError(
|
||||||
|
"private state could not be decrypted"
|
||||||
|
) from error
|
||||||
9
tests/conftest.py
Normal file
9
tests/conftest.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""Test-only secret injection for encrypted private snapshot stores."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
os.environ.setdefault(
|
||||||
|
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
|
||||||
|
"c3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3M=",
|
||||||
|
)
|
||||||
|
|
@ -1,7 +1,78 @@
|
||||||
import threading
|
import threading
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
||||||
|
from src.state_encryption import PrivateStateEncryptionError
|
||||||
|
|
||||||
|
|
||||||
|
PRIVATE_KEY = b"a" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_published_find_work_catalog_is_encrypted_at_rest_and_survives_restart(tmp_path):
|
||||||
|
path = tmp_path / "available.sqlite3"
|
||||||
|
store = AvailableIssueSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
|
||||||
|
owner = store.try_acquire_refresh(lease_seconds=5)
|
||||||
|
canary = "private-find-work-body-canary"
|
||||||
|
|
||||||
|
published = store.publish(
|
||||||
|
owner,
|
||||||
|
items=[{"repository": "stackchain/api", "number": 7, "body": canary}],
|
||||||
|
)
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert payload.startswith("v1:")
|
||||||
|
assert canary not in payload
|
||||||
|
assert AvailableIssueSnapshotStore(
|
||||||
|
path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY
|
||||||
|
).load().items == published.items
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_work_catalog_lazily_migrates_plaintext_without_changing_freshness(tmp_path):
|
||||||
|
path = tmp_path / "available.sqlite3"
|
||||||
|
store = AvailableIssueSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, retry_at = ?",
|
||||||
|
('[{"repository":"stackchain/api","number":7}]', 91.0, 105.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
state = store.load()
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
migrated = connection.execute(
|
||||||
|
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert state.items == [{"repository": "stackchain/api", "number": 7}]
|
||||||
|
assert (state.created_at, state.retry_at) == (91.0, 105.0)
|
||||||
|
assert migrated.startswith("v1:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_work_ciphertext_cannot_be_substituted_into_live_snapshot(tmp_path):
|
||||||
|
available_path = tmp_path / "available.sqlite3"
|
||||||
|
available = AvailableIssueSnapshotStore(
|
||||||
|
available_path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY
|
||||||
|
)
|
||||||
|
owner = available.try_acquire_refresh(lease_seconds=5)
|
||||||
|
available.publish(owner, items=[{"body": "secret"}])
|
||||||
|
with sqlite3.connect(available_path) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
live_path = tmp_path / "live.sqlite3"
|
||||||
|
from src.live_snapshot_store import LiveSnapshotStore
|
||||||
|
live = LiveSnapshotStore(live_path, encryption_key=PRIVATE_KEY)
|
||||||
|
with sqlite3.connect(live_path) as connection:
|
||||||
|
connection.execute("UPDATE live_snapshot SET value_json = ?", (payload,))
|
||||||
|
|
||||||
|
with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
|
||||||
|
live.load()
|
||||||
|
|
||||||
|
|
||||||
def test_independent_workers_allow_only_one_catalog_refresh(tmp_path):
|
def test_independent_workers_allow_only_one_catalog_refresh(tmp_path):
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,74 @@ import pytest
|
||||||
|
|
||||||
from src import live_snapshot_store
|
from src import live_snapshot_store
|
||||||
from src.live_snapshot_store import LiveSnapshotStore, RefreshLeaseLost
|
from src.live_snapshot_store import LiveSnapshotStore, RefreshLeaseLost
|
||||||
|
from src.state_encryption import PrivateStateEncryptionError
|
||||||
|
|
||||||
|
|
||||||
|
PRIVATE_KEY = b"l" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_published_live_snapshot_is_encrypted_at_rest_and_survives_restart(tmp_path):
|
||||||
|
path = tmp_path / "live.sqlite3"
|
||||||
|
store = LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
|
||||||
|
lease = store.try_acquire_refresh({"context"}, lease_seconds=5)
|
||||||
|
canary = "private-live-title-canary"
|
||||||
|
|
||||||
|
published = store.publish_refresh(
|
||||||
|
lease,
|
||||||
|
value={"context": {"title": canary}, "events": [], "notifications": []},
|
||||||
|
created_at={section: 100.0 for section in live_snapshot_store.SECTIONS},
|
||||||
|
failure_count={section: 0 for section in live_snapshot_store.SECTIONS},
|
||||||
|
retry_at={section: None for section in live_snapshot_store.SECTIONS},
|
||||||
|
changed_sections={"context"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT value_json FROM live_snapshot WHERE singleton = 1"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert payload.startswith("v1:")
|
||||||
|
assert canary not in payload
|
||||||
|
assert LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY).load().value == published.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_snapshot_lazily_migrates_plaintext_without_changing_metadata(tmp_path):
|
||||||
|
path = tmp_path / "live.sqlite3"
|
||||||
|
store = LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
|
||||||
|
legacy = {"context": {"title": "legacy"}, "events": [], "notifications": []}
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
|
||||||
|
('{"context":{"title":"legacy"},"events":[],"notifications":[]}',
|
||||||
|
'{"context":4,"events":2,"notifications":1}'),
|
||||||
|
)
|
||||||
|
|
||||||
|
state = store.load()
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
migrated = connection.execute(
|
||||||
|
"SELECT value_json FROM live_snapshot WHERE singleton = 1"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert state.value == legacy
|
||||||
|
assert state.revisions == {"context": 4, "events": 2, "notifications": 1}
|
||||||
|
assert migrated.startswith("v1:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_snapshot_authentication_failure_returns_no_private_content(tmp_path):
|
||||||
|
path = tmp_path / "live.sqlite3"
|
||||||
|
store = LiveSnapshotStore(path, clock=lambda: 100.0, encryption_key=PRIVATE_KEY)
|
||||||
|
lease = store.try_acquire_refresh({"context"}, lease_seconds=5)
|
||||||
|
metadata = {section: None for section in live_snapshot_store.SECTIONS}
|
||||||
|
store.publish_refresh(
|
||||||
|
lease,
|
||||||
|
value={"context": {"title": "secret"}},
|
||||||
|
created_at=metadata,
|
||||||
|
failure_count={section: 0 for section in metadata},
|
||||||
|
retry_at=metadata,
|
||||||
|
changed_sections={"context"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
|
||||||
|
LiveSnapshotStore(path, encryption_key=b"x" * 32).load()
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_load_does_not_retrieve_or_decode_snapshot_value(tmp_path):
|
def test_metadata_load_does_not_retrieve_or_decode_snapshot_value(tmp_path):
|
||||||
|
|
@ -24,7 +92,7 @@ def test_metadata_load_does_not_retrieve_or_decode_snapshot_value(tmp_path):
|
||||||
assert metadata.revisions == {
|
assert metadata.revisions == {
|
||||||
section: 0 for section in ("context", "events", "notifications")
|
section: 0 for section in ("context", "events", "notifications")
|
||||||
}
|
}
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(PrivateStateEncryptionError):
|
||||||
store.load()
|
store.load()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user