security: encrypt idempotency ledger at rest (Closes #1122)
This commit is contained in:
1 parent
b278367e3b
commit
3822ec4536
3 files changed
+184
-9
No files matched your search
+51
-4
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, private_state_encryption_key
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -29,12 +30,17 @@ class IdempotencyLedger:
|
||||
max_entries: int,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
clock: Callable[[], float] = time.time,
|
||||
encryption_key: bytes | None = None,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.max_entries = max_entries
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
self.clock = clock
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
store="idempotency-ledger",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
@@ -67,6 +73,17 @@ class IdempotencyLedger:
|
||||
def _fingerprint(value: tuple[Any, ...]) -> str:
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
def _seal(self, value: str, *, key: str, field: str) -> str:
|
||||
return self._cipher.seal(value, binding=f"{key}:{field}")
|
||||
|
||||
def _open(self, payload: str, *, key: str, field: str) -> tuple[str, bool]:
|
||||
value, legacy = self._cipher.open(payload, binding=f"{key}:{field}")
|
||||
if legacy:
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True), True
|
||||
if not isinstance(value, str):
|
||||
raise RuntimeError("idempotency ledger payload is invalid")
|
||||
return value, legacy
|
||||
|
||||
def reserve(self, key: str, fingerprint: tuple[Any, ...]) -> Reservation:
|
||||
encoded = self._fingerprint(fingerprint)
|
||||
now = self.clock()
|
||||
@@ -84,10 +101,40 @@ class IdempotencyLedger:
|
||||
(key,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row[0] != encoded:
|
||||
stored_fingerprint, legacy_fingerprint = self._open(
|
||||
row[0], key=key, field="fingerprint"
|
||||
)
|
||||
if legacy_fingerprint:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET fingerprint = ? "
|
||||
"WHERE key = ? AND fingerprint = ?",
|
||||
(
|
||||
self._seal(
|
||||
stored_fingerprint, key=key, field="fingerprint"
|
||||
),
|
||||
key,
|
||||
row[0],
|
||||
),
|
||||
)
|
||||
if stored_fingerprint != encoded:
|
||||
return Reservation("conflict")
|
||||
if row[1] == "completed":
|
||||
return Reservation("completed", json.loads(row[2]))
|
||||
stored_response, legacy_response = self._open(
|
||||
row[2], key=key, field="response"
|
||||
)
|
||||
if legacy_response:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET response_json = ? "
|
||||
"WHERE key = ? AND response_json = ?",
|
||||
(
|
||||
self._seal(
|
||||
stored_response, key=key, field="response"
|
||||
),
|
||||
key,
|
||||
row[2],
|
||||
),
|
||||
)
|
||||
return Reservation("completed", json.loads(stored_response))
|
||||
if row[3] <= now - self.ttl_seconds:
|
||||
return Reservation("uncertain")
|
||||
return Reservation("pending")
|
||||
@@ -110,7 +157,7 @@ class IdempotencyLedger:
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency_operations "
|
||||
"(key, fingerprint, status, created_at) VALUES (?, ?, 'pending', ?)",
|
||||
(key, encoded, now),
|
||||
(key, self._seal(encoded, key=key, field="fingerprint"), now),
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
||||
@@ -129,7 +176,7 @@ class IdempotencyLedger:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET status = 'completed', "
|
||||
"response_json = ?, completed_at = ? WHERE key = ?",
|
||||
(encoded, self.clock(), key),
|
||||
(self._seal(encoded, key=key, field="response"), self.clock(), key),
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
||||
|
||||
Reference in new issue
Block a user