Testing¶
Three layers, each catching what the others cannot: host tests against a simulated chip, mutation testing of those tests, and hardware-in-the-loop runs on a real dongle with real clients.
Philosophy¶
- Test first. A test is written and seen failing before the code that makes it pass.
- A test that never fails proves nothing. Every new test is checked by planting a bug (a mutant) and confirming the test catches it.
- One fault at a time, everywhere. Power cuts and lost replies are injected at every chip call of an operation; the device must end before or after the operation, never in between in a way that grants access or leaks a secret. Where the hardware makes a lockout unavoidable (an interrupted last PIN try), the test pins that down.
- Expected values come from outside. Standard test vectors, an independent
implementation (Python
cryptography), and real clients (gpg, libfido2, browsers). A test that only compares the code with itself can agree with its own bug. - Review before code and after. Designs that touch the chip are reviewed against the vendor documentation before implementation; code is reviewed after.
- Hardware has the last word. The simulator models only what is known. Real
hardware found bugs the simulator could not show (USB timing between the host's
pcscdand the device), so every milestone ends on the dongle.
Host tests¶
The firmware's C code is compiled for the build machine and runs against
tests/host/mock_tropic.c, a simulated TROPIC01 with key slots, data slots,
MAC-and-Destroy, counters and fault injection (a cut before or after any call, a
lost reply, a damaged slot). AddressSanitizer and UndefinedBehaviorSanitizer are on.
nix build .#host-tests # or: nix flake check
| Program | Covers |
|---|---|
test_cbor, test_ctap2_parse |
CTAP2 input validation and parsing, nesting limits, truncation |
test_cose |
COSE key and signature encodings, DER edge cases |
test_pin_token |
permission tokens: permissions, RP binding, lifetime |
test_fido_state, test_fido_pin_cmd |
FIDO state recovery, checked reset, PIN commands |
test_l3_result |
which chip results keep or drop the session |
test_trezor_rng |
TRNG failure stops the device, never returns a value |
test_rec_pair |
record pairs: every combination of copies, repair, uncertain writes |
test_pin_md_kernel |
the MAC-and-Destroy PIN binding: setup, verify, change, the last-try case, faults at every call |
test_openpgp_state, test_openpgp_applet |
OpenPGP storage and APDU behaviour, PIN rules, lifecycle, fault sweeps over generate and reset |
test_pgp_dec |
decryption-key seal: AES-GCM test vector, a golden record made outside the code, tamper on every field, nonce freshness, no key or PIN in any data slot |
test_ctaphid (release and debug), test_user_presence, test_rpc_gate |
CTAPHID transport: KEEPALIVE cadence and status, a busy or slow IN endpoint, INIT resync and CANCEL during a request, reply ordering; touch wait; debug lt-rpc held while FIDO2 runs |
test-dfu-flash.sh, test-lib-fixture.sh |
the flashing script's error handling, the PIN fixture loader |
Mutation testing¶
For each milestone, bugs were planted by hand in the new code (a skipped check, a wrong constant, a missing wipe) and the host tests run against each. A surviving mutant means a missing test. In Phase 9 more than 150 mutants were planted; every one was caught except those that change nothing by construction. A code reviewer then planted further mutants against the tests themselves; the survivors became new tests.
Hardware in the loop¶
The suites run against a real dongle. They change its state (they generate keys and
reset the applications), so they use a dedicated test dongle and the PINs in
tools/fixture.env (copied from tools/fixture.env.example, never committed).
| Command | What it does |
|---|---|
nix run .#validate |
FIDO2 (validate-fido) and OpenPGP (validate-openpgp) basic suites |
tools/validate_fido_m3.py |
60 FIDO2 checks: tokens, RP binding, discoverable credentials, alwaysUv, credMgmt, cancel, reset at plug-in |
tools/validate-openpgp-m4.sh |
42 OpenPGP APDU checks: PIN separation, C4, change, Reset Code, decryption-key lifecycle |
tools/validate-pin-md.sh --destroy-my-pin |
spends real PIN tries to show the chip-enforced limit |
gpg in a throwaway GNUPGHOME |
key generation on a fresh card, sign, decrypt, PIN change and reset |
The runs go through a shared terminal so the owner sees every prompt and presses SW1 when asked. Results per milestone are in the milestone log.
KEEPALIVE timing¶
The debug build prints one line per CTAPHID request: handler time, KEEPALIVE count, and
the longest gap between packets until the answer is accepted by the USB stack. v0.3.1,
validate_fido_m3.py on the dongle, 19 October 2026:
| Request | Longest gap | What fills it |
|---|---|---|
| GetInfo, MakeCredential, credential management, authenticatorConfig | about 108 ms | one chip operation |
| GetAssertion, GetNextAssertion | 210 ms | the final ES256 signature |
| ClientPIN token, setPIN, changePIN | 251 ms | P-256 key agreement in software on the STM32 |
| Reset | 250 ms | a new P-256 key pair in software after the wipe |
CTAP recommends a KEEPALIVE at least every 100 ms (a SHOULD over USB); the software P-256 steps cannot be interrupted. Windows captures show a longest silence of 240 ms.
Review¶
Each Phase 9 milestone had:
- a design document reviewed before code by a reviewer checking every chip statement against the official TROPIC01 datasheet, API reference and application notes;
- a C code review after implementation, which also probed the tests' blind spots;
- a final hardware run on both builds.
Limits¶
- The simulated chip encodes what the vendor documents say; it cannot prove the real chip's behaviour under power loss.
- No fault injection on hardware (deliberately: one dongle, irreversible risks).
- No fuzzing campaign yet; the CTAP2 parser has bounded-input tests.
- No independent audit or certification test suite.