From fc9ef1f8ae0e93b156071769659c038442b9a58a Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 18 Aug 2026 23:04:30 +0000 Subject: [PATCH] feat: rotate synchronized Draft encryption keys (Closes #1100) --- README.md | 24 +++- scripts/build_release.py | 6 +- scripts/rotate_unfiled_drafts.py | 45 ++++++++ src/main.py | 12 ++ src/unfiled_draft_store.py | 130 +++++++++++++++++++--- tests/test_release_bundle.py | 4 + tests/test_rotate_unfiled_drafts.py | 56 ++++++++++ tests/test_unfiled_draft_store.py | 167 +++++++++++++++++++++++++++- 8 files changed, 421 insertions(+), 23 deletions(-) create mode 100755 scripts/rotate_unfiled_drafts.py create mode 100644 tests/test_rotate_unfiled_drafts.py diff --git a/README.md b/README.md index 1473399..b0a14d8 100644 --- a/README.md +++ b/README.md @@ -260,13 +260,25 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10 export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000 # Optional; defaults to STACKCHAIN_STATE_DIR/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 -# exactly 32 random bytes from a secret manager; never commit the value. -# Generate once, for example: openssl rand -base64 32 +# 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. export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY='' -# Keep this key stable and back it up separately from the database. There is no -# online key rotation: replacing or losing it makes existing encrypted Drafts -# unavailable. Restore the prior key to recover them before planning a rotation. +# After every worker runs keyring-capable code, replace the single-key setting with +# a bounded JSON object (at most eight keys) and name one active write key. During +# 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":"","2026-08":""}' +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. export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8' # Optional Web Push. Generate a VAPID key pair outside the repo and inject it. diff --git a/scripts/build_release.py b/scripts/build_release.py index d217878..d622962 100644 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -16,7 +16,11 @@ from pathlib import Path, PurePosixPath 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_SUFFIXES = (".pyc", ".pyo") COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}") diff --git a/scripts/rotate_unfiled_drafts.py b/scripts/rotate_unfiled_drafts.py new file mode 100755 index 0000000..0e0163a --- /dev/null +++ b/scripts/rotate_unfiled_drafts.py @@ -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()) diff --git a/src/main.py b/src/main.py index ad97d6b..569e169 100644 --- a/src/main.py +++ b/src/main.py @@ -65,6 +65,7 @@ from src.unfiled_draft_store import ( UnfiledDraftEncryptionError, UnfiledDraftStore, decode_unfiled_draft_encryption_key, + decode_unfiled_draft_encryption_keyring, ) from src.security_event_store import SecurityEventStore, SecurityEventStoreError from src.suggestion_engine import compute @@ -2289,6 +2290,17 @@ def _completed_filed_review_store() -> CompletedFiledReviewStore: 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( os.getenv("STACKCHAIN_UNFILED_DRAFT_DB", str(_state_dir / "unfiled-drafts.sqlite3")), encryption_key=decode_unfiled_draft_encryption_key( diff --git a/src/unfiled_draft_store.py b/src/unfiled_draft_store.py index 7df0557..413103d 100644 --- a/src/unfiled_draft_store.py +++ b/src/unfiled_draft_store.py @@ -18,6 +18,7 @@ from src.private_state import connect_private_sqlite _DRAFT_ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$") _REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") _CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"} +_KEY_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$") class UnfiledDraftConflict(ValueError): @@ -47,22 +48,78 @@ def decode_unfiled_draft_encryption_key(encoded: str) -> bytes: 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: def __init__( self, 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, max_total_bytes: int = 12 * 1024 * 1024, timeout: float = 1.0, ): self.path = Path(path) - if not isinstance(encryption_key, bytes) or len(encryption_key) != 32: - raise UnfiledDraftEncryptionError( - "unfiled draft encryption requires exactly 32 key bytes" - ) - self._cipher = AESGCM(encryption_key) + if encryption_keys is None: + if not isinstance(encryption_key, bytes) or len(encryption_key) != 32: + raise UnfiledDraftEncryptionError( + "unfiled draft encryption requires exactly 32 key bytes" + ) + 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.max_total_bytes = max_total_bytes self.timeout = timeout @@ -93,22 +150,42 @@ class UnfiledDraftStore: def _aad(login: str, revision: int) -> bytes: 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: plaintext = json.dumps(drafts, separators=(",", ":")).encode() nonce = os.urandom(12) - sealed = nonce + self._cipher.encrypt( - nonce, plaintext, self._aad(login, revision) + if self._active_key_id is None: + 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]: 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: raise ValueError("encrypted payload is too short") - plaintext = self._cipher.decrypt( - sealed[:12], sealed[12:], self._aad(login, revision) - ) + plaintext = cipher.decrypt(sealed[:12], sealed[12:], aad) drafts = json.loads(plaintext) if not isinstance(drafts, list): raise ValueError("decrypted payload is not a collection") @@ -122,9 +199,12 @@ class UnfiledDraftStore: if row is None: return {"revision": 0, "drafts": []}, False revision, payload = int(row[0]), row[1] - if payload.startswith("v1:"): + if payload.startswith(("v1:", "v2:")): 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: drafts = json.loads(payload) except (TypeError, json.JSONDecodeError) as error: @@ -156,6 +236,26 @@ class UnfiledDraftStore: ) 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 def _filing_plan(raw: object) -> dict | None: if raw is None: diff --git a/tests/test_release_bundle.py b/tests/test_release_bundle.py index 77172a4..4b80742 100644 --- a/tests/test_release_bundle.py +++ b/tests/test_release_bundle.py @@ -14,10 +14,12 @@ VERIFIER = ROOT / "scripts" / "verify_release.py" def _fixture(root: Path) -> None: (root / "src").mkdir(parents=True) (root / "frontend").mkdir() + (root / "scripts").mkdir() (root / "src" / "main.py").write_text("print('ready')\n") (root / "frontend" / "index.html").write_text("

Stackchain

\n") (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") def _commit_fixture(source: Path) -> str: @@ -85,6 +87,7 @@ def test_release_bundle_is_reproducible_from_declared_commit(tmp_path): "README.md", "frontend/index.html", "requirements.txt", + "scripts/rotate_unfiled_drafts.py", "src/main.py", ] 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", "release-manifest.json", "requirements.txt", + "scripts/rotate_unfiled_drafts.py", "src/main.py", ] assert embedded["commit"] == commit diff --git a/tests/test_rotate_unfiled_drafts.py b/tests/test_rotate_unfiled_drafts.py new file mode 100644 index 0000000..8f1ca52 --- /dev/null +++ b/tests/test_rotate_unfiled_drafts.py @@ -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 == "" diff --git a/tests/test_unfiled_draft_store.py b/tests/test_unfiled_draft_store.py index e0bd225..0dd7a3d 100644 --- a/tests/test_unfiled_draft_store.py +++ b/tests/test_unfiled_draft_store.py @@ -5,12 +5,71 @@ import httpx import pytest 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 +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): return { "id": draft_id, @@ -55,6 +114,112 @@ def test_unfiled_drafts_encrypt_private_content_and_authenticate_the_account(tmp 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): path = tmp_path / "unfiled.sqlite3" store = UnfiledDraftStore(path, encryption_key=ENCRYPTION_KEY)