feat: rotate shared private-state keys (Closes #1237)
All checks were successful
CI / lint (pull_request) Successful in 3m24s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m2s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-21 21:12:54 +00:00
parent 067fb76ebd
commit 965a73ab49
14 changed files with 549 additions and 38 deletions

View File

@ -331,7 +331,18 @@ export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts
# it. Missing, malformed, wrong-key, or modified state fails closed without # it. Missing, malformed, wrong-key, or modified state fails closed without
# returning content. Legacy Today/Later, Saved Search, and Security activity rows # returning content. Legacy Today/Later, Saved Search, and Security activity rows
# migrate atomically on first use without changing logical revisions or journal IDs. # migrate atomically on first use without changing logical revisions or journal IDs.
# The single-key setting remains the compatible first deployment and writes v1 envelopes.
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>' export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
# After all workers run keyring-capable code, configure at most four named keys and
# select the active write key. Keep the old key present during the rolling deployment.
# Key IDs use 1-32 letters, digits, underscores, or hyphens.
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS='{"legacy":"<old-base64-key>","2026-08":"<new-base64-key>"}'
export STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID='2026-08'
# New writes use authenticated v2 envelopes carrying the active key ID. Rewrap every
# shared store with the restart-safe command; output contains store-level counts only.
# Run it again until every store reports failed=0, migrated=0, and current=total,
# then remove the old key from every worker. Retain all keys and investigate if it exits 1.
python3 scripts/rotate_private_state.py
# 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.

View File

@ -19,6 +19,7 @@ RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
RUNTIME_FILES = ( RUNTIME_FILES = (
"README.md", "README.md",
"requirements.txt", "requirements.txt",
"scripts/rotate_private_state.py",
"scripts/rotate_unfiled_drafts.py", "scripts/rotate_unfiled_drafts.py",
) )
EXCLUDED_PARTS = {"__pycache__", ".pytest_cache"} EXCLUDED_PARTS = {"__pycache__", ".pytest_cache"}

176
scripts/rotate_private_state.py Executable file
View File

@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Rewrap shared private-state envelopes under the configured active key."""
from __future__ import annotations
import json
import os
import sqlite3
import sys
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.private_state import connect_private_sqlite # noqa: E402
from src.state_encryption import ( # noqa: E402
PrivateStateCipher,
PrivateStateEncryptionError,
private_state_encryption_config,
)
@dataclass(frozen=True)
class Field:
column: str
binding: str
alias: str | None = None
plaintext_string: bool = False
@dataclass(frozen=True)
class Table:
name: str
context_columns: tuple[str, ...]
fields: tuple[Field, ...]
@dataclass(frozen=True)
class Store:
name: str
identity: str
env: str
filename: str
tables: tuple[Table, ...]
STORES = (
Store("today", "today", "STACKCHAIN_TODAY_DB", "today.sqlite3", (
Table("today_plans", ("login",), (Field("ids", "plan:{login}"),)),
Table("tomorrow_plans", ("login",), (Field("payload", "tomorrow:{login}"),)),
Table("tomorrow_promotions", ("login", "promotion_id"), (Field("result", "tomorrow-promotion:{login}:{promotion_id}"),)),
Table("week_plans", ("login",), (Field("payload", "week:{login}"),)),
Table("week_promotions", ("login", "promotion_id"), (Field("result", "week-promotion:{login}:{promotion_id}"),)),
Table("week_reschedules", ("login", "operation_id"), (Field("result", "week-reschedule:{login}:{operation_id}"),)),
Table("today_sessions", ("login",), (Field("device_id", "session:{login}"),)),
Table("today_recaps", ("login",), (
Field("session_id", "recap-id:{login}", "session_id", True),
Field("items", "recap-items:{login}:{session_id}"),
)),
Table("today_time_logs", ("login",), (
Field("session_id", "time-log-session:{login}", "session_id", True),
Field("identity", "time-log-identity:{login}:{session_id}", "identity", True),
Field("actual_minutes", "time-log-payload:{login}:{session_id}:{identity}"),
)),
)),
Store("later", "later", "STACKCHAIN_LATER_DB", "later.sqlite3", (
Table("later_plans", ("login",), (Field("records", "plan:{login}"),)),
Table("later_item_revisions", ("login",), (Field("item_id", "item-revision:{login}", plaintext_string=True),)),
)),
Store("live-snapshot", "live-snapshot", "STACKCHAIN_LIVE_SNAPSHOT_DB", "live-snapshot.sqlite3", (
Table("live_snapshot", ("generation",), (Field("value_json", "{generation}"),)),
)),
Store("available-issue-snapshot", "available-issue-snapshot", "STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB", "available-issue-snapshot.sqlite3", (
Table("available_issue_snapshot", (), (Field("items_json", "singleton"),)),
)),
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
)),
Store("security-events", "security-events", "STACKCHAIN_SECURITY_EVENT_DB", "security-events.sqlite3", (
Table("security_events", ("id",), (Field("payload", "event:{id}"),)),
)),
Store("idempotency-ledger", "idempotency-ledger", "STACKCHAIN_IDEMPOTENCY_DB", "idempotency.sqlite3", (
Table("idempotency_operations", ("key",), (
Field("fingerprint", "{key}:fingerprint"),
Field("response_json", "{key}:response"),
)),
)),
)
def _tables(connection: sqlite3.Connection) -> set[str]:
return {
row[0]
for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
)
}
def rotate_store(path: Path, spec: Store, config) -> dict[str, int]:
counts = {"current": 0, "failed": 0, "migrated": 0, "total": 0}
if not path.exists():
return counts
cipher = PrivateStateCipher(config, store=spec.identity)
with connect_private_sqlite(path, timeout=1.0) as connection:
connection.row_factory = sqlite3.Row
available = _tables(connection)
for table in spec.tables:
if table.name not in available:
continue
columns = (*table.context_columns, *(field.column for field in table.fields))
query = f"SELECT rowid AS _rotation_rowid, {', '.join(columns)} FROM {table.name}"
for row in connection.execute(query).fetchall():
context = {name: row[name] for name in table.context_columns}
updates: dict[str, str] = {}
row_failed = False
row_counts = {"current": 0, "migrated": 0, "total": 0}
for field in table.fields:
payload = row[field.column]
if payload is None:
continue
row_counts["total"] += 1
try:
binding = field.binding.format(**context)
if isinstance(payload, str) and not payload.startswith(("v1:", "v2:")) and field.plaintext_string:
value, stale = payload, True
else:
value, stale = cipher.open(str(payload), binding=binding)
if field.alias:
context[field.alias] = value
if stale:
updates[field.column] = cipher.seal(value, binding=binding)
row_counts["migrated"] += 1
else:
row_counts["current"] += 1
except (KeyError, PrivateStateEncryptionError, ValueError):
row_failed = True
break
counts["total"] += row_counts["total"]
if row_failed:
counts["failed"] += 1
continue
if updates:
assignments = ", ".join(f"{name} = ?" for name in updates)
connection.execute(
f"UPDATE {table.name} SET {assignments} WHERE rowid = ?",
(*updates.values(), row["_rotation_rowid"]),
)
counts["migrated"] += row_counts["migrated"]
counts["current"] += row_counts["current"]
return counts
def main() -> int:
try:
config = private_state_encryption_config()
if isinstance(config, bytes):
raise PrivateStateEncryptionError("rotation requires a keyring")
state = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"))
report = {
spec.name: rotate_store(
Path(os.getenv(spec.env, str(state / spec.filename))), spec, config
)
for spec in STORES
}
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
print(json.dumps({"error": "Private-state rotation configuration is unavailable"}, sort_keys=True))
return 2
print(json.dumps(report, sort_keys=True))
return 1 if any(item["failed"] for item in report.values()) else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -12,7 +12,7 @@ from src.private_state import connect_private_sqlite
from src.state_encryption import ( from src.state_encryption import (
PrivateStateCipher, PrivateStateCipher,
PrivateStateEncryptionError, PrivateStateEncryptionError,
private_state_encryption_key, private_state_encryption_config,
) )
@ -34,7 +34,7 @@ class AvailableIssueSnapshotStore:
self.path = Path(path) self.path = Path(path)
self.clock = clock or time.time self.clock = clock or time.time
self._cipher = PrivateStateCipher( self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(), encryption_key if encryption_key is not None else private_state_encryption_config(),
store="available-issue-snapshot", store="available-issue-snapshot",
) )
with self._connect() as connection: with self._connect() as connection:

View File

@ -6,7 +6,7 @@ from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from src.private_state import connect_private_sqlite from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, private_state_encryption_key from src.state_encryption import PrivateStateCipher, private_state_encryption_config
@dataclass(frozen=True) @dataclass(frozen=True)
@ -38,7 +38,7 @@ class IdempotencyLedger:
self.lock_timeout_seconds = lock_timeout_seconds self.lock_timeout_seconds = lock_timeout_seconds
self.clock = clock self.clock = clock
self._cipher = PrivateStateCipher( self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(), encryption_key if encryption_key is not None else private_state_encryption_config(),
store="idempotency-ledger", store="idempotency-ledger",
) )
self._initialize() self._initialize()

View File

@ -6,7 +6,7 @@ from datetime import datetime
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 from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
class LaterStore: class LaterStore:
@ -26,7 +26,7 @@ class LaterStore:
self.operation_retention_seconds = operation_retention_seconds self.operation_retention_seconds = operation_retention_seconds
self.clock = clock self.clock = clock
self._cipher = PrivateStateCipher( self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(), encryption_key if encryption_key is not None else private_state_encryption_config(),
store="later", store="later",
) )
self._initialize() self._initialize()
@ -124,7 +124,7 @@ class LaterStore:
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?", "SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
(login,), (login,),
): ):
if stored_item_id.startswith("v1:"): if stored_item_id.startswith(("v1:", "v2:")):
item_id, _legacy = self._cipher.open( item_id, _legacy = self._cipher.open(
stored_item_id, binding=f"item-revision:{login}" stored_item_id, binding=f"item-revision:{login}"
) )
@ -141,7 +141,7 @@ class LaterStore:
) -> dict[str, str]: ) -> dict[str, str]:
migrated = dict(stored_ids) migrated = dict(stored_ids)
for item_id, stored_item_id in stored_ids.items(): for item_id, stored_item_id in stored_ids.items():
if stored_item_id.startswith("v1:"): if stored_item_id.startswith(("v1:", "v2:")):
continue continue
sealed_item_id = self._cipher.seal( sealed_item_id = self._cipher.seal(
item_id, binding=f"item-revision:{login}" item_id, binding=f"item-revision:{login}"

View File

@ -15,7 +15,7 @@ from src.private_state import connect_private_sqlite
from src.state_encryption import ( from src.state_encryption import (
PrivateStateCipher, PrivateStateCipher,
PrivateStateEncryptionError, PrivateStateEncryptionError,
private_state_encryption_key, private_state_encryption_config,
) )
SECTIONS = ("context", "events", "notifications") SECTIONS = ("context", "events", "notifications")
@ -63,7 +63,7 @@ class LiveSnapshotStore:
self.path = Path(path) self.path = Path(path)
self.clock = clock or time.time self.clock = clock or time.time
self._cipher = PrivateStateCipher( self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(), encryption_key if encryption_key is not None else private_state_encryption_config(),
store="live-snapshot", store="live-snapshot",
) )
self._initialize() self._initialize()

View File

@ -5,7 +5,7 @@ import sqlite3
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 from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") _REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
@ -33,7 +33,7 @@ class SavedSearchStore:
self.limit = limit self.limit = limit
self.timeout = timeout self.timeout = timeout
self._cipher = PrivateStateCipher( self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(), encryption_key if encryption_key is not None else private_state_encryption_config(),
store="saved-searches", store="saved-searches",
) )
self._initialize() self._initialize()

View File

@ -10,7 +10,7 @@ from src.private_state import connect_private_sqlite
from src.state_encryption import ( from src.state_encryption import (
PrivateStateCipher, PrivateStateCipher,
PrivateStateEncryptionError, PrivateStateEncryptionError,
private_state_encryption_key, private_state_encryption_config,
) )
@ -53,7 +53,7 @@ class SecurityEventStore:
self.lock_timeout_seconds = lock_timeout_seconds self.lock_timeout_seconds = lock_timeout_seconds
try: try:
self._cipher = PrivateStateCipher( self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(), encryption_key if encryption_key is not None else private_state_encryption_config(),
store="security-events", store="security-events",
) )
except PrivateStateEncryptionError as exc: except PrivateStateEncryptionError as exc:

View File

@ -6,6 +6,8 @@ import base64
import binascii import binascii
import json import json
import os import os
import re
from collections.abc import Mapping
from cryptography.exceptions import InvalidTag from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.ciphers.aead import AESGCM
@ -16,7 +18,7 @@ class PrivateStateEncryptionError(RuntimeError):
def decode_private_state_encryption_key(encoded: str) -> bytes: def decode_private_state_encryption_key(encoded: str) -> bytes:
"""Decode the independently injected 256-bit private-state key.""" """Decode an independently injected 256-bit private-state key."""
try: try:
key = base64.b64decode(encoded, validate=True) key = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError) as error: except (binascii.Error, ValueError) as error:
@ -31,38 +33,130 @@ def decode_private_state_encryption_key(encoded: str) -> bytes:
def private_state_encryption_key() -> bytes: def private_state_encryption_key() -> bytes:
"""Load the legacy single key setting."""
return decode_private_state_encryption_key( return decode_private_state_encryption_key(
os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", "") os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", "")
) )
_KEY_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
_MAX_KEYS = 4
def decode_private_state_encryption_keyring(
encoded: str, active_key_id: str
) -> tuple[dict[str, bytes], str]:
"""Decode a bounded key-ID-to-key JSON object and validate its active key."""
try:
raw = json.loads(encoded)
except (TypeError, json.JSONDecodeError) as error:
raise PrivateStateEncryptionError(
"private state encryption keyring is invalid"
) from error
if not isinstance(raw, dict) or not raw:
raise PrivateStateEncryptionError("private state encryption keyring is invalid")
if len(raw) > _MAX_KEYS:
raise PrivateStateEncryptionError(
f"private state encryption keyring supports at most {_MAX_KEYS} keys"
)
keys: dict[str, bytes] = {}
for key_id, value in raw.items():
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
raise PrivateStateEncryptionError("private state encryption key ID is invalid")
if not isinstance(value, str):
raise PrivateStateEncryptionError("private state encryption keyring is invalid")
keys[key_id] = decode_private_state_encryption_key(value)
if active_key_id not in keys:
raise PrivateStateEncryptionError(
"private state active key is missing from the keyring"
)
return keys, active_key_id
def private_state_encryption_config() -> bytes | tuple[dict[str, bytes], str]:
"""Load a rotation keyring when configured, otherwise the legacy single key."""
encoded = os.getenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS", "")
active = os.getenv("STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID", "")
if encoded or active:
return decode_private_state_encryption_keyring(encoded, active)
return private_state_encryption_key()
class PrivateStateCipher: class PrivateStateCipher:
"""Seal JSON values with store-specific authenticated context.""" """Seal JSON values with store-specific authenticated context."""
def __init__(self, key: bytes, *, store: str): def __init__(
if not isinstance(key, bytes) or len(key) != 32: self,
raise PrivateStateEncryptionError( key: bytes | Mapping[str, bytes] | tuple[dict[str, bytes], str],
"private state encryption requires exactly 32 key bytes" *,
) store: str,
active_key_id: str | None = None,
):
if isinstance(key, tuple):
key, active_key_id = key
if isinstance(key, Mapping):
if not key or len(key) > _MAX_KEYS or active_key_id not in key:
raise PrivateStateEncryptionError(
"private state encryption keyring configuration is invalid"
)
ciphers: dict[str, AESGCM] = {}
for key_id, raw_key in key.items():
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
raise PrivateStateEncryptionError(
"private state encryption key ID is invalid"
)
if not isinstance(raw_key, bytes) or len(raw_key) != 32:
raise PrivateStateEncryptionError(
"private state encryption requires exactly 32 key bytes"
)
ciphers[key_id] = AESGCM(raw_key)
self._ciphers = ciphers
self._active_key_id = active_key_id
self._legacy_cipher = None
else:
if not isinstance(key, bytes) or len(key) != 32:
raise PrivateStateEncryptionError(
"private state encryption requires exactly 32 key bytes"
)
self._ciphers = {}
self._active_key_id = None
self._legacy_cipher = AESGCM(key)
if not store or "\0" in store: if not store or "\0" in store:
raise ValueError("private state store identity is invalid") raise ValueError("private state store identity is invalid")
self._cipher = AESGCM(key)
self._store = store self._store = store
def _aad(self, binding: str) -> bytes: def _aad(self, binding: str, *, version: str = "v1", key_id: str = "") -> bytes:
return f"stackchain:private-state:v1\0{self._store}\0{binding}".encode() prefix = f"stackchain:private-state:{version}\0{self._store}\0{binding}"
if version == "v2":
prefix += f"\0{key_id}"
return prefix.encode()
def seal(self, value: object, *, binding: str = "singleton") -> str: def seal(self, value: object, *, binding: str = "singleton") -> str:
plaintext = json.dumps(value, separators=(",", ":")).encode() plaintext = json.dumps(value, separators=(",", ":")).encode()
nonce = os.urandom(12) nonce = os.urandom(12)
sealed = nonce + self._cipher.encrypt(nonce, plaintext, self._aad(binding)) if self._active_key_id is not None:
key_id = self._active_key_id
sealed = nonce + self._ciphers[key_id].encrypt(
nonce, plaintext, self._aad(binding, version="v2", key_id=key_id)
)
return f"v2:{key_id}:" + base64.urlsafe_b64encode(sealed).decode()
sealed = nonce + self._legacy_cipher.encrypt(
nonce, plaintext, self._aad(binding)
)
return "v1:" + base64.urlsafe_b64encode(sealed).decode() return "v1:" + base64.urlsafe_b64encode(sealed).decode()
@staticmethod
def _decode_sealed(payload: str) -> bytes:
sealed = base64.b64decode(payload, altchars=b"-_", validate=True)
if len(sealed) < 28:
raise ValueError("encrypted payload is too short")
return sealed
def open(self, payload: str, *, binding: str = "singleton") -> tuple[object, bool]: def open(self, payload: str, *, binding: str = "singleton") -> tuple[object, bool]:
"""Return the decoded value and whether plaintext migration is required.""" """Return the decoded value and whether active-key rewrapping is required."""
if not isinstance(payload, str): if not isinstance(payload, str):
raise PrivateStateEncryptionError("private state could not be decrypted") raise PrivateStateEncryptionError("private state could not be decrypted")
if not payload.startswith("v1:"): if not payload.startswith(("v1:", "v2:")):
try: try:
return json.loads(payload), True return json.loads(payload), True
except (TypeError, json.JSONDecodeError) as error: except (TypeError, json.JSONDecodeError) as error:
@ -70,13 +164,37 @@ class PrivateStateCipher:
"private state could not be decrypted" "private state could not be decrypted"
) from error ) from error
try: try:
sealed = base64.b64decode(payload[3:], altchars=b"-_", validate=True) if payload.startswith("v2:"):
if len(sealed) < 28: _, key_id, encoded = payload.split(":", 2)
raise ValueError("encrypted payload is too short") cipher = self._ciphers.get(key_id)
plaintext = self._cipher.decrypt( if cipher is None:
sealed[:12], sealed[12:], self._aad(binding) raise ValueError("encrypted payload uses an unavailable key")
sealed = self._decode_sealed(encoded)
plaintext = cipher.decrypt(
sealed[:12],
sealed[12:],
self._aad(binding, version="v2", key_id=key_id),
)
return json.loads(plaintext), key_id != self._active_key_id
sealed = self._decode_sealed(payload[3:])
candidates = (
[self._legacy_cipher]
if self._legacy_cipher is not None
else list(self._ciphers.values())
) )
return json.loads(plaintext), False plaintext = None
for cipher in candidates:
try:
plaintext = cipher.decrypt(
sealed[:12], sealed[12:], self._aad(binding)
)
break
except InvalidTag:
continue
if plaintext is None:
raise InvalidTag
return json.loads(plaintext), self._active_key_id is not None
except ( except (
binascii.Error, binascii.Error,
InvalidTag, InvalidTag,

View File

@ -8,7 +8,7 @@ from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
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 from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
class TodayPlanFull(ValueError): class TodayPlanFull(ValueError):
@ -77,7 +77,7 @@ class TodayStore:
self.recap_limit = recap_limit self.recap_limit = recap_limit
self.clock = clock self.clock = clock
self._cipher = PrivateStateCipher( self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(), encryption_key if encryption_key is not None else private_state_encryption_config(),
store="today", store="today",
) )
self._initialize() self._initialize()
@ -228,7 +228,7 @@ class TodayStore:
def _snapshot(self, row, login: str) -> tuple[dict, bool]: def _snapshot(self, row, login: str) -> tuple[dict, bool]:
if row is None: if row is None:
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}, False return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}, False
if row[1].startswith("v1:"): if row[1].startswith(("v1:", "v2:")):
payload, legacy = self._cipher.open(row[1], binding=f"plan:{login}") payload, legacy = self._cipher.open(row[1], binding=f"plan:{login}")
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise PrivateStateEncryptionError("private state could not be decrypted") raise PrivateStateEncryptionError("private state could not be decrypted")
@ -828,7 +828,7 @@ class TodayStore:
def _session_snapshot(self, row, login: str) -> tuple[dict, bool]: def _session_snapshot(self, row, login: str) -> tuple[dict, bool]:
if row is None: if row is None:
return self._empty_session(), False return self._empty_session(), False
if row[1].startswith("v1:"): if row[1].startswith(("v1:", "v2:")):
payload, legacy = self._cipher.open(row[1], binding=f"session:{login}") payload, legacy = self._cipher.open(row[1], binding=f"session:{login}")
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise PrivateStateEncryptionError("private state could not be decrypted") raise PrivateStateEncryptionError("private state could not be decrypted")
@ -929,7 +929,7 @@ class TodayStore:
def _recap_snapshot( def _recap_snapshot(
self, login: str, encrypted_session_id: str, created_at: float, serialized: str self, login: str, encrypted_session_id: str, created_at: float, serialized: str
) -> tuple[dict, bool]: ) -> tuple[dict, bool]:
if encrypted_session_id.startswith("v1:"): if encrypted_session_id.startswith(("v1:", "v2:")):
session_id, legacy_session = self._cipher.open( session_id, legacy_session = self._cipher.open(
encrypted_session_id, binding=f"recap-id:{login}" encrypted_session_id, binding=f"recap-id:{login}"
) )
@ -1021,7 +1021,7 @@ class TodayStore:
def _time_log_snapshot(self, login: str, row) -> tuple[dict, bool]: def _time_log_snapshot(self, login: str, row) -> tuple[dict, bool]:
encrypted_session, encrypted_identity, payload_value, status_value = row encrypted_session, encrypted_identity, payload_value, status_value = row
if encrypted_session.startswith("v1:"): if encrypted_session.startswith(("v1:", "v2:")):
session_id = self._cipher.open( session_id = self._cipher.open(
encrypted_session, binding=f"time-log-session:{login}" encrypted_session, binding=f"time-log-session:{login}"
)[0] )[0]

View File

@ -20,6 +20,7 @@ def _fixture(root: Path) -> None:
(root / "requirements.txt").write_text("fastapi==1.0\n") (root / "requirements.txt").write_text("fastapi==1.0\n")
(root / "README.md").write_text("# Stackchain\n") (root / "README.md").write_text("# Stackchain\n")
(root / "scripts" / "rotate_unfiled_drafts.py").write_text("print('rotate')\n") (root / "scripts" / "rotate_unfiled_drafts.py").write_text("print('rotate')\n")
(root / "scripts" / "rotate_private_state.py").write_text("print('rotate private')\n")
def _commit_fixture(source: Path) -> str: def _commit_fixture(source: Path) -> str:
@ -87,6 +88,7 @@ def test_release_bundle_is_reproducible_from_declared_commit(tmp_path):
"README.md", "README.md",
"frontend/index.html", "frontend/index.html",
"requirements.txt", "requirements.txt",
"scripts/rotate_private_state.py",
"scripts/rotate_unfiled_drafts.py", "scripts/rotate_unfiled_drafts.py",
"src/main.py", "src/main.py",
] ]
@ -98,6 +100,7 @@ def test_release_bundle_is_reproducible_from_declared_commit(tmp_path):
"frontend/index.html", "frontend/index.html",
"release-manifest.json", "release-manifest.json",
"requirements.txt", "requirements.txt",
"scripts/rotate_private_state.py",
"scripts/rotate_unfiled_drafts.py", "scripts/rotate_unfiled_drafts.py",
"src/main.py", "src/main.py",
] ]

View File

@ -0,0 +1,83 @@
import base64
import json
import os
import sqlite3
import subprocess
import sys
from pathlib import Path
from src.saved_search_store import SavedSearchStore
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "rotate_private_state.py"
def encoded(value: bytes) -> str:
return base64.b64encode(value * 32).decode()
def run_rotation(state: Path, *, tamper: bool = False):
env = {
**os.environ,
"STACKCHAIN_STATE_DIR": str(state),
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS": json.dumps(
{"old": encoded(b"o"), "next": encoded(b"n")}
),
"STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID": "next",
}
if tamper:
with sqlite3.connect(state / "saved-searches.sqlite3") as connection:
connection.execute(
"UPDATE saved_searches SET views = 'v1:tampered' WHERE login = 'timmy'"
)
return subprocess.run(
[sys.executable, str(SCRIPT)], env=env, text=True, capture_output=True
)
def test_rotation_command_rewraps_shared_store_rows_without_changing_metadata(tmp_path):
state = tmp_path / "state"
path = state / "saved-searches.sqlite3"
store = SavedSearchStore(path, encryption_key=b"o" * 32)
expected = store.replace(
"timmy",
0,
[{"id": "mine", "name": "My private work", "query": "assignee:timmy"}],
)
completed = run_rotation(state)
assert completed.returncode == 0, completed.stderr
report = json.loads(completed.stdout)
assert report["saved-searches"] == {
"current": 0,
"failed": 0,
"migrated": 1,
"total": 1,
}
with sqlite3.connect(path) as connection:
revision, payload = connection.execute(
"SELECT revision, views FROM saved_searches WHERE login = 'timmy'"
).fetchone()
assert revision == expected["revision"]
assert payload.startswith("v2:next:")
assert SavedSearchStore(
path, encryption_key=({"next": b"n" * 32}, "next")
).get("timmy") == expected
def test_rotation_command_fails_closed_and_never_prints_private_content(tmp_path):
state = tmp_path / "state"
secret = "private launch roadmap"
store = SavedSearchStore(state / "saved-searches.sqlite3", encryption_key=b"o" * 32)
store.replace("timmy", 0, [{"id": "secret", "name": secret, "query": "is:open"}])
completed = run_rotation(state, tamper=True)
assert completed.returncode == 1
report = json.loads(completed.stdout)
assert report["saved-searches"]["failed"] == 1
assert secret not in completed.stdout
assert "timmy" not in completed.stdout
assert completed.stderr == ""

View File

@ -0,0 +1,119 @@
import base64
import json
import sqlite3
import pytest
from src.today_store import TodayStore
from src.state_encryption import (
PrivateStateCipher,
PrivateStateEncryptionError,
decode_private_state_encryption_keyring,
private_state_encryption_config,
)
def encoded(byte: bytes) -> str:
return base64.b64encode(byte * 32).decode()
def test_keyring_writes_active_versioned_envelope_and_reads_it_current():
keys, active = decode_private_state_encryption_keyring(
json.dumps({"old": encoded(b"o"), "next": encoded(b"n")}), "next"
)
cipher = PrivateStateCipher(keys, active_key_id=active, store="today")
payload = cipher.seal({"ids": [7]}, binding="timmy")
assert payload.startswith("v2:next:")
assert cipher.open(payload, binding="timmy") == ({"ids": [7]}, False)
def test_keyring_reads_legacy_v1_and_marks_it_for_rewrap():
legacy = PrivateStateCipher(b"o" * 32, store="today")
payload = legacy.seal({"ids": [7]}, binding="timmy")
rotating = PrivateStateCipher(
{"old": b"o" * 32, "next": b"n" * 32},
active_key_id="next",
store="today",
)
assert rotating.open(payload, binding="timmy") == ({"ids": [7]}, True)
def test_keyring_reads_inactive_v2_and_marks_it_for_rewrap():
old = PrivateStateCipher(
{"old": b"o" * 32, "next": b"n" * 32},
active_key_id="old",
store="today",
)
payload = old.seal({"ids": [7]}, binding="timmy")
rotating = PrivateStateCipher(
{"old": b"o" * 32, "next": b"n" * 32},
active_key_id="next",
store="today",
)
assert rotating.open(payload, binding="timmy") == ({"ids": [7]}, True)
def test_keyring_fails_closed_for_unknown_key_id():
cipher = PrivateStateCipher(
{"next": b"n" * 32}, active_key_id="next", store="today"
)
with pytest.raises(PrivateStateEncryptionError, match="could not be decrypted"):
cipher.open("v2:retired:AAAA", binding="timmy")
def test_keyring_configuration_is_bounded_and_requires_active_key():
with pytest.raises(PrivateStateEncryptionError, match="active key"):
decode_private_state_encryption_keyring(
json.dumps({"old": encoded(b"o")}), "missing"
)
with pytest.raises(PrivateStateEncryptionError, match="at most 4"):
decode_private_state_encryption_keyring(
json.dumps({str(i): encoded(bytes([65 + i])) for i in range(5)}), "0"
)
def test_environment_prefers_keyring_and_keeps_single_key_rollout(monkeypatch):
monkeypatch.setenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", encoded(b"l"))
monkeypatch.delenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS", raising=False)
monkeypatch.delenv("STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID", raising=False)
assert private_state_encryption_config() == b"l" * 32
monkeypatch.setenv(
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS",
json.dumps({"legacy": encoded(b"l"), "next": encoded(b"n")}),
)
monkeypatch.setenv("STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID", "next")
keys, active = private_state_encryption_config()
assert keys == {"legacy": b"l" * 32, "next": b"n" * 32}
assert active == "next"
def test_today_store_reads_and_lazily_rewraps_v2_state_during_rotation(tmp_path, monkeypatch):
path = tmp_path / "today.sqlite3"
legacy = TodayStore(path, encryption_key=b"l" * 32)
legacy.apply("timmy", "seed", "add", "issue:stackchain/dashboard#1237")
monkeypatch.setenv(
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS",
json.dumps({"legacy": encoded(b"l"), "next": encoded(b"n")}),
)
monkeypatch.setenv("STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID", "next")
rotating = TodayStore(path)
assert rotating.get("timmy")["ids"] == ["issue:stackchain/dashboard#1237"]
assert rotating.apply(
"timmy", "add-next", "add", "issue:stackchain/dashboard#1238"
)["ids"] == [
"issue:stackchain/dashboard#1237",
"issue:stackchain/dashboard#1238",
]
with sqlite3.connect(path) as connection:
payload = connection.execute(
"SELECT ids FROM today_plans WHERE login = 'timmy'"
).fetchone()[0]
assert payload.startswith("v2:next:")