The board signs with either secure element. It finds out which one is on I2C0 at every boot and
uses the same console, the same pin (QUORUM_EXPECT) and the same Safe owner scheme (a P-256
passkey signer). The Trust M gets its own profile, quorum-tm1, and its own ceremony. Both
were built and tested in the emulator first. On a real chip (TM1), every step through the seal has
run (T7, 2026-09-29), and a Trust M approval has executed onchain (Base Sepolia, 2026-09-30). T9,
freezing the spare objects, ran on a sacrificial chip (TM2, 2026-10-01) along with the refusals a
sealed and frozen chip must give; TM1 is next. The shareable docs for a mainnet
Safe are in mainnet/.
Decisions (2026-09-28):
signer.detect():
i2c.scan().scan() alone misses it: on the real board scan() returned [] with a Trust M on the bus.signer.load(soft, pins) then picks one:
| On the bus | Signer |
|---|---|
| ATECC608 (0x60, 0x35, 0x6A) | ChipSigner, as before |
| Trust M (0x30) | TrustMSigner |
| both | the pinned one (QUORUM_EXPECT); none pinned: the ATECC, and it logs that |
| neither | NoSigner (production), or the software key (development) |
The console never names a chip type. It goes through serial(fresh), pubkey(), sign(),
key_row(), profile(), use_counter(), needs_ceremony() and info(). main_dual.which()
routes a fresh or half-done Trust M to the ceremony, the same as an ATECC.
A Trust M pin names the object instead of a slot number:
{"serial": "0a091b5c000b0062006c", "slot": 0xE0F1, "owner": "0x…", "locked": True, "chip": "trustm"}
serial[-8:] show the wafer position.| Object | What it holds | Factory state (read off the real chip) | After the seal |
|---|---|---|---|
| E0F1 | the Safe key, P-256, key usage Sign, Execute ALW | empty, LcsO creation, Change LcsO<op |
LcsO operational: GenKeyPair can never run on it again |
| F1D0 | the record, 110 bytes: PQ, v2, profile, serial, key OID, qx, qy, owner, date, build |
140 bytes, zeros, Change LcsO<op, Read ALW |
frozen |
| F1D1 | the binding, 83 bytes: PB, v1, E0F0’s (r, s) over binding_digest, SHA-256(cert)[:16] |
as F1D0 | frozen |
| E0F0 | Infineon’s factory key, certified in E0E0 | Change NEV, Execute ALW, P-256, usage Auth | untouched |
binding_digest = SHA-256("PicoQuorum Trust M key binding v1" ‖ UID ‖ E0F1 ‖ qx ‖ qy), signed by E0F0.
TrustMAttest.sol can check E0F0’s certificate on chain today; checking the binding on chain is a later step.LcsO < operational, so the seal freezes them.firmware/provision_tm.py)| Step | What | Reversible? |
|---|---|---|
| T1 Pre-flight | UID, LcsA, security events. The certificate is verified against Infineon’s CA key on the Pico, and E0F0 signs a probe that verifies under the certificate’s key. Checks E0F1, F1D0 and F1D1 are factory. Saves every object’s metadata to snapshot-tm-<serial>.json |
read-only |
| T2 Make the key | GenKeyPair in E0F1 (ALLOW_GENKEY, the arm, a 3 s hold) | yes, it can be made again until T7 |
| T3 Test | Two signatures (a plain digest and a WebAuthn assertion), verified on the Pico, low s | read-only |
| T4 Bind | E0F0 signs the binding, verified under the certificate | read-only |
| T5 Owner | The owner address, to compare on the laptop | read-only |
| T6 Record | F1D0 and F1D1 written and read back | yes, until T7 |
| T7 Seal | LcsO operational on F1D1, F1D0, E0F1 (ALLOW_LOCK, the arm, a 3 s hold) | no |
| T8 Sealed | Checks everything is sealed, the record matches, the binding verifies, the key still signs, and E0F0 still signs as its certificate’s key (live) with Infineon’s rules unchanged. Registers the chip in chips.json |
read-only |
| T9 Freeze the spares | LcsO operational on every object the profile doesn’t use (tmcfg.SPARES, 23 on the real chip) and on Infineon’s E0F0, E0E0, E140 with their rules as they came (tmcfg.FACTORY_OBJS), except any TRUSTM_KEEP_OPEN names (ALLOW_LOCK, the arm, a 3 s hold) |
no |
| T10 Done | quorum-tm1 sealed, the spares frozen, the key signs; reminds you to turn ALLOW_* off | read-only |
tools/fw ceremony start --unpin); a pinned chip always gets the console.On the board (the full runbook: mainnet/RUNBOOK.md):
tools/fw ceremony start (--unpin for a pinned chip, --keep-open F1D2 to leave a spare open). One yes: ALLOW_* on, the pin out if asked, then the dual image pushed; the board restarts into the ceremony.tools/fw ceremony finish. One yes: ALLOW_* off, pin, the console image pushed and verified.tools/fw attest, which only reads, writes the chip’s public record to docs/mainnet/attest/. tools/quorum verify checks it with no board.The signing console never carries the ceremony (test-vectors/manifest.mjs). Flash was never the
reason: Build 1’s filesystem is 2,560 KB with 2,208 KB free, the console image is 176 KB, and the
Trust M ceremony adds about 22 KB.
X on the banner shows each object’s lifecycle and access conditions, read-only.
The chip: Adafruit 4351, UID cd16338401001c000100000a091b5c000b0062006c801010710809, serial 0a091b5c000b0062006c.
What it is:
1d560a9b, subject “Infineon IoT Node”.Certificate and signing:
p256.verify in MicroPython).The security event counter (E0C5):
Error codes (F1C2): 0x01 unknown OID, 0x07 reading a key object’s data, 0x0B CalcSign with an empty key.
T1–T6 passed on this chip, driven headlessly from the laptop (firmware mounted over USB, flags injected in RAM, nothing written to the board’s flash):
ef677807…, qx 0xef6778077c0bf33ca82391a38d941cf33515da7900f02931e7c83399ee6817190x47c0998C6a7A1955794071b9f4058e7814178f35, the same on the Pico and on the laptop (viem)Not yet seen on silicon: whether GenKeyPair raises the security event counter (TM2’s T2 read 0 after, but on an idle credit).
Serial 0a091b5c000b00620076, same batch as TM1 (firmware 80101071, build 0809, CA 101,
certificate 34dc2cab). The whole ceremony on the device (the Waveshare board, dual image), then
the refusals from the laptop (tools/board/tm_refusals.py, which runs only on the serial it names).
The ceremony: T1–T10 all green, security counter 0 throughout. Every step’s result is in
the board’s snapshot-tm-<serial>-steps.json.
C0 01 to C0 07). The security counter read 0
before and after.The refusals, each tried for real:
| Tried | Answer |
|---|---|
| GenKeyPair on the sealed E0F1 | refused, error 07 |
| SetDataObject on the sealed F1D0, the same bytes | refused, 07 |
| SetDataObject on a frozen spare (F1D2) | refused, 07; still zeros |
| GenKeyPair on a frozen spare key slot (E0F2) | refused, 07 |
| Lowering a frozen lifecycle (F1D3, op to init) | refused, 05 |
| Changing a frozen object’s Change condition (F1D4 to ALW) | refused, 07 |
Afterwards the key still signed as the record’s key, F1D0 was unchanged, and no metadata had
moved. So “sealed” and “frozen” mean what this profile says: nothing can make a key, write data
or change the rules of an operational object with Change LcsO<op. tools/quorum verify on
TM2’s record passes every line, “23 of 23 frozen” included.
The emulator now gives the same codes (trustm_sim.py: 05 for a lower lifecycle, checked
before the access condition; 07 for the rest).
Every other way to rewrite it (tools/board/tm_rewrites.py, later that night):
| Tried on TM2 | Answer |
|---|---|
| Frozen F1D2: a write at offset 0 or 100, an erase-and-write | refused, 07 |
| Frozen F1D2: a write past the end (offset 140) | refused, 08 (the bounds are checked first) |
| Frozen F1D2: a write of nothing | refused, 04 |
| Sealed F1D0: one byte at 109 | refused, 07 |
| Frozen F1E0, E0E1 (cert slot), E0E8 (trust anchor), E120 (8 bytes), the UID | refused, 07 |
| Frozen counter E120: count +1 | refused, 0E |
| Frozen F1D2: Read NEV, or LcsO op + Change ALW | refused, 07 |
| Sealed E0F1: Execute NEV | refused, 07 |
| Frozen F1D2: LcsO op again (the same value) | accepted, nothing changes |
| An empty metadata TLV | refused, 05 |
| LcsA lowered to initialization | refused, 05 |
| E0F0 (factory key, LcsO creation): Change NEV to ALW | ACCEPTED |
| E0F0, E0E0, E140: LcsO raised to operational | accepted |
tools/fw attest has the factory key sign a challenge from the laptop, and tools/quorum
verify checks it (live:) along with E0F0/E0E0’s Change NEV. TM2’s record now fails both.The security event counter under a burst (tools/board/tm_burst.py; raw numbers in the local
chips/trustm-0a091b5c000b00620076-sec.json):
trustm.wait_response). The driver
reports “no response” while the chip is still working. A normal approval never gets near this,
so the console keeps the 1 s wait. A console under a burst would show a failed signature,
which is fine.The console on the real chip (2026-09-28). The dual image ran with TM1 pinned as locked: False, against the laptop’s mock Safe (tools/quorum serve), with nothing on chain.
0x47c0…8f35, and you approved #0.The real chip lists 23 objects that quorum-tm1 doesn’t use, all with the factory Change condition
LcsO < op:
T9 raises each one’s LcsO to operational, and nothing else, exactly as T7 did for the key. From then on nothing can write them or make a key in them. Before it writes, T9 reads each object again and touches only one that is still as it came.
Infineon’s own objects too (since TM2’s rewrite tests). The factory key E0F0, its certificate
E0E0 and the binding secret E140 also come at LcsO creation. TM2 showed that, below operational,
an object’s metadata can be rewritten whatever its Change condition says, so T9 raises their
lifecycle as well and leaves their rules as Infineon set them: E0F0 and E0E0 Change NEV, E140
LcsO < op || Conf(E140). E140 then can’t be read any more, which is what it’s for. That makes 26
objects in all. A factory object whose rules already differ counts as tampered, and T8 stops.
Why freeze them. The ATECC profile freezes every byte of its chip; until T9 a Trust M had three frozen objects and 23 writable ones. Writable spares don’t endanger the key. Frozen, the chip has nothing else on it to explain.
Keeping one open. TRUSTM_KEEP_OPEN = ["E0F2", "E0F3"] in secrets.py (or tools/fw ceremony
start --keep-open E0F2,E0F3) keeps spares open, such as the spare key slots for a second key later
(mainnet/CONFIGURATION.md).
The console’s Chip page shows them: “all 26 frozen” (23 spares and Infineon’s three), or which are still open. It reads them when the page opens, never at boot.
While it signs, the console shows short facts read from the chip itself (tmcfg.info_lines, quorum.chip_info):
log.txt records it.E0F1 locked · Trust M, and a Security row where the ATECC has Key uses.Walkthrough: docs/quorum-ui/README.md, iteration 5.
trustm_sim.py: a virtual Trust M at 0x30. It speaks the registers, the IFX frames and the APDUs above. Its factory state is the real chip’s, and its certificate comes from a test CA that tmcfg accepts only off the Pico.tools/emu chip takes trustm-fresh, trustm-sealed, both, or atecc.node test-vectors/run-trustm.mjs runs 70 checks:
tools/quorum shots trustm redraws the walkthrough’s Trust M screens.