feat: rotate synchronized Draft encryption keys (Closes #1100)
This commit is contained in:
1 parent
8cd1d1f684
commit
fc9ef1f8ae
8 files changed
+421
-23
No files matched your search
@@ -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("<h1>Stackchain</h1>\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
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user