Formal model
formal/ in the repository holds a TLA+ model of the authenticator’s security
state, the mutation matrix that keeps it falsifiable, and the registry that
ties its properties to the code. This page is the map; the deep prose — every
abstraction with its direction, every hole a review found and what closing it
cost — lives in formal/README.md next to the model itself.
RS-Key is not formally verified, and the model’s own page opens by saying so. What exists is narrower and it is measured: the paragraph to quote is in Testing, under “Formal claims — what is and is not verified”.
The nine modules
RSKeySecurityState.tla models the FIDO security state: PIN retries, the
pinUvAuthToken and its permissions, which transport owns the touch, which
channel owns a stateful walk, the reset window, the persistent gate records,
and the position at which power is lost inside a multi-write flash sequence.
TLC checks its invariants exhaustively at small constants.
RSKeyAppletSeams.tla models what the first module deliberately leaves out:
the applets’ access statuses — PIV, OpenPGP and OATH’s seven doors, what a
SELECT means for each, what a refused authentication costs, and the
access-code removal gate.
RSKeyStore.tla models the flash layer one level beneath both — rsk-fs’s
key/value store over a Storage backend: whether a torn delete can orphan a
file’s metadata, and whether the in-RAM present-cache can read a committed key
as absent. It is a lift of the Rust power-cut oracle (powercut.rs) that had
been reachable only by the fuzzer, and it is the store model the roadmap’s
refinement pilot inducts its persistent-state invariant over.
RSKeyRetryLattice.tla models the retry & recovery budget lattice of the two
applets that have one — PIV (PIN, PUK) and OpenPGP (PW1, PW3, RC): the finite
counter behind each reference, the recovery reference that refills it, and the
anti-bruteforce arithmetic that is identical at every one. It is the part of the
applet surface with no safe oracle — exhausting a real PUK ladder blocks the
card and the only way back takes the keys — so an exhaustive check of every
verify/block/recover interleaving can run only in a model.
RSKeyAppletPolicies.tla covers the four applets’ remaining stateful doors:
PIV NEVER/ONCE/ALWAYS slot policy and freshness spending, OpenPGP algorithm-
attribute invalidation, OATH access-code plus touch gates, and Yubico OTP slot-
code mutation plus its combined use/session replay position. OATH and OTP codes
have no retry counter; keeping this separate avoids proving invented budgets.
All four fit in one exhaustive graph: 2,268 distinct states at depth 14.
RSKeyAdminSurface.tla models the surface above all of them: the
enabled-applications mask, the always-on carve-out that keeps ykman config usb --disable reversible, and the operator-presence gate on the privileged rescue
commands. Two of its four mutants rebuild defects that actually shipped — the
mask that was a DeviceInfo report rather than an enforcement, and the lock-code
write that silently re-enabled every disabled application.
RSKeyTrustedDisplay.tla models the confirm ceremony — the display build’s
anti-phishing promise, what is confirmed is what is shown, as three
machine-checkable rules: an RP-naming operation completes only through the card
that names it, a press that predates the card approves nothing, and no exit but
a deliberate Allow ever reads as Confirmed. Two of its three mutants are
shipped display-build defects.
RSKeyBootHardening.tla models the two machines at the reset line — the
one-shot at-rest scrub lap (EF_HARDENED never lies about superseded
weak-sealed copies, and every lazy re-key re-arms it) and the scratch-word
lock carry (a warm reset moves the whole soft lock, never half of it). It
exists because firmware/ has no host tests by construction: the model is
the only instrument that exercises these interleavings. Its
PowerOnClearsScratch2 assumption is deliberately explicit and still awaits
an RP2350 hardware measurement; TLC does not turn it into a hardware fact.
RSKeyTransport.tla models the CTAPHID frame reassembler — the channel,
sequence and length checks a multi-frame message passes before dispatch: one
host application’s continuation never assembles into another’s message, an
out-of-order frame aborts rather than fills the gap, and a declared length
never overruns the buffer. It is already unit-tested and fuzzed per frame;
the model checks the invariants that live in the interleaving, which those
do not assert.
Refinement pilots
The token pilot connects a small abstract token machine
to the detailed FIDO model and a bounded projection of FidoState. The
cross-reset pilot closes the deliberately deferred reboot
seam for ResetNeverWeakensSurvivingState: concrete reset phases share the
production FID classifier, Kani proves the finite projection inductive, and the
existing rsk-fs oracle drives the full reset through byte-granular power cuts.
The real-board script is a destructive witness, not a proof, and its presence in
the evidence graph does not assert that a current hardware run passed.
Trace validation
The models’ fidelity to the code is kept by hand — citations, mutants,
co-refutation — and one thing none of that measures is whether the code as
it runs stays inside a model’s behaviors. TraceSeams.tla closes that
empirically: a real session recorded from the software emulator is replayed
against the applet-seams model step by step, and a step the model refuses is
a TLC deadlock at that exact position. A second, hand-written session the
model must reject is required to go red, so the replay harness is proven
able to refuse. A green replay is evidence about the recorded sessions, not
a proof about all runs; coverage grows by recording richer sessions.
The checks of the checks
An invariant no defect can violate is the TLA+ analogue of a test that cannot fail, and this tree has been bitten by that class enough times to check for it mechanically:
- every invariant carries mutants — each
Bug*switch rebuilds a real RS-Key defect or removes a defence the tree has, and itsSolo_*.cfgrun must come back RED; - every green run has a floor (
formal/floors.txt) — a GREEN that got smaller than its recorded distinct-state count is reported as FLOOR, because a collapsed state space passes every invariant vacuously and once did; - a vacuous run is named — a spec nothing enabled exits non-zero rather than reading as a pass;
- every scope constant carries a measured minimum (
formal/scopes.txt) — the floor watches the search, this watches the CONSTANTS the search runs over. Three mutants are GREEN one element below the shipped scope: two need a second channel to have somebody to splice into, one needs a second FID to have a record worth losing. A configuration below its recorded minimum is refused; - the source is linted first — two TLA+ traps that leave a spec
well-formed and meaningless (a precedence slip turning an assignment into a
guard, an action pinned to a no-op by its own
UNCHANGED) are refused before TLC runs.
scripts/test_run_tlc.py keeps the runner itself falsifiable in the merge
gate. Its four artificial corruptions are a broken jar, a Solo invariant that
misses its mutant, a one-state VACUOUS run, and a muted Mut switch. Direct RED
and FLOOR cases keep all three job verdict boundaries explicit.
Co-refutation
TLC proving that a model invariant rejects a defect does not show that the
production tests reject the same defect. scripts/comutate.py closes that gap:
each model mutant is an exact patch that re-injects the same semantic defect
into Rust, then runs the smallest relevant host-test slice in a throwaway git
worktree. A failing test is co-refuted; a green slice is an abstraction gap;
a defect made impossible by a shipped structural fix is unreachable only
with recorded evidence. A compile failure is never counted as a kill.
The roadmap’s fixed phase-2 denominator is the original 28 FIDO mutants. The
generated table in formal/README.md records all 28, their target invariant,
model verdict and code-level verdict: 26/28 are co-refuted, two are
unreachable, and none is a gap. Deriving that roster found six real coverage
gaps; each now has a regression harness. Later modules extend the live roster
to 67 entries: all 63 executable patches are killed and four are unreachable.
The applet batch — the 24 seam, retry-lattice and policy mutants — was added
because the roster had a measurable skew: 31 of its first 43 patches sat in
three crates, and the four applet crates four of the nine modules are written
about held none. Measuring them found three more coverage gaps — including the Rust half of
SEC-SEAM-006, whose model half had been closed two revisions earlier — each now
closed by a regression harness. Two further gap verdicts turned out to be the
batch’s own defects rather than the tree’s: an adversarial review found both
patches modelled a different defect from the switch they were named after, and
the faithful versions are unreachable — defence in depth whose removal changes
nothing observable. A red run is not evidence until the reason it went red is
read.
The merge gate cheaply checks the closed roster, patch anchors, expectations,
floors and generated table freshness. The expensive full measurement runs
weekly next to cargo-mutants; run --write-readme publishes the 28-row table
only after measuring every executable phase-2 patch.
The registry
Every property TLC checks has an entry in assurance/properties.toml — id,
statement, source and status, nothing else hand-written. A check.sh row,
scripts/assurance_gate.py, derives the rest per run: which module defines
the property, which configurations check it, which mutants target it, which
Kani harnesses, fuzz targets, Rust files and device tests carry its name. The
gate holds the graph closed in both directions — nothing TLC checks may be
unregistered, nothing registered may be unchecked — and a status must equal
the evidence ceiling: a Kani harness carrying the property’s name forces
BOUNDED, and PROVEN is refused until that evidence class exists in the tree.
The owner functions carry the property back into the code: a doc line of the
form Refines `RSKeySecurityState!NoTokenAfterInvalidation` — SEC-FIDO-003
sits on each function the model’s ownership table names, the gate validates
every tag, and every invariant in all nine shipped baseline configurations must
be named in production Rust somewhere. Firmware sources count as owners for the
boot module. The shared check runs from both assurance_gate.py and
citation_gate.py.
The evidence table and 28-member workspace coverage ledger in
formal/README.md are generated from that same audit. Cross-model Supports
tags close the two FIDO properties whose persistent half is owned by the store
module. The ordinary gate rejects a stale block; regenerate it after evidence
moves with python scripts/assurance_gate.py --write-readme.
assurance/crates.toml is the same discipline one level up: all 28 workspace
members classified — state modelled, modelled in part with the gap named,
unmodelled with the roadmap module named, pure with the differential or proof
files named, or out of scope with a reason. The ledger exists because
enumerating crates from memory has already missed four of them.
Running it
nix develop # pins TLC and exports TLA2TOOLS_JAR
cd formal
./run-tlc.sh safety # model + mutants + floors, ~30 min
./run-tlc.sh liveness # the temporal half — needs a 12g heap
./run-tlc.sh all # both
./run-tlc.sh Shipped.cfg # one configuration
./run-tlc.sh --tiers # what each tier runs, for the gate
python3 ../scripts/assurance_gate.py # the registry, held against the tree
python3 ../scripts/assurance_gate.py --write-readme # refresh its README table
python3 ../scripts/comutate.py --lint # closed roster + patch/table freshness
python3 ../scripts/comutate.py run # re-inject the whole live defect roster
python3 ../scripts/comutate.py run --write-readme # measure + refresh 28 rows
CI runs the safety tier weekly (deep-checks.yml, the formal job) and on
any push touching formal/, so an edit to the model is checked at once. The
liveness tier is deliberately not in CI: Liveness.cfg needs the 12 GB heap
floors.txt records for it, and a hosted runner has already died under less.
The registry and co-refutation lint gates run on every pull request as part of
check.sh; the full co-refutation roster runs weekly.