feat: encrypt completed Filed review history (Closes #1252)
All checks were successful
CI / lint (pull_request) Successful in 3m8s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m27s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-22 06:09:06 +00:00
parent a4498fdbbc
commit 4b645d8460
5 changed files with 205 additions and 55 deletions

View File

@ -254,10 +254,12 @@ each envelope to its operation key and field purpose so rows and fields cannot b
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
likewise migrate on first read. Synchronized Saved Search collections use the private-state key and likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
authenticate each envelope to its normalized account, preventing rows from being substituted between receipts use the private-state key and authenticate each envelope to its normalized account, preventing
operators. Existing plaintext Saved Searches migrate atomically on first read without advancing their rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
revision; missing, wrong, or modified key material returns no saved-view content. Other private stores first read without advancing their revision; existing completed Filed receipts migrate transactionally at
startup without changing order or acknowledgement state. Missing, wrong, or modified key material
returns no saved-view or receipt content. Other private stores
are not encrypted at the application layer. Web are not encrypted at the application layer. Web
Push subscriptions use a third, independent Push subscriptions use a third, independent
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
@ -330,7 +332,7 @@ 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 worker-shared live/Find Work snapshots, synchronized Today/Later # Required for worker-shared live/Find Work snapshots, synchronized Today/Later
# planning and Saved Search state, and the Security activity journal. Keep this key independent # planning, Saved Search state, completed Filed review receipts, and the Security activity journal. Keep this key independent
# from the Draft key and inject the # from the Draft key and inject the
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit # base64 encoding of exactly 32 random bytes from a secret manager. Never commit
# it. Missing, malformed, wrong-key, or modified state fails closed without # it. Missing, malformed, wrong-key, or modified state fails closed without

View File

@ -78,6 +78,19 @@ STORES = (
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", ( Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)), Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
)), )),
Store(
"completed-filed-reviews",
"completed-filed-reviews",
"STACKCHAIN_COMPLETED_FILED_REVIEW_DB",
"completed-filed-reviews.sqlite3",
(
Table(
"completed_filed_review_collections",
("login",),
(Field("receipts", "receipts:{login}"),),
),
),
),
Store("security-events", "security-events", "STACKCHAIN_SECURITY_EVENT_DB", "security-events.sqlite3", ( Store("security-events", "security-events", "STACKCHAIN_SECURITY_EVENT_DB", "security-events.sqlite3", (
Table("security_events", ("id",), (Field("payload", "event:{id}"),)), Table("security_events", ("id",), (Field("payload", "event:{id}"),)),
)), )),

View File

