feat: rotate shared private-state keys (Closes #1237)
CI / lint (pull_request) Successful in 3m24s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m2s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-21 21:12:54 +00:00
1 parent 067fb76ebd
commit 965a73ab49
14 files changed
+549 -38

No files matched your search

+3
View File
@@ -20,6 +20,7 @@ def _fixture(root: Path) -> None:
(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")
(root / "scripts" / "rotate_private_state.py").write_text("print('rotate private')\n")
def _commit_fixture(source: Path) -> str:
@@ -87,6 +88,7 @@ def test_release_bundle_is_reproducible_from_declared_commit(tmp_path):
"README.md",
"frontend/index.html",
"requirements.txt",
"scripts/rotate_private_state.py",
"scripts/rotate_unfiled_drafts.py",
"src/main.py",
]
@@ -98,6 +100,7 @@ def test_release_bundle_is_reproducible_from_declared_commit(tmp_path):
"frontend/index.html",
"release-manifest.json",
"requirements.txt",
"scripts/rotate_private_state.py",
"scripts/rotate_unfiled_drafts.py",
"src/main.py",
]
+83
View 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
View 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:")