Nockster is a hardware signer built on a Waveshare ESP32-S3-Touch-LCD-1.47 class board. This document describes how the device protects seeds and signing keys, which mechanisms are active today, and the reasoning behind each choice. The security-relevant behavior comes from the ESP32-S3 chipset.
The seed never leaves the device in plaintext. The protections are layered so that compromising one does not immediately expose the seed:
- A lost or stolen device is gated by the PIN, with on-device entry and rate limiting.
- A flash dump (desoldering or reading the SPI flash) yields only AES-256-GCM ciphertext. The remaining work is to defeat the key derivation, not the cipher.
- A malicious firmware swap (a thief reflashing a modified signer) is the job of secure boot and the signed-update trust boundary.
- Physical chip attacks (JTAG, download mode, glitching) are addressed by the production lockdown fuses.
Transaction signing crypto is independent of all of this — these mechanisms protect storage, boot, and provisioning, not the signature scheme.
Seeds are kept in a custom NVS (non-volatile storage) region at flash offset
0x9000, separate from the system NVS. Each seed slot is stored as its own
record: a random per-record salt and GCM nonce in the clear, followed by the
seed encrypted with AES-256-GCM. The PIN is never stored — it is only ever an
input to the key derivation below — and the authentication tag means a tampered
record fails to decrypt rather than returning garbage.
When the device unlocks, it derives the storage key, decrypts the slots into a single in-RAM session object, and holds the cleartext seeds and the derived key only there. That session state is zeroized whenever a seed is replaced, deleted, or the device is locked or reset, so cleartext key material does not linger.
All random material — salts, GCM nonces, and on-device seed generation — comes from the ESP32-S3 hardware TRNG through esp-hal's RNG driver, with the entropy source kept enabled for the life of the app so randomness is ready before the first salt or nonce is needed.
A low-entropy PIN is the weakpoint. The salt and the encrypted
seed both sit in flash, so an attacker who dumps flash can attack
PBKDF2(PIN, salt) offline, and that is only as strong as the PIN plus the cost
of the KDF.
The ESP32-S3 has a a read-protected eFuse key block with
purpose HMAC_UP. Firmware can ask the HMAC peripheral for
HMAC-SHA256(efuse_key, message) without the key ever being readable by
software. The storage key mixes that output into the derivation, so it can only
be reproduced on that specific chip, turning an offline flash attack back into
an on-device one. With fixed domain-separation labels, the derivation is:
pin_key = PBKDF2-HMAC-SHA256(pin, salt, rounds)
pepper = HMAC-SHA256(efuse_key, domain || salt || mac)
key = SHA256(domain || pin_key || pepper)
The pepper source depends on the build:
- A
chip-securityfirmware build owns the ESP32-S3 HMAC peripheral on the app core, detects a provisionedHMAC_UPslot by its eFuse purpose, and routes unlock, first initialization, and PIN-change storage through the pepper-aware path. If anHMAC_UPslot is present but the peripheral calculation fails, the storage operation fails closed with a crypto error rather than falling back. - Default dev/test builds use a fixed software pepper, so they need no eFuse provisioning and burn nothing. The storage format is identical either way; only the pepper source differs, which is why the hardware-bound guarantee is something production validation has to assert separately.
Secure boot v2 ensures only signed images run, which is what stops a thief from replacing the firmware with a modified signer. It does not replace PIN/NVS protection.
The same release trust boundary covers self-updates. The host may transport an
update bundle, but the firmware verifies the manifest signature on-device
against a pinned release public-key-hash trust anchor before accepting it, then
hashes the streamed firmware image on-device before OTA activation. The OTA
writer writes the signed stream into the inactive slot and marks the running
image valid after boot when an OTA data partition is present. Building with
NOCKSTER_RELEASE_VERSION=<n> gives a rollback floor — the on-device verifier
rejects any bundle whose signed release counter is not greater than the current
firmware — without burning eFuses, so dev and test release flows get rollback
protection too.
esp-bootloader-esp-idf provides the ESP-IDF app descriptor and partition
helpers, but secure-boot signing and provisioning stay outside ordinary
flashing. The signing and provisioning tooling exists today:
make generate-secure-boot-v2-keycreates a local signing key with repo-local path rejection, overwrite protection, and restrictive permissions; it touches no eFuses.make release-sign-secure-boot-v2signs an already-built app image with Espressif's tooling and verifies the resulting signature block, after checking the input looks like an ESP app image, fits the app slot, and is not signed in place.make provision-secure-boot-v2-digestburns the digest behind an explicitCONFIRM_IRREVERSIBLE=burn-secure-boot-v2token and an interactive prompt.
Flash encryption is for release devices; application-level NVS AES-GCM stays in place even when it is enabled, so storage is protected at two layers.
The key-handling and provisioning tooling:
make generate-flash-encryption-keycreates a local 32-byte XTS key with the same path/permission guards as the other key generators; it touches no eFuses.make provision-flash-encryption-keyburns the key behindCONFIRM_IRREVERSIBLE=burn-flash-encryption-key.make provision-flash-encryption-enableenables it (burnsSPI_BOOT_CRYPT_CNT) behind a separateCONFIRM_IRREVERSIBLE=enable-flash-encryption.make release-preflightchecks the flash-encryption key path/permissions whenFLASH_ENCRYPTION_KEY_FILEis provided (required for strict production preflight) and checks the flashing partition table:nvsstays unflagged for partition-level encryption unlessNVS_PARTITION_ENCRYPTION_VALIDATED=1is set after board-specific raw NVS read/write testing, factory stays at0x10000, and the app image limit fits the OTA slot.
The final hardening disables the physical debug/recovery surface: USB JTAG, pad JTAG, download mode, ROM USB serial/JTAG printing, and (optionally) power-glitch protection. These are one-way eFuse writes that can make a board hard or impossible to recover through the normal USB-C workflow, so they are intended only after secure boot, flash encryption, a recovery procedure, and at least one sacrificial-board test are in place.
Each fuse is its own guarded target — provision-lockdown-jtag,
provision-lockdown-download, provision-lockdown-direct-boot,
provision-lockdown-rom-print — and refuses to run without a matching
CONFIRM_IRREVERSIBLE=... token, prints the current eFuse summary, and asks for a
typed confirmation before calling espefuse burn-efuse. There is intentionally
no one-shot lockdown target. Power-glitch protection is kept separate
(provision-power-glitch-protection, checked with
--expect-power-glitch-protection) because it still needs board-specific
false-positive testing before it belongs in the default checklist.
The CLI reports NVS storage state in every build, and chip-security state when that feature is compiled in:
nockster-cli security --port hidDefault builds do not read the eFuse security registers, so the CLI shows
chip: hidden (firmware built without chip-security) until firmware is built
with FW_PROFILE=chip-security (FW_PROFILE=chip-security make fw / make flash). With chip-security enabled, the report adds secure boot, flash
encryption, key purposes, JTAG/download-mode fuses, and power-glitch protection.
The browser device panel reads the same status over WebHID/Web Serial when the
firmware advertises the security feature.
The command can also assert expected provisioning state and exit nonzero on mismatch, which is how provisioned hardware is verified without trusting visual inspection:
nockster-cli security --port hid \
--expect-chip-security \
--expect-hmac-up \
--expect-hmac-up-read-protectedeFuse writes are dangerous and irreversible, so the build is structured to make them impossible to trigger by accident:
- Never burn eFuses from a normal build/test target.
make flashandmake fware fordevorchip-securitytest builds.FW_PROFILE=productionrefuses unsigned firmware unlessALLOW_UNSIGNED_PRODUCTION=1is set for release-flow dry runs. - Provisioning is always separate, explicit, and confirmed. Each
provisioning target prints the current eFuse summary first and requires a
matching
CONFIRM_IRREVERSIBLE=...token plus an interactive confirmation. Key generators (generate-hmac-up-key,generate-secure-boot-v2-key,generate-flash-encryption-key) only write a local secret file, reject repo-local output paths, and touch no eFuses. SetESPEFUSE=/path/to/espefuseif the Espressif tool is not onPATH. Never use--no-read-protectfor production, and keep generated key files out of the repo. - Review before touching hardware.
make provision-plan PROVISION_STAGE=productionprints the ordered irreversible checklist without invoking flash, signing, CLI, or eFuse tools. - Preflight is non-destructive by default.
make release-preflightchecks release metadata, update trust-anchor format, signing-key/trust-hash consistency, local secret-file hygiene, signed-update bundle verification when artifacts are provided, browser latest-release index consistency whenUPDATE_INDEXis provided, and tracked secret-looking paths — without reading eFuses. SetRUN_EFUSE_SUMMARY=1 PROVISION_PORT=/dev/ttyACM0to include the optional read-only chip status. Missingespsecure/espefusetools are warnings in the default pass and failures in strict/production preflight. - Validate provisioned hardware read-only.
make validate-device-state VALIDATE_STAGE=productionruns the CLI expectation checks for HMAC_UP / read-protection, storage initialization, secure boot, flash encryption, production lockdown, and OTA layout readiness (VALIDATE_DRY_RUN=1prints the commands first).
The HMAC_UP pepper key:
make generate-hmac-up-key HMAC_KEY_FILE=../nockster-secrets/hmac-up.bin
make provision-summary PROVISION_PORT=/dev/ttyACM0
make provision-hmac-up PROVISION_PORT=/dev/ttyACM0 HMAC_KEY_FILE=../nockster-secrets/hmac-up.bin CONFIRM_IRREVERSIBLE=burn-hmac-up
nockster-cli security --expect-chip-security --expect-hmac-up --expect-hmac-up-read-protectedFlash encryption:
make generate-flash-encryption-key FLASH_ENCRYPTION_KEY_FILE=../nockster-secrets/flash-encryption-key.bin
make provision-summary PROVISION_PORT=/dev/ttyACM0
make provision-flash-encryption-key PROVISION_PORT=/dev/ttyACM0 FLASH_ENCRYPTION_KEY_FILE=../nockster-secrets/flash-encryption-key.bin FLASH_ENCRYPTION_KEY_BLOCK=BLOCK_KEY4 CONFIRM_IRREVERSIBLE=burn-flash-encryption-key
make provision-flash-encryption-enable PROVISION_PORT=/dev/ttyACM0 CONFIRM_IRREVERSIBLE=enable-flash-encryption
nockster-cli security --port hid --expect-flash-encryptionProduction lockdown is then enforced with:
nockster-cli security --port hid --expect-production-lockdown