feat: rotate shared private-state keys (Closes #1237)
This commit is contained in:
1 parent
067fb76ebd
commit
965a73ab49
14 files changed
+549
-38
No files matched your search
@@ -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"}
|
||||
|
||||
Executable
+176
@@ -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())
|
||||
Reference in new issue
Block a user