From f4d8d64b9df6709fbca310464b527549b6080630 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 19 Aug 2026 05:32:20 +0000 Subject: [PATCH] security: encrypt synchronized Later state (Closes #1114) --- README.md | 10 ++-- src/later_store.py | 119 ++++++++++++++++++++++++++++++-------- tests/test_later_store.py | 104 +++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 238fea7..2e627e6 100644 --- a/README.md +++ b/README.md @@ -267,10 +267,12 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10 export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000 # Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3. export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3' -# Required for worker-shared live and Find Work snapshots. Keep this key -# independent from the Draft key and inject the base64 encoding of exactly 32 -# random bytes from a secret manager. Never commit it. Missing, malformed, -# wrong-key, or modified snapshot state fails closed without returning content. +# Required for worker-shared live/Find Work snapshots and synchronized Today/Later +# planning state. Keep this key independent from the Draft key and inject the +# base64 encoding of exactly 32 random bytes from a secret manager. Never commit +# it. Missing, malformed, wrong-key, or modified state fails closed without +# returning content. Legacy Today/Later rows migrate atomically on first use +# without advancing their logical revision. export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='' # Required for cross-device unfiled Draft sync. The single-key setting remains # supported for the first deployment of keyring-capable code and writes v1 envelopes. diff --git a/src/later_store.py b/src/later_store.py index c763b76..163c50e 100644 --- a/src/later_store.py +++ b/src/later_store.py @@ -1,12 +1,12 @@ """Durable, account-scoped Later deferrals.""" -import json import sqlite3 import time from datetime import datetime from pathlib import Path from src.private_state import connect_private_sqlite +from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key class LaterStore: @@ -17,6 +17,7 @@ class LaterStore: timeout: float = 1.0, operation_limit: int = 4096, operation_retention_seconds: float = 30 * 24 * 60 * 60, + encryption_key: bytes | None = None, clock=time.time, ): self.path = Path(path) @@ -24,6 +25,10 @@ class LaterStore: self.operation_limit = operation_limit self.operation_retention_seconds = operation_retention_seconds self.clock = clock + self._cipher = PrivateStateCipher( + encryption_key if encryption_key is not None else private_state_encryption_key(), + store="later", + ) self._initialize() def _initialize(self) -> None: @@ -99,11 +104,55 @@ class LaterStore: raise ValueError("login is required") return normalized - @staticmethod - def _snapshot(row) -> dict: + def _snapshot(self, row, login: str) -> tuple[dict, bool]: if row is None: - return {"revision": 0, "records": {}} - return {"revision": int(row[0]), "records": json.loads(row[1])} + return {"revision": 0, "records": {}}, False + records, legacy = self._cipher.open(row[1], binding=f"plan:{login}") + if not isinstance(records, dict): + raise PrivateStateEncryptionError("private state could not be decrypted") + return {"revision": int(row[0]), "records": records}, legacy + + def _sealed_records(self, login: str, records: dict) -> str: + return self._cipher.seal(records, binding=f"plan:{login}") + + def _item_revisions( + self, connection: sqlite3.Connection, login: str + ) -> tuple[dict[str, int], dict[str, str]]: + revisions: dict[str, int] = {} + stored_ids: dict[str, str] = {} + for stored_item_id, revision in connection.execute( + "SELECT item_id, revision FROM later_item_revisions WHERE login = ?", + (login,), + ): + if stored_item_id.startswith("v1:"): + item_id, _legacy = self._cipher.open( + stored_item_id, binding=f"item-revision:{login}" + ) + else: + item_id = stored_item_id + if not isinstance(item_id, str) or not item_id: + raise PrivateStateEncryptionError("private state could not be decrypted") + revisions[item_id] = int(revision) + stored_ids[item_id] = stored_item_id + return revisions, stored_ids + + def _migrate_item_ids( + self, connection: sqlite3.Connection, login: str, stored_ids: dict[str, str] + ) -> dict[str, str]: + migrated = dict(stored_ids) + for item_id, stored_item_id in stored_ids.items(): + if stored_item_id.startswith("v1:"): + continue + sealed_item_id = self._cipher.seal( + item_id, binding=f"item-revision:{login}" + ) + connection.execute( + "UPDATE later_item_revisions SET item_id = ? " + "WHERE login = ? AND item_id = ?", + (sealed_item_id, login, stored_item_id), + ) + migrated[item_id] = sealed_item_id + return migrated @staticmethod def _validate_wake_at(wake_at: str | None) -> str: @@ -116,12 +165,21 @@ class LaterStore: return wake_at def get(self, login: str) -> dict: + login = self._normalize_login(login) with self._connect() as connection: row = connection.execute( "SELECT revision, records FROM later_plans WHERE login = ?", - (self._normalize_login(login),), + (login,), ).fetchone() - return self._snapshot(row) + snapshot, legacy_plan = self._snapshot(row, login) + _revisions, stored_item_ids = self._item_revisions(connection, login) + if row is not None and legacy_plan: + connection.execute( + "UPDATE later_plans SET records = ? WHERE login = ? AND records = ?", + (self._sealed_records(login, snapshot["records"]), login, row[1]), + ) + self._migrate_item_ids(connection, login, stored_item_ids) + return snapshot def apply( self, @@ -152,7 +210,7 @@ class LaterStore: row = connection.execute( "SELECT revision, records FROM later_plans WHERE login = ?", (login,) ).fetchone() - snapshot = self._snapshot(row) + snapshot, legacy_plan = self._snapshot(row, login) records = dict(snapshot["records"]) revision = snapshot["revision"] accepted: list[str] = [] @@ -161,15 +219,19 @@ class LaterStore: # Existing databases predate per-item revisions. Conservatively mark # active deferrals as changed at the latest known plan revision. + item_revisions, stored_item_ids = self._item_revisions(connection, login) + stored_item_ids = self._migrate_item_ids(connection, login, stored_item_ids) for item_id in records: - connection.execute( - "INSERT OR IGNORE INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)", - (login, item_id, revision), - ) - item_revisions = dict(connection.execute( - "SELECT item_id, revision FROM later_item_revisions WHERE login = ?", - (login,), - ).fetchall()) + if item_id not in item_revisions: + stored_item_id = self._cipher.seal( + item_id, binding=f"item-revision:{login}" + ) + connection.execute( + "INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)", + (login, stored_item_id, revision), + ) + item_revisions[item_id] = revision + stored_item_ids[item_id] = stored_item_id batch_start_item_revisions = dict(item_revisions) for operation in operations: @@ -218,21 +280,32 @@ class LaterStore: revision += 1 if changed else 0 if changed: item_revisions[item_id] = revision - connection.execute( - "INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?) " - "ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision", - (login, item_id, revision), - ) + stored_item_id = stored_item_ids.get(item_id) + if stored_item_id is None: + stored_item_id = self._cipher.seal( + item_id, binding=f"item-revision:{login}" + ) + stored_item_ids[item_id] = stored_item_id + connection.execute( + "INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)", + (login, stored_item_id, revision), + ) + else: + connection.execute( + "UPDATE later_item_revisions SET revision = ? " + "WHERE login = ? AND item_id = ?", + (revision, login, stored_item_id), + ) self._record_operation(connection, login, operation_id) accepted.append(operation_id) - serialized = json.dumps(records, separators=(",", ":"), sort_keys=True) + serialized = self._sealed_records(login, records) if row is None: connection.execute( "INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)", (login, revision, serialized), ) - elif accepted: + elif accepted or legacy_plan: connection.execute( "UPDATE later_plans SET revision = ?, records = ? WHERE login = ?", (revision, serialized, login), diff --git a/tests/test_later_store.py b/tests/test_later_store.py index b72f86e..8560240 100644 --- a/tests/test_later_store.py +++ b/tests/test_later_store.py @@ -1,3 +1,4 @@ +import json import sqlite3 import httpx @@ -5,6 +6,10 @@ import pytest from src import main from src.later_store import LaterStore +from src.state_encryption import PrivateStateEncryptionError + + +PRIVATE_KEY = b"l" * 32 @pytest.mark.anyio @@ -65,6 +70,105 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path ) == {"revision": 2, "records": {}} +def test_later_plans_encrypt_private_content_and_authenticate_the_account(tmp_path): + path = tmp_path / "later.sqlite3" + item_id = "issue:private/repository:363:" + wake_at = "2026-08-10T09:17:00.000Z" + store = LaterStore(path, encryption_key=PRIVATE_KEY) + + expected = store.apply( + "timmy", "op-private", "defer", item_id, + wake_at=wake_at, handoff="today", + ) + + with sqlite3.connect(path) as connection: + records = connection.execute( + "SELECT records FROM later_plans WHERE login = 'timmy'" + ).fetchone()[0] + stored_item_id = connection.execute( + "SELECT item_id FROM later_item_revisions WHERE login = 'timmy'" + ).fetchone()[0] + assert records.startswith("v1:") + assert stored_item_id.startswith("v1:") + assert item_id not in records + stored_item_id + assert wake_at not in records + assert "today" not in records + assert LaterStore(path, encryption_key=PRIVATE_KEY).get("timmy") == expected + with sqlite3.connect(path) as connection: + connection.execute( + "INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)", + ("alexander", 1, records), + ) + with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"): + store.get("alexander") + with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"): + LaterStore(path, encryption_key=b"x" * 32).get("timmy") + + +def test_later_read_atomically_migrates_legacy_private_content_without_new_revision(tmp_path): + path = tmp_path / "later.sqlite3" + item_id = "issue:legacy/private:17:" + records = {item_id: {"wake_at": "2026-08-20T08:00:00.000Z", "handoff": "today"}} + store = LaterStore(path, encryption_key=PRIVATE_KEY) + with sqlite3.connect(path) as connection: + connection.execute( + "INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)", + ("timmy", 7, json.dumps(records)), + ) + connection.execute( + "INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)", + ("timmy", item_id, 7), + ) + + assert store.get("timmy") == {"revision": 7, "records": records} + with sqlite3.connect(path) as connection: + migrated_plan = connection.execute( + "SELECT revision, records FROM later_plans WHERE login = 'timmy'" + ).fetchone() + migrated_revision = connection.execute( + "SELECT revision, item_id FROM later_item_revisions WHERE login = 'timmy'" + ).fetchone() + assert migrated_plan[0] == migrated_revision[0] == 7 + assert migrated_plan[1].startswith("v1:") + assert migrated_revision[1].startswith("v1:") + assert item_id not in migrated_plan[1] + migrated_revision[1] + + +def test_duplicate_write_migrates_legacy_later_content_without_advancing_revision(tmp_path): + path = tmp_path / "later.sqlite3" + item_id = "issue:legacy/private:18:" + records = {item_id: "2026-08-21T08:00:00.000Z"} + store = LaterStore(path, encryption_key=PRIVATE_KEY) + with sqlite3.connect(path) as connection: + connection.execute( + "INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)", + ("timmy", 4, json.dumps(records)), + ) + connection.execute( + "INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)", + ("timmy", item_id, 4), + ) + connection.execute( + "INSERT INTO later_operations(login, operation_id, created_at) VALUES (?, ?, ?)", + ("timmy", "already-confirmed", 1.0), + ) + + result = store.apply( + "timmy", "already-confirmed", "restore", item_id, base_revision=4, + ) + + assert result == {"revision": 4, "records": records} + with sqlite3.connect(path) as connection: + stored = connection.execute( + "SELECT records FROM later_plans WHERE login = 'timmy'" + ).fetchone()[0] + stored_item_id = connection.execute( + "SELECT item_id FROM later_item_revisions WHERE login = 'timmy'" + ).fetchone()[0] + assert stored.startswith("v1:") + assert stored_item_id.startswith("v1:") + + def test_next_day_today_handoff_is_preserved_without_changing_legacy_deferrals(tmp_path): store = LaterStore(tmp_path / "later.sqlite3") -- 2.43.0