Decouple user provisioning from HTTPS identity storage while retaining compatible v1 user records and migrating TLS material to the credential-free v2 format. Add focused security regression coverage and update operator documentation.
Production web-security host regression
Run from the repository root:
python3 tests/web_security/run.py
Requires a C11 compiler, nm, Python 3, and installed mbedTLS 3.6 headers plus
libmbedx509 / libmbedcrypto. Verified with host mbedTLS 3.6.7. No downloads,
firmware build, device access, asset generation or persistent build outputs.
The runner creates adapters and binaries in a temporary directory.
security.c includes the entire unchanged production src/web_security.c.
No crypto function, generator, validator, decoder or transaction is extracted or
replaced. Actual mbedTLS generates P-256 keys and signed certificates and parses,
hashes, checks the key pair and verifies the self-signature. The legacy fixture
is independently assembled at fixed little-endian byte offsets from a freshly
generated real identity, not a production legacy encoder. No real device secret
or fixed private key is checked in.
Host adapters provide a fixed device MAC, Linux getrandom, a lock-ownership
assertion, and fault-injectable NVS with staged writes/commit. At every write and
commit the adapter checks that production live state is still its predecessor.
Legacy wipe calls are counted and checked. Production restart is simulated by
clearing only module RAM while retaining the adapter's stored record. These
adapters do not prove ESP entropy initialization, allocation failure inside
mbedTLS, FreeRTOS concurrency, ESP NVS flash/power-loss semantics, TLS handshakes,
HTTPD restarts, target stack margins, or secure physical flash erasure. In
particular, modeled failed commits retain predecessor storage; real flash fault
and power-loss behavior needs target validation. No claim that NVS logical
replacement securely erases historical flash pages.
The suite reports 15 production security groups plus one API/console static absence check. Coverage includes fresh and stored-v2 paths, exact v1 migration, metadata/pair-copy bounds, NVS failures and retries, 21 legacy corruptions, 14 v2 corruptions, unknown sizes, real bad signatures with recomputed hashes, mismatched private keys, wrong-device certificates, RNG/MAC/mutex failures, rotation/reset commit-before-publication, unavailable explicit recovery, generation exhaustion and invalid arguments. Console checks establish removal of legacy command/secret/synchronization references, not runtime console lifecycle execution. Read-only database status and login-failure counters are intentionally retained.
Integration/API contract
Five public functions remain:
web_security_init(web_security_load_result_t *)web_security_copy_tls_material(...)(unchanged pair-copy API)web_security_get_certificate_metadata(...)(unchanged metadata)web_security_rotate_certificate(void)web_security_reset_all(void)(TLS only, changed signature)
Removed: two credential functions (show_credentials, rotate_credentials),
one credential struct type, three username/password capacity/length constants,
and two console operations (web credentials show, web credentials rotate).
There is no credential generation/display/synchronization path. Authentication
continues to belong to the user database; read-only status does not mutate it.
The integration owner must remove legacy startup callers in main.c and adapt
other console policy/completion/UI/test callers outside this ownership scope.
Load results retain STORED=0, GENERATED_MISSING=1, and add MIGRATED_V1=2.
Repeated successful init returns the remembered result without reloading.
Migration must validate and commit before publication; no fallback generation
or overwrite follows migration failure. Reset explicitly overwrites missing,
valid, or incompatible material, increments a live generation or uses one when
no live identity exists, and fails on live generation exhaustion. Rotation
requires live material and also fails at UINT32_MAX.
web reset --force retains the old lifecycle: commit first; when running,
stop then start, with no start after failed stop; otherwise attempt start.
Lifecycle failure does not roll back committed identity. Database accounts are
never synchronized, reset or otherwise mutated by these operations.
Storage contract
The namespace/key remain web_sec/material, one blob, no new NVS keys.
V1 is exactly 1392 bytes, read only through a private fixed-offset decoder.
Its credential layout must still match the shipped admin/24-character URL-safe
format, zero padding/reserved fields, and strict TLS validation.
V2 is exactly 1340 bytes (52 bytes smaller), native little-endian ESP32 layout with compile-time offset/size assertions:
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | schema version = 2 |
| 4 | 2 | blob size = 1340 |
| 6 | 2 | reserved, zero |
| 8 | 4 | nonzero generation |
| 12 | 2 | private key DER length |
| 14 | 2 | certificate DER length |
| 16 | 256 | private key DER, unused bytes zero |
| 272 | 1024 | certificate DER, unused bytes zero |
| 1296 | 32 | certificate SHA-256 fingerprint |
| 1328 | 12 | reserved, zero |
Migration preserves exact DER bytes, fingerprint and generation, including
UINT32_MAX; it never regenerates TLS identity. TLS validation includes exact
outer DER lengths, P-256 pair consistency, self-signature, fingerprint, device
CN/SAN, validity and existing certificate extension policy. Unknown sizes or
schema versions fail with ESP_ERR_INVALID_VERSION; malformed known records
fail with ESP_ERR_INVALID_RESPONSE (underlying operational failures propagate).
Every legacy input buffer is wiped on all post-read exits. The live blob has no
credential fields. No new task, queue, mutex, heap allocation or storage key is
introduced; the existing mutex remains. Migration adds a bounded 1392-byte
transient decoder buffer alongside the 1340-byte candidate; target call-stack
high-water usage is unmeasured.