One board, and this one (decision 2026-09-07). Everything here was measured on the board, not read from a datasheet. Failures in this project are silent: a button on the wrong pin simply "does not work" and never says why. Measure first, believe afterwards.
Threat analysis: threat-model.md. User-facing commands and capacity:
../README.md.
For TOTP over USB-serial the C6 is a complete fit: the USB Serial/JTAG port shows up as a
serial device on any OS without drivers, and the VTC2 protocol on top of it is ours.
It cannot be USB HID, and therefore cannot be a FIDO/passkey key: USB is a hard-wired
Serial/JTAG block, not reconfigurable into HID or any other class (USB OTG exists only on
S2/S3/P4). Software USB does not help: CTAPHID requires 64-byte packets, bit-banging gives
low-speed with 8. Closed; not revisited. It has no radio code either: Wi-Fi 6, BLE and
802.15.4 exist in silicon, but in bare-metal Rust they could only arrive through esp-radio,
and tools/build-vaultkey.sh fails the build if that crate appears in firmware/Cargo.lock.
Not "disabled in config" — absent.
| ESP32-C6-Zero | |
|---|---|
| Chip | ESP32-C6FH4 (QFN32) rev v0.1, RISC-V @ 160 MHz, 4 MB flash, no PSRAM |
| USB | Serial/JTAG straight into the chip; on the host 303a:1001, /dev/ttyACM* |
| BOOT button | GPIO9, active low (not GPIO0 as on older ESP32); the label BOOT lives in board.toml, because the CLI names the button to the user |
| WS2812 | GPIO8, one LED, channel order RGB (not GRB) — the driver assumes GRB, so main.rs sets color_order::Rgb; ask for amber, get green, and it is diagnosed in a second |
| USB D−/D+ | GPIO12 / GPIO13 — do not touch |
| Security | Secure Boot v2, Flash Encryption, HMAC with a key in eFuse, hardware SHA/AES; no ECDSA peripheral |
| Form factor | USB-C receptacle: works over a cable like a devboard, not like a fob in a port |
LED colours (the rules live in core/src/ui.rs; the board only paints):
| Colour | Meaning |
|---|---|
| Amber | waiting for a short press (code, password or .env) |
| Red, held | wipe after five seconds |
| Blue | waiting for two presses in a row (backup) |
| Green flash / red flash | accepted / rejected |
The bootloader reads only the app partition, so the partition table stays stock and describes
nothing else; firmware and CLI address the vault region absolutely through esp-storage. A
Secure Boot bootloader is 40 KiB plus 4 KiB of signature and does not fit before 0x8000, so
the table moved to 0xC000 with only factory in it; the plain bootloader was rebuilt at
the same offset so there is a single table for both.
0x000000 bootloader.bin | bootloader-sb.signed.bin
0x00C000 partition table (stock: factory only)
0x010000 factory app: vaultkey.bin | vaultkey.signed.bin
0x110000 +-- vault region, raw flash, 428 KiB = 107 sectors of 4 KiB --+
| attempt counter 1 sector |
| vault image copy A: PIN header + 256 entries 21 sectors |
| vault image copy B 21 sectors |
| 16 .env blob slots x 2 copies x 8 KiB 64 sectors |
0x17B000 +------------------------------------------------------------+
Every image and blob copy carries its own sequence number and CRC; the attempt counter is one
word written with no erase (NorFlash::write, not the inherent write, which does
read-erase-write). Core's flash requirements — 4 KiB sector, 4-byte word writes, three regions
on sector boundaries — are checked at startup. In RAM: 128 KiB Argon2, ~80 KiB state, 8 KiB
blob buffer, three statics in main.rs. To make the region visible to esptool it is one
line, vault, data, 0x40, 0x110000, 0x1B000, via espflash partition-table --to-binary —
not part of the flashed table.
| Decision | Why |
|---|---|
| 256 entries, not more | RAM. State holds every slot, Argon2 takes 128 KiB, and the C6 stack after that is 308 KiB — the first 256-entry build overflowed it because State was passed by value. It now lives as one instance in a board static (VAULT), filled in place, never copied; 512 is a one-constant change |
SECRET_MAX = 256, not 512 |
Measured on the board: 512-byte slots cost 1.7 s per add against ~0.65 s, because save erases all image sectors (37 x ~45 ms) — 200 imported passwords would take 6 minutes |
| Import via CSV, terminal only | vkey import <file> from the export 1Password and Google Password Manager offer: even 256 slots are not a whole vault, and the key should hold only what must be offline. An entry already there is skipped unless --replace, and one the export has stopped offering is deleted, which is what keeps the slot count from creeping up. Parser is csv-core — already in the tree via espflash, no buffers of its own, correct on multi-line Notes |
.env blobs are a region, not entries |
Kind::Env is never an Entry: a blob of up to ENV_MAX = 8000 bytes takes one of the 16 slots |
A damaged blob copy reads as empty, not Corrupt |
A truncated first write has nothing to roll back to and must not lock the key. Deletion erases both copies, newer first, or the older resurrects |
| Blob key is random, sealed under the DEK in the image header | A PIN change then reseals 60 bytes inside the atomic save rather than 16 blobs outside it, where power loss would leave blobs under a key the header no longer describes |
| One frame, no chunking | MAX_PAYLOAD bounds requests only, so 8034 bytes in a stack buffer are cheaper than a staging command, an offset counter and a reset on auto-lock. If 8000 bytes run out, ENV_MAX, ENV_COPY_SECTORS and ENV_BUF double with no format change |
Everything chip- and board-specific lives in firmware/boards/<name>/. firmware/core knows
no chip: flash arrives through embedded-storage, entropy through rand_core, the rest
through three small traits.
| File | Contents |
|---|---|
src/main.rs |
pins, peripherals, the four vaultkey_core::hal trait impls (port, clock, button+LED, chip key), the flash Layout, and the statics for the Argon2 buffer (128 KiB), the vault state (~80 KiB) and one .env blob (8 KiB) |
Cargo.toml |
chip crates (esp-hal with its feature, esp-storage, esp-println, …) and vaultkey-core |
.cargo/config.toml |
the chip's rustup target |
board.toml |
name, chip for espflash, target, USB VID/PID, button label (button), and the image list with offsets and a secure_boot flag (which chip state the image is for) |
bootloader/ |
partitions.csv and two ESP-IDF sdkconfig files: plain bootloader and Secure Boot v2; built by tools/build-bootloader.sh in Docker |
images/ |
bootloader.bin, bootloader-sb.bin (+ .signed.bin), partition-table.bin (at 0xC000), vaultkey.bin (+ .signed.bin) — the CLI embeds these at build time |
Adding a board: copy the esp32c6-zero folder, measure the pins, fix the four files
above; ./tools/build-vaultkey.sh builds it and embeds the images into the CLI; acceptance
is vkey check --wipe-everything plus the selftest on the new board. No other file in the
repository changes. Measuring is a separate minimal probe project (esp-hal or ESP-IDF):
every candidate is an input with pull-up, and the main loop prints level changes every 20 ms
— press the button, see the pin number; for the LED, walk the driver across candidates until
one lights up. Exclude the USB pins (GPIO12/13 on the C6): reconfigured as GPIO, the port
disappears, and with it the ability to flash over USB.
esp-hal 1.2, no_std, no ESP-IDF at runtime, no RTOS, ~1000 lines; RustCrypto for crypto;
stable rustup with target riscv32imac-unknown-none-elf — the C6 is RISC-V, so no compiler
fork and no espup. The C version was the same ~1000 lines of ours on top of FreeRTOS, NVS,
mbedTLS and vendor drivers — a large foreign base nobody reads — and frame parsing from a
hostile host was the one place C could overflow. The VTC1 protocol stayed byte for byte, so
the acceptance tests validated the rewrite on the same board. Dropping NVS meant writing the
vault by hand, as laid out above. ESP-IDF survives in Docker only, to build the two
bootloaders, because esp-hal has none.
cli/ is one static binary (3 MB, mostly espflash): clap, serialport, flashing via
espflash as a library with images embedded by include_bytes!, and a hand-drawn crossterm
shell — TUI frameworks produced resize artifacts. vkey install copies it to ~/.local/bin;
no Python, venv or pipx.
| Symptom | Cause and cure |
|---|---|
Device gone from lsusb entirely |
GPIO12/13 were configured as ordinary GPIO. Not broken, but it only comes back by hand: hold BOOT, tap RESET, release BOOT. Easy to hit while sweeping pins in a loop |
cat /dev/ttyACM0 prints nothing |
Logs share the port with the protocol (esp-println, jtag-serial); frames carry a magic word, so that is safe. USB Serial/JTAG does not buffer without an attached monitor — read with espflash monitor |
| Device "remembers the old state" | Flashing does not erase the vault region. espflash erase-flash or vkey setup --erase |
| Dropped connection when flashing | The board re-enumerates faster than espflash reconnects after automatic reset. Cured by BOOT+RESET |
vkey: "no permission"; espflash: "port is busy or doesn't exist" |
Not in the dialout group. Membership applies after re-login; in the current session sg dialout -c '...' |
"Device or resource busy", fuser shows nobody |
ModemManager probes every new serial device and holds it exclusively (as root). The CLI waits up to 8 s; the fix is a udev rule, once per machine (below) |
The port became /dev/ttyACM1 |
Normal after flashing or reset. Never hardcode: vkey (cli/src/device.rs) and espflash find the port by Espressif's VID |
| RNG output is not random | In esp-hal the register is random only with TrngSource (SAR-ADC) enabled; ESP-IDF's bootloader used to do it. main.rs enables it and holds the handle for the whole run |
sudo tee /etc/udev/rules.d/99-vkey.rules <<'EOF'
ATTRS{idVendor}=="303a", ATTRS{idProduct}=="1001", ENV{ID_MM_DEVICE_IGNORE}="1"
EOF
sudo udevadm control --reload-rules && sudo udevadm trigger- Plug in over USB-C.
lsusbmust show303a:1001. vkey→ sees there is no firmware →/setup: flashes the embedded images, asks for a PIN, runs the RFC 6238 selftest. Orvkey setup.vkey check --wipe-everything— the full lifecycle on the board: it says when to tap, when to double tap and when to hold; wipes everything and leaves the board empty, without a PIN. Then/pinto set your own. Do this now, while the board is empty: on a key that already holds secrets it destroys them, so from here on the routine check after a firmware change isvkey totp selftest, which destroys nothing.- From your own build:
./tools/build-vaultkey.sh(rustup with theriscv32imac-unknown-none-elftarget andespflash), on a Secure Boot chip also./tools/sign-vaultkey.sh, thencargo build --releaseincli/,vkey install,vkey setup. Do not flash any other way (espflash flash): it writes its own bootloader and partition table at 0x8000.
Irreversible. The developer runs every command below personally, after summary; a wrong
bit does not erase data — it makes the board useless. The tool is espefuse.py from
esptool (pipx install esptool; espflash has no eFuse support). The port must be free
(close vkey); espefuse.py puts the chip into the bootloader itself and asks for
confirmation with the word BURN. After any espefuse.py command the chip stays in the
bootloader: run espflash reset (or replug), otherwise vkey will say "does not answer";
the port number may change — check ls /dev/ttyACM*.
Never: DIS_USB_SERIAL_JTAG (kills the serial port — the only link to the board),
DIS_DOWNLOAD_MODE (never flashable again; we have no in-place update path).
State before burning on this board (2026-09-07): all factory —
KEY_PURPOSE_0 = USER, BLOCK_KEY0 empty, DIS_USB_JTAG, DIS_PAD_JTAG,
SECURE_BOOT_EN, SPI_BOOT_CRYPT_CNT, DIS_DOWNLOAD_MODE, ENABLE_SECURITY_DOWNLOAD =
0. Burned 2026-09-08 with steps 1 and 2 below: KEY_PURPOSE_0 = HMAC_UP (R/-),
BLOCK_KEY0 written and read-protected, DIS_USB_JTAG = DIS_PAD_JTAG = 1; the rest
factory. A terminal without the dialout group gives Permission denied — run
newgrp dialout before the commands. No copy of the key is kept: a key in a file makes a
flash dump useful again. The vault written before that burn became Incompatible and was
wiped.
espefuse.py --chip esp32c6 --port /dev/ttyACM0 summary # read only; state before
# 1. Chip key: 32 random bytes into BLOCK_KEY0 with purpose HMAC_UP. espefuse sets read
# protection itself; no copy of the key is kept - the developer's decision.
head -c 32 /dev/urandom > devkey.bin
espefuse.py --chip esp32c6 --port /dev/ttyACM0 burn-key BLOCK_KEY0 devkey.bin HMAC_UP
shred -u devkey.bin
espefuse.py --chip esp32c6 --port /dev/ttyACM0 summary # KEY_PURPOSE_0 = HMAC_UP, BLOCK_KEY0 read-protected
# 2. JTAG off: both over USB and over the pads.
espefuse.py --chip esp32c6 --port /dev/ttyACM0 burn-efuse DIS_USB_JTAG 1 DIS_PAD_JTAG 1The firmware needs no change: at startup main.rs reads KEY_PURPOSE_0, and if it is
HMAC_UP the key is derived through the HMAC peripheral (vkey info → key: chip-bound),
otherwise from the PIN alone (key: PIN only). A vault written before the burn will not
open afterwards with any PIN — the firmware answers Incompatible without spending
attempts; vkey wipe (5 s hold) or vkey check --wipe-everything clears it. After the
burn: vkey info shows chip-bound, the port is alive, check is green.
Done 2026-09-09 on the single board, with no spare, after rehearsing the whole espefuse
sequence against an emulated eFuse (espefuse --virt). Flash Encryption is not done (below).
What it buys. The ROM starts only a bootloader whose signature matches the public key digest in eFuse; the bootloader starts only a signed image. Foreign firmware over USB no longer runs — and that was the last way to brute-force the PIN on a chip with access to the HMAC peripheral (~1 s per attempt, no counter). Download mode stays: an update means uploading a new signed image.
Signing key. RSA-3072, one per project, produced by tools/signing-key.sh: generated on
tmpfs and stored on the vkey itself as the .env entry vkey-signing
(SIGNING_KEY=<base64 DER>); never on disk in the clear, second copy is vkey backup.
Signing costs a PIN and a tap (tools/sign-vaultkey.sh), which writes
images/vaultkey.signed.bin and images/bootloader-sb.signed.bin. Losing it freezes every
board that trusts it; leaking it removes Secure Boot. Public half and digest:
firmware/secure-boot/public.pem and digest.bin, in the repository. images/vaultkey.bin
stays unsigned and reproducible (a hook rebuilds and compares), with vaultkey.signed.bin
beside it, verified by the hook with the public key.
A fork enables Secure Boot with its own key, never this one. The private half exists
only on the maintainer's device, so the committed public.pem, digest.bin and
*.signed.bin are useless to anyone else: burn that digest and no image you can sign will
ever boot. Delete firmware/secure-boot/ and the *.signed.bin images, run
tools/signing-key.sh (it refuses while a public.pem is present), then follow the
procedure below with the digest it produces.
| Decision | Why |
|---|---|
Two bootloaders, built by tools/build-bootloader.sh (ESP-IDF 5.5 in Docker) |
bootloader.bin is plain, bootloader-sb.bin has CONFIG_SECURE_BOOT; esp-hal supplies neither |
Each image in board.toml declares its chip state (secure_boot = true/false) |
On a chip where SECURE_BOOT_EN is still 0, the Secure Boot bootloader burns eFuses by itself on first boot (esp_secure_boot_v2_permanently_enable in bootloader_utility.c), and not the set below — only the developer may burn. So vkey setup asks the ROM (GET_SECURITY_INFO, bit SECURE_BOOT_EN) and uploads only the matching images |
vkey setup flashes without the espflash stub (Flasher::connect(_, false, …)) |
Whether the C6 ROM runs a stub after SECURE_BOOT_EN is undocumented, and the first write after the point of no return is exactly the one where the board boots nothing |
Order: digest → byte check → SECURE_BOOT_EN → signed images → confirmed boot → slot revocation and RD_DIS |
The official process revokes before flashing; here after, because the free DIGEST1 slot is the only retreat if the digest is wrong, and the final eFuse set is identical |
| Rejected: "flash signed first, then burn" | A bootloader with a signed app would burn everything itself, with its own set |
Rejected: CONFIG_BOOTLOADER_LOG_LEVEL_NONE to fit under 0x8000 without moving the table |
It removes the log lines that prove the bootloader burned nothing |
Rejected: --secure-pad-v2 |
espflash save-image cannot pad to a 64 KiB MMU page, and esptool elf2image on an esp-hal ELF is reported to produce a broken image — recorded in threat-model.md as an unassessed residual |
| No Flash Encryption | Secrets are already under AES-256-GCM with a key routed through the eFuse HMAC, so a flash dump is useless without the chip. The vault sits outside the partition table and is written as raw words through esp-storage, which FE neither encrypts nor supports; it would protect only the firmware code, which is open |
Rejected: the build-vaultkey.sh flash mode |
espflash flash writes its own bootloader and table at 0x8000 and stops a Secure Boot board from booting — only vkey setup flashes now |
Green below is reversible; red is irreversible — everything from step 1 onwards. Amber is the retreat, open only while a digest slot is still free.
flowchart TD
S0["Step 0 — readiness<br/>build, sign, verify signature and digest,<br/>vkey backup, efuse summary snapshot"]
S1["Step 1 — burn-key-digest into BLOCK_KEY1<br/>purpose SECURE_BOOT_DIGEST0"]
CHK{"BLOCK_KEY1 bytes == digest.bin ?"}
STOP["STOP — do not proceed<br/>BLOCK_KEY2 / SECURE_BOOT_DIGEST1 free, DIGEST0 revocable"]
S2["Step 2 — POINT OF NO RETURN<br/>burn-efuse DIS_DIRECT_BOOT 1 SECURE_BOOT_EN 1"]
S3["Step 3 — flash signed images<br/>vkey setup, ROM path without the stub"]
S4{"Step 4 — boots and answers?<br/>espflash monitor, vkey info, selftest"}
RET["Retreat still open<br/>burn-key-digest BLOCK_KEY2 SECURE_BOOT_DIGEST1,<br/>re-sign, vkey setup, then SECURE_BOOT_KEY_REVOKE0 1"]
S5["Step 5 — irreversible<br/>SECURE_BOOT_KEY_REVOKE1/2, write-protect-efuse RD_DIS<br/>no signing key rotation after this"]
S0 --> S1 --> CHK
CHK -- no --> STOP
CHK -- yes --> S2 --> S3 --> S4
S4 -- no --> RET --> S4
S4 -- yes --> S5
classDef rev fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20;
classDef irrev fill:#ffebee,stroke:#c62828,color:#b71c1c,stroke-width:3px;
classDef halt fill:#fff8e1,stroke:#f9a825,color:#7f5700;
class S0,CHK rev;
class S1,S2,S3,S4,S5 irrev;
class STOP,RET halt;
Procedure (port free, vkey closed, mains power, do not disturb the cable; check PORT
against ls -l /dev/serial/by-id/; the developer runs every espefuse command personally
and reads what it is about to burn before typing BURN; never pass --do-not-confirm).
Rehearsed on emulated eFuse (espefuse --virt) on 2026-09-09.
PORT=/dev/ttyACM0
# 0. Readiness - nothing irreversible. Every line must be green, otherwise do not proceed.
./tools/build-bootloader.sh # if bootloader/ changed
./tools/build-vaultkey.sh && ./tools/sign-vaultkey.sh # PIN and a tap: signed images
(cd cli && cargo build --release) && cli/target/release/vkey install
vkey setup && vkey totp selftest # "secure boot off": plain bootloader, table
# at 0xC000, vaultkey.bin - and the same ROM path
# without the stub that step 3 will take
vkey backup ~/vault.vkb # the only second copy of the signing key - also here
I=firmware/boards/esp32c6-zero/images; P=firmware/secure-boot/public.pem
espsecure verify-signature --version 2 --keyfile $P $I/bootloader-sb.signed.bin # signature <-> public.pem
espsecure verify-signature --version 2 --keyfile $P $I/vaultkey.signed.bin
espsecure signature-info-v2 $I/bootloader-sb.signed.bin # signature <-> digest.bin:
xxd -p -c 32 firmware/secure-boot/digest.bin # "Public key digest for block 0" == this line
espsecure digest-sbv2-public-key --keyfile $P --output /dev/shm/d.bin \
&& cmp /dev/shm/d.bin firmware/secure-boot/digest.bin && echo digest-ok # public.pem <-> digest.bin
espefuse --chip esp32c6 --port $PORT summary | tee /dev/shm/efuse-before.txt
# must be: KEY_PURPOSE_0 = HMAC_UP R/-, KEY_PURPOSE_1 = USER R/W, BLOCK_KEY1 empty,
# SECURE_BOOT_EN = False, SECURE_BOOT_KEY_REVOKE0/1/2 = False, SECURE_BOOT_AGGRESSIVE_REVOKE = False,
# DIS_DIRECT_BOOT = False, DIS_DOWNLOAD_MODE = ENABLE_SECURITY_DOWNLOAD = False,
# DIS_USB_SERIAL_JTAG = DIS_USB_SERIAL_JTAG_DOWNLOAD_MODE = False, DIS_USB_JTAG = DIS_PAD_JTAG = True
# 1. Public key digest into BLOCK_KEY1 (KEY0 is taken by HMAC). Not Secure Boot yet:
# without SECURE_BOOT_EN the ROM does not look at it. Before BURN: "[05] BLOCK_KEY1 is empty"
# and "'KEY_PURPOSE_1': 'USER' -> 'SECURE_BOOT_DIGEST0'"; after - "Disabling write",
# and NO "Disabling read": the ROM must be able to read the digest.
espefuse --chip esp32c6 --port $PORT burn-key-digest BLOCK_KEY1 $P SECURE_BOOT_DIGEST0
espefuse --chip esp32c6 --port $PORT summary BLOCK_KEY1 # "Purpose: SECURE_BOOT_DIGEST0", then 32 bytes R/-
xxd -p -c 32 firmware/secure-boot/digest.bin # the same 32 bytes in the same order.
# THE MAIN ANTI-BRICK CHECK. No match - stop: BLOCK_KEY2/SECURE_BOOT_DIGEST1 is still free,
# and DIGEST0 is revocable. `dump` is no good for comparison (words are reversed).
# 2. POINT OF NO RETURN. After this the ROM starts only a signed bootloader, and the board
# will not boot until step 3. Before BURN - exactly two "0b0 -> 0b1" lines.
espefuse --chip esp32c6 --port $PORT burn-efuse DIS_DIRECT_BOOT 1 SECURE_BOOT_EN 1
espefuse --chip esp32c6 --port $PORT summary | grep -E '^(SECURE_BOOT_EN|DIS_DIRECT_BOOT|ENABLE_SECURITY_DOWNLOAD|DIS_DOWNLOAD_MODE) '
# SECURE_BOOT_EN = True, DIS_DIRECT_BOOT = True, both DL = False. The fact that summary
# answered at all is the proof that download mode and espefuse are alive. Continue only
# once True has been seen.
# 3. Signed images over ROM without the stub - the same path already taken in step 0.
# setup sees "secure boot on" and uploads bootloader-sb.signed.bin, the table,
# vaultkey.signed.bin. If the port does not answer - hold BOOT and replug (manual
# download mode), retry. Fallback path, the same offsets from board.toml:
# esptool --chip esp32c6 --port $PORT --no-stub write-flash 0x0 $I/bootloader-sb.signed.bin \
# 0xC000 $I/partition-table.bin 0x10000 $I/vaultkey.signed.bin
vkey setup
# 4. Confirm boot BEFORE any revocation.
espflash monitor --port $PORT # or replug the board and watch the log
# must be: "enabling secure boot v2..." -> "secure boot v2 is already enabled, continuing.." ->
# "Loaded app from partition at offset 0x10000". There must be NO "blowing secure boot efuse",
# "Burning public key hash", "Revoking empty key digest slot".
vkey info && vkey totp selftest # chip-bound, code matches; the vault is intact - step 3 did not touch it
# Does not boot - this is not a brick: download mode is alive, slot DIGEST1 is free. Compare
# `summary BLOCK_KEY1` with `signature-info-v2`; if needed burn-key-digest BLOCK_KEY2 <public.pem>
# SECURE_BOOT_DIGEST1, re-sign, vkey setup, then SECURE_BOOT_KEY_REVOKE0 1.
# 5. Second irreversible boundary: close the free slots (otherwise anyone can add their own
# digest through the same download mode), and close digest reads too (WR_DIS.RD_DIS,
# which is what ESP-IDF does). After this there is no signing key rotation any more.
# Before BURN: REVOKE1 and REVOKE2 "0b0 -> 0b1", and under no circumstances REVOKE0.
espefuse --chip esp32c6 --port $PORT burn-efuse SECURE_BOOT_KEY_REVOKE1 1 SECURE_BOOT_KEY_REVOKE2 1
espefuse --chip esp32c6 --port $PORT write-protect-efuse RD_DIS
espefuse --chip esp32c6 --port $PORT summary | tee /dev/shm/efuse-after.txt
diff /dev/shm/efuse-before.txt /dev/shm/efuse-after.txt # exactly: KEY_PURPOSE_1, BLOCK_KEY1, SECURE_BOOT_EN,
# DIS_DIRECT_BOOT, REVOKE1/2, RD_DIS R/-, WR_DIS
vkey setup && vkey totp selftest # the board is alive after revocation tooBurned 2026-09-09 with exactly this sequence, on the single board. State afterwards
(espefuse summary, difference against the before snapshot — exactly these lines):
KEY_PURPOSE_1 = SECURE_BOOT_DIGEST0 R/-, BLOCK_KEY1 = 00 a5 b8 40 … 5d bb 1b R/-
(= digest.bin), SECURE_BOOT_EN = True, DIS_DIRECT_BOOT = True,
SECURE_BOOT_KEY_REVOKE1 = REVOKE2 = True, REVOKE0 = False,
SECURE_BOOT_AGGRESSIVE_REVOKE = False, RD_DIS = 1 R/-, WR_DIS = 0x01800301;
DIS_DOWNLOAD_MODE, ENABLE_SECURITY_DOWNLOAD, DIS_USB_SERIAL_JTAG* = False. Between
steps 3 and 5 summary showed that the bootloader burned nothing on its own (slots not
revoked, SOFT_DIS_JTAG = 0); after all of it vkey setup uploads the signed images, the
board answers, selftest is green.
There is exactly one way to ruin the board — a digest in BLOCK_KEY1 that does not match the
key the images are signed with, plus closed slots; against that stand the three checks of
step 0, the byte comparison after step 1, and revocation only after a confirmed boot. A
wrongly signed image in flash is not a brick: download mode is alive and vkey setup uploads
the right one. espefuse objects to none of this: on the emulator it silently burned both
REVOKE0 over an occupied slot and SECURE_BOOT_EN without a digest. ROM download mode
stays fully open: ENABLE_SECURITY_DOWNLOAD is incompatible with the espflash stub and with
espefuse, and blocks only flash reads, useless without the chip; it does not stop erasing
the attempt-counter sector, and nothing does but DIS_DOWNLOAD_MODE, which will never be
burned — there is no other update path.
- Never burn an eFuse "just to check" — it is irreversible.
- Never burn
DIS_USB_SERIAL_JTAG,DIS_USB_SERIAL_JTAG_DOWNLOAD_MODE,DIS_DOWNLOAD_MODEorENABLE_SECURITY_DOWNLOAD— they kill the only link to the board. - Never enable
SECURE_BOOT_AGGRESSIVE_REVOKE— with a single signing key it bricks the board. - Never pass
--do-not-confirmtoespefuse; read what it is about to burn before typingBURN. - Never guess the PIN: eight wrong attempts wipe the device.
- Never reconfigure GPIO12/13 — the USB port disappears.