diff --git a/README.md b/README.md index 493e45c..b3a6746 100644 --- a/README.md +++ b/README.md @@ -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 # returning content. Legacy Today/Later, Saved Search, and Security activity rows # 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='' +# 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":"","2026-08":""}' +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 # 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. diff --git a/scripts/build_release.py b/scripts/build_release.py index d622962..9c3e617 100644 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -19,6 +19,7 @@ RUNTIME_DIRECTORIES = ("docs", "frontend", "src") RUNTIME_FILES = ( "README.md", "requirements.txt", + "scripts/rotate_private_state.py", "scripts/rotate_unfiled_drafts.py", ) EXCLUDED_PARTS = {"__pycache__", ".pytest_cache"} diff --git a/scripts/rotate_private_state.py b/scripts/rotate_private_state.py new file mode 100755 index 0000000..7709b93 --- /dev/null +++ b/scripts/rotate_private_state.py @@ -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()) diff --git a/src/available_issue_snapshot_store.py b/src/available_issue_snapshot_store.py index ae6649b..f29c732 100644 --- a/src/available_issue_snapshot_store.py +++ b/src/available_issue_snapshot_store.py @@ -12,7 +12,7 @@ from src.private_state import connect_private_sqlite from src.state_encryption import ( PrivateStateCipher, PrivateStateEncryptionError, - private_state_encryption_key, + private_state_encryption_config, ) @@ -34,7 +34,7 @@ class AvailableIssueSnapshotStore: 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(), + encryption_key if encryption_key is not None else private_state_encryption_config(), store="available-issue-snapshot", ) with self._connect() as connection: diff --git a/src/idempotency.py b/src/idempotency.py index b9ff44d..f1c0a94 100644 --- a/src/idempotency.py +++ b/src/idempotency.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any, Callable 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) @@ -38,7 +38,7 @@ class IdempotencyLedger: self.lock_timeout_seconds = lock_timeout_seconds self.clock = clock 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", ) self._initialize() diff --git a/src/later_store.py b/src/later_store.py index 163c50e..43ae030 100644 --- a/src/later_store.py +++ b/src/later_store.py @@ -6,7 +6,7 @@ from datetime import datetime from pathlib import Path 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: @@ -26,7 +26,7 @@ class LaterStore: self.operation_retention_seconds = operation_retention_seconds self.clock = clock 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", ) self._initialize() @@ -124,7 +124,7 @@ class LaterStore: "SELECT item_id, revision FROM later_item_revisions WHERE login = ?", (login,), ): - if stored_item_id.startswith("v1:"): + if stored_item_id.startswith(("v1:", "v2:")): item_id, _legacy = self._cipher.open( stored_item_id, binding=f"item-revision:{login}" ) @@ -141,7 +141,7 @@ class LaterStore: ) -> dict[str, str]: migrated = dict(stored_ids) for item_id, stored_item_id in stored_ids.items(): - if stored_item_id.startswith("v1:"): + if stored_item_id.startswith(("v1:", "v2:")): continue sealed_item_id = self._cipher.seal( item_id, binding=f"item-revision:{login}" diff --git a/src/live_snapshot_store.py b/src/live_snapshot_store.py index 2707c59..65ed8c8 100644 --- a/src/live_snapshot_store.py +++ b/src/live_snapshot_store.py @@ -15,7 +15,7 @@ from src.private_state import connect_private_sqlite from src.state_encryption import ( PrivateStateCipher, PrivateStateEncryptionError, - private_state_encryption_key, + private_state_encryption_config, ) SECTIONS = ("context", "events", "notifications") @@ -63,7 +63,7 @@ class LiveSnapshotStore: 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(), + encryption_key if encryption_key is not None else private_state_encryption_config(), store="live-snapshot", ) self._initialize() diff --git a/src/saved_search_store.py b/src/saved_search_store.py index 6603576..bcd1054 100644 --- a/src/saved_search_store.py +++ b/src/saved_search_store.py @@ -5,7 +5,7 @@ import sqlite3 from pathlib import Path 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_.-]+$") @@ -33,7 +33,7 @@ class SavedSearchStore: self.limit = limit self.timeout = timeout 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", ) self._initialize() diff --git a/src/security_event_store.py b/src/security_event_store.py index c60a4b7..a653cb2 100644 --- a/src/security_event_store.py +++ b/src/security_event_store.py @@ -10,7 +10,7 @@ from src.private_state import connect_private_sqlite from src.state_encryption import ( PrivateStateCipher, PrivateStateEncryptionError, - private_state_encryption_key, + private_state_encryption_config, ) @@ -53,7 +53,7 @@ class SecurityEventStore: self.lock_timeout_seconds = lock_timeout_seconds try: 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", ) except PrivateStateEncryptionError as exc: diff --git a/src/state_encryption.py b/src/state_encryption.py index 655265d..0b62c42 100644 --- a/src/state_encryption.py +++ b/src/state_encryption.py @@ -6,6 +6,8 @@ import base64 import binascii import json import os +import re +from collections.abc import Mapping from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM @@ -16,7 +18,7 @@ class PrivateStateEncryptionError(RuntimeError): 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: key = base64.b64decode(encoded, validate=True) 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: + """Load the legacy single key setting.""" return decode_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: """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" - ) + def __init__( + self, + key: bytes | Mapping[str, bytes] | tuple[dict[str, bytes], str], + *, + 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: 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 _aad(self, binding: str, *, version: str = "v1", key_id: str = "") -> bytes: + 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: plaintext = json.dumps(value, separators=(",", ":")).encode() 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() + @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]: - """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): raise PrivateStateEncryptionError("private state could not be decrypted") - if not payload.startswith("v1:"): + if not payload.startswith(("v1:", "v2:")): try: return json.loads(payload), True except (TypeError, json.JSONDecodeError) as error: @@ -70,13 +164,37 @@ class PrivateStateCipher: "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) + if payload.startswith("v2:"): + _, key_id, encoded = payload.split(":", 2) + cipher = self._ciphers.get(key_id) + if cipher is None: + 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 ( binascii.Error, InvalidTag, diff --git a/src/today_store.py b/src/today_store.py index 32baf0c..7d410af 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -8,7 +8,7 @@ from pathlib import Path from zoneinfo import ZoneInfo, ZoneInfoNotFoundError 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): @@ -77,7 +77,7 @@ class TodayStore: self.recap_limit = recap_limit self.clock = clock 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", ) self._initialize() @@ -228,7 +228,7 @@ class TodayStore: def _snapshot(self, row, login: str) -> tuple[dict, bool]: if row is None: 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}") if not isinstance(payload, dict): raise PrivateStateEncryptionError("private state could not be decrypted") @@ -828,7 +828,7 @@ class TodayStore: def _session_snapshot(self, row, login: str) -> tuple[dict, bool]: if row is None: 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}") if not isinstance(payload, dict): raise PrivateStateEncryptionError("private state could not be decrypted") @@ -929,7 +929,7 @@ class TodayStore: def _recap_snapshot( self, login: str, encrypted_session_id: str, created_at: float, serialized: str ) -> tuple[dict, bool]: - if encrypted_session_id.startswith("v1:"): + if encrypted_session_id.startswith(("v1:", "v2:")): session_id, legacy_session = self._cipher.open( 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]: 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( encrypted_session, binding=f"time-log-session:{login}" )[0] diff --git a/tests/test_release_bundle.py b/tests/test_release_bundle.py index 4b80742..f7a8e3c 100644 --- a/tests/test_release_bundle.py +++ b/tests/test_release_bundle.py @@ -20,6 +20,7 @@ def _fixture(root: Path) -> None: (root / "requirements.txt").write_text("fastapi==1.0\n") (root / "README.md").write_text("# Stackchain\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: @@ -87,6 +88,7 @@ def test_release_bundle_is_reproducible_from_declared_commit(tmp_path): "README.md", "frontend/index.html", "requirements.txt", + "scripts/rotate_private_state.py", "scripts/rotate_unfiled_drafts.py", "src/main.py", ] @@ -98,6 +100,7 @@ def test_release_bundle_is_reproducible_from_declared_commit(tmp_path): "frontend/index.html", "release-manifest.json", "requirements.txt", + "scripts/rotate_private_state.py", "scripts/rotate_unfiled_drafts.py", "src/main.py", ] diff --git a/tests/test_rotate_private_state.py b/tests/test_rotate_private_state.py new file mode 100644 index 0000000..c00398a --- /dev/null +++ b/tests/test_rotate_private_state.py @@ -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 == "" diff --git a/tests/test_state_encryption_rotation.py b/tests/test_state_encryption_rotation.py new file mode 100644 index 0000000..c257253 --- /dev/null +++ b/tests/test_state_encryption_rotation.py @@ -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:")