@ -1,4 +1,4 @@
"""Durable, account-scoped completed Filed review receipts.""" """Durable, encrypted, account-scoped completed Filed review receipts."""
import re import re
import sqlite3 import sqlite3
@ -6,33 +6,68 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from src.private_state import connect_private_sqlite from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") _REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
class CompletedFiledReviewStore: class CompletedFiledReviewStore:
def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0): def __init__(
self,
path: str | Path,
*,
limit: int = 200,
timeout: float = 1.0,
encryption_key: bytes | None = None,
):
self.path = Path(path) self.path = Path(path)
self.limit = limit self.limit = limit
self.timeout = timeout self.timeout = timeout
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(),
store="completed-filed-reviews",
)
self._initialize() self._initialize()
def _initialize(self) -> None: def _initialize(self) -> None:
with self._connect() as connection: with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL") connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA secure_delete=ON")
connection.execute( connection.execute(
""" """
CREATE TABLE IF NOT EXISTS completed_filed_reviews ( CREATE TABLE IF NOT EXISTS completed_filed_review_collections (
login TEXT NOT NULL, login TEXT PRIMARY KEY,
repository TEXT NOT NULL, receipts TEXT NOT NULL
issue_number INTEGER NOT NULL,
updated_at TEXT NOT NULL,
touched_at INTEGER NOT NULL,
PRIMARY KEY (login, repository, issue_number)
) )
""" """
) )
legacy = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'completed_filed_reviews'"
).fetchone()
if legacy:
connection.execute("BEGIN IMMEDIATE")
rows = connection.execute(
"SELECT login, repository, issue_number, updated_at "
"FROM completed_filed_reviews ORDER BY login, touched_at"
).fetchall()
grouped: dict[str, list[dict]] = {}
for login, repository, number, updated_at in rows:
grouped.setdefault(login, []).append({
"repository": repository,
"number": int(number),
"updated_at": updated_at,
})
for login, receipts in grouped.items():
normalized = [self._receipt(item) for item in receipts][-self.limit:]
connection.execute(
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
(login, self._seal(login, normalized)),
)
connection.execute("DROP TABLE completed_filed_reviews")
with self._connect() as connection:
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
def _connect(self) -> sqlite3.Connection: def _connect(self) -> sqlite3.Connection:
return connect_private_sqlite(self.path, timeout=self.timeout) return connect_private_sqlite(self.path, timeout=self.timeout)
@ -45,7 +80,7 @@ class CompletedFiledReviewStore:
return normalized return normalized
@staticmethod @staticmethod
def _receipt(raw: dict) -> tuple[str, int, str]: def _receipt(raw: dict) -> dict:
if not isinstance(raw, dict): if not isinstance(raw, dict):
raise ValueError("receipt must be an object") raise ValueError("receipt must be an object")
repository = raw.get("repository") repository = raw.get("repository")
@ -63,23 +98,41 @@ class CompletedFiledReviewStore:
raise ValueError("updated_at is invalid") from error raise ValueError("updated_at is invalid") from error
if parsed.tzinfo is None: if parsed.tzinfo is None:
raise ValueError("updated_at is invalid") raise ValueError("updated_at is invalid")
return repository, number, updated_at return {"repository": repository, "number": number, "updated_at": updated_at}
def _open(self, login: str, payload: str | None) -> tuple[list[dict], bool]:
if payload is None:
return [], False
value, stale = self._cipher.open(payload, binding=f"receipts:{login}")
if not isinstance(value, list):
raise PrivateStateEncryptionError("private state could not be decrypted")
try:
return [self._receipt(item) for item in value], stale
except ValueError as error:
raise PrivateStateEncryptionError("private state could not be decrypted") from error
def _seal(self, login: str, receipts: list[dict]) -> str:
return self._cipher.seal(receipts, binding=f"receipts:{login}")
@staticmethod @staticmethod
def _snapshot(rows) -> dict: def _snapshot(receipts: list[dict]) -> dict:
return {"receipts": [ return {"receipts": receipts}
{"repository": row[0], "number": int(row[1]), "updated_at": row[2]}
for row in rows
]}
def get(self, login: str) -> dict: def get(self, login: str) -> dict:
login = self._login(login)
with self._connect() as connection: with self._connect() as connection:
rows = connection.execute( row = connection.execute(
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews " "SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
"WHERE login = ? ORDER BY touched_at", (login,),
(self._login(login),), ).fetchone()
).fetchall() receipts, stale = self._open(login, row[0] if row else None)
return self._snapshot(rows) if row is not None and stale:
connection.execute(
"UPDATE completed_filed_review_collections SET receipts = ? "
"WHERE login = ? AND receipts = ?",
(self._seal(login, receipts), login, row[0]),
)
return self._snapshot(receipts)
def merge(self, login: str, receipts: list[dict]) -> dict: def merge(self, login: str, receipts: list[dict]) -> dict:
login = self._login(login) login = self._login(login)
@ -88,35 +141,25 @@ class CompletedFiledReviewStore:
normalized = [self._receipt(receipt) for receipt in receipts] normalized = [self._receipt(receipt) for receipt in receipts]
with self._connect() as connection: with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE") connection.execute("BEGIN IMMEDIATE")
touched = int(connection.execute( row = connection.execute(
"SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?", "SELECT receipts FROM completed_filed_review_collections WHERE login = ?",
(login,), (login,),
).fetchone()[0]) ).fetchone()
for repository, number, updated_at in normalized: current, _stale = self._open(login, row[0] if row else None)
current = connection.execute( for incoming in normalized:
"SELECT updated_at FROM completed_filed_reviews " match = next((
"WHERE login = ? AND repository = ? AND issue_number = ?", item for item in current
(login, repository, number), if item["repository"] == incoming["repository"] and item["number"] == incoming["number"]
).fetchone() ), None)
if current is not None and current[0] >= updated_at: if match is not None and match["updated_at"] >= incoming["updated_at"]:
continue continue
touched += 1 if match is not None:
connection.execute( current.remove(match)
"INSERT INTO completed_filed_reviews " current.append(incoming)
"(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) " current = current[-self.limit:]
"ON CONFLICT(login, repository, issue_number) DO UPDATE SET "
"updated_at=excluded.updated_at, touched_at=excluded.touched_at",
(login, repository, number, updated_at, touched),
)
connection.execute( connection.execute(
"DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN (" "INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) "
"SELECT rowid FROM completed_filed_reviews WHERE login = ? " "ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts",
"ORDER BY touched_at DESC LIMIT ?)", (login, self._seal(login, current)),
(login, login, self.limit),
) )
rows = connection.execute( return self._snapshot(current)
"SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
"WHERE login = ? ORDER BY touched_at",
(login,),
).fetchall()
return self._snapshot(rows)

View File

