feat: rotate synchronized Draft encryption keys (Closes #1100)
This commit is contained in:
parent
8cd1d1f684
commit
fc9ef1f8ae
24
README.md
24
README.md
|
|
@ -260,13 +260,25 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
|
||||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||||
# Required for cross-device unfiled Draft sync. Inject the base64 encoding of
|
# Required for cross-device unfiled Draft sync. The single-key setting remains
|
||||||
# exactly 32 random bytes from a secret manager; never commit the value.
|
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
|
||||||
# Generate once, for example: openssl rand -base64 32
|
# Inject the base64 encoding of exactly 32 random bytes from a secret manager.
|
||||||
export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||||
# Keep this key stable and back it up separately from the database. There is no
|
# After every worker runs keyring-capable code, replace the single-key setting with
|
||||||
# online key rotation: replacing or losing it makes existing encrypted Drafts
|
# a bounded JSON object (at most eight keys) and name one active write key. During
|
||||||
# unavailable. Restore the prior key to recover them before planning a rotation.
|
# the first rotation, preserve the original single key under the reserved `legacy`
|
||||||
|
# ID so existing v1 envelopes remain readable. Key IDs use 1-32 letters, digits,
|
||||||
|
# underscores, or hyphens. Never commit either setting.
|
||||||
|
export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS='{"legacy":"<old-base64-key>","2026-08":"<new-base64-key>"}'
|
||||||
|
export STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID='2026-08'
|
||||||
|
# New writes use authenticated v2 envelopes carrying the active key ID. Reads
|
||||||
|
# atomically rewrap plaintext, v1, and inactive-key rows without advancing their
|
||||||
|
# logical revision. Run the content-free, restart-safe migration until it exits 0:
|
||||||
|
python3 scripts/rotate_unfiled_drafts.py
|
||||||
|
# A result such as {"current":42,"failed":0,"migrated":0,"total":42} proves the
|
||||||
|
# old key has no remaining row dependencies. Only then remove `legacy`/old keys and
|
||||||
|
# restart. A nonzero exit reports unreadable row counts but never account or Draft
|
||||||
|
# content. Roll back only to a keyring-capable build and retain every configured key.
|
||||||
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
||||||
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||||
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
|
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,11 @@ from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
|
||||||
RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
|
RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
|
||||||
RUNTIME_FILES = ("README.md", "requirements.txt")
|
RUNTIME_FILES = (
|
||||||
|
"README.md",
|
||||||
|
"requirements.txt",
|
||||||
|
"scripts/rotate_unfiled_drafts.py",
|
||||||
|
)
|
||||||
EXCLUDED_PARTS = {"__pycache__", ".pytest_cache"}
|
EXCLUDED_PARTS = {"__pycache__", ".pytest_cache"}
|
||||||
EXCLUDED_SUFFIXES = (".pyc", ".pyo")
|
EXCLUDED_SUFFIXES = (".pyc", ".pyo")
|
||||||
COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}")
|
COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}")
|
||||||
|
|
|
||||||
45
scripts/rotate_unfiled_drafts.py
Executable file
45
scripts/rotate_unfiled_drafts.py
Executable file
|
|
@ -0,0 +1,45 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Rewrap synchronized Draft rows under the configured active encryption key."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
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.unfiled_draft_store import ( # noqa: E402
|
||||||
|
UnfiledDraftEncryptionError,
|
||||||
|
UnfiledDraftStore,
|
||||||
|
decode_unfiled_draft_encryption_keyring,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
keys, active = decode_unfiled_draft_encryption_keyring(
|
||||||
|
os.getenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS", ""),
|
||||||
|
os.getenv("STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID", ""),
|
||||||
|
)
|
||||||
|
store = UnfiledDraftStore(
|
||||||
|
os.getenv(
|
||||||
|
"STACKCHAIN_UNFILED_DRAFT_DB",
|
||||||
|
str(Path(os.getenv("STACKCHAIN_STATE_DIR", ".")) / "unfiled-drafts.sqlite3"),
|
||||||
|
),
|
||||||
|
encryption_keys=keys,
|
||||||
|
active_key_id=active,
|
||||||
|
)
|
||||||
|
result = store.rewrap_all()
|
||||||
|
except (OSError, UnfiledDraftEncryptionError):
|
||||||
|
print(json.dumps({"error": "Draft rotation configuration is unavailable"}, sort_keys=True))
|
||||||
|
return 2
|
||||||
|
print(json.dumps(result, sort_keys=True))
|
||||||
|
return 1 if result["failed"] else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
12
src/main.py
12
src/main.py
|
|
@ -65,6 +65,7 @@ from src.unfiled_draft_store import (
|
||||||
UnfiledDraftEncryptionError,
|
UnfiledDraftEncryptionError,
|
||||||
UnfiledDraftStore,
|
UnfiledDraftStore,
|
||||||
decode_unfiled_draft_encryption_key,
|
decode_unfiled_draft_encryption_key,
|
||||||
|
decode_unfiled_draft_encryption_keyring,
|
||||||
)
|
)
|
||||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||||
from src.suggestion_engine import compute
|
from src.suggestion_engine import compute
|
||||||
|
|
@ -2289,6 +2290,17 @@ def _completed_filed_review_store() -> CompletedFiledReviewStore:
|
||||||
|
|
||||||
|
|
||||||
def _unfiled_draft_store() -> UnfiledDraftStore:
|
def _unfiled_draft_store() -> UnfiledDraftStore:
|
||||||
|
encoded_keyring = os.getenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS")
|
||||||
|
if encoded_keyring is not None:
|
||||||
|
keys, active = decode_unfiled_draft_encryption_keyring(
|
||||||
|
encoded_keyring,
|
||||||
|
os.getenv("STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID", ""),
|
||||||
|
)
|
||||||
|
return UnfiledDraftStore(
|
||||||
|
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3")),
|
||||||
|
encryption_keys=keys,
|
||||||
|
active_key_id=active,
|
||||||
|
)
|
||||||
return UnfiledDraftStore(
|
return UnfiledDraftStore(
|
||||||
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3")),
|
os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3")),
|
||||||
encryption_key=decode_unfiled_draft_encryption_key(
|
encryption_key=decode_unfiled_draft_encryption_key(
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from src.private_state import connect_private_sqlite
|
||||||
_DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
|
_DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$")
|
||||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||||
_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||||
|
_KEY_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
|
||||||
|
|
||||||
|
|
||||||
class UnfiledDraftConflict(ValueError):
|
class UnfiledDraftConflict(ValueError):
|
||||||
|
|
@ -47,22 +48,78 @@ def decode_unfiled_draft_encryption_key(encoded: str) -> bytes:
|
||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def decode_unfiled_draft_encryption_keyring(
|
||||||
|
encoded: str, active_key_id: str
|
||||||
|
) -> tuple[dict[str, bytes], str]:
|
||||||
|
"""Decode a bounded JSON object of named keys without exposing key material."""
|
||||||
|
def unique_object(pairs):
|
||||||
|
value = {}
|
||||||
|
for key, item in pairs:
|
||||||
|
if key in value:
|
||||||
|
raise ValueError("duplicate key id")
|
||||||
|
value[key] = item
|
||||||
|
return value
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = json.loads(encoded, object_pairs_hook=unique_object)
|
||||||
|
if not isinstance(raw, dict) or not raw or len(raw) > 8:
|
||||||
|
raise ValueError("invalid keyring size")
|
||||||
|
if active_key_id not in raw:
|
||||||
|
raise ValueError("active key is unavailable")
|
||||||
|
keys = {}
|
||||||
|
for key_id, value in raw.items():
|
||||||
|
if not isinstance(key_id, str) or not _KEY_ID.fullmatch(key_id):
|
||||||
|
raise ValueError("invalid key id")
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError("invalid key value")
|
||||||
|
keys[key_id] = decode_unfiled_draft_encryption_key(value)
|
||||||
|
if len(set(keys.values())) != len(keys):
|
||||||
|
raise ValueError("duplicate encryption key")
|
||||||
|
return keys, active_key_id
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError, UnfiledDraftEncryptionError) as error:
|
||||||
|
raise UnfiledDraftEncryptionError(
|
||||||
|
"unfiled draft encryption keyring is invalid"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
class UnfiledDraftStore:
|
class UnfiledDraftStore:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
path: str | Path,
|
path: str | Path,
|
||||||
*,
|
*,
|
||||||
encryption_key: bytes,
|
encryption_key: bytes | None = None,
|
||||||
|
encryption_keys: dict[str, bytes] | None = None,
|
||||||
|
active_key_id: str | None = None,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
max_total_bytes: int = 12 * 1024 * 1024,
|
max_total_bytes: int = 12 * 1024 * 1024,
|
||||||
timeout: float = 1.0,
|
timeout: float = 1.0,
|
||||||
):
|
):
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
if not isinstance(encryption_key, bytes) or len(encryption_key) != 32:
|
if encryption_keys is None:
|
||||||
raise UnfiledDraftEncryptionError(
|
if not isinstance(encryption_key, bytes) or len(encryption_key) != 32:
|
||||||
"unfiled draft encryption requires exactly 32 key bytes"
|
raise UnfiledDraftEncryptionError(
|
||||||
)
|
"unfiled draft encryption requires exactly 32 key bytes"
|
||||||
self._cipher = AESGCM(encryption_key)
|
)
|
||||||
|
self._ciphers = {"legacy": AESGCM(encryption_key)}
|
||||||
|
self._active_key_id = None
|
||||||
|
else:
|
||||||
|
if (
|
||||||
|
not encryption_keys
|
||||||
|
or len(encryption_keys) > 8
|
||||||
|
or active_key_id not in encryption_keys
|
||||||
|
or any(
|
||||||
|
not isinstance(key_id, str)
|
||||||
|
or not _KEY_ID.fullmatch(key_id)
|
||||||
|
or not isinstance(key, bytes)
|
||||||
|
or len(key) != 32
|
||||||
|
for key_id, key in encryption_keys.items()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise UnfiledDraftEncryptionError("unfiled draft encryption keyring is invalid")
|
||||||
|
self._ciphers = {
|
||||||
|
key_id: AESGCM(key) for key_id, key in encryption_keys.items()
|
||||||
|
}
|
||||||
|
self._active_key_id = active_key_id
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.max_total_bytes = max_total_bytes
|
self.max_total_bytes = max_total_bytes
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
@ -93,22 +150,42 @@ class UnfiledDraftStore:
|
||||||
def _aad(login: str, revision: int) -> bytes:
|
def _aad(login: str, revision: int) -> bytes:
|
||||||
return f"stackchain:unfiled-drafts:v1\0{login}\0{revision}".encode()
|
return f"stackchain:unfiled-drafts:v1\0{login}\0{revision}".encode()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _v2_aad(login: str, revision: int, key_id: str) -> bytes:
|
||||||
|
return f"stackchain:unfiled-drafts:v2\0{key_id}\0{login}\0{revision}".encode()
|
||||||
|
|
||||||
def _encrypt(self, login: str, revision: int, drafts: list[dict]) -> str:
|
def _encrypt(self, login: str, revision: int, drafts: list[dict]) -> str:
|
||||||
plaintext = json.dumps(drafts, separators=(",", ":")).encode()
|
plaintext = json.dumps(drafts, separators=(",", ":")).encode()
|
||||||
nonce = os.urandom(12)
|
nonce = os.urandom(12)
|
||||||
sealed = nonce + self._cipher.encrypt(
|
if self._active_key_id is None:
|
||||||
nonce, plaintext, self._aad(login, revision)
|
sealed = nonce + self._ciphers["legacy"].encrypt(
|
||||||
|
nonce, plaintext, self._aad(login, revision)
|
||||||
|
)
|
||||||
|
return "v1:" + base64.urlsafe_b64encode(sealed).decode()
|
||||||
|
key_id = self._active_key_id
|
||||||
|
sealed = nonce + self._ciphers[key_id].encrypt(
|
||||||
|
nonce, plaintext, self._v2_aad(login, revision, key_id)
|
||||||
)
|
)
|
||||||
return "v1:" + base64.urlsafe_b64encode(sealed).decode()
|
return f"v2:{key_id}:" + base64.urlsafe_b64encode(sealed).decode()
|
||||||
|
|
||||||
def _decrypt(self, login: str, revision: int, payload: str) -> list[dict]:
|
def _decrypt(self, login: str, revision: int, payload: str) -> list[dict]:
|
||||||
try:
|
try:
|
||||||
sealed = base64.b64decode(payload[3:], altchars=b"-_", validate=True)
|
if payload.startswith("v1:"):
|
||||||
|
encoded = payload[3:]
|
||||||
|
cipher = self._ciphers.get("legacy")
|
||||||
|
aad = self._aad(login, revision)
|
||||||
|
elif payload.startswith("v2:"):
|
||||||
|
_version, key_id, encoded = payload.split(":", 2)
|
||||||
|
cipher = self._ciphers.get(key_id)
|
||||||
|
aad = self._v2_aad(login, revision, key_id)
|
||||||
|
else:
|
||||||
|
raise ValueError("encrypted payload version is invalid")
|
||||||
|
if cipher is None:
|
||||||
|
raise ValueError("encrypted payload key is unavailable")
|
||||||
|
sealed = base64.b64decode(encoded, altchars=b"-_", validate=True)
|
||||||
if len(sealed) < 12 + 16:
|
if len(sealed) < 12 + 16:
|
||||||
raise ValueError("encrypted payload is too short")
|
raise ValueError("encrypted payload is too short")
|
||||||
plaintext = self._cipher.decrypt(
|
plaintext = cipher.decrypt(sealed[:12], sealed[12:], aad)
|
||||||
sealed[:12], sealed[12:], self._aad(login, revision)
|
|
||||||
)
|
|
||||||
drafts = json.loads(plaintext)
|
drafts = json.loads(plaintext)
|
||||||
if not isinstance(drafts, list):
|
if not isinstance(drafts, list):
|
||||||
raise ValueError("decrypted payload is not a collection")
|
raise ValueError("decrypted payload is not a collection")
|
||||||
|
|
@ -122,9 +199,12 @@ class UnfiledDraftStore:
|
||||||
if row is None:
|
if row is None:
|
||||||
return {"revision": 0, "drafts": []}, False
|
return {"revision": 0, "drafts": []}, False
|
||||||
revision, payload = int(row[0]), row[1]
|
revision, payload = int(row[0]), row[1]
|
||||||
if payload.startswith("v1:"):
|
if payload.startswith(("v1:", "v2:")):
|
||||||
drafts = self._decrypt(login, revision, payload)
|
drafts = self._decrypt(login, revision, payload)
|
||||||
return {"revision": revision, "drafts": drafts}, False
|
active_prefix = (
|
||||||
|
"v1:" if self._active_key_id is None else f"v2:{self._active_key_id}:"
|
||||||
|
)
|
||||||
|
return {"revision": revision, "drafts": drafts}, not payload.startswith(active_prefix)
|
||||||
try:
|
try:
|
||||||
drafts = json.loads(payload)
|
drafts = json.loads(payload)
|
||||||
except (TypeError, json.JSONDecodeError) as error:
|
except (TypeError, json.JSONDecodeError) as error:
|
||||||
|
|
@ -156,6 +236,26 @@ class UnfiledDraftStore:
|
||||||
)
|
)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
def rewrap_all(self) -> dict[str, int]:
|
||||||
|
"""Rewrap every readable row and return aggregate counts only."""
|
||||||
|
with self._connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT login, drafts FROM unfiled_drafts ORDER BY login"
|
||||||
|
).fetchall()
|
||||||
|
result = {"total": len(rows), "migrated": 0, "current": 0, "failed": 0}
|
||||||
|
active_prefix = (
|
||||||
|
"v1:" if self._active_key_id is None else f"v2:{self._active_key_id}:"
|
||||||
|
)
|
||||||
|
for login, payload in rows:
|
||||||
|
was_current = payload.startswith(active_prefix)
|
||||||
|
try:
|
||||||
|
self.get(login)
|
||||||
|
except UnfiledDraftEncryptionError:
|
||||||
|
result["failed"] += 1
|
||||||
|
else:
|
||||||
|
result["current" if was_current else "migrated"] += 1
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _filing_plan(raw: object) -> dict | None:
|
def _filing_plan(raw: object) -> dict | None:
|
||||||
if raw is None:
|
if raw is None:
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,12 @@ VERIFIER = ROOT / "scripts" / "verify_release.py"
|
||||||
def _fixture(root: Path) -> None:
|
def _fixture(root: Path) -> None:
|
||||||
(root / "src").mkdir(parents=True)
|
(root / "src").mkdir(parents=True)
|
||||||
(root / "frontend").mkdir()
|
(root / "frontend").mkdir()
|
||||||
|
(root / "scripts").mkdir()
|
||||||
(root / "src" / "main.py").write_text("print('ready')\n")
|
(root / "src" / "main.py").write_text("print('ready')\n")
|
||||||
(root / "frontend" / "index.html").write_text("<h1>Stackchain</h1>\n")
|
(root / "frontend" / "index.html").write_text("<h1>Stackchain</h1>\n")
|
||||||
(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")
|
||||||
|
|
||||||
|
|
||||||
def _commit_fixture(source: Path) -> str:
|
def _commit_fixture(source: Path) -> str:
|
||||||
|
|
@ -85,6 +87,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_unfiled_drafts.py",
|
||||||
"src/main.py",
|
"src/main.py",
|
||||||
]
|
]
|
||||||
with tarfile.open(first_archive, "r:gz") as archive:
|
with tarfile.open(first_archive, "r:gz") as archive:
|
||||||
|
|
@ -95,6 +98,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_unfiled_drafts.py",
|
||||||
"src/main.py",
|
"src/main.py",
|
||||||
]
|
]
|
||||||
assert embedded["commit"] == commit
|
assert embedded["commit"] == commit
|
||||||
|
|
|
||||||
56
tests/test_rotate_unfiled_drafts.py
Normal file
56
tests/test_rotate_unfiled_drafts.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.unfiled_draft_store import UnfiledDraftStore
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).parents[1] / "scripts" / "rotate_unfiled_drafts.py"
|
||||||
|
OLD_KEY = "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28="
|
||||||
|
NEW_KEY = "bm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm4="
|
||||||
|
|
||||||
|
|
||||||
|
def _draft(title):
|
||||||
|
return {"id": "draft", "title": title, "body": "private", "saved_at": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_cli_migrates_readable_rows_and_fails_closed_on_unreadable_rows(tmp_path):
|
||||||
|
path = tmp_path / "drafts.sqlite3"
|
||||||
|
store = UnfiledDraftStore(path, encryption_key=b"o" * 32)
|
||||||
|
store.replace("timmy", 0, [_draft("Secret launch")])
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO unfiled_drafts(login, revision, drafts) VALUES (?, ?, ?)",
|
||||||
|
("broken", 1, "v2:missing:not-ciphertext"),
|
||||||
|
)
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"STACKCHAIN_UNFILED_DRAFT_DB": str(path),
|
||||||
|
"STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS": json.dumps(
|
||||||
|
{"legacy": OLD_KEY, "new": NEW_KEY}
|
||||||
|
),
|
||||||
|
"STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID": "new",
|
||||||
|
}
|
||||||
|
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT)],
|
||||||
|
cwd=SCRIPT.parents[1],
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert completed.returncode == 1
|
||||||
|
assert json.loads(completed.stdout) == {
|
||||||
|
"current": 0,
|
||||||
|
"failed": 1,
|
||||||
|
"migrated": 1,
|
||||||
|
"total": 2,
|
||||||
|
}
|
||||||
|
assert "timmy" not in completed.stdout
|
||||||
|
assert "Secret launch" not in completed.stdout
|
||||||
|
assert completed.stderr == ""
|
||||||
|
|
@ -5,12 +5,71 @@ import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src import main, unfiled_draft_store
|
from src import main, unfiled_draft_store
|
||||||
from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
|
from src.unfiled_draft_store import (
|
||||||
|
UnfiledDraftConflict,
|
||||||
|
UnfiledDraftStore,
|
||||||
|
decode_unfiled_draft_encryption_keyring,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ENCRYPTION_KEY = b"d" * 32
|
ENCRYPTION_KEY = b"d" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_draft_keyring_configuration_decodes_named_keys():
|
||||||
|
encoded = json.dumps({
|
||||||
|
"legacy": "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28=",
|
||||||
|
"next": "bm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm4=",
|
||||||
|
})
|
||||||
|
|
||||||
|
keys, active = decode_unfiled_draft_encryption_keyring(encoded, "next")
|
||||||
|
|
||||||
|
assert keys == {"legacy": b"o" * 32, "next": b"n" * 32}
|
||||||
|
assert active == "next"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("encoded", "active"),
|
||||||
|
[
|
||||||
|
('{"one":"b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28=","one":"bm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm4="}', "one"),
|
||||||
|
(json.dumps({"bad id": "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28="}), "bad id"),
|
||||||
|
(json.dumps({"one": "short"}), "one"),
|
||||||
|
(json.dumps({"one": "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28="}), "missing"),
|
||||||
|
(
|
||||||
|
json.dumps({
|
||||||
|
"one": "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28=",
|
||||||
|
"alias": "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28=",
|
||||||
|
}),
|
||||||
|
"one",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unfiled_draft_keyring_configuration_rejects_ambiguous_or_unusable_keys(
|
||||||
|
encoded, active
|
||||||
|
):
|
||||||
|
with pytest.raises(
|
||||||
|
unfiled_draft_store.UnfiledDraftEncryptionError,
|
||||||
|
match="keyring is invalid",
|
||||||
|
):
|
||||||
|
decode_unfiled_draft_encryption_keyring(encoded, active)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_draft_store_uses_keyring_environment(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_DB", str(tmp_path / "drafts.sqlite3"))
|
||||||
|
monkeypatch.delenv("STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY", raising=False)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS",
|
||||||
|
json.dumps({"old": "b29vb29vb29vb29vb29vb29vb29vb29vb29vb29vb28=", "new": "bm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm5ubm4="}),
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID", "new")
|
||||||
|
|
||||||
|
store = main._unfiled_draft_store()
|
||||||
|
store.replace("timmy", 0, [draft()])
|
||||||
|
|
||||||
|
with sqlite3.connect(tmp_path / "drafts.sqlite3") as connection:
|
||||||
|
payload = connection.execute("SELECT drafts FROM unfiled_drafts").fetchone()[0]
|
||||||
|
assert payload.startswith("v2:new:")
|
||||||
|
|
||||||
|
|
||||||
def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None):
|
def draft(draft_id="phone-capture", *, title="Broken checkout", evidence=None):
|
||||||
return {
|
return {
|
||||||
"id": draft_id,
|
"id": draft_id,
|
||||||
|
|
@ -55,6 +114,112 @@ def test_unfiled_drafts_encrypt_private_content_and_authenticate_the_account(tmp
|
||||||
store.get("alexander")
|
store.get("alexander")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_drafts_keyring_writes_with_the_named_active_key(tmp_path):
|
||||||
|
path = tmp_path / "unfiled.sqlite3"
|
||||||
|
store = UnfiledDraftStore(
|
||||||
|
path,
|
||||||
|
encryption_keys={"old": b"o" * 32, "2026-08": b"n" * 32},
|
||||||
|
active_key_id="2026-08",
|
||||||
|
)
|
||||||
|
|
||||||
|
created = store.replace("timmy", 0, [draft()])
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
payload = connection.execute(
|
||||||
|
"SELECT drafts FROM unfiled_drafts WHERE login = 'timmy'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert payload.startswith("v2:2026-08:")
|
||||||
|
assert "Broken checkout" not in payload
|
||||||
|
assert store.get("timmy") == created
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_drafts_keyring_rewraps_v1_without_advancing_revision(tmp_path):
|
||||||
|
path = tmp_path / "unfiled.sqlite3"
|
||||||
|
old_store = UnfiledDraftStore(path, encryption_key=b"o" * 32)
|
||||||
|
created = old_store.replace("timmy", 0, [draft()])
|
||||||
|
rotating_store = UnfiledDraftStore(
|
||||||
|
path,
|
||||||
|
encryption_keys={"legacy": b"o" * 32, "new": b"n" * 32},
|
||||||
|
active_key_id="new",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rotating_store.get("timmy") == created
|
||||||
|
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
revision, payload = connection.execute(
|
||||||
|
"SELECT revision, drafts FROM unfiled_drafts WHERE login = 'timmy'"
|
||||||
|
).fetchone()
|
||||||
|
assert revision == 1
|
||||||
|
assert payload.startswith("v2:new:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_drafts_keyring_rewraps_a_named_inactive_key(tmp_path):
|
||||||
|
path = tmp_path / "unfiled.sqlite3"
|
||||||
|
old_store = UnfiledDraftStore(
|
||||||
|
path, encryption_keys={"old": b"o" * 32}, active_key_id="old"
|
||||||
|
)
|
||||||
|
created = old_store.replace("timmy", 0, [draft()])
|
||||||
|
rotating_store = UnfiledDraftStore(
|
||||||
|
path,
|
||||||
|
encryption_keys={"old": b"o" * 32, "new": b"n" * 32},
|
||||||
|
active_key_id="new",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rotating_store.get("timmy") == created
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
revision, payload = connection.execute(
|
||||||
|
"SELECT revision, drafts FROM unfiled_drafts WHERE login = 'timmy'"
|
||||||
|
).fetchone()
|
||||||
|
assert revision == 1
|
||||||
|
assert payload.startswith("v2:new:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_drafts_keyring_authenticates_the_envelope_key_id(tmp_path):
|
||||||
|
path = tmp_path / "unfiled.sqlite3"
|
||||||
|
store = UnfiledDraftStore(
|
||||||
|
path,
|
||||||
|
encryption_keys={"old": b"o" * 32, "new": b"n" * 32},
|
||||||
|
active_key_id="new",
|
||||||
|
)
|
||||||
|
store.replace("timmy", 0, [draft()])
|
||||||
|
with sqlite3.connect(path) as connection:
|
||||||
|
payload = connection.execute("SELECT drafts FROM unfiled_drafts").fetchone()[0]
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE unfiled_drafts SET drafts = ?",
|
||||||
|
(payload.replace("v2:new:", "v2:old:", 1),),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
unfiled_draft_store.UnfiledDraftEncryptionError,
|
||||||
|
match="could not be decrypted",
|
||||||
|
):
|
||||||
|
store.get("timmy")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfiled_drafts_rewrap_all_reports_content_free_aggregate(tmp_path):
|
||||||
|
path = tmp_path / "unfiled.sqlite3"
|
||||||
|
old_store = UnfiledDraftStore(path, encryption_key=b"o" * 32)
|
||||||
|
old_store.replace("timmy", 0, [draft()])
|
||||||
|
old_store.replace("alexander", 0, [draft(title="Private roadmap")])
|
||||||
|
rotating_store = UnfiledDraftStore(
|
||||||
|
path,
|
||||||
|
encryption_keys={"legacy": b"o" * 32, "new": b"n" * 32},
|
||||||
|
active_key_id="new",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = rotating_store.rewrap_all()
|
||||||
|
|
||||||
|
assert result == {"total": 2, "migrated": 2, "current": 0, "failed": 0}
|
||||||
|
assert "timmy" not in json.dumps(result)
|
||||||
|
assert "Private roadmap" not in json.dumps(result)
|
||||||
|
assert rotating_store.rewrap_all() == {
|
||||||
|
"total": 2,
|
||||||
|
"migrated": 0,
|
||||||
|
"current": 2,
|
||||||
|
"failed": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_unfiled_drafts_migrate_plaintext_without_changing_revision_or_order(tmp_path):
|
def test_unfiled_drafts_migrate_plaintext_without_changing_revision_or_order(tmp_path):
|
||||||
path = tmp_path / "unfiled.sqlite3"
|
path = tmp_path / "unfiled.sqlite3"
|
||||||
store = UnfiledDraftStore(path, encryption_key=ENCRYPTION_KEY)
|
store = UnfiledDraftStore(path, encryption_key=ENCRYPTION_KEY)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user