Compare commits

..

No commits in common. "24cadf88f88bf5293733ef7d96fb1c048a4daf91" and "a4498fdbbc96efd435b7558ccea50c46e9470158" have entirely different histories.

5 changed files with 55 additions and 205 deletions

View File

@ -254,12 +254,10 @@ 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 and completed Filed review likewise migrate on first read. Synchronized Saved Search collections use the private-state key and
receipts use the private-state key and authenticate each envelope to its normalized account, preventing authenticate each envelope to its normalized account, preventing rows from being substituted between
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on operators. Existing plaintext Saved Searches migrate atomically on first read without advancing their
first read without advancing their revision; existing completed Filed receipts migrate transactionally at revision; missing, wrong, or modified key material returns no saved-view content. Other private stores
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
@ -332,7 +330,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, Saved Search state, completed Filed review receipts, and the Security activity journal. Keep this key independent # planning and Saved Search state, 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,19 +78,6 @@ 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, encrypted, account-scoped completed Filed review receipts.""" """Durable, account-scoped completed Filed review receipts."""
import re import re
import sqlite3 import sqlite3
@ -6,68 +6,33 @@ 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__( def __init__(self, path: str | Path, *, limit: int = 200, timeout: float = 1.0):
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_review_collections ( CREATE TABLE IF NOT EXISTS completed_filed_reviews (
login TEXT PRIMARY KEY, login TEXT NOT NULL,
receipts 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)
) )
""" """
) )
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)
@ -80,7 +45,7 @@ class CompletedFiledReviewStore:
return normalized return normalized
@staticmethod @staticmethod
def _receipt(raw: dict) -> dict: def _receipt(raw: dict) -> tuple[str, int, str]:
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")
@ -98,41 +63,23 @@ 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": repository, "number": number, "updated_at": updated_at} return repository, number, 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(receipts: list[dict]) -> dict: def _snapshot(rows) -> dict:
return {"receipts": receipts} return {"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:
row = connection.execute( rows = connection.execute(
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?", "SELECT repository, issue_number, updated_at FROM completed_filed_reviews "
(login,), "WHERE login = ? ORDER BY touched_at",
).fetchone() (self._login(login),),
receipts, stale = self._open(login, row[0] if row else None) ).fetchall()
if row is not None and stale: return self._snapshot(rows)
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)
@ -141,25 +88,35 @@ 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")
row = connection.execute( touched = int(connection.execute(
"SELECT receipts FROM completed_filed_review_collections WHERE login = ?", "SELECT COALESCE(MAX(touched_at), 0) FROM completed_filed_reviews WHERE login = ?",
(login,), (login,),
).fetchone() ).fetchone()[0])
current, _stale = self._open(login, row[0] if row else None) for repository, number, updated_at in normalized:
for incoming in normalized: current = connection.execute(
match = next(( "SELECT updated_at FROM completed_filed_reviews "
item for item in current "WHERE login = ? AND repository = ? AND issue_number = ?",
if item["repository"] == incoming["repository"] and item["number"] == incoming["number"] (login, repository, number),
), None) ).fetchone()
if match is not None and match["updated_at"] >= incoming["updated_at"]: if current is not None and current[0] >= updated_at:
continue continue
if match is not None: touched += 1
current.remove(match) connection.execute(
current.append(incoming) "INSERT INTO completed_filed_reviews "
current = current[-self.limit:] "(login, repository, issue_number, updated_at, touched_at) VALUES (?, ?, ?, ?, ?) "
"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(
"INSERT INTO completed_filed_review_collections(login, receipts) VALUES (?, ?) " "DELETE FROM completed_filed_reviews WHERE login = ? AND rowid NOT IN ("
"ON CONFLICT(login) DO UPDATE SET receipts=excluded.receipts", "SELECT rowid FROM completed_filed_reviews WHERE login = ? "
(login, self._seal(login, current)), "ORDER BY touched_at DESC LIMIT ?)",
(login, login, self.limit),
) )
return self._snapshot(current) rows = connection.execute(
"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,5 +1,3 @@
import sqlite3
import httpx import httpx
import pytest import pytest
@ -23,30 +21,6 @@ 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")
@ -58,42 +32,6 @@ 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,7 +6,6 @@ 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
@ -82,32 +81,3 @@ 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]}