Tier 1 — Whole-store anti-rollback¶
Status: implementation-ready specification; implementation not authorized.
Activation gate: Milestone 3; requires
ADR-011.
Related: tests/tier1/test_store.py::test_whole_store_rollback_remains_
an_explicit_external_anchor_blocker (existing, executable proof of the
gap this spec closes).
Purpose¶
Detect and refuse the one class of attack the current store cannot defend
against by construction: an attacker (or a careless backup/restore
procedure) replacing the entire SQLite file with an older, internally
self-consistent, correctly-HMAC-authenticated copy. Every record-level
check in store.py passes against such a copy today, because the HMAC
key and verification logic were the same when the old copy was genuine.
This spec adds one additional check — comparing store state against an
independent monotonic anchor — that a same-file, same-directory
attacker cannot roll back along with the database.
Security goals¶
- G1: Restoring any earlier, internally-valid copy of the store is detected at the next store operation, before any contract state is trusted or any mutation is permitted.
- G2: The anchor itself cannot be rolled back by an attacker who can only modify the store's own file or directory — it must live outside that blast radius (independent device, independent host, or independent administrative domain).
- G3: Anchor unavailability at startup or at any mutating operation is a hard stop for mutation, never a silent skip of the check.
- G4: The anchor check does not require the anchor to be consulted on every read-only operation (load/audit inspection) — only before a mutating state transition is allowed to proceed — so anchor unavailability degrades to "read-only, cannot mutate," not "server unusable."
Invariants¶
- I1: The store maintains a monotonically increasing high-water mark
(the maximum
state_versionever committed, summed across all contracts, or an equivalent strictly-increasing counter) that is reported to the anchor on every committed state transition. - I2: Before any transition into
EXECUTING(the state that authorizes an actual send), the anchor's currently-recorded value must exactly equal the store's own last-persisted high-water mark — not merely be greater-than-or-equal. Under normal single-writer operation the two are always equal immediately before a new attempt: the previous attempt advanced the anchor by exactly one and persisted that same new value. A restored older store file remembers a smaller mark than the (untouched, externally durable) anchor now reports (anchor > persisted); a tampered or reset anchor reports a smaller value than the (unaffected) file remembers (anchor < persisted). Both directions are real anomalies and both must refuse — only exact equality proceeds. Implementation note (corrected during Phase 2 implementation): an earlier draft of this invariant specifiedanchor < persistedas the sole detection condition. That check is wrong for the primary threat this subsystem exists to address — restoring an old file makes the file's mark smaller, not the anchor's, soanchor < persistednever fires for a whole-store rollback.tests/tier1/test_anti_rollback.py:: test_whole_store_rollback_is_detected_when_anchor_configuredcaught this with a real DID-NOT-RAISE failure before the fix landed. The corrected, implemented, and tested condition is exact inequality in either direction (current != persisted), not one-sided comparison. A store with no persisted mark yet defaults to comparing against0— deliberately, not treated as "nothing to compare" — because a store restored to a point before its first-everEXECUTINGattempt is indistinguishable, from the file alone, from a genuinely fresh store (seewhole_store_anti_rollback.py::HighWaterMark.read's docstring). The configured anchor must therefore be dedicated to the store and start at0, or be explicitly provisioned to the correct baseline as part of the concrete backend's setup procedure — this is a provisioning responsibility forADR-011's chosen backend, not something this generic class can safely infer. - I3: The anchor update and the store's local transaction commit are not
required to be a single atomic operation (that would require distributed
transactions across two independent systems, which is disproportionate
here) — but the order is fixed: anchor advance must be confirmed
before the local
EXECUTINGtransition commits, never after. A crash between the two leaves the anchor ahead of the store, which is the safe direction (see Failure modes). - I4: The anchor mechanism is pluggable behind one interface (see
Interfaces) so that a TPM-based implementation and a remote-witness
implementation are interchangeable without changing
store.py.
Trust boundaries¶
| Boundary | Trusted side | Untrusted side | Enforcement point |
|---|---|---|---|
| Local SQLite file vs. anchor | Anchor's independently-durable counter | Any copy/backup/restore of the SQLite file | Anchor comparison before EXECUTING (I2) |
| Anchor availability vs. mutation | "Anchor reachable and confirmed" | "Anchor unreachable" | Hard refusal of mutation-path transitions (G3); read paths unaffected (G4) |
State ownership¶
src/pfsense_mcp/tier1/anti_rollback.py(new module) owns theAntiRollbackAnchorprotocol and the store-side high-water-mark bookkeeping helperHighWaterMark.store.pygains one new call site: immediately before thePREPARED -> EXECUTINGtransition's_replace()call, it consults the configuredAntiRollbackAnchor.store.pydoes not know which concrete anchor implementation is in use — it depends only on the protocol.- The anchor's own durable state (TPM NV index value, or the remote
witness log) is owned entirely outside
pfsense_mcp— this repository only owns the client code that talks to it.
Interfaces¶
# src/pfsense_mcp/tier1/anti_rollback.py (new; not created yet)
class AntiRollbackAnchor(Protocol):
def read(self) -> int:
"""Return the anchor's current monotonic value. Raises
AnchorUnavailableError if the anchor cannot be reached/read."""
def advance(self, *, expected_current: int) -> int:
"""Atomically advance the anchor past expected_current and return
the new value. Raises AnchorConflictError if the anchor's actual
current value does not match expected_current (someone else
advanced it, or it moved backward — both are refusals, never
auto-resolved). Raises AnchorUnavailableError if unreachable."""
class HighWaterMark:
"""Store-side bookkeeping: what value does the store believe the
anchor was last confirmed at. Persisted in a dedicated, HMAC-
authenticated `anchor_state` table (schema v3), using the same
per-row integrity discipline as every other authenticated store
field. (Implementation note: landed as its own table, not a
`metadata` key as an earlier draft of this spec suggested — the
metadata table's existing rows are unauthenticated, and this value
specifically needs authentication.)"""
def before_executing_transition(self, anchor: AntiRollbackAnchor, connection: sqlite3.Connection) -> None:
"""Raises WholeStoreRollbackDetected if the anchor's read() value
does not *exactly equal* the persisted high-water mark (both
directions are anomalies — see Invariant I2). Raises
AnchorUnavailableError/AnchorConflictError (propagated) if the
anchor cannot be reached or advanced — caller must treat this
identically to a detected rollback for the purpose of refusing
EXECUTING."""
store.py's transition() gains one parameter,
anti_rollback_anchor: AntiRollbackAnchor | None, defaulting to None
(no anchor configured — see Activation requirements for why None must
itself refuse EXECUTING once this spec is implemented, not silently skip
the check). This is an additive, backward-compatible signature change to
an already-inert module.
Failure modes¶
| Failure | Detection | Resulting state | Automatic retry |
|---|---|---|---|
| Whole-store rollback (old copy restored) | HighWaterMark.before_executing_transition sees anchor behind local mark |
WholeStoreRollbackDetected; every interrupted/executing contract forced to RECONCILIATION at next reconciliation scan |
Never |
| Anchor temporarily unreachable | AnchorUnavailableError from read()/advance() |
EXECUTING transitions refused; PREPARED, load, audit-inspection unaffected |
Caller may retry the anchor call later; the mutation itself is never auto-retried |
| Anchor and store both crash mid-transition, anchor already advanced | On restart, anchor read() ahead of local high-water mark |
Safe: this is the expected "anchor ahead" case, not rollback; store proceeds normally, updates its local high-water mark to match | N/A |
| Two processes race to advance the anchor | advance()'s expected_current mismatch |
AnchorConflictError; caller must re-read and retry the anchor call (not the mutation) or refuse |
Anchor-level retry only, never blind |
Recovery behavior¶
- On every store startup, before
reconcile_interrupted()runs, read the anchor once and compare to the persisted high-water mark. A detected rollback at this point must force every non-terminal contract intoRECONCILIATION, not just ones that wereEXECUTING/ROLLING_BACK— because a whole-store rollback can also reintroduce contracts that were alreadyVERIFIED/FAILEDlocally-terminal but whose real-world outcome is now unknown relative to the restored point in time. - If the anchor is unreachable at startup, the store must still start
(read-only operations continue to work — G4) but must set an internal
"mutation refused: anchor unavailable" flag that every
EXECUTINGtransition attempt checks and reports clearly, rather than failing with a generic error that looks like a different kind of bug.
Non-goals¶
- This spec does not select the anchor backend (TPM vs. remote witness) —
that is
ADR-011. It defines the interface both backends must satisfy. - This spec does not implement distributed consensus, leader election, or multi-writer coordination for the anchor — this server is a single local process; the anchor only needs to detect this process's store being rolled back, not coordinate multiple writers.
- This spec does not attempt to make whole-store rollback impossible (that would require the SQLite file itself to be unwritable by the operating account, which contradicts the server's own need to write it) — only detectable before it can authorize a new mutation. A rollback that only affects already-terminal, already-audited history is a forensic/audit concern, not something this spec tries to prevent.
Required tests¶
- Extend the existing
test_whole_store_rollback_remains_an_explicit_external_anchor_blockertest: after adding the anchor check, the identical rollback scenario must now raiseWholeStoreRollbackDetected(or force reconciliation) at the nextEXECUTINGattempt, where today it silently succeeds — this test is the direct, measurable proof this spec closes the gap the prior review confirmed. - Anchor-ahead test (normal case): store restarts with anchor correctly
ahead → no false positive,
EXECUTINGproceeds normally. - Anchor-unavailable test:
EXECUTINGrefused;PREPARED/load/audit operations unaffected. - Anchor-conflict test (concurrent advance):
advance()raisesAnchorConflictError; store does not silently proceed. - Fake/in-memory
AntiRollbackAnchortest double used throughouttests/tier1/(never a real TPM or network call in offline tests, per existingMockTransport-equivalent conventions).
Activation requirements¶
- [ ]
ADR-011accepted, naming the concrete anchor backend for the actual production deployment target. (Genuine owner/infrastructure decision — blocked pending TPM availability confirmation on the actual production host; not resolved by this implementation pass.) - [x]
anti_rollback.pyimplemented and tested (protocol + store-side bookkeeping; concrete backend deliberately not implemented — see above). - [ ]
store.pymodified to require a non-Noneanchor before anyEXECUTINGtransition is permitted once this spec activates — i.e., theanti_rollback_anchor=Nonedefault must itself become a refusal path at activation time, not a silent bypass. Deliberately not done in this implementation pass:store.py's current behavior preservesanti_rollback_anchor=Noneas "check skipped," not "refused," so the 223 pre-existing Tier 1 tests that drive contracts throughEXECUTINGwithout configuring an anchor continue to pass unchanged. Flipping this default is the correct activation-time behavior but requires ADR-011's backend to be selected first (there is no anchor to require yet) and, separately, updating every existing test that exercisesEXECUTINGto inject a test-double anchor — tracked as follow-up work for whoever performs activation, not deferred silently. - [ ] Anchor backend concretely provisioned and reachable in the target deployment (TPM present and initialized, or remote witness endpoint configured) — verified as part of Milestone 8 (private test- appliance acceptance), not assumed.
Implementation checklist¶
- [x] Create
src/pfsense_mcp/tier1/anti_rollback.pywith theAntiRollbackAnchorprotocol andHighWaterMark. - [x] Add
WholeStoreRollbackDetectedandAnchorUnavailableError/AnchorConflictErrortoerrors.py. - [x] Add the high-water-mark storage to
store.py's schema (schema version bump to 3, per the existing_SCHEMA_VERSION/_verify_schemadiscipline). Landed as a dedicated, HMAC- authenticatedanchor_statetable rather than ametadatakey, as an earlier draft of this spec suggested —metadata's existing rows are unauthenticated, and this value specifically needs authentication (see Invariant I2's implementation note). - [x] Wire the anchor check into
transition()immediately before thePREPARED -> EXECUTING_replace()call — opt-in via the constructor'santi_rollback_anchorparameter (see Activation requirements above for why the default stays permissive for now). - [ ] Implement the chosen concrete anchor backend per
ADR-011as a separate module (e.g.,anti_rollback_tpm.pyoranti_rollback_remote.py) implementing the protocol — keep the protocol and the concrete backend in separate files so the protocol stays swappable. Blocked onADR-011.
Review checklist¶
- [ ] Confirm the anchor check happens before the
_replace()call commits, not after — an after-the-fact check cannot prevent the unsafe transition from having already been recorded. - [ ] Confirm anchor unavailability really does block
EXECUTINGin a test, not just in the docstring. - [ ] Confirm the schema-version bump follows the existing
_verify_schemaexact-match discipline (type/nullability/PK/UK/FK), not just a new column name. - [ ] Confirm a rollback that reintroduces a
VERIFIED/FAILED(locally terminal) contract is still caught by the startup-time anchor check, not only by theEXECUTING-time check (Recovery behavior, second bullet).
Security checklist¶
- [ ] Confirm the anchor backend implementation cannot be satisfied by anything living in the same 0700 directory as the SQLite store — review the concrete backend's storage location explicitly against this requirement (G2), don't just trust the interface name.
- [ ] Confirm
AnchorConflictError/AnchorUnavailableErrormessages contain no key material, no anchor credentials, no store contents.
Test checklist¶
- [ ] Regression test on the existing whole-store-rollback proof (must now detect, not just document, the rollback).
- [ ] Anchor-ahead (normal), anchor-unavailable, anchor-conflict tests.
- [ ] Startup-time detection test for reintroduced terminal contracts.
- [ ] Schema-version bump test (old-schema store refuses to open under
new code, per existing
_verify_schematest pattern).