@ -1,3 +1,5 @@
import sqlite3
import httpx import httpx
import pytest import pytest
@ -21,6 +23,30 @@ def test_receipts_merge_without_lost_updates_and_remain_account_scoped(tmp_path)
assert store.get("alexander") == {"receipts": []} assert store.get("alexander") == {"receipts": []}
def test_receipt_history_is_encrypted_and_bound_to_its_account(tmp_path):
database = tmp_path / "completed-filed.sqlite3"
store = CompletedFiledReviewStore(database, encryption_key=b"f" * 32)
timmy = receipt("private-timmy/repository", 912)
alexander = receipt("private-alexander/repository", 427)
store.merge("timmy-private-login", [timmy])
store.merge("alexander-private-login", [alexander])
raw = database.read_bytes()
assert b"private-timmy/repository" not in raw
assert b"private-alexander/repository" not in raw
with sqlite3.connect(database) as connection:
rows = connection.execute(
"SELECT login, receipts FROM completed_filed_review_collections ORDER BY login"
).fetchall()
connection.execute(
"UPDATE completed_filed_review_collections SET receipts = ? WHERE login = ?",
(rows[1][1], rows[0][0]),
)
with pytest.raises(RuntimeError, match="private state could not be decrypted"):
store.get("alexander-private-login")
def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path): def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2) store = CompletedFiledReviewStore(tmp_path / "completed-filed.sqlite3", limit=2)
newer = receipt(updated_at="2026-08-16T12:00:00Z") newer = receipt(updated_at="2026-08-16T12:00:00Z")
@ -32,6 +58,42 @@ def test_receipts_keep_newest_revision_and_bound_each_account(tmp_path):
assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]} assert bounded == {"receipts": [receipt("stackchain/web", 2), receipt("stackchain/docs", 3)]}
def test_legacy_plaintext_receipts_migrate_for_every_account_without_loss(tmp_path):
database = tmp_path / "completed-filed.sqlite3"
with sqlite3.connect(database) as connection:
connection.execute(
"CREATE TABLE completed_filed_reviews ("
"login TEXT NOT NULL, repository TEXT NOT NULL, issue_number INTEGER NOT NULL, "
"updated_at TEXT NOT NULL, touched_at INTEGER NOT NULL, "
"PRIMARY KEY (login, repository, issue_number))"
)
connection.executemany(
"INSERT INTO completed_filed_reviews VALUES (?, ?, ?, ?, ?)",
[
("timmy", "legacy-private/first", 1, "2026-08-14T12:00:00Z", 1),
("timmy", "legacy-private/second", 2, "2026-08-15T12:00:00Z", 2),
("alexander", "legacy-private/other", 3, "2026-08-16T12:00:00Z", 1),
],
)
store = CompletedFiledReviewStore(database, encryption_key=b"m" * 32)
assert store.get("timmy") == {"receipts": [
receipt("legacy-private/first", 1, "2026-08-14T12:00:00Z"),
receipt("legacy-private/second", 2, "2026-08-15T12:00:00Z"),
]}
assert store.get("alexander") == {"receipts": [
receipt("legacy-private/other", 3, "2026-08-16T12:00:00Z")
]}
raw = database.read_bytes()
assert b"legacy-private/first" not in raw
assert b"legacy-private/other" not in raw
with sqlite3.connect(database) as connection:
assert connection.execute(
"SELECT count(*) FROM sqlite_master WHERE name = 'completed_filed_reviews'"
).fetchone()[0] == 0
@pytest.mark.parametrize("candidate", [ @pytest.mark.parametrize("candidate", [
{"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"}, {"repository": "bad", "number": 1, "updated_at": "2026-08-15T12:00:00Z"},
{"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"}, {"repository": "stackchain/api", "number": 0, "updated_at": "2026-08-15T12:00:00Z"},

View File

@ -6,6 +6,7 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from src.completed_filed_review_store import CompletedFiledReviewStore
from src.saved_search_store import SavedSearchStore from src.saved_search_store import SavedSearchStore
@ -81,3 +82,32 @@ def test_rotation_command_fails_closed_and_never_prints_private_content(tmp_path
assert secret not in completed.stdout assert secret not in completed.stdout
assert "timmy" not in completed.stdout assert "timmy" not in completed.stdout
assert completed.stderr == "" assert completed.stderr == ""
def test_rotation_command_rewraps_completed_filed_history_without_printing_it(tmp_path):
state = tmp_path / "state"
path = state / "completed-filed-reviews.sqlite3"
private_receipt = {
"repository": "private-rotation/canary",
"number": 1252,
"updated_at": "2026-08-22T12:00:00Z",
}
CompletedFiledReviewStore(path, encryption_key=b"o" * 32).merge("timmy", [private_receipt])
completed = run_rotation(state)
assert completed.returncode == 0, completed.stderr
report = json.loads(completed.stdout)
assert report["completed-filed-reviews"] == {
"current": 0, "failed": 0, "migrated": 1, "total": 1
}
assert "private-rotation" not in completed.stdout
assert "timmy" not in completed.stdout
with sqlite3.connect(path) as connection:
payload = connection.execute(
"SELECT receipts FROM completed_filed_review_collections WHERE login = 'timmy'"
).fetchone()[0]
assert payload.startswith("v2:next:")
assert CompletedFiledReviewStore(
path, encryption_key=({"next": b"n" * 32}, "next")
).get("timmy") == {"receipts": [private_receipt]}