vaulttecdev — a hardware key on ESP32-C6: TOTP codes, passwords and project .env
files. One board, own code.
| Part | What it is |
|---|---|
firmware/core |
Firmware as a chip-free library: proto (frames), device (PIN, entries), vault (KDF/AEAD), store (flash, A/B image), oath (entry kinds, HOTP/TOTP), ui (button rules), hal (four traits — port, clock, button+LED, chip key; flash and RNG arrive via embedded-storage and rand_core). Bare-metal Rust, no_std, no RTOS; crypto from RustCrypto. |
firmware/boards/<board>/ |
One board = one folder: Cargo.toml with the chip crates, .cargo/config.toml with the target, src/main.rs with pins and trait impls, board.toml, bootloader/, images/. The chip name appears nowhere outside such a folder. |
cli/ |
The vkey command: one static binary. Subcommands for scripts (clap), an interactive shell (crossterm, draws itself), flashing through espflash as a library with images embedded via include_bytes!. All user work goes through this CLI. |
Decision of 2026-09-07. The key is built for the maintainer's own accounts. Sales, production runs, enclosures, provisioning, certifications and marketing are not built and not proposed unless the maintainer says otherwise. Passkey/WebAuthn is closed permanently.
Consequence for code: "convenience for a buyer" is not an argument; security of the maintainer's own secrets and minimal code are.
Everything a product user sees is English only: CLI and script output, error text,
README. Code comments and docs/ are English too.
Commit messages are English too. The commit-msg hook checks the subject length, not the
language.
These principles conflict. When they do, the higher one wins. Without an order, a set of principles can justify anything.
- Correctness and security
- Simplicity — less code, fewer states, fewer execution paths
- Readability — code is read more often than written
- DRY — removing real duplication
- Flexibility and extensibility — last, and almost always premature
If removing duplication makes the code more confusing, keep the duplication. If an abstraction adds flexibility not needed today, do not add it.
| Principle | The rule | Warning sign |
|---|---|---|
| YAGNI (first among these) | Write nothing that is not needed now | An abstraction layer with one implementation; configurability with no second consumer; a hook "just in case"; a generalisation for a hypothetical second case; a branch for a board not in hand |
| KISS | Of two working solutions, take the one simpler to explain out loud | One execution path needs three files open; a boolean parameter that switches behaviour; Manager, Helper, Util, Handler without specifics |
| DRY, for knowledge only | Rule of three: write it, tolerate it, then generalise | Two identical fragments that change for different reasons — that is coincidence, not duplication |
| SRP | One reason to change per module | A function whose description contains "and"; a name that needs a conjunction or a generic word |
| Minimal surface | pub only where a second consuming module exists |
— |
| Orthogonality | A change in one place must not force one elsewhere | Every feature touches five files |
| Explicit errors, early exit | Validate at the entry, return early, keep the happy path unnested |
An error swallowed, logged-and-continued, or replaced by a "safe" default. A silent failure in a PIN check is a vulnerability |
| Locality of reasoning | A fragment is understandable without the whole project in mind | Global state, implicit side effects, action at a distance |
Dead code is a security issue, not clutter: functionality that is not the product is
deleted, not kept "as an example". Record what was removed in docs/ with the commit hash
it can be restored from.
Irreversible actions are a separate class. Anything that cannot be rolled back needs confirmation and separate attention: here that is eFuse and publishing a release.
SOLID was formulated for OOP; dragging it mechanically into Rust is cargo cult.
| Applicability | |
|---|---|
| S — Single Responsibility | Fully. The most useful of the five |
| I — Interface Segregation | Yes, as small traits and narrow signatures |
| D — Dependency Inversion | Partly. Through traits — but only when there really are two implementations |
| O — Open/Closed | Almost never. Extension without modification costs more than it gives here |
| L — Liskov Substitution | No. No inheritance, no subject |
Do not invent traits for D, do not build dispatch tables for O. With one implementation, a direct call beats any indirection.
Useless wrappers (a function that only calls another with the same arguments); premature
configurability (a constant becomes a parameter at the second call site with a different
value, not before); comments that restate the code (a comment explains why); defensive
programming with no scenario (checking for the impossible hides real bugs); logging "just
in case" (in a release every log is flash, time and a potential leak); abstractions built
for tests; default branches that swallow the unknown (_ => must be an error, not silence).
This is a security device. The rules below are not recommendations.
- Compare secrets in constant time only:
subtle::ConstantTimeEq. Plain==on a PIN, HMAC or verifier is a vulnerability. - Wipe secrets after use with
zeroize, not a manualfill(0)(the compiler may drop that as dead code). - Never log keys, PINs or secrets. Not even in a debug build: a debug build eventually ships.
- Randomness comes only from
TrngwithTrngSourceenabled. Without it the RNG register is not random. The handle lives for the whole runtime. - Validate every input that arrives over USB. The host is assumed hostile.
- Fail closed. If anything goes wrong, the operation does not happen.
- Spend the PIN attempt before checking, not after. Otherwise cutting power mid-check yields free attempts. In flash this is one word write without an erase.
- A claim in the protocol must hold in the code. Advertising a guarantee only half the paths enforce is worse than not advertising it.
- Irreversible actions get their own gesture. Code, password and a
vkey authlogin — a tap (amber); wipe — hold 5 s (red); backup — double tap (blue). A tap never wipes and never exports; a hold never exports; a double tap never wipes. Password on a tap is the maintainer's decision of 2026-09-08 — do not reintroduce a separate hold. A new command with consequences gets a new gesture, not an existing one. unsafeonly with a comment on why it is unavoidable; in the frame parser, never.- No
alloc. Everything on the stack and in statics; every buffer size is known ahead.
A new board is firmware/boards/<name>/ and nothing else: copy an existing folder,
measure the pins with a probe (never copy from the datasheet — silent breakage), fix
main.rs, the chip crates, the target and board.toml. ./tools/build-vaultkey.sh builds
it and embeds its images into the CLI. Acceptance is the same as for any board.
Core requires flash with a 4 KiB sector and word-aligned 4-byte reads and writes (checked in
Store::new; the fake flash in tests rejects unaligned access too, because esp-storage
without bytewise-read behaves that way). Everything else is the board folder's business.
The one board today is the Waveshare ESP32-C6-Zero: USB is a fixed Serial/JTAG
(303a:1001), HID is impossible, so passkey will never exist here; one port carries both
the protocol and logs — hence the VTC2 magic word in frames. Pins and pitfalls:
docs/hardware.md.
-
Crates:
esp-hal(featuresesp32c6,rt,unstable),esp-storage,esp-hal-smartled,esp-println(jtag-serial),esp-backtrace; RustCrypto:aes-gcm,argon2,hmac,sha1,sha2,subtle,zeroize;ed25519-dalek(built on RustCrypto'ssignatureandsha2) forvkey auth;crc. Notesp-idf-hal, not own crypto, notesp-radio. Toolchain: stable rustup with targetriscv32imac-unknown-none-elf; no espup, no compiler fork. Read exact API signatures in~/.cargo/registry/src/*/esp-hal-*/src/rather than recalling them. -
Keys: pre = Argon2id(PIN, salt; 128 KiB,
tmeasured for ~1 s on the board); KEK =DeviceKey::mac(pre) — HMAC with the eFuse key when burned (2026-09-08), otherwiseUnbound; DEK = HMAC(KEK, "vaultkey/dek/v1"); verifier = HMAC(KEK, "vaultkey/verify/v1"); AEAD with the entry name in the AAD. The header remembers the cost and whether the key is chip-bound: firmware answering differently refuses (Incompatible) instead of burning attempts. PIN is exactly 8 digits (PIN_LEN, 0.9; it was 6–8 until the brute force through the real firmware with the counter erased was costed: a fortnight for six digits, four years for eight). A backup passphrase is 12–128 bytes, and the CLI asks for five or six random words, because 128 KiB of Argon2id does not make up for a phrase a person invented. -
A field's class decides what may leave the device, not the item's category (2026-09-19, maintainer's decision;
docs/items-v2.md). An item is a name, a category and up to 32 fields; a field isclass | kind | section | label | value, the value up toVALUE_MAX= 8056 bytes and the packed item up toITEM_MAX= 8192.Openneeds the PIN alone,Secreta tap,Seeda tap for the code it computes and the double tap for the seed itself. The host names a reach per request and gets only those fields; an unknown reach byte is refused, never rounded down. The singlematchon class lives indevice.rs. The category is in the AAD with the name: a category byte rewritten in flash breaks the tag, even with the CRC repaired. -
The answer to
ItemGetcarries the classes the item holds (HAS_OPEN/HAS_SECRET/HAS_SEED) in front of the fields, so the host can tell a TOTP item from a password one without spending a gesture to find out. What exists, never what it is. -
A
.envis an item of categoryEnvwith one secret field;KEY=valuelines are checked by the CLI, not the firmware. There is no env region and no env key any more: blobs are items, sealed under the DEK and re-sealed with everything else when the PIN changes. Names are one namespace. Everything,listincluded, requires the PIN; auto-lock is 2 minutes. -
A seed's value is
algo | digits | period | secret: the parameters travel with it because the device has nowhere else to keep them, and a code's shape is not a secret. The CLI turns anotpauth://URI into that on the way in and back into a URI on the way out (totp::seed_field,totp::seed_uri). -
The one exception is
Auth(2026-09-14, maintainer's decision): a 32-byte Ed25519 seed forvkey auth(sudoand the lock screen viapam_exec), sealed underDeviceKey::mac("vaultkey/auth-key/v1")— the eFuse key alone, never the DEK — soRespondsignsAUTH_SIGNED_PREFIX | challengeafter a tap while locked, spending no attempt. Refused on an unbound chip. Which key an item is under is decided in one place,Device::item_key; a PIN change carriesAuthitems across untouched. A login waits 10 s for the tap (AUTH_TOUCH_TIMEOUT_MS), then PAM asks for the password. The host keeps only the public key,/etc/vkey/auth/<user>, and writes nothing at login.sudo vkey auth enableis the one place the CLI writes system files:/usr/local/bin/vkey, the public key, and onesufficientline in the PAM services ofSERVICES(cli/src/auth.rs), each original kept as*.before-vkey;disableundoes it. -
A TOTP seed leaves the device two ways. The backup (
ExportBegin/ExportNext, after a double tap; 2026-09-09) reseals every item under a key derived from a passphrase (Argon2id,Passphrase12–128 bytes, without the chip key, so the file opens on another board) and returns them one at a time; the host sees only ciphertext and assembles a.vkb.ImportBegin/ImportItem/ImportEndis the reverse, under the PIN with no gesture: the items are appended to a fresh image as they arrive and become the vault atImportEnd, so a restore that stops half-way leaves what was there before. A backup session is killed by anyload(), bylock, and by the first error. An item's AAD isBACKUP_AADplus its index in the file: a reordered or altered item does not open (BadBackup). The second way isvkey export(2026-09-19): an item written back into 1Password throughop item create, under the same double tap, with the seed in the clear. That ends the claim that a seed never leaves —docs/threat-model.mdsays what it costs. -
Secrets and PINs never come from command arguments or environment variables: the CLI asks hidden or reads a line from stdin, and holds them in
Zeroizing. -
Flash:
store.rsknows how, the board says where (Layoutin itsmain.rs; on C6-Zero 0x110000..0x17B000, raw flash past thefactorypartition, which the stock partition table does not describe). Three regions: one sector of attempt words and two image copies ofSTATE_SECTORS= 53 each, filling the region exactly. -
The image is the PIN header and every item, packed -
name_len | category | sealed_len | name | pad | sealed | pad- not fixed slots: a seed is twenty bytes and an SSH key three kilobytes, and slots would make every write cost what the biggest item might have been. A write erases only the sectors the image grows into, plus whatever the image it replaces left beyond it, so anaddcosts the vault's size (~0.8 s for 66 items), not the region's. -
The magic word is written last, after the items and the CRC: until it lands the copy reads as blank, so a torn write is not a copy a reader has to reason about. The CRC is computed over what was actually read back from the target, not over what was meant to be written.
-
The image never sits in RAM. What RAM holds is an
Index- name, category, offset and length per item, ~10 KiB for 256 - in the board's static, and bodies stream through the working buffer. That is what pays for items bigger than the old 256-byte secret.Storeremembers only which copy is newer, never the content;pin_statusanswers from the 20-byte head of each copy without a CRC — it is the shell's status tick once a second. -
The board's buffer is two item-sized halves (
BUF_LEN= 2 ×ITEM_BUF_LEN): one to open or build an item in, one for the items carried across while the image is rewritten. The attempt counter is a separate sector; an attempt is one word write with no erase, only viaNorFlash::write. -
Entropy:
TrngSource::new(RNG, ADC1)beforeTrng::try_new(); do not drop the handle. -
Acceptance happens on the board, in two tiers, because the full test wipes the device and a working key cannot be wiped after every change:
- After any firmware change:
vkey totp selftestgreen — one tap, destroys nothing, proves the live code against an independent HMAC. This one is not optional. - Before a release, and on every new board:
vkey check --wipe-everythinggreen too (it says when to tap, when to double tap and when to hold). It leaves the device with no PIN and no entries, so run it on an empty board — never on a key holding secrets. Without a spare board, say plainly in the release that the full lifecycle test was not re-run, rather than pretending it was.
Gesture rules are proven on the host (
firmware/core/tests/key.rs); the board verifies the hardware honours them. - After any firmware change:
eFuses are irreversible. Secure Boot, Flash Encryption and HMAC keys burn the chip
forever. Never burn an eFuse without a direct instruction from the maintainer — and even
then the maintainer runs espefuse.py personally, following docs/hardware.md, after
summary. No wrappers around burning in the CLI or scripts: once per board, irreversibly,
and not one line away from an everyday setup. The firmware detects a burned key itself
(ChipKey::detect). The HMAC key was burned 2026-09-08, Secure Boot v2 on 2026-09-09.
After any change under firmware/, run ./tools/build-vaultkey.sh: it builds each board
folder (from that folder, so its .cargo/config.toml applies), fails if a radio crate
appeared in firmware/Cargo.lock, writes images/vaultkey.bin and rebuilds the CLI, since
images are embedded in the binary; then cli/target/release/vkey install. Commit images
with the firmware change. After a change under cli/, cargo build --release and vkey install.
Bootloaders and the partition table are built from the board's bootloader/ folder in an
ESP-IDF 5.5 Docker image by ./tools/build-bootloader.sh, only when bootloader/ changes.
There are two: a plain bootloader, and a Secure Boot v2 one for a chip where
SECURE_BOOT_EN is already burned. vkey setup asks the ROM (GET_SECURITY_INFO) which to
flash, because a Secure Boot bootloader on a plain chip burns the eFuse itself on first
boot. Signed images (*.signed.bin) come from ./tools/sign-vaultkey.sh with the signing
key held in vkey itself (PIN and a tap) after every firmware change; a hook verifies a
signed image is the unsigned one plus a signature under firmware/secure-boot/public.pem.
The partition table sits at 0xC000, because the Secure Boot bootloader does not fit below
0x8000.
Compilation is the only exception to the check rule. Linters, formatters, tests and
static analysers are not run without a direct request: git hooks in .githooks/
(tools/checks.sh) run them on pre-commit and pre-push. When the maintainer sends the
output of a failed git commit, fix what broke the hook and repeat the commit.
Both Cargo.toml files carry the same [lints] table: unsafe_code = "forbid",
clippy::all and clippy::pedantic as errors, unwrap_used denied. Any relaxation is a
targeted #[expect(lint, reason = "...")] (#[allow] is denied by allow_attributes):
when the reason disappears, the compiler says the expect is no longer needed.
Invalid state does not exist as a value: Pin, Name, Params, Entry, Record are
built only by constructors returning Option; in the CLI likewise EnvBlob. Parsing from
the wire and from flash happens once, at the edge (proto.rs, store.rs), and nothing
revalidates afterwards. The protocol is firmware/core/src/wire.rs and the CLI compiles the
same file: a command, error code or flag that is not there exists on neither end of the
cable. Add a new validity condition to the type's constructor, not to the call site.
- Do not hardcode the port: after flashing or a reset
ttyACM0may becomettyACM1. Find it by VID0x303A. pkill -f <pattern>matches the shell that contains the pattern and kills the session. Kill by PID.- After a
cdin a command, relative paths do not lead where they seem to. Use absolute. - A session without the
dialoutgroup cannot open the port; espflash says "busy or doesn't exist" andvkeysays "no permission". Fix: re-login afterusermod; in scripts,sg dialout -c '...'. - Firmware logs go to the same USB port as the protocol. Watch with
espflash monitor;cat /dev/ttyACM0stays silent. - Before a physical test, confirm the board runs the intended firmware:
inforeturns the version and the board name. - Do not guess the device PIN: every wrong attempt costs one of eight. Only the maintainer
knows it.
check --wipe-everythingwipes the device and leaves it without a PIN; test PINs live only insidecheck. After each such run, tell the maintainer their PIN and entries were wiped.
cli/src/shell.rs draws the frame itself through crossterm in raw mode: a captioned rule,
the input line, menu lines, a rule, the status line — exactly as many lines as needed, the
cursor always on the input line. The transcript is printed above the frame (out(): erase
the frame, print, draw again; in raw mode use \r\n). The status line is never wider than
one line; the frame is never taller than the terminal. On resize: draw nothing during the
event storm, then after 120 ms of quiet move up by as many physical lines as the old top rule
now occupies (ceil((old_width-1)/new_width)), erase downward and draw. No TUI frameworks —
their hidden decisions are exactly what produced artifacts.
Check the look in a real VTE, not by eye: tools/tui_snap.py drives the shell with keys and
resizes and prints the screen with the scrollback. Every rendering change gets a resize storm
run (40 ms and 150 ms), a scenario with the menu open, and a prompt flow.
docs/threat-model.md is not a formality. Project failure is defined as "shipping an unsafe
product", so any change that affects the threat model updates that file in the same change
set. The list of forbidden marketing claims lives there — honour it in the README, in
descriptions and in commits.
What was investigated and rejected is recorded with its reason so it is not re-argued:
security decisions in docs/threat-model.md, hardware and build decisions in
docs/hardware.md, permanently closed directions in the README.