security: encrypt synchronized Later state (Closes #1114)
All checks were successful
CI / lint (pull_request) Successful in 2m43s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m51s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-19 05:32:20 +00:00
parent 9763bb410b
commit f4d8d64b9d
3 changed files with 206 additions and 27 deletions

View File

@ -267,10 +267,12 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000 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 and Find Work snapshots. Keep this key # Required for worker-shared live/Find Work snapshots and synchronized Today/Later
# independent from the Draft key and inject the base64 encoding of exactly 32 # planning state. Keep this key independent from the Draft key and inject the
# random bytes from a secret manager. Never commit it. Missing, malformed, # base64 encoding of exactly 32 random bytes from a secret manager. Never commit
# wrong-key, or modified snapshot state fails closed without returning content. # 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='<base64-encoded-32-byte-key>' export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
# Required for cross-device unfiled Draft sync. The single-key setting remains # 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. # supported for the first deployment of keyring-capable code and writes v1 envelopes.

View File

@ -1,12 +1,12 @@
"""Durable, account-scoped Later deferrals.""" """Durable, account-scoped Later deferrals."""
import json
import sqlite3 import sqlite3
import time import time
from datetime import datetime 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_key
class LaterStore: class LaterStore:
@ -17,6 +17,7 @@ class LaterStore:
timeout: float = 1.0, timeout: float = 1.0,
operation_limit: int = 4096, operation_limit: int = 4096,
operation_retention_seconds: float = 30 * 24 * 60 * 60, operation_retention_seconds: float = 30 * 24 * 60 * 60,
encryption_key: bytes | None = None,
clock=time.time, clock=time.time,
): ):
self.path = Path(path) self.path = Path(path)
@ -24,6 +25,10 @@ class LaterStore:
self.operation_limit = operation_limit self.operation_limit = operation_limit
self.operation_retention_seconds = operation_retention_seconds self.operation_retention_seconds = operation_retention_seconds
self.clock = clock self.clock = clock
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(),
store="later",
)
self._initialize() self._initialize()
def _initialize(self) -> None: def _initialize(self) -> None:
@ -99,11 +104,55 @@ class LaterStore:
raise ValueError("login is required") raise ValueError("login is required")
return normalized return normalized
@staticmethod def _snapshot(self, row, login: str) -> tuple[dict, bool]:
def _snapshot(row) -> dict:
if row is None: if row is None:
return {"revision": 0, "records": {}} return {"revision": 0, "records": {}}, False
return {"revision": int(row[0]), "records": json.loads(row[1])} 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 @staticmethod
def _validate_wake_at(wake_at: str | None) -> str: def _validate_wake_at(wake_at: str | None) -> str:
@ -116,12 +165,21 @@ class LaterStore:
return wake_at return wake_at
def get(self, login: str) -> dict: def get(self, login: str) -> dict:
login = self._normalize_login(login)
with self._connect() as connection: with self._connect() as connection:
row = connection.execute( row = connection.execute(
"SELECT revision, records FROM later_plans WHERE login = ?", "SELECT revision, records FROM later_plans WHERE login = ?",
(self._normalize_login(login),), (login,),
).fetchone() ).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( def apply(
self, self,
@ -152,7 +210,7 @@ class LaterStore:
row = connection.execute( row = connection.execute(
"SELECT revision, records FROM later_plans WHERE login = ?", (login,) "SELECT revision, records FROM later_plans WHERE login = ?", (login,)
).fetchone() ).fetchone()
snapshot = self._snapshot(row) snapshot, legacy_plan = self._snapshot(row, login)
records = dict(snapshot["records"]) records = dict(snapshot["records"])
revision = snapshot["revision"] revision = snapshot["revision"]
accepted: list[str] = [] accepted: list[str] = []
@ -161,15 +219,19 @@ class LaterStore:
# Existing databases predate per-item revisions. Conservatively mark # Existing databases predate per-item revisions. Conservatively mark
# active deferrals as changed at the latest known plan revision. # 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: for item_id in records:
connection.execute( if item_id not in item_revisions:
"INSERT OR IGNORE INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)", stored_item_id = self._cipher.seal(
(login, item_id, revision), item_id, binding=f"item-revision:{login}"
) )
item_revisions = dict(connection.execute( connection.execute(
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?", "INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
(login,), (login, stored_item_id, revision),
).fetchall()) )
item_revisions[item_id] = revision
stored_item_ids[item_id] = stored_item_id
batch_start_item_revisions = dict(item_revisions) batch_start_item_revisions = dict(item_revisions)
for operation in operations: for operation in operations:
@ -218,21 +280,32 @@ class LaterStore:
revision += 1 if changed else 0 revision += 1 if changed else 0
if changed: if changed:
item_revisions[item_id] = revision item_revisions[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( connection.execute(
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?) " "INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
"ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision", (login, stored_item_id, revision),
(login, 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) self._record_operation(connection, login, operation_id)
accepted.append(operation_id) accepted.append(operation_id)
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True) serialized = self._sealed_records(login, records)
if row is None: if row is None:
connection.execute( connection.execute(
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)", "INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
(login, revision, serialized), (login, revision, serialized),
) )
elif accepted: elif accepted or legacy_plan:
connection.execute( connection.execute(
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?", "UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
(revision, serialized, login), (revision, serialized, login),

View File

@ -1,3 +1,4 @@
import json
import sqlite3 import sqlite3
import httpx import httpx
@ -5,6 +6,10 @@ import pytest
from src import main from src import main
from src.later_store import LaterStore from src.later_store import LaterStore
from src.state_encryption import PrivateStateEncryptionError
PRIVATE_KEY = b"l" * 32
@pytest.mark.anyio @pytest.mark.anyio
@ -65,6 +70,105 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path
) == {"revision": 2, "records": {}} ) == {"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): def test_next_day_today_handoff_is_preserved_without_changing_legacy_deferrals(tmp_path):
store = LaterStore(tmp_path / "later.sqlite3") store = LaterStore(tmp_path / "later.sqlite3")