Heartwood ESP32
September 1, 2026 · View on GitHub

Heartwood ESP32
Beta: firmware
0.18.0-beta.2is the first retained-state beta. Automated suites, signed board builds, the named Heltec V4 migration, reboot/unlock and small-value settlement are release gates. Keep an independent recovery copy and use small values while the complete destructive cross-board recovery matrix remains unfinished.
A hardware signing device for Nostr on supported ESP32 boards: Heltec WiFi LoRa 32 V3/V4, LilyGO/TENSTAR T-Display, and Waveshare ESP32-C6-LCD-1.47. Pick the target with the board-aware build script. Heartwood holds multi-master nsec material and signs on request; master private keys never leave the chip. Signing is policy-gated: requests outside automatic authority need the physical button and are shown on the display, while an authenticated operator can install an exact per-client method/event-kind policy for unattended signing.
For the full architecture walkthrough with sequence diagrams and trust-boundary analysis, see docs/architecture.md.
The shared common crate (derivation, NIP-44/46, policy — no_std, host-tested) also powers heartwood-ledger, the same signer as a Ledger embedded app: same seed phrase, same npub, same personas, with curve operations on the secure element via the ledger-backend feature.
flowchart LR
Bark["Bark<br/>(NIP-07 browser ext)"]
Relays[("Nostr relays")]
Bridge["heartwood-bridge<br/>on Pi<br/>(pure transport,<br/>holds no signing keys)"]
HSM["Heartwood HSM<br/>(master nsecs,<br/>button / exact-policy signing)"]
Bark <-->|"NIP-44<br/>ciphertext"| Relays
Relays <-->|"wss"| Bridge
Bridge <-->|"USB<br/>serial"| HSM
style HSM fill:#1a1a2e,stroke:#16a34a,stroke-width:3px,color:#e8f4f8
style Bridge fill:#0f1419,stroke:#3b82f6,stroke-width:2px,color:#e8f4f8
The device is always the component that produces the signature. Authority comes from either a physical button approval or a previously installed slot policy. New remotely managed v2 slots are exact and fail closed: methods and event kinds outside their ceiling are denied, while matching requests can run unattended only when auto_approve=true. The Pi-side bridge holds no master key and cannot expand that authority. The separate Sapwood operator key is more powerful: it can create/revoke clients and install a signing policy, so compromise of that key must be treated as compromise of the configured remote-management authority.
Deployment modes, selected at runtime from the NVS network config:
Home HSM — USB-attached to a Raspberry Pi (shipped)
Holds the master secrets (up to 8 masters across bunker / tree-mnemonic / tree-nsec modes). All radios are disabled in this mode. The Pi running heartwood-bridge handles networking (Nostr relays, NIP-46 transport). The ESP32 handles all cryptography — decryption, signing, envelope construction. A compromised Pi cannot extract a master key or broaden a slot policy; an already-authorised client can still receive whatever signatures its current policy permits. Management UI via Sapwood is served by the bridge.
WiFi-standalone — on-chip relay client, no Pi (shipped, opt-in)
The ESP32 joins WiFi and connects to Nostr relays directly, running the full NIP-46 signing loop on-chip (firmware/src/relay.rs) — no Raspberry Pi. Keys still never leave the chip and NIP-44 is still decrypted on-device. Exact v2 client policies can permit unattended signing for a bounded method/event-kind set; other requests are denied or require the OLED/button according to their legacy policy. An unbound relay peer cannot enter the 30-second button loop: remote physical approval is available only after the client has been provisioned and slot-bound, so strangers cannot keep a shelf signer busy with prompts. Enabled only when the device is provisioned with an SSID + relay list (mode="wifi" in the NVS net config); the USB cable stays fully usable in parallel, so a bad SSID or relay is recoverable over the cable. Relay-side device management (kind 24134) is authenticated to a provisioned operator pubkey, uses a durable one-time mutation challenge, and can manage clients and staged WiFi changes remotely. USB can read password-redacted network/operator state, patch network fields with keep/set/clear password semantics, and replace the operator only through a separate stale-revision-checked physical confirmation. Seed replacement, PIN changes, factory reset, and OTA remain local/USB operations.
The device stores an ordered list of up to eight WiFi networks (v0.16.0): the join loop walks the list in priority order and rotates on failure — home network first, phone hotspot as fallback, say. Reordering or promoting a stored network never resends its password; per-SSID keep semantics resolve secrets on-device. Short button presses while idle page through an info carousel: identity, network (SSID + live connection stage), and device (firmware version, board, uptime).
This is the convenience tier — it accepts a larger attack surface (a live TCP/IP stack on a key-holding device) in exchange for dropping the Pi. The USB-attached mode above remains the high-assurance default; leave the radios off where that matters.
Portable signer — battery-powered, BLE to phone (roadmap)
Holds a child key derived by the home HSM (purpose="device/mobile"). Only BLE enabled — short range, requires physical proximity. If lost or compromised, burn that branch on the HSM and derive a new one at the next index. The master secret and all other branches are untouched.
Key hierarchy
Master secret (home HSM)
├── persona/social — public Nostr identity
├── persona/forgesworn — project identity
├── client/bray — NIP-46 client key
├── device/mobile-0 — portable signer #0 ← child key lives here
├── device/mobile-1 — replacement if #0 is compromised
└── ...
The nsec-tree hierarchy means each device gets its own branch. Compromise of a child never threatens the root or siblings.
Hardware
| Component | Detail |
|---|---|
| Boards | Heltec WiFi LoRa 32 V3/V4; LilyGO/TENSTAR T-Display; Waveshare ESP32-C6-LCD-1.47 |
| Chip | ESP32-S3 (Heltec), classic ESP32 (T-Display), or ESP32-C6 (Waveshare) |
| Display | 128x64 SSD1306 OLED (Heltec), ST7789 TFT (T-Display), or JD9853 TFT (Waveshare C6) |
| GNSS | L76K on V4 only (available for portable mode) |
| LoRa | SX1262 (never initialised -- no use case for signing) |
| WiFi | Built in (off in USB-bridged mode; enabled in WiFi-standalone mode) |
| BLE | Built in (reserved for future portable mode; not built) |
| USB-C (V4) | Wired direct to native USB-Serial-JTAG on GPIO19/20 |
| USB-C (V3) | Wired through CP2102 bridge to UART0 on GPIO43/44 |
| Battery | JST PH 2.0 connector + charging circuit (portable mode) |
| Buttons | Board-specific local confirmation controls; T-Display provides two buttons |
Setup
Install the ESP Rust toolchain:
cargo install espup ldproxy espflash
espup install
source ~/export-esp.sh
Build
Three independent crates -- build each from its own directory:
cd common && cargo test # shared crypto tests
cd provision && cargo build # host CLI tool
cd firmware && cargo build # ESP32 firmware (V4 default)
The firmware crate selects its board at compile time. The default cargo feature
is heltec-v4, but production builds should use the wrapper so the cargo
feature, target triple, MCU, and sdkconfig fragment cannot drift apart:
./scripts/build-firmware.sh v3 --release
./scripts/build-firmware.sh v4 --release
./scripts/build-firmware.sh tdisplay --release
./scripts/build-firmware.sh c6 --release
Or the manual form:
cd firmware
# V4 (default)
cargo build --release
# V3
ESP_IDF_SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.heltec-v3" \
cargo build --release --no-default-features --features heltec-v3
The wrapper script also writes a board-tagged ELF copy to
target/heartwood-<board>.elf so you cannot accidentally flash the wrong
binary onto the wrong hardware.
Flash & provision
Direct flash (USB cable to build machine)
# Heltec V4 (default, native USB CDC, enumerates as /dev/ttyACM*)
./scripts/build-firmware.sh v4 --release
espflash flash firmware/target/xtensa-esp32s3-espidf/release/heartwood-esp32
# Heltec V3 (CP2102 bridge, typically enumerates as /dev/ttyUSB* or /dev/cu.usbserial-*)
./scripts/build-firmware.sh v3 --release
espflash flash firmware/target/xtensa-esp32s3-espidf/release/heartwood-esp32
Always use --release -- debug builds (~2.2MB) exceed the 2 MB OTA partition.
Remote flash via esptool (ESP32 attached to a Pi)
Build the app binary locally, transfer it, and flash with esptool on the Pi.
Serial port naming depends on the board. V4 enumerates as USB CDC
(/dev/ttyACM0 on Linux) because it uses the ESP32-S3's native USB. V3
goes through a CP2102 UART bridge and enumerates as /dev/ttyUSB0.
Substitute the right path in the commands below.
# Build and convert to app binary (0xE9 format, not raw ELF)
./scripts/build-firmware.sh v4 --release # or: v3
espflash save-image --chip esp32s3 firmware/target/xtensa-esp32s3-espidf/release/heartwood-esp32 /tmp/heartwood-esp32.bin
# Transfer to the Pi
scp /tmp/heartwood-esp32.bin pi-host:/tmp/
# On the Pi: stop any process holding the serial port, then flash.
# PORT=/dev/ttyACM0 for V4, PORT=/dev/ttyUSB0 for V3.
PORT=/dev/ttyACM0
sudo systemctl stop <heartwood-services>
sudo fuser -k "$PORT"
python3 -m esptool --chip esp32s3 --port "$PORT" --before default-reset \
write-flash 0x10000 /tmp/heartwood-esp32.bin
# Erase otadata to force boot from ota_0
python3 -m esptool --chip esp32s3 --port "$PORT" --before default-reset \
erase-region 0xd000 0x2000
Important: if any daemon holds the serial port, the flash will fail mid-write. Use systemctl mask (not just stop) if services auto-restart, and stop ModemManager which probes new serial devices on USB re-enumeration.
OTA update (over serial, device already running)
# Stop the daemon holding the serial port
sudo systemctl stop <heartwood-service>
# Flash via OTA (requires button approval on the device -- hold 2s).
# PORT=/dev/ttyACM0 for V4, PORT=/dev/ttyUSB0 for V3.
heartwood-ota --port /dev/ttyACM0 --firmware /tmp/heartwood-esp32.bin
# Restart the daemon
sudo systemctl start <heartwood-service>
The OTA tool is built from the ota/ crate. The firmware binary must be an app binary (use espflash save-image), not the raw ELF from cargo build.
Provision
After first flash, wait for OLED to show "Awaiting secret...", then:
cd provision && cargo run -- --port /dev/cu.usbserial-*
Enter mnemonic and passphrase when prompted. After ACK, the device reboots with the stored identity.
Sapwood and Heartwood 0.18.0-beta.2 produce typed 19/31-word recovery sequences with
an embedded recovery kind, nsec-tree version, and public fingerprint. The
provision CLI recognises these automatically. --mode bunker, --mode tree-nsec, and --mode tree-mnemonic now apply only to explicit legacy nsec or
bare BIP-39 input. Historical 24-word key backups remain supported but require
the original meaning to be selected out of band. Format and instructions:
sapwood/docs/key-backup.md.
Subsequent boots display the master npub immediately (no provisioning needed).
Test vector
| Parameter | Value |
|---|---|
| Root secret | [0x01, 0x02, ..., 0x20] (32 bytes, sequential) |
| Root path | Raw secret → SigningKey (no HMAC intermediate) |
| Purpose | persona/test |
| Index | 0 |
| Expected child npub | npub1rx8u4wk9ytu8aak4f9wcaqdgk0lj4rjhdu4j9n7dj2mg68l9cdqs2fjf2t |
This must match heartwood-core's output for the same inputs. The nsec-tree derivation is:
context = b"nsec-tree\0" || b"persona/test" || 0x00 || 0x00000000
child_secret = HMAC-SHA256(key=root_secret, msg=context)
child_pubkey = SigningKey(child_secret).verifying_key()
npub = bech32_encode("npub", child_pubkey)
GPIO safety
Verified against Heltec factory test code, Meshtastic firmware, and ESPHome configs:
- GPIO 17, 18, 21 -- OLED I2C, shared wiring on both V3 and V4.
- GPIO 19, 20 -- native USB D-/D+ on V4. Not wired to USB-C on V3, so
UsbSerialDriverwill never enumerate on V3; the V3 code path uses UART0 instead. - GPIO 43, 44 -- UART0 TX/RX on both boards, but only V3 has them wired through to the USB-C port via the CP2102 bridge.
- PSRAM pins on ESP32-S3R2 (V4 only) are GPIO 26-32 (quad-SPI). Nowhere near our I2C pins. The V3 has no PSRAM.
- GPIO 33-37 are free on both boards (only reserved on octal PSRAM variants like S3R8).
Structure
common/ Shared crypto, frame protocol, NIP-46/44/04 types
src/
lib.rs, derive.rs, encoding.rs, types.rs, hex.rs, frame.rs
nip46.rs NIP-46 event types, canonical serialisation, event id
nip44.rs NIP-44 v2 (XChaCha20 + HMAC-SHA256, conversation key)
nip04.rs NIP-04 legacy (AES-256-CBC)
firmware/ ESP32 firmware (NIP-46 signing bunker)
src/
main.rs Boot flow → PIN unlock → frame dispatch loop
sign.rs BIP-340 Schnorr signing (secp256k1 C FFI)
nvs.rs NVS read/write for masters, bridge secret, policies, PIN
provision.rs Multi-master provisioning (add/remove/list)
transport.rs 0x10 encrypted requests + 0x34 SIGN_ENVELOPE handler
session.rs Bridge session auth (0x21/0x22) + SET_BRIDGE_SECRET (0x23)
policy.rs Client approval policies, two-tier TOFU
ota.rs Serial OTA (0x30-0x33) with SHA-256 + auto-rollback
pin.rs Boot PIN, NVS-persisted failed-attempt counter
nip46_handler.rs NIP-46 dispatch (sign_event, connect, NIP-44/04, heartwood_*)
identity_cache.rs Derived persona cache for tree-mode masters
approval.rs Button approval loop with OLED countdown
oled.rs SSD1306 display helpers, boot animation
masters.rs LoadedMaster lookup, npub encoding
build.rs, sdkconfig.defaults, rust-toolchain.toml, .cargo/config.toml
provision/ Host CLI tool
src/
main.rs Mnemonic / nsec / hex → serial push to device
sign-test/ Signing test harness
src/
main.rs Send NIP-46 requests over serial, display responses
heartwoodd/ Pi-side daemon (formerly bridge/)
src/
main.rs Nostr relay ↔ NIP-44 transport ↔ serial ↔ ESP32
Master-routed, calls SIGN_ENVELOPE for outer events
api.rs Management HTTP API on :3100, bearer-token auth
backend/ Soft mode (sealed keyfile) and hard mode (serial) backends
ota/ Pi-side serial OTA tool
src/
main.rs Chunked firmware upload with SHA-256 verification
scripts/
setup-hsm.py Interactive provisioning and bridge-start helper
git-hooks/ Pre-commit secret scanner (64-hex + nsec1 detection)
install-hooks.sh Installer for the git hooks
docs/
architecture.md System architecture with mermaid diagrams
plans/ Design notes, including the zero-trust bridge refactor plan
specs/ Protocol specs
Roadmap
Phase 1 — Prove the crypto (current spike)
- nsec-tree HMAC-SHA256 derivation on ESP32-S3
- bech32 npub encoding
- Runtime assertion against heartwood-core test vectors
- Display npub on OLED
- Sign a dummy 32-byte hash and display the signature
Phase 2 — Provisioning
- CLI tool to derive 32-byte root secret from mnemonic + passphrase (offline PC)
- NVS storage for root secret (plaintext — encryption deferred, see excluded)
- First-boot provisioning mode: accept root secret over USB serial
- Subsequent boots read from NVS, skip provisioning
- Show master npub on OLED after boot
Phase 3 — USB signing oracle
- NIP-46 JSON-RPC over serial (ESP32 is the bunker, Pi is a transport bridge)
- Unified frame protocol:
[magic][type][length][payload][crc32] - OLED shows what you're signing (identity, event kind, content preview, countdown)
- Physical button: long hold (>=2s) to approve, short press to deny, 30s timeout
- Per-request child key derivation with Heartwood extension field
- Test harness CLI (
sign-test/) for end-to-end validation - Flash and verify end-to-end signing flow on hardware (2026-04-03)
- Pi-side relay bridge (
bridge/) — NIP-46 over Nostr relays ←→ NIP-44 ←→ serial ←→ ESP32
Phase 4 — Full NIP-46 HSM (shipped)
- Multi-master NVS storage (up to 8 masters, three modes: bunker/tree-mnemonic/tree-nsec)
- Extended provisioning protocol (add/remove/list masters with mode + label)
- NIP-44 v2 on-device (conversation key derivation + XChaCha20 + HMAC-SHA256)
- NIP-04 legacy on-device (AES-256-CBC)
- Zero-trust Pi transport (ESP32 decrypts inbound 0x10 frames, Pi only sees ciphertext)
- Bridge session authentication (shared secret, constant-time comparison)
- Client approval policies (per-master, per-client, RAM-only, pushed from bridge)
- Policy engine (auto-approve / OLED-notify / button-required tiers)
- NIP-46 core methods plus Heartwood extension dispatch (proof generation/verification currently return explicit
not yet implementederrors) - Multi-master OLED UX (boot screen, bridge status, master labels, auto-approve flash)
- Bridge passthrough mode (0x10/0x11 encrypted frames, fallback to legacy)
-
connectmethod with per-master connect secret validation + two-tier TOFU - Encrypted response flow (handler returns JSON, transport encrypts 0x11)
- NIP-44/NIP-04 encrypt/decrypt method bodies
Phase 5 — Hardening (shipped)
- Serial OTA with SHA-256 verification and automatic rollback
- Factory reset with button confirmation
- PIN lock with NVS-persisted failed-attempt counter
- Host-held vault key — seeds sealed under a 256-bit host-side key with unattended reboot (Pi auto-unlock over USB, or one-tap remote unlock from Sapwood over relays); see
docs/specs/2026-08-08-encrypted-at-rest-unlock-design.md - Rate limiting in policy engine (per-client counter exists in
ClientSessionbut is not yet wired into the request dispatch path) - Mutual-exclusivity guard between device-decrypts and legacy modes
- Bearer token auth on bridge management API
- Bridge secrets read from env vars via
clap env = ..., never enter argv or/proc/cmdline - Radios off in USB-bridged mode — LoRa/BLE never initialised; WiFi initialised only in the opt-in WiFi-standalone mode
- JTAG disable in production build
- Task watchdog enablement (60 s, panic → crash crumb, fed by every blocking loop — landed 2026-08-08)
-
cargo denysetup — licence checking, security advisories, crate bans (enforced in CI per crate; no workspace root)
Phase 6 — Zero-trust bridge (shipped 2026-04-05)
-
SIGN_ENVELOPEframe (0x34) — HSM builds and signs NIP-46 kind:24133 envelope events on-device - Bridge queries device for master list at startup via
PROVISION_LIST - Bridge routes NIP-46 traffic to the real master pubkey (no Pi-side bunker identity masquerade)
- Pi-side
bunker_keysdemoted to ephemeral relay-layer transport identity with no signing authority -
EventBuilder::sign_with_keysreplaced with device round-trip in the response path - 1.5 MB OTA partition slots (up from 896 KB) — accommodates current firmware size with 50% headroom
- Dedicated on-device transport key distinct from user masters
- NIP-46 transport architecture spec contribution
Field-test hardening (shipped 2026-08-14, v0.16.0)
- Two-button boards: B is an explicit cancel during approvals, with on-screen button hints ("hold lower 2s = yes / tap upper = no"); a floating second button is detected at boot and ignored
- Approval timeouts paint an explicit "Request expired / no change made" card — a stale countdown can never linger looking live; browser-driven windows widened to 45 s
- Display wakes on button press (not release) — a serial bridge pinning GPIO 0 after a web flash no longer makes the device look dead
- Idle info carousel: short presses page identity / network / device screens
- Multiple prioritised WiFi networks with per-SSID password
keep, join-loop rotation, and redacted list reporting over USB and relay - Locked vault-unlock relay phase associates the WiFi station itself (it previously ran before the only connect call and could never reach a relay)
- Board-check demo game bin (
scripts/build-firmware.sh demo) for pre-flashing handed-out boards — branded, two-button jump-and-duck, deliberately not the signer
Phase 7 — Portable signer
- Cargo feature flags:
hsm(default, USB-only) vsportable(BLE, battery) - HSM provisions a child key onto the portable device (
device/mobile-N) - BLE GATT service: NIP-46 request/response profile
- Phone pairs to device over BLE
- OLED shows signing request details, button to approve/deny
- Battery management: deep sleep between requests, wake on BLE connect
- Child key revocation: HSM increments index, re-provisions replacement device
Phase 8 — Portable extras (stretch)
- GPS location stamp on signed events (opt-in, portable mode only)
- QR code display of npub on OLED
- Multi-identity: carry several child keys, select on OLED before signing
Deliberately excluded
- WiFi signing as the default — the high-assurance default keeps all radios off and networks via the USB-attached Pi; a live TCP/IP stack is a real liability on a key-holding device. WiFi is off unless you explicitly opt into WiFi-standalone mode (see above), which trades that surface for dropping the Pi.
- Master secret on portable device — only child keys leave the home HSM. If the portable device is lost, the damage is one branch.
- LoRa signing — signing is a response to a request, and the requester needs internet anyway. LoRa solves a problem that doesn't exist for this use case. The SX1262 is never initialised (safe without antenna).
- Flash encryption / eFuse burning — permanently locks the chip to one firmware, prevents reuse (e.g. Meshtastic), and risks bricking if anything goes wrong. At-rest protection is instead provided by the opt-in PIN or host-held vault key (both seal the seeds without touching eFuses); physical custody remains part of the model. May revisit on a dedicated production unit.
Part of the ForgeSworn Toolkit
ForgeSworn builds open-source cryptographic identity, payments, and coordination tools for Nostr.
| Library | What it does |
|---|---|
| nsec-tree | Deterministic sub-identity derivation |
| ring-sig | SAG/LSAG ring signatures on secp256k1 |
| range-proof | Pedersen commitment range proofs |
| canary-kit | Coercion-resistant spoken verification |
| spoken-token | Human-speakable verification tokens |
| toll-booth | L402 payment middleware |
| geohash-kit | Geohash toolkit with polygon coverage |
| nostr-attestations | NIP-VA verifiable attestations |
| dominion | Epoch-based encrypted access control |
| nostr-veil | Privacy-preserving Web of Trust |