Architecture¶
System view¶
flowchart TB
subgraph host["Host"]
browser["Browser / libfido2"]
gpg["gpg · scdaemon · ssh"]
pcsc["pcscd + libccid"]
gpg --> pcsc
end
subgraph mcu["STM32U535 · nixtropic firmware"]
usb["TinyUSB composite device"]
fido["FIDO2 / CTAP2<br/>fido_hid/"]
pgp["OpenPGP card applet<br/>openpgp/ · ccid/"]
state["Persistent state<br/>record pairs · M&D PIN kernel<br/>tropic/"]
lt["libtropic 3.2.1<br/>L1 SPI · L2 framing · L3 session"]
sw["trezor-crypto<br/>SHA-256 · HMAC · AES-GCM · X25519 · P-256"]
usb --> fido & pgp
fido & pgp --> state --> lt
fido & pgp --> sw
end
subgraph se["TROPIC01 secure element"]
ecc["32 ECC key slots"]
md["128 MAC-and-Destroy slots"]
rmem["512 data slots (R-memory)"]
misc["TRNG · counters · pairing keys"]
end
browser -- "USB HID (CTAPHID)" --> usb
pcsc -- "USB CCID (APDUs)" --> usb
lt -- "SPI1 · Noise_KK1 encrypted session" --> se
The STM32 does protocol work: USB, CTAP2 and OpenPGP card parsing, PIN logic, persistent-state bookkeeping, and the cryptography the chip does not offer. The TROPIC01 holds keys and secrets, signs, provides randomness, and enforces PIN retry limits through MAC-and-Destroy.
What runs where¶
| Operation | Where | Notes |
|---|---|---|
| FIDO2 signatures (EdDSA, ES256) | TROPIC01 | private key generated in a chip slot, never exported |
| OpenPGP signing and SSH authentication (Ed25519) | TROPIC01 | same |
| OpenPGP decryption (X25519) | STM32 | the chip has no user-key ECDH; the key is stored sealed (see security) |
| ES256 message hashing | STM32 | libtropic hashes, the chip signs the 32-byte digest |
| FIDO PIN protocol (ECDH P-256, AES, HMAC) | STM32 | CTAP2 ClientPIN, PIN/UV protocol 1 |
| PIN retry enforcement | TROPIC01 | MAC-and-Destroy, see design |
| Random numbers for keys, nonces, PIN secrets | TROPIC01 TRNG | |
| Blinding inside the P-256 software arithmetic | STM32 TRNG | a TRNG failure resets the device instead of returning a substitute value |
| Secure session to the chip | both | X25519 handshake + AES-256-GCM per command |
USB interfaces¶
| Build | Interfaces | Product string |
|---|---|---|
Release (.#open-firmware) |
IF0 HID FIDO (usage page 0xF1D0) · IF1 CCID (OpenPGP) |
nixtropic |
Debug (.#open-firmware-debug) |
IF0/1 CDC log console · IF2 vendor HID (lt-rpc) · IF3 HID FIDO · IF4 CCID | nixtropic debug |
USB full speed, VID:PID cafe:4001. Debug code is compiled out of the release build,
not hidden; a Nix check (release-surface) fails the build if debug symbols reach
the release image.
The FIDO interface speaks CTAPHID (CTAP 2.2 USB transport) with selected CTAP 2.1 commands; the CCID interface carries ISO/IEC 7816-4 APDUs for the OpenPGP card 3.4.1 application. Links and sections: references, Protocols.
Firmware modules¶
| Directory | Responsibility |
|---|---|
platform/ |
clocks (48 MHz), GPIO, LED, SPI1, STM32 RNG self-test |
usb/ |
TinyUSB integration, descriptors, CCID class driver |
fido_hid/ |
CTAPHID framing, CTAP2 commands, CBOR, COSE, ClientPIN, credential store, user presence |
ccid/ |
ISO 7816 APDU dispatch |
openpgp/ |
OpenPGP card 3.4.1 applet, PINs, key operations, decryption-key wrap, lifecycle |
tropic/ |
the only module that calls libtropic: session handling, record pairs, MAC-and-Destroy kernel |
hid_rpc/ |
lt-rpc vendor interface, debug build only |
Request handling¶
USB callbacks only collect data. Requests run from the main loop, one at a time:
CTAP2 from fido_hid_task(), APDUs from usb_ccid_task(). While a request waits for
a button press, other requests are answered busy (CTAPHID) or held (CCID) — never run
in between. See design decision 18.
Boot order¶
Clock → GPIO → SPI → USB clock → STM32 RNG self-test → TROPIC01 session → FIDO state and PIN → then USB. Starting USB last means the host never talks to a device that cannot answer yet (decision 21). A failing stage blinks a distinct LED pattern.
Persistent state¶
Everything the dongle remembers lives in the TROPIC01. This is the current layout (storage schema of v0.3.0).
Key slots (ECC)¶
| Slots | Owner | Content |
|---|---|---|
| 0–28 | FIDO2 | one credential key each (Ed25519 or P-256) |
| 29 | OpenPGP | signature key (Ed25519) |
| 30 | — | reserved, unused (the decryption key cannot live in a slot) |
| 31 | OpenPGP | authentication key (Ed25519) |
Data slots (R-memory)¶
Objects O1–O6 are record pairs: two slots holding the same record, written one after the other and each read back before the write counts. A record carries a magic, object id, schema, generation counter and a truncated SHA-256 checksum (an integrity check, not a MAC). One damaged slot or one interrupted write never rolls back an acknowledged write; the availability limits are in the security model. Credential metadata is a single slot per credential.
| Object | Content | Slots |
|---|---|---|
| — | FIDO credential metadata (one slot per credential, written once) | 1–29 |
| O1 | FIDO global state: credential bitmap, alwaysUv, PIN-set flag |
0 / 40 |
| O2 | FIDO PIN binding | 41 / 42 |
| O3 | OpenPGP state: lifecycle, data objects, counters, touch flag, sealed decryption key (schema 2) | 33 / 43 |
| O4 | OpenPGP PW1 binding | 50 / 53 |
| O5 | OpenPGP PW3 binding | 51 / 54 |
| O6 | OpenPGP Reset Code binding | 52 / 55 |
An empty read is never trusted to mean “fresh device”. A missing PIN binding is checked against a guard elsewhere: the FIDO PIN against a flag in O1, PW1 and PW3 against the OpenPGP lifecycle in O3, the Reset Code against its “set” flag in O3. If the guard says the binding should exist, the PIN reports an error instead of “not set”. A missing O1 or O3, which has no guard, triggers a checked wipe of its whole application before defaults are written.
MAC-and-Destroy slots¶
Each PIN uses two ranges (A/B); a PIN change writes the unused range, commits, then retires the old one.
| PIN | Range A | Range B |
|---|---|---|
| FIDO PIN (8 tries) | 0–7 | 8–15 |
| OpenPGP PW1 (3) | 16–18 | 19–21 |
| OpenPGP PW3 (3) | 22–24 | 25–27 |
| OpenPGP Reset Code (3) | 28–30 | — |
Counters¶
Monotonic counter 0 holds the FIDO signature counter (the chip's counters count down;
the firmware reports MAX − value).
Build¶
flowchart LR
lock["flake.lock<br/>pinned inputs"] --> nix["nix build"]
nix --> rel[".#open-firmware<br/>release"]
nix --> dbg[".#open-firmware-debug"]
nix --> stock[".#stock-firmware"]
nix --> tests[".#host-tests"]
nix --> docs[".#docs"]
The toolchain is nixpkgs' gcc-arm-embedded (Arm GNU 15.2 on nixos-26.05). CMake
builds the firmware; nothing is fetched at build time. nix flake check runs the host
tests, the release-surface check and the strict docs build.