feat: rotate shared private-state keys (Closes #1237)
This commit is contained in:
1 parent
067fb76ebd
commit
965a73ab49
14 files changed
+549
-38
No files matched your search
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 == ""
|
||||
@@ -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:")
|
||||
Reference in new issue
Block a user