security: encrypt worker-shared snapshots (Closes #1106)
CI / lint (pull_request) Successful in 2m48s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m55s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-19 01:58:22 +00:00
1 parent 8ad02933c9
commit 9d9b28af8e
7 files changed
+311 -17

No files matched your search

+27 -6
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import json
import secrets
import sqlite3
import time
@@ -10,6 +9,11 @@ from dataclasses import dataclass
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import (
PrivateStateCipher,
PrivateStateEncryptionError,
private_state_encryption_key,
)
class RefreshLeaseLost(RuntimeError):
@@ -26,9 +30,13 @@ class AvailableIssueSnapshotState:
class AvailableIssueSnapshotStore:
def __init__(self, path, *, clock=None):
def __init__(self, path, *, clock=None, encryption_key=None):
self.path = Path(path)
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:
connection.executescript(
"""
@@ -91,8 +99,21 @@ class AvailableIssueSnapshotStore:
"SELECT expires_at FROM available_issue_refresh_lease "
"WHERE singleton = 1 AND expires_at > ?", (now,)
).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(
items=json.loads(row["items_json"]) if row["items_json"] else None,
items=items,
created_at=row["created_at"],
retry_at=row["retry_at"],
refreshing=lease is not None,
@@ -127,7 +148,7 @@ class AvailableIssueSnapshotStore:
connection.execute(
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, "
"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.commit()
@@ -143,7 +164,7 @@ class AvailableIssueSnapshotStore:
row = connection.execute(
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
).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:
items = [
item for item in items
@@ -151,7 +172,7 @@ class AvailableIssueSnapshotStore:
]
connection.execute(
"UPDATE available_issue_snapshot SET items_json = ? WHERE singleton = 1",
(json.dumps(items, separators=(",", ":")),),
(self._cipher.seal(items),),
)
connection.commit()
return self.load()
+33 -6
View File
@@ -12,6 +12,11 @@ from pathlib import Path
from typing import Callable, Iterable
from src.private_state import connect_private_sqlite
from src.state_encryption import (
PrivateStateCipher,
PrivateStateEncryptionError,
private_state_encryption_key,
)
SECTIONS = ("context", "events", "notifications")
@@ -53,9 +58,14 @@ class LiveSnapshotStore:
path: str | os.PathLike[str],
*,
clock: Callable[[], float] | None = None,
encryption_key: bytes | None = None,
):
self.path = Path(path)
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()
def _connect(self) -> sqlite3.Connection:
@@ -138,8 +148,23 @@ class LiveSnapshotStore:
(now,),
).fetchone()
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(
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"]),
failure_count=json.loads(row["failure_count_json"]),
retry_at=json.loads(row["retry_at_json"]),
@@ -198,7 +223,7 @@ class LiveSnapshotStore:
connection.rollback()
raise RefreshLeaseLost("live refresh lease expired or changed owner")
row = connection.execute(
"SELECT revisions_json FROM live_snapshot WHERE singleton = 1"
"SELECT revisions_json, generation FROM live_snapshot WHERE singleton = 1"
).fetchone()
revisions = json.loads(row["revisions_json"])
notifications = value.get("notifications")
@@ -232,7 +257,7 @@ class LiveSnapshotStore:
failure_count_json = ?, retry_at_json = ?, revisions_json = ?
WHERE singleton = 1""",
(
json.dumps(value, separators=(",", ":")),
self._cipher.seal(value, binding=row["generation"]),
json.dumps(created_at, separators=(",", ":")),
json.dumps(failure_count, separators=(",", ":")),
json.dumps(retry_at, separators=(",", ":")),
@@ -257,9 +282,11 @@ class LiveSnapshotStore:
((notification_id,) for notification_id in read_ids),
)
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()
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"])
if value is not None and isinstance(value.get("notifications"), list):
previous = value["notifications"]
@@ -274,7 +301,7 @@ class LiveSnapshotStore:
connection.execute(
"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=(",", ":")),
),
)
+89
View 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