security: encrypt synchronized Later state (Closes #1114)
This commit is contained in:
parent
9763bb410b
commit
f4d8d64b9d
10
README.md
10
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user