Security Model
Security work on a hobby-buildable appliance has to answer "against whom?" before "how?". This chapter states the threat model plainly, then walks the layers from network to silicon.
The threat model
| Adversary | In scope | Primary defenses |
|---|---|---|
| Passive observer on the LAN | yes | TLS everywhere |
| Active attacker on the LAN (hostile guest, compromised laptop) | yes | Bearer token on all mutations, input validation, rate limits |
| Malicious or hijacked update source | yes | HTTPS + CA verification, URL pinning, signed images under secure boot |
| Person with physical possession, casual | yes (production profile) | Flash encryption, NVS encryption |
| Person with physical possession, lab-grade | explicitly out of scope | Fault injection and decapping defeat any hobby-class device; pretending otherwise would be theater |
| The vendor (us) spying | structurally out | No telemetry, no cloud, no accounts; there is nothing to exfiltrate to |
Runtime layers
Each was detailed in its own chapter; assembled here as one posture:
- Transport: everything over TLS, including the dashboard and WebSocket (HTTPS chapter). Streams and OTA verify real CAs via the certificate bundle.
- Authentication: one 256-bit bearer token, minted from the hardware RNG on first boot, printed only on the physical serial port, compared in constant time. Possession of the hardware is the root credential, matching how humans actually own appliances (REST design).
- Authorization surface: reads that a pre-token dashboard needs are public and redacted (the settings endpoint returns the SSID, never the password); every mutation requires the token.
- Input validation at the boundary: every externally supplied value is checked in the handler layer before reaching a component: URL scheme allowlists, path traversal rejection, range clamps (REST design).
- Resource defense: per-class rate limits sized to protect RAM, not to slow password guessing (256-bit tokens do not get guessed).
Silicon layers (the production profile)
Development builds run without these for debuggability; sdkconfig.production turns them on, and they only make sense together:
Secure boot
ESP32-S3 Secure Boot V2: the ROM verifies the bootloader's RSA-3072 signature against a public-key digest burned into eFuses; the bootloader verifies the app image the same way. The chain means the chip will not run code that was not signed with your key, which is what makes OTA trustworthy even against a compromised release host: TLS authenticates the server, the signature authenticates the image.
The private key is generated once, lives outside the repository forever, and build.sh --production requires it via environment (SAFI_SECURE_BOOT_SIGNING_KEY). Burning the eFuse is irreversible per device; the provisioning chapter sequences it so a mistake costs one sacrificial board, not a batch.
Flash encryption
XTS-AES over external flash contents with a per-device key in eFuse: a desoldered flash chip reads as noise. This is what actually protects the WiFi password, the API token, and the TLS private key against the "casual physical possession" adversary; NVS encryption (with its key in the nvs_key partition) layers the same guarantee onto the settings store specifically. Cost: negligible runtime overhead (the crypto is in the cache path), plus real operational discipline: an encrypted device cannot be reflashed over USB casually, which is the feature.
Coredump policy
Coredumps are disabled in sdkconfig.production (CONFIG_ESP_COREDUMP_ENABLE_TO_NONE selects the no-destination choice): a dump holds a RAM snapshot that contains the WiFi credentials and the API token, and while flash encryption would protect that blob at rest, production has no serial debug loop to ever consume it, so storing it is pure risk with no reader.
The production gates
CONFIG_SAFI_ENFORCE_PRODUCTION_GATES makes the hardened profile self-defending at compile time: a production build fails to build if insecure OTA pull is enabled, if secure boot or flash encryption is off, or if debug conveniences are left in. The design premise is that checklists decay but compilers do not; the one person building a release at 2 a.m. cannot forget what the build refuses to do. tools/production_preflight.sh runs the same checks plus key-material presence before flashing.
ESP-IDF v6 migration
The firmware moved from ESP-IDF 5.5.1 to v6.0.2. Nothing in the threat model above changed; what changed is the crypto substrate and the provisioning path, so the migration is documented here, security first.
PSA Crypto and MbedTLS 4
ESP-IDF v6 does its internal cryptography through the PSA Crypto API, and the app inherits MbedTLS 4.x hardening because IDF owns the TLS stack. Safi's app code is otherwise PSA-free: NVS encryption, the TLS sessions, and the provisioning handshake are all IDF-internal PSA consumers. The two deliberate app-side touches are the PoP derivation (main/pop_derive.c uses psa_mac_compute directly for the HMAC) and a defensive psa_crypto_init() tripwire at the end of system_init.c: it runs after everything that needs PSA is already up and only logs an early warning, it never repairs. Its whole job is to fail loudly in the log on a future IDF change that stops initializing PSA during startup, so a broken device never degrades silently.
What MbedTLS 4 inherits from the IDF v6 move: the DHE, RSA key exchange, and static ECDH suites are removed, and curves below 250 bits are unsupported. Modern TLS clients are unaffected. None of Safi's peers needed anything removed, so the migration cost nothing and gained the smaller attack surface.
Provisioning: protocomm security 2, PoP label
ESP-IDF v6 removed SmartConfig (ESPTouch) entirely, so the WiFi onboarding path was rebuilt on the espressif/network_provisioning component: the device advertises a SoftAP, the Espressif "ESP SoftAP Provisioning" phone app connects to it, and credentials move over protocomm security 2 (SRP6a authentication plus AES-GCM session encryption), not plaintext.
Security 2 is anchored by a proof of possession (PoP): a 16-character value, unique per device, that the app must present before the device will accept anything. The app also asks for a provisioning username, which is the firmware-wide constant safi (PROV_USERNAME in main/wifi_manager.c), so the box label prints both username and PoP and nothing is left to guess.
On production builds the PoP is derived, not stored: main/pop_derive.c computes HMAC-SHA256(factory secret, base WiFi MAC) with PSA (psa_mac_compute, SHA-256) and maps the first 16 digest bytes modulo 62 onto [A-Za-z0-9]. The factory secret is a hex string (even length, >=32 chars) injected at build time through the SAFI_POP_FACTORY_SECRET environment variable (main/CMakeLists.txt passes it as a compile definition), so no PoP is ever written to NVS and a leaked device NVS dump leaks no PoP. The same derivation exists in tools/nvs_mass_provisioning.py (per-device mac manifest column) and tools/provision_device.sh (reads the MAC with esptool.py read_mac), so the label printed on the line always matches what the firmware derives at boot. The MAC source is the factory eFuse base MAC (esp_read_mac(ESP_MAC_WIFI_STA)); the Safi sdkconfigs keep the default universal MAC addressing (STA MAC equals the base MAC), so the value is stable. tools/production_preflight.sh refuses a production build without a well-formed SAFI_POP_FACTORY_SECRET, and a secret-bearing build whose derivation fails refuses to provision rather than falling back to a random PoP.
What this scheme is and is not, stated plainly: the factory secret is a compile-time string present in every production binary, and the MAC is observable on the box and over the air. Anyone who extracts the firmware (which flash encryption defeats for the casual owner but not for a lab-grade adversary) can reproduce any device's PoP. The derived PoP therefore defends against opportunistic pairing (someone near the device during provisioning cannot guess or skim the PoP off the network), not against firmware extraction. Per-device burned-key derivation (a per-chip secret in eFuse) is noted as future hardening for that stronger threat; the full design is specified in the per-device PoP keys section.
Development builds have no factory secret: the firmware falls back to NVS (namespace provisioning, key pop), and a device without a factory PoP generates one on first boot and prints it on the serial console once, next to the username. The security property that matters: a random attacker on the LAN can see the SoftAP but cannot join the provisioning session without the PoP printed on the box, which is the same possession-based trust model as the API token.
Per-device PoP keys: the burned-key design
The shared factory secret has one structural weakness: it is a single string compiled into every production binary, so one extracted firmware image reproduces the PoP of every device ever shipped. This section specifies the design that removes the shared secret entirely. It is a design document: nothing in this section is implemented in the current firmware or tooling. No code path reads a per-device key, no tool burns one, and SAFI_POP_FACTORY_SECRET remains the mechanism of record. The section exists so the move, when made, is a build decision against a reviewed design rather than a fresh engineering exercise.
The goal. Replace the compile-time shared secret with a per-device 256-bit key so that extracting one device's firmware no longer yields any other device's PoP. The derivation itself is untouched: PoP = HMAC-SHA256(device key, base WiFi MAC), byte-for-byte the same pop_derive_compute pipeline in main/pop_derive.c (PSA psa_mac_compute, SHA-256, first 16 digest bytes modulo 62 onto [A-Za-z0-9]), and the same derive_pop in tools/nvs_mass_provisioning.py. Only the key source changes: the 32 bytes come from an eFuse key block instead of a compile-time string.
The mechanism. Each unit gets a fresh 256-bit key (32 raw bytes, openssl rand 32), burned at the factory into a spare eFuse key block with the key purpose that matches how the key is used. The current production layout pins BLOCK_KEY0 (secure boot digest), BLOCK_KEY1 (XTS flash encryption) and BLOCK_KEY2 (NVS HMAC, see the layout); BLOCK_KEY3, KEY4 and KEY5 are spare, and BLOCK_KEY3 is the natural home. Because pop_derive.c runs the HMAC in software through PSA, the firmware must be able to read the key, which is exactly what the eFuse API's USER key purpose exists for ("software-only use"): the block is write-protected after burning, stays readable to firmware, and is invisible to anyone who only has the flash contents. The burn command, per unit:
espefuse.py --port "$PORT" burn_key --no-read-protect BLOCK_KEY3 key.bin USER
The key file must be exactly 32 bytes of raw binary key data; espefuse applies the RS coding and write-protects the block and the purpose field as part of the burn. The --no-read-protect flag is mandatory for this design: espefuse read-protects burned keys by default, and a read-protected key block cannot be read by the firmware, which breaks the software HMAC derivation outright. The factory flow mirrors the existing sequence in the provisioning chapter: generate the key on the bench, burn it into BLOCK_KEY3, derive the label PoP from that key and the base MAC, and only then let the unit leave the line. The label tooling changes in one place: where SAFI_POP_FACTORY_SECRET is a shared environment variable, the per-device key flows from the generator straight into the burn and into the label derivation, and it is key material the whole time it exists outside the device, so a factory that records it (a pop_key manifest column, say) must treat that manifest the way the current factory secret is treated today: vaulted, not committed, not shared.
The irreversible-burn warning, stated prominently. An eFuse burn is irreversible. A wrong key, a wrong purpose, or a wrong block cannot be undone and cannot be rewritten, and a burned key block is consumed forever. This design must be validated on sacrificial boards before any manufacturing use, exactly as the secure boot and flash encryption burns are today (the provisioning chapter sequences every irreversible step on one board first so a mistake costs one board, not a batch), and no production unit may ship with this key until a sacrificial board has proven that firmware, label tooling, and burned key all derive the same PoP.
What this design does and does not defend against, stated plainly. It does defend against the shared-secret mass compromise: a firmware dump (or the repository's own build artifacts) no longer contains a key that works for any other device, and a factory that keys each unit uniquely limits any single leak to that unit. It does not defend against per-device extraction: USER keys are readable by the firmware, so an adversary who defeats secure boot and runs code on one device can read that device's key and derive that device's PoP. The physical-access game stays exactly where the threat model put it: lab-grade possession of a specific device remains out of scope, and this design narrows blast radius rather than pretending to close it. A stronger variant exists and is deliberately deferred: burning the key with the HMAC_UP purpose and deriving the PoP with the hardware HMAC peripheral (esp_hmac_calculate) would make the key unreadable even to the firmware, but it would change pop_derive.c, and it would break the offline label derivation, because a read-protected key cannot be recovered at the factory to print the label. That trade-off (firmware and tooling changes for a stronger per-device property) is a separate decision; this section specifies only the USER-purpose design above.
The open SoftAP decision
The provisioning AP is open (no passphrase), which is the opposite of casual instinct, and it is a decision, not an oversight:
- The AP exists only inside the provisioning window, and possession of the PoP is what authenticates the session; an AP passphrase would protect nothing the PoP does not already protect and would add a second secret for the same purpose.
- Security 2 gives the phone and device a mutually authenticated, encrypted session over an open AP; the channel itself carries no plaintext regardless of the link layer.
wpa3_compatible_mode(SAE) is deferred because SAE requires a passphrase-protected AP, which drags the second-secret problem back in. If a future IDF makes SAE feasible on a PoP-only open AP, revisit then.
The user-facing walkthrough is in the user guide; the runtime mechanics are in the WiFi chapter.
eFuse key block layout
The three production key purposes are pinned to separate eFuse key blocks on the ESP32-S3:
| Block | Purpose | Burned by |
|---|---|---|
| BLOCK_KEY0 | Secure Boot V2 digest (SECURE_BOOT_DIGEST0) | tools/provision_device.sh |
| BLOCK_KEY1 | XTS flash-encryption key (XTS_AES_128_KEY) | tools/provision_device.sh |
| BLOCK_KEY2 | NVS HMAC key (HMAC_UP) | tools/provision_device.sh |
CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID is therefore 2, not 1: key id 1 addresses BLOCK_KEY1, which holds the XTS key. The two would collide, and the NVS layer would "encrypt" with the flash-encryption key, breaking NVS at best and silently reusing a key purpose at worst. The NVS HMAC key is random per device (32 bytes from openssl rand), burned with purpose HMAC_UP before first boot; the XTS key is the 32-byte flash-encryption key on BLOCK_KEY1 per the established flow.
Build-time and platform changes
- Picolibc is now the C library: 2.34 percent smaller image (2,549,403 to 2,489,727 bytes). The concurrency gate passed on hardware: 6000 of 6000 sentinel lines untorn under three tasks writing stdio simultaneously with the production watchdog enabled. A newlib fallback remains one Kconfig away (
CONFIG_LIBC_NEWLIB=y). - GCC 15.1 treats warnings as errors by default; the codebase is warning-clean. Toolchain floor: CMake 3.22.1 or newer, Python 3.10 or newer.
- The built-in
jsoncomponent is gone from IDF; Safi now declaresespressif/cjson ^1.7.19, same API. - Component set on the v6 line:
esp_lvgl_port ^2.9.0, LVGL 9.5.0,joltwallet/littlefs ~1.22.3, mdns 1.11.3 (unchanged),esp_audio_codec 2.3.0,pn532 0.2.1. - The production gates (secure boot, flash encryption, NVS encryption, anti-rollback, OTA pinning) are unchanged: they were already expressed as compile-time checks and survived the toolchain move intact.
Handling the unavoidable secrets
| Secret | Lives | Never |
|---|---|---|
| WiFi credentials | NVS (encrypted in production); sdkconfig.local on dev machines, which is gitignored | in git, in API responses |
| API token | NVS; serial console on first boot | over the network |
| TLS private key | storage partition (dev) or provisioned per device | in the repository (production certs are generated outside the tree) |
| Signing key | your vault | anywhere near the repository |
Reporting
Security reports are welcome via the repository's private vulnerability reporting; a plain issue is fine for anything already public. The maintainers' commitment is honest triage and credited fixes.