#!/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())