feat: rotate shared private-state keys (Closes #1237)
This commit is contained in:
parent
067fb76ebd
commit
965a73ab49
11
README.md
11
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
|
# 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.
|
||||||
|
|
|
||||||
|
|
@ -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
176
scripts/rotate_private_state.py
Executable 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())
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
83
tests/test_rotate_private_state.py
Normal file
83
tests/test_rotate_private_state.py
Normal 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 == ""
|
||||||
119
tests/test_state_encryption_rotation.py
Normal file
119
tests/test_state_encryption_rotation.py
Normal 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:")
|
||||||
Loading…
Reference in New Issue
Block a user