Tier 1 — Confirmation authority¶
Status: implementation-ready specification; implementation not authorized.
Activation gate: Milestone 6 in TIER1_ROADMAP.md;
requires ADR-012.
Related: src/pfsense_mcp/tier1/confirmation.py (existing, unchanged by
this spec — this spec defines the ConfirmationVerifier implementation,
not the already-built evidence/protocol shape).
Purpose¶
Define a concrete, production-usable implementation of the
ConfirmationVerifier Protocol already defined in confirmation.py, so
that an actual human owner — not an LLM assertion, not a plaintext token —
can authorize exactly one Recovery Contract's execution. The Protocol and
the store-side enforcement (store.confirm()) already exist and are
already tested; this spec is scoped to the missing piece: what a real
verifier does.
Security goals¶
- G1: A prompt, chat message, or any LLM-generated boolean can never satisfy confirmation — confirmation requires a cryptographic signature produced by a process the LLM/agent does not control.
- G2: Confirmation evidence for one contract cannot be replayed to confirm
a different contract, even one that happens to target the same pfSense
object (already enforced by
ConfirmationEvidence.verify_bindingsbindingcontract_id/operation_idexactly — this spec's verifier must not weaken that). - G3: The signing key never resides on the host running the MCP server process.
- G4: An expired or revoked signing key cannot produce evidence this verifier accepts, even if the signature itself is mathematically valid.
- G5: The human confirming can see, in full, what they are approving (capability, endpoint, target, intent) before signing — the verifier must not be satisfiable by signing a digest alone without an operator-facing rendering step existing somewhere in the workflow (the rendering step is out of this module's scope but is a hard activation requirement — see Activation requirements).
Invariants¶
- I1: The verifier checks a detached signature over a canonical digest of
every
ConfirmationEvidencefield exceptproof— against exactly one pinned public key identified byevidence.authority_id. Implementation note (corrected during Phase 2 implementation): an earlier draft of this invariant specified signing overConfirmationEvidence.evidence_digest. That property is circular as a signature pre-image: its own construction includesproof_digest(sha256(evidence.proof)), so the message to sign would depend on the signature that becomesproofalready existing.test_valid_signature_from_active_authority_is_acceptedcaught this with a real signature-verification failure before the fix landed. The corrected, implemented design signs a dedicatedconfirmation_providers.signing_payload(evidence)— a canonicalization of every field exceptproof— andevidence_digestremains reserved for its original, non-circular purpose: binding the already-verified evidence (proof included) intoRecoveryContract.confirmation_digestfor audit purposes, computed only after verification succeeds. - I2:
authority_idmust match a currently-active entry in the verifier's pinned-key table; anauthority_idnaming a retired or unknown key is refused, not matched against "the newest key" or any fallback. - I3: The verifier itself never persists
evidence.proof— it is handed the evidence object, verifies, and returns a boolean, matching the existingstore.confirm()contract exactly (store.pyalready ensures raw proof bytes are never written to the database). - I4: Verification failures never leak why a signature failed (wrong
key, malformed signature, expired key) beyond a generic refusal, in
logs or exceptions — mirroring the existing "sanitized failure class"
discipline used throughout
pfsense_mcp.tier1. - I5: The verifier is synchronous, pure with respect to store state (it
reads its own pinned-key configuration and the passed-in evidence only;
it does not query
store.pyor any other stateful component), so it can be unit-tested without a store fixture.
Trust boundaries¶
| Boundary | Trusted side | Untrusted side | Enforcement point |
|---|---|---|---|
| Signing key vs. MCP host | Owner's separate signing device/workflow | This process, this host | Private key never present here (G3); verifier only ever holds public keys |
| Evidence vs. contract | store.confirm()'s authoritative contract load |
Caller-supplied ConfirmationEvidence |
evidence.verify_bindings(contract) (existing) + this verifier's signature check |
| Human intent vs. agent intent | Operator-facing rendering + signature | Agent/LLM-constructed MutationPlan/intent |
Signature can only be produced by a process holding the private key, which the agent never has |
State ownership¶
src/pfsense_mcp/tier1/confirmation_providers.py(new module) owns the concrete verifier implementation(s):Ed25519ConfirmationVerifier.- The pinned public key table is owned by configuration, loaded the same
way
PfSenseConfigloads other settings — fail-closed, no default — and is a list of(authority_id, public_key_bytes, active: bool)tuples, not a single key, so rotation (I2) is representable without a code change. - The verifier does not own or construct a
SqliteRecoveryContractStore— it is constructed independently and passed into the store atApplication-equivalent construction time (seesealed_executor.mdfor where that wiring lives once activated).
Interfaces¶
# src/pfsense_mcp/tier1/confirmation_providers.py (new; not created yet)
@dataclass(frozen=True)
class PinnedAuthority:
authority_id: str
public_key: bytes # 32 bytes, Ed25519 raw public key
active: bool
class Ed25519ConfirmationVerifier:
"""Concrete ConfirmationVerifier (satisfies the Protocol in
confirmation.py) using detached Ed25519 signatures."""
def __init__(self, authorities: tuple[PinnedAuthority, ...]) -> None:
"""Raises ConfigurationError-equivalent if authorities is empty,
contains duplicate authority_id values, or any public_key is not
exactly 32 bytes."""
def verify(self, evidence: ConfirmationEvidence) -> bool:
"""Looks up evidence.authority_id among active authorities;
returns False (never raises) for: unknown authority_id, inactive
authority_id, malformed proof, or signature mismatch. Signature
is verified over signing_payload(evidence) -- a canonicalization
of every field except proof (see Invariant I1's implementation
note for why evidence_digest is unsuitable here) -- using the
pinned public key. algorithm field must equal a fixed accepted
string (e.g. "ed25519-v1") or verification returns False."""
Signing-side tooling (a separate CLI/script, not part of the MCP
server process, intentionally outside pfsense_mcp entirely) is
out of scope for this spec but is a hard activation requirement — see
below.
Failure modes¶
| Failure | Detection | Resulting state | Automatic retry |
|---|---|---|---|
| Wrong/forged signature | Ed25519 verification failure | verify() returns False → store.confirm() raises ConfirmationError |
No |
Unknown authority_id |
Lookup miss in pinned table | verify() returns False |
No |
Retired authority_id (rotated out) |
active=False in pinned table |
verify() returns False |
No |
| Malformed proof bytes (not a valid signature encoding) | Signature library raises → caught, returns False |
Same as above | No |
| Verifier misconfigured (empty authority table) | Constructor-time validation | Refuses to construct — server cannot start with confirmation enabled and no authority, closing the "no verifier configured" gap from the other direction | No |
Recovery behavior¶
- This module holds no durable state of its own — all state is either the
static configuration (pinned keys) or delegated to
store.py's existing crash/restart handling. There is nothing new to recover. - Key rotation (revoking one
authority_id, adding another) takes effect on next process start (configuration reload is not a requirement for v1) — an in-flightPREPAREDcontract awaiting confirmation under a key that gets rotated out mid-wait will simply fail confirmation and eventually expire toEXPIRED, which is the correct, safe outcome (no special-cased "grandfather the old key for pending contracts" logic, which would reintroduce exactly the kind of implicit trust-extension this system is designed to avoid).
Non-goals¶
- This spec does not implement the signing-side tool the human owner uses
to review and sign a confirmation request. That tool is deliberately
outside
pfsense_mcp(it must not run on the same host/process as the thing being confirmed, per G3) and is a separate deliverable, tracked as an activation requirement below, not part of this module. - This spec does not implement hardware-backed signing (e.g., a hardware
security key).
ADR-012records this as a future upgrade path that requires no change toEd25519ConfirmationVerifier— only the signing tool changes, since the verifier only ever sees a public key and a signature, regardless of where the private key lived. - This spec does not implement a nonce-replay database. Per the
architecture review, replay is already prevented structurally by
contract-id-exact binding and single-use-per-contract confirmation; see
ADR-012for the explicit reasoning and the condition under which this decision should be revisited.
Required tests¶
- Valid signature from an active pinned authority →
verify()returnsTrue. - Valid signature from a key not in the pinned table →
False. - Valid signature from a retired (
active=False) authority →False. - Signature over a different payload than
signing_payload(evidence)(e.g., signed the raw contract_id instead) →False. - Malformed/truncated
proofbytes →False, no unhandled exception. - Empty authority table at construction → refuses to construct.
- Duplicate
authority_idat construction → refuses to construct. - End-to-end test through
store.confirm(): a full create → transition(PREPARED) → confirm() flow usingEd25519ConfirmationVerifier, both success and refusal paths, replacing the synthetic_AcceptingVerifiertest double currently used intests/tier1/test_store.pyfor at least one representative test (the synthetic double should remain for tests that aren't specifically about the verifier's own correctness, to keep those tests focused).
Activation requirements¶
- [x]
ADR-012accepted (algorithm, key custody model). - [x]
confirmation_providers.pyimplemented and tested (tests/tier1/test_confirmation_providers.py, 9 tests). - [ ] A separate, reviewed signing-side workflow/tool exists and is
documented in an operator runbook — this is a hard gate: a verifier
with no usable signing tool is unusable, and building the tool
hastily under deployment pressure is how key-custody mistakes
happen. Not built in this pass — genuinely separate tooling
outside
pfsense_mcp, per this spec's own Non-goals. - [ ] The operator-facing rendering step (G5 — showing the human what
they're about to approve) is implemented somewhere in the
confirmation workflow and reviewed for completeness (it must show
capability, endpoint, target identity, and intent in
human-readable form, not just digests) before this module is wired
into any executor. Deferred to the signing-tool deliverable above.
W3 Slice 5A (2026-08-15) closed the artifact-schema gap that
previously made this box unsatisfiable at all:
PendingConfirmationRequest(schema version 2,tier1/artifact_exchange.py) now carries the plaintextalias_name/previous/requested description fields G5 requires, non-authorizing, alongside the digest fields it already carried. The signing-tool deliverable itself — the actual operator-facing rendering and signing workflow — remains unbuilt; this box stays unchecked until it exists. - [x]
cryptography's Ed25519 support (already available once the dependency is added forprotected_artifact_encryption.md; no second library needed) confirmed sufficient — no new dependency required beyond what encryption already introduces.
Implementation checklist¶
- [x] Create
src/pfsense_mcp/tier1/confirmation_providers.py. - [ ] Add configuration loading for the pinned-authority table (fail
closed on empty/malformed, no default, following
config.py's existing "missing required value fails closed" pattern). Deferred to Phase 3 (sealed_executor.md):PinnedAuthorityconstruction from environment/config only makes sense once something actually constructs the executor that consumes it — building that wiring now, with no caller, would be exactly the premature scaffolding this project's conventions warn against. - [x] Implement
Ed25519ConfirmationVerifierexactly per Interfaces. One addition beyond the original interface sketch: a module-levelsigning_payload(evidence)function, made necessary by theevidence_digestcircularity fix (see Invariant I1). A second addition, made oncereconciliation_authority.mdneeded the near- identical mechanics:PinnedAuthority/the pinned-key verification loop were extracted into a shareded25519_authority.pymodule after writing the same logic twice, not designed up front —confirmation_providers.pynow re-exportsPinnedAuthorityfrom there for import-path compatibility. - [ ] Build the separate signing-side CLI as its own deliverable (outside
pfsense_mcp, likely its own small script/repo) — track as a distinct implementation task, not a subtask of this module.
Review checklist¶
- [ ] Confirm
verify()never raises — every failure path returnsFalse, matchingstore.confirm()'s existingexcept Exception: raise ConfirmationError(...) from Nonewrapper expectations (defense in depth: even ifverify()did raise,store.pyalready sanitizes it, but the verifier itself should not rely on that). - [ ] Confirm the
algorithmfield is checked against a fixed accepted value, not accepted as any string — this prevents a downgrade attack where evidence claims a weaker/different algorithm. - [ ] Confirm the signing tool (reviewed separately) never has network access to the MCP server's host, or if it does, that access is read-only/one-directional (receiving the rendering, never able to push a signature automatically without human action).
Security checklist¶
- [ ] No private key material anywhere in
pfsense_mcp— grep the entire package for any private-key-shaped constant or fixture; only public keys are ever loaded byconfirmation_providers.py. - [ ] Confirm
PinnedAuthority.public_keyvalidation rejects anything that isn't exactly 32 bytes (Ed25519 public key length) — no lenient parsing. - [ ] Confirm failure messages are generic ("confirmation refused"), never distinguishing "wrong key" from "unknown authority" from "malformed signature" in any user-visible or logged text (I4).
Test checklist¶
- [ ] Valid/invalid signature tests (all failure modes above).
- [ ] Retired-authority refusal test.
- [ ] Algorithm-downgrade refusal test.
- [ ] Empty/duplicate authority-table construction refusal tests.
- [ ] End-to-end
store.confirm()integration test using the real verifier. - [ ] Negative test: module has zero forbidden imports (AST isolation).