From c724685d442948ef7ae7129fa81ab7b70fa14859 Mon Sep 17 00:00:00 2001 From: TheusHen Date: Sun, 13 Sep 2026 13:27:39 -0300 Subject: [PATCH 01/11] docs: crypto audit evidence pack, compliance checklist, no-cmake guide Add per-release crypto audit evidence template (provider version/hashes, upstream audit ref, KAT log incl. altered-tag rejection, provisioning, integration review) and ISO27001/SOC2/HIPAA self-assessed mapping. Link both from crypto-assurance.md without claiming a full independent audit. Add no-cmake.md (vendoring + gcc/clang/MSVC/ESP-IDF/PlatformIO + vector validation). tools/check_docs.py passes. --- docs/no-cmake.md | 55 +++++++++++++++++++++++++++ docs/security/audit-evidence.md | 49 ++++++++++++++++++++++++ docs/security/compliance-checklist.md | 40 +++++++++++++++++++ docs/security/crypto-assurance.md | 3 ++ 4 files changed, 147 insertions(+) create mode 100644 docs/no-cmake.md create mode 100644 docs/security/audit-evidence.md create mode 100644 docs/security/compliance-checklist.md diff --git a/docs/no-cmake.md b/docs/no-cmake.md new file mode 100644 index 0000000..47ee4eb --- /dev/null +++ b/docs/no-cmake.md @@ -0,0 +1,55 @@ +# Latch without CMake (vendoring + direct compile) + +Latch is portable C11 with no mandatory build system. CMake is the tested +path (`cmake --preset host-debug`), but you can vendor the sources and +compile with anything: gcc, clang, MSVC, ESP-IDF, PlatformIO. + +## 1. Vendor (copy what you need) + +Smallest useful set (host example proves the API surface): + +```sh +mkdir -p vendor/latch +cp -r include/ vendor/latch/include +cp -r src/envelope/ src/storage/ src/transport/ src/capture/ vendor/latch/src +# KEEP: include/laststate/config.h — edit the LS_* knobs for your target +``` + +Verify against the repo: `python tools/minify_sources.py --output /tmp/latch-production --verify`. + +## 2. Compile (no CMake) + +```sh +# gcc / clang — freestanding-friendly C11, warnings as errors +cc -std=c11 -Wall -Wextra -Werror \ + -I vendor/latch/include \ + $(find vendor/latch/src -name '*.c') \ + -c # then link into your firmware + +# MSVC (host tools / latch-dump) +cl /std:clatest /W4 /I vendor\latch\include vendor\latch\src\*.c +``` + +Link only what you use: storage + transport are swappable — provide your +own `ls_storage_*` / `ls_transport_*` backends (flash, RTC RAM, UART) and +skip the host in-memory ones. + +## 3. ESP-IDF / PlatformIO (no CMakeLists edits on your side) + +- ESP-IDF: add `vendor/latch/src/*.c` + `vendor/latch/include` to your + component `CMakeLists` `SRCS`/`INCLUDE_DIRS` (or list files explicitly); + set `LS_*` options in a copied `config.h`. Full panic→reboot→ACK walkthrough: + `examples/esp32-first-crash/README.md`, HIL fixture: `hil/esp32_relay/README.md`. +- PlatformIO: same files under `lib/latch/`; add `build_flags = -I lib/latch/include`. + +## 4. Validate the vendored copy + +```sh +# vectors (no build system needed beyond a C compiler + latch-dump): +latch-dump --hex tests/vectors/lep-v1-basic.hex +latch-dump --hex tests/vectors/lep-v2-basic.hex +``` + +Keep vectors + `latch-dump` in CI: they catch envelope regressions before +flashing. For crypto posture (built-in vs commercial provider), see +`docs/security/crypto-assurance.md`. diff --git a/docs/security/audit-evidence.md b/docs/security/audit-evidence.md new file mode 100644 index 0000000..3de1e76 --- /dev/null +++ b/docs/security/audit-evidence.md @@ -0,0 +1,49 @@ +# Crypto audit evidence (per-release retention) + +Independent audit of Latch's full integration and of any vehicle's key +lifecycle has **not** been performed — see `crypto-assurance.md`. This file +defines exactly what evidence a product owner must retain so that a future +audit (or a customer security review) can verify the crypto actually shipped. + +## What "audited" means here (and what it does not) + +- The **built-in** HKDF-SHA256 / XChaCha20-Poly1305 implementation is + `builtin-unqualified`: strong automated vectors, no external audit claim. +- The **commercial profile** (`LS_COMMERCIAL_PROFILE=ON`) refuses to run + unless an externally-audited provider is pinned; the provider's own audit + reference must be recorded below. +- The **libsodium adapter** (`ports/libsodium/`) inherits upstream libsodium's + third-party audit — that covers libsodium itself, not Latch's integration, + provisioning, or key storage. + +## Per-release evidence pack (retain with qualification evidence) + +| # | Artifact | Example | +|---|----------|---------| +| 1 | Provider name + exact version | `libsodium 1.0.19-stable`, git sha / tarball sha256 | +| 2 | Binary/source hashes + build options | `sha256sum lib/libsodium.a`, `-DLS_BUILD_LIBSODIUM_PROVIDER=ON` flags | +| 3 | Upstream audit reference | report title, auditor, date, URL (e.g. libsodium audit per `crypto-assurance.md` links) | +| 4 | KAT result log | `ctest -R crypto_kat` output showing HKDF + XChaCha decrypt success **and** altered-tag rejection | +| 5 | Provider install log | `LS_REQUIRE_EXTERNAL_CRYPTO_PROVIDER=1` install success / `LS_EAUTH` on missing provider | +| 6 | Provisioning procedure | how device keys are generated, injected, stored (secure element part # if any) | +| 7 | Integration review note | reviewer, date, scope (envelope path, replay-window, key-id handling) | +| 8 | Key lifecycle statement | rotation period, revocation path, what happens on compromise | + +## KAT procedure (copy into release notes) + +```sh +cmake -S . -B build -DLS_COMMERCIAL_PROFILE=ON -DLS_BUILD_LIBSODIUM_PROVIDER=ON ... +cmake --build build +ctest --preset host-debug -R 'crypto|kat|assurance' --output-on-failure +# Expected: HKDF-SHA256 known-answer PASS, XChaCha20-Poly1305 decrypt PASS, +# altered-tag rejection PASS, unqualified-builtin rejection PASS. +``` + +## Upstream references (consulted for this policy) + +- https://libsodium.gitbook.io/doc/roadmap +- https://libsodium.gitbook.io/doc +- https://libsodium.gitbook.io/doc/commercial_support + +Pin the exact pages/revisions you relied on in the release pack — upstream +docs move; your pack must not depend on a live URL. diff --git a/docs/security/compliance-checklist.md b/docs/security/compliance-checklist.md new file mode 100644 index 0000000..913b825 --- /dev/null +++ b/docs/security/compliance-checklist.md @@ -0,0 +1,40 @@ +# Crypto compliance checklist (ISO 27001 / SOC 2 / HIPAA) + +Maps Latch crypto controls to the frameworks regulated customers ask about. +This is a **self-assessed control mapping**, not a certification — retain the +evidence pack (`audit-evidence.md`) alongside any customer questionnaire. + +## ISO/IEC 27001:2022 + +| Control | Latch implementation | Evidence | +|---------|----------------------|----------| +| A.8.24 Cryptography use | XChaCha20-Poly1305 envelopes, HKDF-SHA256 keys; commercial profile mandates external-audited provider | `crypto-assurance.md`, KAT log | +| A.8.24 Key management | Key IDs + replay window on-wire; provisioning procedure per release | evidence pack rows 6+8 | +| A.8.13 Backup / A.8.32 Change mgmt | Crypto changes gated by KAT + `ctest` vectors | CI log | + +## SOC 2 (CC6 — Logical access / encryption) + +| Criterion | Latch implementation | Evidence | +|-----------|----------------------|----------| +| CC6.1/6.6 encryption in transit+at rest | LEP AEAD envelopes; Relay TLS + spool encryption where configured | `relay/docs/security.md`, enrollment config | +| CC6.8 integrity | Poly1305 tags; altered-tag rejection tested | KAT log (altered-tag row) | +| CC7.2 monitoring | Failed-auth (`LS_EAUTH`) counters surfaced to Relay metrics | Relay observability docs | + +## HIPAA (Technical safeguards §164.312) + +| Safeguard | Latch implementation | Evidence | +|-----------|----------------------|----------| +| (a)(2)(iv) encryption | AEAD envelopes end-to-end (device → Relay → Trace) | architecture + KAT | +| (e)(1)/(e)(2) transmission security/integrity | HMAC/AEAD wire + TLS transport | `relay/docs/security.md` | +| (b) audit controls | Envelope key-id + auth-failure logging | log samples in release pack | + +## Customer questionnaire block (paste-ready) + +> Latch ships a portable built-in cipher suite labelled +> `builtin-unqualified` (tested, not externally audited) and a commercial +> profile that enforces an externally-audited provider +> (`LS_REQUIRE_EXTERNAL_CRYPTO_PROVIDER=1`). Per release we retain provider +> version/hashes, upstream audit reference, KAT logs (decrypt + altered-tag +> rejection), provisioning procedure, and integration review — see +> `docs/security/audit-evidence.md`. No independent audit of Latch's full +> integration or customer key lifecycle has been performed to date. diff --git a/docs/security/crypto-assurance.md b/docs/security/crypto-assurance.md index 3a582ee..a83b75a 100644 --- a/docs/security/crypto-assurance.md +++ b/docs/security/crypto-assurance.md @@ -45,3 +45,6 @@ Upstream evidence consulted for this policy: For an AUV release, retain the exact library version, binary/source hashes, build options, KAT result, provisioning procedure and integration review with the release qualification evidence. +See [audit-evidence.md](audit-evidence.md) for the per-release pack template +and [compliance-checklist.md](compliance-checklist.md) for the ISO 27001 / +SOC 2 / HIPAA control mapping (self-assessed, not a certification). From 0c914915d604aa5275b27edd5a78f1940dec362f Mon Sep 17 00:00:00 2001 From: TheusHen Date: Sat, 19 Sep 2026 21:14:39 -0300 Subject: [PATCH 02/11] feat: NMI-safe power-fail seal with LEP TLV 23, registry and readme updates --- CHANGELOG.md | 5 + CMakeLists.txt | 8 +- README.md | 44 +++-- docs/architecture.md | 2 + docs/concurrency.md | 2 + docs/known-limitations.md | 6 + docs/lep-v1.md | 2 +- docs/powerfail-seal.md | 52 ++++++ hil/brownout_seal/EVIDENCE.md | 10 + hil/brownout_seal/README.md | 66 +++++++ include/laststate/config.h | 6 + include/laststate/envelope.h | 4 +- include/laststate/latch.h | 1 + include/laststate/powerfail.h | 82 ++++++++ spec/registry/tlv-types.md | 7 + src/core/boot.c | 3 + src/core/internal.h | 3 + src/core/powerfail.c | 342 ++++++++++++++++++++++++++++++++++ src/core/runtime.c | 3 + src/envelope/lep.c | 41 ++++ tests/test_powerfail_seal.c | 177 ++++++++++++++++++ tools/latch_dump.c | 14 ++ 22 files changed, 861 insertions(+), 19 deletions(-) create mode 100644 docs/powerfail-seal.md create mode 100644 hil/brownout_seal/EVIDENCE.md create mode 100644 hil/brownout_seal/README.md create mode 100644 include/laststate/powerfail.h create mode 100644 src/core/powerfail.c create mode 100644 tests/test_powerfail_seal.c diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f9b3e..20a3dc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Latch — embedded failure-capture runtime. +## [Unreleased] + +### Added +- **Power-fail seal (Last-Microjoule commit)** — NMI-safe `ls_powerfail_seal()` freezes a fixed retained record plus VBAT backup-RAM mirror; `ls_boot()` promotes it as an `EMERGENCY` reset event with additive LEP TLV 23 (`POWERFAIL_SEAL`); torn writes ignored, clear-after-append preserved. Host-tested; HIL brownout rig not yet run (`not hardware-tested`). + ## [1.0.0-rc.2] - 2026-08-19 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index a7e9c87..3e8569a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,7 @@ if(ESP_PLATFORM) src/core/peripheral.c src/core/assert.c src/core/log.c src/core/performance.c src/core/boot.c src/core/build_id.c src/core/policy.c src/core/blackbox.c src/core/mission.c src/core/time_sync.c src/core/fingerprint.c src/core/supervisor.c - src/core/trace.c src/core/provisioning.c src/core/selftest.c src/core/fault_injection.c src/core/ota.c src/core/environment.c + src/core/trace.c src/core/provisioning.c src/core/selftest.c src/core/fault_injection.c src/core/ota.c src/core/environment.c src/core/powerfail.c src/capture/capture.c src/envelope/lep.c src/envelope/compression.c src/spool/spool.c src/storage/memory_storage.c src/storage/storage_sim.c src/storage/flash_mirror.c src/storage/wear_level.c src/storage/secure_storage.c @@ -65,7 +65,7 @@ include(cmake/GenerateBuildId.cmake) set(LS_CONFIGURABLE_DEFINITIONS LS_CONSTRAINED_PROFILE LS_REQUIRE_EXTERNAL_CRYPTO_PROVIDER LS_MIN_CRYPTO_ASSURANCE LS_STORE_STRINGS LS_ENABLE_BREADCRUMBS LS_ENABLE_METRICS LS_ENABLE_LOGS LS_ENABLE_POWER_SAMPLES LS_ENABLE_PERFORMANCE LS_ENABLE_DUMPS LS_ENABLE_ASSERTS - LS_ENABLE_WIDE_CONTEXT + LS_ENABLE_WIDE_CONTEXT LS_ENABLE_POWERFAIL_SEAL LS_ENABLE_STACK_SNAPSHOT LS_COMPILED_MIN_LEVEL LS_MAX_EVENT_SIZE LS_BREADCRUMB_CAPACITY LS_BREADCRUMB_MESSAGE_MAX LS_BREADCRUMB_CATEGORY_MAX LS_BREADCRUMB_KV_MAX LS_METRIC_CAPACITY LS_METRIC_NAME_MAX LS_METRIC_WINDOW_SIZE @@ -92,7 +92,7 @@ if(LS_COMMERCIAL_PROFILE) add_compile_definitions(LS_REQUIRE_EXTERNAL_CRYPTO_PROVIDER=1 LS_MIN_CRYPTO_ASSURANCE=3) endif() -set(LS_CORE_SOURCES src/core/runtime.c src/core/util.c src/core/memory.c src/core/health.c src/core/peripheral.c src/core/assert.c src/core/log.c src/core/performance.c src/core/boot.c src/core/build_id.c src/core/policy.c src/core/blackbox.c src/core/mission.c src/core/time_sync.c src/core/fingerprint.c src/core/supervisor.c src/core/trace.c src/core/provisioning.c src/core/selftest.c src/core/fault_injection.c src/core/ota.c src/core/environment.c) +set(LS_CORE_SOURCES src/core/runtime.c src/core/util.c src/core/memory.c src/core/health.c src/core/peripheral.c src/core/assert.c src/core/log.c src/core/performance.c src/core/boot.c src/core/build_id.c src/core/policy.c src/core/blackbox.c src/core/mission.c src/core/time_sync.c src/core/fingerprint.c src/core/supervisor.c src/core/trace.c src/core/provisioning.c src/core/selftest.c src/core/fault_injection.c src/core/ota.c src/core/environment.c src/core/powerfail.c) set(LS_CAPTURE_SOURCES src/capture/capture.c) set(LS_ENVELOPE_SOURCES src/envelope/lep.c src/envelope/compression.c) set(LS_SPOOL_SOURCES src/spool/spool.c) @@ -315,7 +315,7 @@ if(LS_BUILD_TESTS) add_executable(latch-core-observability-tests tests/test_core_observability.c) target_link_libraries(latch-core-observability-tests PRIVATE latch) add_test(NAME latch-core-observability-tests COMMAND latch-core-observability-tests) - foreach(test_name security crypto-vectors crypto-negative secure-element secure-storage secure-power-loss wear-level compression flash policy transport envelope-fuzz power-loss spool-stress property storage-bounds transport-policy build-id envelope-errors spool-priority crypto-provider update defensive-paths stream-edges memory-capture spool-edges envelope-capacity runtime-edges auv-runtime auv-edges ota fault-injection-pipeline replay-property) + foreach(test_name security crypto-vectors crypto-negative secure-element secure-storage secure-power-loss wear-level compression flash policy transport envelope-fuzz power-loss powerfail-seal spool-stress property storage-bounds transport-policy build-id envelope-errors spool-priority crypto-provider update defensive-paths stream-edges memory-capture spool-edges envelope-capacity runtime-edges auv-runtime auv-edges ota fault-injection-pipeline replay-property) string(REPLACE "-" "_" test_source ${test_name}) add_executable(latch-${test_name}-tests tests/test_${test_source}.c) target_link_libraries(latch-${test_name}-tests PRIVATE latch) diff --git a/README.md b/README.md index 23317db..c4a6c62 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,17 @@ The embedded runtime is heap-free C11 with bounded buffers: you keep control of [![C11](https://img.shields.io/badge/C-C11-00599C.svg)](CMakeLists.txt) [![Rust no_std](https://img.shields.io/badge/Rust-no__std-000000.svg)](rust/README.md) -```text -without Latch HardFault -> reboot -> "could not reproduce" - -with Latch HardFault -> retained snapshot -> reboot -> persistent spool -> your transport - +-> CPU context, reset reason, build ID, - breadcrumbs, metrics, and health data +```mermaid +flowchart TB + subgraph without["without Latch"] + direction LR + f1["HardFault"] --> r1["reboot"] --> lost["“could not reproduce”"] + end + subgraph with["with Latch"] + direction LR + f2["HardFault"] --> snap["retained snapshot"] --> r2["reboot"] --> spool["persistent spool"] --> t["your transport"] + end + snap -. "CPU context · reset reason · build ID
breadcrumbs · metrics · health data" .-> spool ``` [![Latch physical ESP32 crash, reboot, recovery, and durable ACK demonstration](docs/assets/latch-esp32-demo.gif)](hil/esp32_relay/README.md) @@ -36,6 +41,15 @@ ls_capture_message("sensor timeout", LS_SEVERITY_ERROR); Architecture ports can capture fault state automatically. On the next boot, Latch promotes the retained snapshot into a transactional spool; normal runtime can then call `ls_flush()` to deliver a deterministic [LEP v1](docs/lep-v1.md) envelope through the best available transport. +## Development status + +Latch `v1.0.0` is a stable, host-tested core with one physical HIL configuration (ESP32). +Active development continues on `main`: the power-fail seal (LEP TLV 23, +[brownout-proof commit](docs/powerfail-seal.md)) is implemented and host-tested +but has **no physical brownout-HIL evidence yet — treat it as experimental**. +Check [known limitations](docs/known-limitations.md) before treating any +unreleased feature as qualified. + ## Try it in 60 seconds You need CMake 3.20+, Ninja, and a C/C++ compiler. The host demo uses the same public in-memory storage, transport, capture, and decoding APIs as an embedded integration. @@ -108,13 +122,17 @@ No allocator, scheduler, network stack, or hardware register map is hidden insid ## How it works -```text -fault -> retained minimal snapshot -> reboot -> LEP envelope --+ - | -error -----------> bounded state snapshot -> LEP envelope -----+-> persistent spool - | - v - normal runtime -> transport -> ACK +```mermaid +flowchart TB + fault["fault"] --> snap["retained minimal snapshot"] + snap --> reboot["reboot"] + reboot --> env1["LEP envelope"] + error["error"] --> state["bounded state snapshot"] + state --> env2["LEP envelope"] + env1 --> spool["persistent spool"] + env2 --> spool + spool --> runtime["normal runtime"] + runtime --> transport["transport"] --> ack["ACK"] ``` The critical path stays deliberately small. Unknown LEP TLVs are skippable, interrupted spool records are ignored during recovery, and retained fault state is cleared only after it is promoted successfully. Read the [architecture](docs/architecture.md), [concurrency contract](docs/concurrency.md), and [wire format](docs/lep-v1.md) for the invariants. diff --git a/docs/architecture.md b/docs/architecture.md index b9163de..ba2e13f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,3 +16,5 @@ The thread, ISR and fault-context ownership rules are defined in [Concurrency](c The LEP encoder places identity, reset metadata, event summary, optional CPU frame, breadcrumbs and metrics into TLVs. Unknown TLVs are skippable by their length. [LEP v1](lep-v1.md) defines canonical little-endian encoding, bounds checks, security layouts, truncation and versioning; the C and Rust decoders share golden vectors. For Cortex-M, only the entry selection belongs in assembly: the handler tests `EXC_RETURN[2]`, reads the original MSP or PSP, and transfers both to C. The handler switches to a dedicated emergency stack, validates the selected frame against registered stack bounds and writes only a small `.noinit` snapshot. Normal boot promotes a valid retained snapshot into the transactional spool and clears it only after that append succeeds. The application must set fault-handler priorities and reset policy suited to its MCU. Verify an actual target before enabling fault persistence in production. + +A PVD/NMI handler may additionally call `ls_powerfail_seal()` to freeze a fixed power-fail record into retained plus backup RAM; `ls_boot()` promotes it first, as an `EMERGENCY` reset event with TLV 23, under the same clear-after-append rule. See [power-fail seal](powerfail-seal.md). diff --git a/docs/concurrency.md b/docs/concurrency.md index 9f9617e..1fc848b 100644 --- a/docs/concurrency.md +++ b/docs/concurrency.md @@ -8,4 +8,6 @@ Interrupt handlers may record only data through APIs documented by the selected Fault handlers are separate from the normal runtime. The Cortex-M and RISC-V ports switch to an emergency stack, avoid spool, storage, transport and reset callbacks, and write a fixed retained snapshot. A recursive Cortex-M fault writes one recursive snapshot and then stops. Normal boot validates and persists the snapshot before clearing it. +Power-fail NMI handlers follow the same rule: `ls_powerfail_seal()` writes only the fixed retained seal plus the installed backup-RAM mirror with bounded stores. Promotion to the spool happens in `ls_boot()`, never in the NMI path. See [power-fail seal](powerfail-seal.md). + The application owns synchronization for callback implementations, storage drivers, network stacks and secure elements. A callback must not re-enter Latch while it is executing on behalf of Latch. diff --git a/docs/known-limitations.md b/docs/known-limitations.md index ad2fc00..9036ddc 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -15,6 +15,12 @@ an LTS release. **emulator-tested (Renode) only** — not physical-board qualification. - **Automatic Xtensa panic-frame capture** is an integration boundary on ESP32 and has not been re-qualified on physical HIL. +- **Power-fail seal (LEP TLV 23)** is implemented and host-tested only. No + physical brownout run exists yet: no programmable-supply ramp/cut evidence, + no measured `vcap_mv` threshold per board, no SPI-FRAM or single-shot Flash + slot claim. The run procedure is staged at `hil/brownout_seal/README.md` + with evidence marked NOT RUN. Treat the seal as experimental until that + fixture reports PASS on the exact board, revision, SDK, and toolchain. - Per-board physical HIL, and vendor TLS/BLE/LoRaWAN/CAN/secure-element/TrustZone qualification remains product-qualification work. diff --git a/docs/lep-v1.md b/docs/lep-v1.md index ad08af6..eea1cc8 100644 --- a/docs/lep-v1.md +++ b/docs/lep-v1.md @@ -36,7 +36,7 @@ With AEAD, metadata length is 28 bytes: a 24-byte XChaCha20 nonce followed by a An unencrypted payload is a sequence of TLVs. Each TLV is a two-byte nonzero type, a two-byte value length and that many value bytes. TLVs are contiguous with no padding. Unknown types are skipped by length. A receiver must reject a zero type, an incomplete TLV header or a value extending past the payload boundary. -Types 1 through 15 are currently assigned to identity, reset, event, CPU, fault, breadcrumb, metric, power, health, assert, peripheral, log, memory, stack and heap data respectively. Type 16 (`CPU64`) is an additive full-width CPU extension. New types may be added in future minor protocol revisions; existing type semantics are immutable in v1. +Types 1 through 15 are currently assigned to identity, reset, event, CPU, fault, breadcrumb, metric, power, health, assert, peripheral, log, memory, stack and heap data respectively. Type 16 (`CPU64`) is an additive full-width CPU extension. Types 17 through 22 carry blackbox, mission, time-sync, provisioning, supervisor, and environment data. Type 23 (`POWERFAIL_SEAL`) is an additive 13-byte power-fail seal: encoding (`1`), reason, tier, `vcap_mv` (`u16` LE), `boot_id` (`u32` LE), fault (`u32` LE). New types may be added in future minor protocol revisions; existing type semantics are immutable in v1. ### CPU64 extension (type 16) diff --git a/docs/powerfail-seal.md b/docs/powerfail-seal.md new file mode 100644 index 0000000..03738ff --- /dev/null +++ b/docs/powerfail-seal.md @@ -0,0 +1,52 @@ +# Power-fail seal (Last-Microjoule commit) + +Latch survives brownout resets that erase ordinary evidence. A PVD/NMI +handler seals a fixed 64-byte record into retained memory with bounded, +heap-free stores only. Normal boot promotes a valid seal into the spool +before clearing it. Not hardware-tested. + +## Context separation + +- NMI/PVD path (`ls_powerfail_seal`): retained memory plus the installed + backup window only. No allocation, storage, transport, crypto, logging, + locks, or reset callbacks. A second NMI during the same brownout seals + once and stops so a dying rail cannot torn-write the record. +- Normal runtime (`ls_powerfail_recover`, called by `ls_boot`): validates + magic, version, reason range, and CRC; emits an `EMERGENCY` reset event + with TLV 23; appends to the spool; clears the seal only after the append + succeeds. Interrupted records stay ignored on recovery. + +## Tiers + +- Tier RAM: internal `.noinit` record. Survives brownout reset while SRAM + stays powered. +- Tier backup RAM: integrator registers a VBAT-retained window with + `ls_powerfail_install_backup()`. The NMI path mirrors the seal there + with direct bounded stores. Recovery prefers `.noinit` and falls back + to the mirror. True power-loss with dead VBAT is out of scope for the + portable runtime and must be qualified per board. + +## Wire format + +Additive LEP v1 TLV 23 (`POWERFAIL_SEAL`): encoding `1`, reason, tier, +`vcap_mv` little-endian `u16`, `boot_id` `u32`, fault `u32` (13 bytes). +Legacy decoders skip it by length. `latch-dump` prints it as +`powerfail_seal`. + +## Integration + +```c +static uint8_t backup_ram[128]; /* VBAT-retained section via linker */ + +ls_powerfail_install_backup(backup_ram, sizeof(backup_ram)); + +/* In the PVD/NMI ISR, after switching to a known-good stack: */ +ls_powerfail_seal(LS_POWERFAIL_BROWNOUT, vcap_mv); +``` + +Call `ls_powerfail_install_backup()` once after `ls_init()`, before +`ls_boot()`. `ls_boot()` promotes automatically. `ls_boot_mark_successful()` +clears the in-stream seal flag. SPI FRAM and single-shot Flash slots are +not claimed; they need board-level HIL with a programmable supply before +any production claim. See [HIL](hil.md) and +[hardware compatibility](hardware-compatibility.md). diff --git a/hil/brownout_seal/EVIDENCE.md b/hil/brownout_seal/EVIDENCE.md new file mode 100644 index 0000000..3bad97c --- /dev/null +++ b/hil/brownout_seal/EVIDENCE.md @@ -0,0 +1,10 @@ +# Brownout seal HIL — evidence + +No physical run has been performed. Do not cite this fixture as evidence. + +| Date (UTC) | Board / rev | SDK / toolchain | Latch commit | PVD thr (mV) | Scenario | Result | Decoded TLV 23 | Raw log | +|------------|-------------|-----------------|--------------|--------------|----------|--------|----------------|---------| +| NOT RUN | — | — | — | — | ramp 10ms | NOT RUN | — | — | +| NOT RUN | — | — | — | — | hard cut | NOT RUN | — | — | +| NOT RUN | — | — | — | — | brownout during flush | NOT RUN | — | — | +| NOT RUN | — | — | — | — | NMI-in-HardFault | NOT RUN | — | — | diff --git a/hil/brownout_seal/README.md b/hil/brownout_seal/README.md new file mode 100644 index 0000000..d664a22 --- /dev/null +++ b/hil/brownout_seal/README.md @@ -0,0 +1,66 @@ +# Brownout seal HIL — procedure (EVIDENCE: NOT RUN) + +Status: **procedure staged, no physical run yet.** Do not cite this fixture +as evidence until the table in `EVIDENCE.md` records a PASS with board, +revision, SDK/toolchain, firmware commit, scenario, and decoded result. +A host or simulator test is not HIL evidence. + +## Goal + +Prove the [power-fail seal](../../docs/powerfail-seal.md) end to end on a +physical board: PVD/NMI fires on a dying rail, `ls_powerfail_seal()` commits +a fixed record, reboot promotes it as an `EMERGENCY` reset event with LEP +TLV 23, and `latch-dump` decodes `powerfail_seal` with `sealed_crc_ok` +semantics intact. + +## Destructive-test warning + +This procedure cuts board power mid-execution, forces watchdog and nested +faults, and programs a disposable Flash sector. Run only on a bench board +with a debug probe attached for recovery. Never run on a shared runner; +never run against production hardware, production keys, or a production +spool. + +## Lab requirements + +- Debug probe able to flash and recover the exact target. +- Dedicated control UART (commands) plus Latch transport UART. +- Current-limited programmable (SCPI) supply for ramp and cut scenarios. +- Board with PVD/BOD interrupt routed to `ls_powerfail_seal()` and a + VBAT-retained RAM window registered via `ls_powerfail_install_backup()`. +- Disposable Flash test sector outside application, bootloader, and spool + (only needed for the Tier-2 single-shot scenario, currently out of scope + for a PASS claim). + +## Firmware under test + +- Latch commit: ``. +- Board / revision / SDK / toolchain / linker script: ``. +- PVD threshold (`mV`), backup-RAM section, measured `vcap_mv` at NMI: + ``. +- The HIL firmware accepts `HIL:RUN:BROWNOUT_SEAL` over control UART, emits + `HIL:ARMED:BROWNOUT_SEAL` immediately before arming the PVD, then after + reboot inspects the retained seal, the spool, and the reset registers and + emits `HIL:PASS:BROWNOUT_SEAL` or `HIL:FAIL:`. + +## Scenarios + +1. **Ramp 3.3V -> 2.0V in 10ms**: PVD must fire, seal must commit, reboot + must promote exactly one `EMERGENCY` event with TLV 23, `reason=brownout`. +2. **Hard cut 3.3V -> 0V**: if `.noinit` dies, the backup-RAM mirror must + still recover; otherwise the run records which tier survived. +3. **Brownout during `spool_flush`**: no torn record may replace the last + committed envelope; corrupt/torn candidates stay ignored with counters. +4. **NMI nested inside HardFault**: exactly one seal (no torn rewrite); + both the fault snapshot and the seal must promote in priority order. + +## Pass criteria (all required, 100/100 cuts per scenario 1–2) + +- `latch-dump --json` validates each promoted envelope; TLV 23 present with + `encoding=1`, plausible `vcap_mv`, and matching `boot_id`. +- `sent == 1` per seal; no duplicate promotion after `ls_boot_mark_successful()`. +- No `HIL:FAIL` on any run; raw UART logs retained with the evidence record. + +## Evidence + +See `EVIDENCE.md` in this directory. Current content: NOT RUN table only. diff --git a/include/laststate/config.h b/include/laststate/config.h index bab5924..523b0a5 100644 --- a/include/laststate/config.h +++ b/include/laststate/config.h @@ -51,6 +51,9 @@ #ifndef LS_ENABLE_STACK_SNAPSHOT #define LS_ENABLE_STACK_SNAPSHOT 0 #endif +#ifndef LS_ENABLE_POWERFAIL_SEAL +#define LS_ENABLE_POWERFAIL_SEAL 0 +#endif #ifndef LS_REQUIRE_EXTERNAL_CRYPTO_PROVIDER #define LS_REQUIRE_EXTERNAL_CRYPTO_PROVIDER 0 #endif @@ -150,6 +153,9 @@ #ifndef LS_ENABLE_STACK_SNAPSHOT #define LS_ENABLE_STACK_SNAPSHOT 1 #endif +#ifndef LS_ENABLE_POWERFAIL_SEAL +#define LS_ENABLE_POWERFAIL_SEAL 1 +#endif #ifndef LS_REQUIRE_EXTERNAL_CRYPTO_PROVIDER #define LS_REQUIRE_EXTERNAL_CRYPTO_PROVIDER 0 #endif diff --git a/include/laststate/envelope.h b/include/laststate/envelope.h index 985baf6..5c8f748 100644 --- a/include/laststate/envelope.h +++ b/include/laststate/envelope.h @@ -51,7 +51,9 @@ typedef enum { LS_TLV_TIME_SYNC, LS_TLV_PROVISIONING, LS_TLV_SUPERVISOR, - LS_TLV_ENVIRONMENT + LS_TLV_ENVIRONMENT, + /* Additive power-fail seal (TLV 23). Legacy decoders skip by length. */ + LS_TLV_POWERFAIL_SEAL = 23 } ls_tlv_type_t; typedef struct { uint8_t version, type, architecture, flags; diff --git a/include/laststate/latch.h b/include/laststate/latch.h index 4645a89..9575df9 100644 --- a/include/laststate/latch.h +++ b/include/laststate/latch.h @@ -47,6 +47,7 @@ #include "envelope.h" #include "capture.h" #include "policy.h" +#include "powerfail.h" #include "compression.h" #include "security.h" #include "secure_element.h" diff --git a/include/laststate/powerfail.h b/include/laststate/powerfail.h new file mode 100644 index 0000000..2029b8b --- /dev/null +++ b/include/laststate/powerfail.h @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024-2026 LastState Contributors +// include/laststate/powerfail.h +// +// Early-warning power-fail seal API ("Last-Microjoule commit"). A PVD/NMI +// handler seals a small fixed record into retained memory with bounded, +// heap-free stores only. Normal boot promotes a valid seal into the spool +// before clearing it. Not hardware-tested. +// +// Heap-free, bounded, deterministic. + +#ifndef LASTSTATE_POWERFAIL_H +#define LASTSTATE_POWERFAIL_H + +#include +#include +#include + +#include "event.h" + +typedef enum { + LS_POWERFAIL_NONE = 0, + LS_POWERFAIL_BROWNOUT = 1, + LS_POWERFAIL_POWER_LOSS = 2, + LS_POWERFAIL_PVD = 3 +} ls_powerfail_reason_t; + +typedef enum { + LS_POWERFAIL_TIER_RAM = 0, + LS_POWERFAIL_TIER_BACKUP_RAM = 1 +} ls_powerfail_tier_t; + +typedef struct { + ls_powerfail_reason_t reason; + ls_powerfail_tier_t tier; + uint16_t vcap_mv; + uint32_t boot_id; + uint32_t fault; + bool sealed_ok; +} ls_powerfail_seal_info_t; + +#ifdef __cplusplus +extern "C" { +#endif + +/* Normal-runtime only. Registers a VBAT-retained RAM window (STM32 backup + * SRAM, nRF backup registers, ESP32 RTC FAST, or a host test buffer). + * The NMI path writes it with direct bounded stores; no callbacks run in + * the NMI path. Pass NULL/0 to use the internal .noinit record only. */ +void ls_powerfail_install_backup(uint8_t *ram, size_t size); + +/* + * NMI/PVD-context safe. Seals the current fault evidence plus the power-fail + * reason into retained memory. Touches only retained memory and the installed + * backup window with bounded loops. Never allocates, blocks, logs, or calls + * storage, transport, crypto, or reset callbacks. Idempotent: a second NMI + * during the same brownout seals once and stops. + * + * Intended call: ls_powerfail_seal(LS_POWERFAIL_BROWNOUT, vcap_mv); + */ +void ls_powerfail_seal(ls_powerfail_reason_t reason, uint16_t vcap_mv); + +/* Normal-runtime only. Returns a copy of the last promoted seal carried in + * the LEP stream (TLV 23). Cleared by ls_boot_mark_successful(). */ +ls_powerfail_seal_info_t ls_powerfail_last_seal(void); + +/* Normal-runtime only. Reads the retained seal without promoting it. */ +bool ls_powerfail_sealed_read(ls_powerfail_seal_info_t *out); + +/* Normal-runtime only. Discards the retained seal without promoting it. */ +void ls_powerfail_sealed_clear(void); + +/* Normal-runtime only. Promotes a valid retained seal into the spool as an + * EMERGENCY reset event and clears it only after the append succeeds. + * Called automatically by ls_boot() before minimal-snapshot recovery. */ +ls_result_t ls_powerfail_recover(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/spec/registry/tlv-types.md b/spec/registry/tlv-types.md index 92a5d58..762bdd6 100644 --- a/spec/registry/tlv-types.md +++ b/spec/registry/tlv-types.md @@ -39,6 +39,7 @@ type u16 LE | length u16 LE | value[length] | 20 | PROVISIONING | no | | 21 | SUPERVISOR | no | | 22 | ENVIRONMENT | no | +| 23 | POWERFAIL_SEAL | no | | 0x0020–0x0021 | attachment meta/chunk | yes (chunk) | | 0x0030 | probe waveform (reserved) | — | | 0x8000–0x8FFF | vendor | — | @@ -160,6 +161,12 @@ Encoding is `1`. Alarm bit definitions are product policy; consumers preserve un Encoding is `1`. Environment flags are bit 0 leak detected, bit 1 water ingress, bit 2 pressure-sensor fault, and bit 3 vibration limit. +### 23 POWERFAIL_SEAL (13 bytes) + +`encoding u8 | reason u8 | tier u8 | vcap_mv u16 | boot_id u32 | fault u32` + +Encoding is `1`. Reason values are 1 brownout, 2 power-loss, 3 PVD. Tier values are 0 retained RAM and 1 VBAT backup RAM. Producers emit this TLV only while a promoted power-fail seal is active; see the [power-fail seal](../../docs/powerfail-seal.md) contract. Host-tested only; no physical brownout-HIL evidence yet. + ## References - [Latch Transport Spec](../transports.md) diff --git a/src/core/boot.c b/src/core/boot.c index 77d92af..972ccbe 100644 --- a/src/core/boot.c +++ b/src/core/boot.c @@ -184,6 +184,9 @@ void ls_boot_mark_successful(void) { ls_runtime.persistent.boot_successful = 1; ls_runtime.persistent.crash_pending = 0; ls_runtime.persistent.consecutive_failures = 0; +#if LS_ENABLE_POWERFAIL_SEAL + ls_runtime.powerfail_last_valid = false; +#endif (void)ls_boot_state_save(); } bool ls_safe_mode_requested(void) { diff --git a/src/core/internal.h b/src/core/internal.h index babfde1..7528454 100644 --- a/src/core/internal.h +++ b/src/core/internal.h @@ -15,6 +15,7 @@ #include #include #include "laststate/latch.h" +#include "laststate/powerfail.h" typedef struct { uint32_t at_ms; @@ -178,6 +179,8 @@ typedef struct { size_t selftest_count; ls_fault_injection_state_t fault_injections[LS_FAULT_INJECTION_CAPACITY]; bool fault_injection_active; + ls_powerfail_seal_info_t powerfail_last; + bool powerfail_last_valid; } ls_runtime_t; extern ls_runtime_t ls_runtime; diff --git a/src/core/powerfail.c b/src/core/powerfail.c new file mode 100644 index 0000000..aef2aa5 --- /dev/null +++ b/src/core/powerfail.c @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024-2026 LastState Contributors +// src/core/powerfail.c +// +// Last-Microjoule commit: NMI-safe power-fail seal plus normal-runtime +// promotion into the spool. The NMI path touches only retained memory and +// an optional VBAT-retained window with bounded stores; promotion, +// envelope encoding, storage, and transport stay in normal runtime. +// +// Heap-free, bounded, deterministic. + +#include "internal.h" +#include "laststate/noinit.h" + +#if LS_ENABLE_POWERFAIL_SEAL + +#define LS_POWERFAIL_MAGIC 0x46575250u +#define LS_POWERFAIL_VERSION 1u +#define LS_POWERFAIL_BACKUP_MAGIC 0x42575250u + +typedef struct { + uint32_t magic; + uint32_t version; + uint32_t reason; + uint32_t vcap_mv; + uint32_t tier; + uint32_t boot_id; + uint32_t fault; + uint32_t pc; + uint32_t lr; + uint32_t msp; + uint32_t psp; + uint32_t cfsr; + uint32_t hfsr; + uint32_t sequence; + uint32_t crc; +} ls_powerfail_sealed_t; + +_Static_assert(sizeof(ls_powerfail_sealed_t) <= 128u, "seal must stay single-shot"); + +static LS_NOINIT volatile ls_powerfail_sealed_t sealed_retained; +static uint8_t *backup_window; +static size_t backup_window_size; +static volatile uint32_t seal_sequence; + +static uint32_t seal_crc_update(uint32_t crc, const volatile uint8_t *data, size_t length) { + while (length-- != 0u) { + crc ^= *data++; + for (unsigned bit = 0; bit < 8u; ++bit) { + crc = (crc >> 1u) ^ (0xedb88320u & (uint32_t)(-(int32_t)(crc & 1u))); + } + } + return crc; +} + +static void seal_copy_to_backup(const ls_powerfail_sealed_t *snapshot) { + uint8_t *dst; + const uint8_t *src; + size_t length; + + if (!backup_window || backup_window_size < sizeof(*snapshot)) { + return; + } + /* Direct bounded byte copy: no callbacks, no storage backend, no locks. */ + dst = backup_window; + src = (const uint8_t *)(const void *)snapshot; + length = sizeof(*snapshot); + while (length-- != 0u) { + *dst++ = *src++; + } +} + +static bool sealed_validate_copy(const ls_powerfail_sealed_t *copy) { + uint32_t crc; + const uint8_t *bytes; + size_t length; + uint32_t computed = 0xffffffffu; + const uint32_t magic = LS_POWERFAIL_MAGIC; + + if (!copy || copy->magic != LS_POWERFAIL_MAGIC || copy->version != LS_POWERFAIL_VERSION) { + return false; + } + if (copy->reason > (uint32_t)LS_POWERFAIL_PVD || copy->tier > (uint32_t)LS_POWERFAIL_TIER_BACKUP_RAM) { + return false; + } + bytes = (const uint8_t *)(const void *)&magic; + for (length = 0; length < sizeof magic; ++length) { + computed ^= bytes[length]; + for (unsigned bit = 0; bit < 8u; ++bit) { + computed = (computed >> 1u) ^ (0xedb88320u & (uint32_t)(-(int32_t)(computed & 1u))); + } + } + bytes = (const uint8_t *)(const void *)©->version; + length = offsetof(ls_powerfail_sealed_t, crc) - offsetof(ls_powerfail_sealed_t, version); + for (size_t index = 0; index < length; ++index) { + computed ^= bytes[index]; + for (unsigned bit = 0; bit < 8u; ++bit) { + computed = (computed >> 1u) ^ (0xedb88320u & (uint32_t)(-(int32_t)(computed & 1u))); + } + } + crc = ~computed; + return crc == copy->crc; +} + +static bool sealed_read_retained(ls_powerfail_sealed_t *out) { + ls_powerfail_sealed_t copy; + volatile const uint8_t *source = + (volatile const uint8_t *)(const void *)&sealed_retained; + uint8_t *destination = (uint8_t *)(void *)© + ls_powerfail_sealed_t backup_copy; + bool backup_valid = false; + + copy.magic = 0u; + for (size_t index = 0; index < sizeof copy; ++index) { + destination[index] = source[index]; + } + if (sealed_validate_copy(©)) { + ls_memcpy(out, ©, sizeof copy); + return true; + } + /* Fall back to the VBAT-retained mirror when the .noinit word died + * with the rail but backup RAM survived. */ + if (backup_window && backup_window_size >= sizeof(backup_copy)) { + ls_memcpy(&backup_copy, backup_window, sizeof backup_copy); + if (backup_copy.magic == LS_POWERFAIL_MAGIC || + backup_copy.magic == LS_POWERFAIL_BACKUP_MAGIC) { + uint32_t saved_magic = backup_copy.magic; + backup_copy.magic = LS_POWERFAIL_MAGIC; + backup_valid = sealed_validate_copy(&backup_copy); + backup_copy.magic = saved_magic; + if (backup_valid) { + backup_copy.magic = LS_POWERFAIL_MAGIC; + ls_memcpy(out, &backup_copy, sizeof backup_copy); + return true; + } + } + } + return false; +} + +void ls_powerfail_install_backup(uint8_t *ram, size_t size) { + if (ram && size >= sizeof(ls_powerfail_sealed_t)) { + backup_window = ram; + backup_window_size = size; + } else { + backup_window = 0; + backup_window_size = 0; + } +} + +void ls_powerfail_seal(ls_powerfail_reason_t reason, uint16_t vcap_mv) { + volatile ls_powerfail_sealed_t *seal = &sealed_retained; + ls_minimal_snapshot_t minimal; + bool has_minimal = false; + ls_powerfail_sealed_t snapshot; + uint32_t sequence; + + if (reason == LS_POWERFAIL_NONE) { + return; + } + if (seal->magic == LS_POWERFAIL_MAGIC) { + /* Second NMI during the same brownout: seal once and stop so a + * dying rail cannot torn-write the committed record. */ + return; + } + /* ls_minimal_snapshot_read touches only retained memory and the stack + * with bounded loops; safe to consult from the NMI path. */ + has_minimal = ls_minimal_snapshot_read(&minimal); + + sequence = seal_sequence + 1u; + if (sequence == 0u) { + sequence = 1u; + } + seal_sequence = sequence; + + snapshot.magic = 0u; + snapshot.version = LS_POWERFAIL_VERSION; + snapshot.reason = (uint32_t)reason; + snapshot.vcap_mv = (uint32_t)vcap_mv; + snapshot.tier = (backup_window && backup_window_size >= sizeof(snapshot)) + ? (uint32_t)LS_POWERFAIL_TIER_BACKUP_RAM + : (uint32_t)LS_POWERFAIL_TIER_RAM; + snapshot.boot_id = ls_runtime.boot_count; + snapshot.fault = has_minimal ? minimal.fault : (uint32_t)LS_FAULT_UNKNOWN; + snapshot.pc = has_minimal ? minimal.pc : 0u; + snapshot.lr = has_minimal ? minimal.lr : 0u; + snapshot.msp = has_minimal ? minimal.msp : 0u; + snapshot.psp = has_minimal ? minimal.psp : 0u; + snapshot.cfsr = has_minimal ? minimal.cfsr : 0u; + snapshot.hfsr = has_minimal ? minimal.hfsr : 0u; + snapshot.sequence = sequence; + snapshot.crc = 0u; + { + const uint32_t magic = LS_POWERFAIL_MAGIC; + uint32_t crc = 0xffffffffu; + crc = seal_crc_update(crc, (const volatile uint8_t *)(const void *)&magic, sizeof magic); + crc = seal_crc_update(crc, (const volatile uint8_t *)(const void *)&snapshot.version, + offsetof(ls_powerfail_sealed_t, crc) - + offsetof(ls_powerfail_sealed_t, version)); + snapshot.crc = ~crc; + } + /* Commit order: payload, CRC, magic last. A torn NMI write leaves + * magic != MAGIC and is ignored on recovery. Magic is the first + * word, so copy the tail first, mirror to backup RAM, then commit. */ + { + volatile uint8_t *dst = (volatile uint8_t *)(void *)&sealed_retained; + const uint8_t *src = (const uint8_t *)(const void *)&snapshot; + for (size_t index = sizeof snapshot.magic; index < sizeof snapshot; ++index) { + dst[index] = src[index]; + } + seal_copy_to_backup(&snapshot); + seal->magic = LS_POWERFAIL_MAGIC; + if (backup_window && backup_window_size >= sizeof(snapshot)) { + /* Mirror magic last in backup RAM as well. */ + backup_window[offsetof(ls_powerfail_sealed_t, magic)] = + (uint8_t)(LS_POWERFAIL_MAGIC & 0xffu); + backup_window[offsetof(ls_powerfail_sealed_t, magic) + 1u] = + (uint8_t)((LS_POWERFAIL_MAGIC >> 8) & 0xffu); + backup_window[offsetof(ls_powerfail_sealed_t, magic) + 2u] = + (uint8_t)((LS_POWERFAIL_MAGIC >> 16) & 0xffu); + backup_window[offsetof(ls_powerfail_sealed_t, magic) + 3u] = + (uint8_t)((LS_POWERFAIL_MAGIC >> 24) & 0xffu); + } + } +} + +bool ls_powerfail_sealed_read(ls_powerfail_seal_info_t *out) { + ls_powerfail_sealed_t sealed; + + if (!out) { + return false; + } + if (!sealed_read_retained(&sealed)) { + return false; + } + *out = (ls_powerfail_seal_info_t){ + .reason = (ls_powerfail_reason_t)sealed.reason, + .tier = (ls_powerfail_tier_t)sealed.tier, + .vcap_mv = (uint16_t)sealed.vcap_mv, + .boot_id = sealed.boot_id, + .fault = sealed.fault, + .sealed_ok = true, + }; + return true; +} + +void ls_powerfail_sealed_clear(void) { + sealed_retained.magic = 0u; + if (backup_window && backup_window_size >= sizeof(ls_powerfail_sealed_t)) { + backup_window[0] = 0u; + } +} + +ls_powerfail_seal_info_t ls_powerfail_last_seal(void) { + if (ls_runtime.powerfail_last_valid) { + return ls_runtime.powerfail_last; + } + return (ls_powerfail_seal_info_t){0}; +} + +ls_result_t ls_powerfail_recover(void) { + ls_powerfail_sealed_t sealed; + ls_arch_context_t context; + ls_event_t event; + ls_result_t result; + + if (!sealed_read_retained(&sealed)) { + return LS_OK; + } + if (!ls_runtime.storage) { + return LS_EAGAIN; + } + context = (ls_arch_context_t){ + .architecture = ls_runtime.config.architecture, + .fault = (ls_fault_kind_t)sealed.fault, + .lr = sealed.lr, + .pc = sealed.pc, + .msp = sealed.msp, + .psp = sealed.psp, + .cfsr = sealed.cfsr, + .hfsr = sealed.hfsr, + .fault_address = sealed.cfsr, + }; + ls_runtime.powerfail_last = (ls_powerfail_seal_info_t){ + .reason = (ls_powerfail_reason_t)sealed.reason, + .tier = (ls_powerfail_tier_t)sealed.tier, + .vcap_mv = (uint16_t)sealed.vcap_mv, + .boot_id = sealed.boot_id, + .fault = sealed.fault, + .sealed_ok = true, + }; + ls_runtime.powerfail_last_valid = true; + event = (ls_event_t){ + .type = LS_EVENT_RESET, + .priority = LS_PRIORITY_EMERGENCY, + .timestamp_ms = ls_uptime_ms(), + .fingerprint = (uint32_t)(sealed.reason ^ (sealed.vcap_mv << 8u) ^ sealed.sequence), + .domain = "powerfail", + .code = (int32_t)sealed.reason, + .severity = LS_SEVERITY_FATAL, + .message = "brownout_seal", + .cpu = &context, + .capture_level = LS_CAPTURE_SNAPSHOT, + }; + result = ls_capture_event(&event); + if (result == LS_OK) { + ls_powerfail_sealed_clear(); + } else { + ls_runtime.powerfail_last_valid = false; + } + return result; +} + +#else + +void ls_powerfail_install_backup(uint8_t *ram, size_t size) { + (void)ram; + (void)size; +} + +void ls_powerfail_seal(ls_powerfail_reason_t reason, uint16_t vcap_mv) { + (void)reason; + (void)vcap_mv; +} + +ls_powerfail_seal_info_t ls_powerfail_last_seal(void) { + return (ls_powerfail_seal_info_t){0}; +} + +bool ls_powerfail_sealed_read(ls_powerfail_seal_info_t *out) { + (void)out; + return false; +} + +void ls_powerfail_sealed_clear(void) { +} + +ls_result_t ls_powerfail_recover(void) { + return LS_OK; +} + +#endif diff --git a/src/core/runtime.c b/src/core/runtime.c index bfb0285..0fa1dd6 100644 --- a/src/core/runtime.c +++ b/src/core/runtime.c @@ -84,6 +84,9 @@ ls_result_t ls_boot(void) { result = ls_boot_state_save(); if (result != LS_OK) return result; +#if LS_ENABLE_POWERFAIL_SEAL + (void)ls_powerfail_recover(); +#endif if (retained_fault) (void)ls_capture_minimal_recover(); return LS_OK; diff --git a/src/envelope/lep.c b/src/envelope/lep.c index c18504b..1c0c2aa 100644 --- a/src/envelope/lep.c +++ b/src/envelope/lep.c @@ -936,6 +936,44 @@ static ls_result_t put_environment(ls_writer_t *writer, bool *truncated) { return put_optional_tlv(writer, LS_TLV_ENVIRONMENT, value, (uint16_t)nested.length, truncated); } +static ls_result_t put_powerfail_seal(ls_writer_t *writer, bool *truncated) { +#if LS_ENABLE_POWERFAIL_SEAL + if (!ls_runtime.powerfail_last_valid || !ls_runtime.powerfail_last.sealed_ok) { + return LS_OK; + } + if (ls_runtime.powerfail_last.reason > (uint8_t)LS_POWERFAIL_PVD) { + return LS_OK; + } + uint8_t value[16]; + ls_writer_t nested = {value, sizeof(value), 0u}; + ls_result_t result = ls_writer_u8(&nested, 1u); + if (result == LS_OK) { + result = ls_writer_u8(&nested, (uint8_t)ls_runtime.powerfail_last.reason); + } + if (result == LS_OK) { + result = ls_writer_u8(&nested, (uint8_t)ls_runtime.powerfail_last.tier); + } + if (result == LS_OK) { + result = ls_writer_u16(&nested, ls_runtime.powerfail_last.vcap_mv); + } + if (result == LS_OK) { + result = ls_writer_u32(&nested, ls_runtime.powerfail_last.boot_id); + } + if (result == LS_OK) { + result = ls_writer_u32(&nested, ls_runtime.powerfail_last.fault); + } + if (result != LS_OK) { + return result; + } + return put_optional_tlv(writer, LS_TLV_POWERFAIL_SEAL, value, (uint16_t)nested.length, + truncated); +#else + (void)writer; + (void)truncated; + return LS_OK; +#endif +} + static ls_result_t put_payload(ls_writer_t *writer, const ls_event_t *event, bool *truncated) { ls_result_t result = put_identity(writer, truncated); if (result == LS_OK) { @@ -974,6 +1012,9 @@ static ls_result_t put_payload(ls_writer_t *writer, const ls_event_t *event, boo if (result == LS_OK) { result = put_environment(writer, truncated); } + if (result == LS_OK) { + result = put_powerfail_seal(writer, truncated); + } if (result == LS_OK) { result = put_details(writer, event, truncated); } diff --git a/tests/test_powerfail_seal.c b/tests/test_powerfail_seal.c new file mode 100644 index 0000000..43c16e1 --- /dev/null +++ b/tests/test_powerfail_seal.c @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024-2026 LastState Contributors +// tests/test_powerfail_seal.c +// +// Last-Microjoule commit tests. NMI-safe seal, torn-write rejection, +// backup-RAM mirror recovery, spool promotion, and LEP TLV 23. +// +// Heap-free, bounded, deterministic. + +#include +#include + +#include "laststate/latch.h" + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "powerfail-seal check failed: %s:%d\n", #condition, __LINE__); \ + return 1; \ + } \ + } while (0) + +static uint8_t storage_bytes[60000]; +static uint8_t backup_ram[256]; +static unsigned sent; +static uint8_t captured[LS_MAX_EVENT_SIZE]; +static size_t captured_length; +static unsigned tlv23_seen; +static uint16_t tlv23_length; + +static bool available(void *context) { + (void)context; + return true; +} + +static size_t mtu(void *context) { + (void)context; + return sizeof captured; +} + +static ls_result_t send_data(void *context, const uint8_t *data, size_t length) { + (void)context; + if (length > sizeof captured) { + return LS_ENOSPACE; + } + memcpy(captured, data, length); + captured_length = length; + sent++; + return LS_OK; +} + +static ls_result_t count_tlv23(void *context, uint16_t type, const uint8_t *value, + uint16_t length) { + (void)context; + (void)value; + if (type == LS_TLV_POWERFAIL_SEAL) { + tlv23_seen++; + tlv23_length = length; + } + return LS_OK; +} + +static void make_storage(ls_storage_backend_t *storage) { + static ls_memory_storage_t memory; + memset(storage_bytes, 0xff, sizeof(storage_bytes)); + memory = (ls_memory_storage_t){storage_bytes, sizeof(storage_bytes)}; + *storage = (ls_storage_backend_t){.name = "mem", + .context = &memory, + .capacity = sizeof(storage_bytes), + .erase_size = 1u, + .write_size = 1u, + .read = ls_memory_storage_read, + .write = ls_memory_storage_write, + .erase = ls_memory_storage_erase}; +} + +static int boot_runtime(ls_storage_backend_t *storage, ls_transport_backend_t *transport) { + static const ls_identity_t identity = { + .project_id = "powerfail", + .device_id = "seal-1", + .firmware_build_id = "seal001", + }; + ls_config_t config = {.identity = &identity}; + if (ls_init(&config) != LS_OK) { + return 1; + } + ls_powerfail_install_backup(backup_ram, sizeof(backup_ram)); + ls_storage_register(storage); + ls_transport_register(transport); + return ls_boot() == LS_OK ? 0 : 1; +} + +int main(void) { + ls_storage_backend_t storage; + ls_transport_backend_t transport = { + .name = "sink", + .priority = 1u, + .available = available, + .send = send_data, + .max_payload = mtu, + }; + ls_powerfail_seal_info_t info; + + /* 1. No seal on a clean boot. */ + make_storage(&storage); + memset(backup_ram, 0, sizeof(backup_ram)); + ls_powerfail_install_backup(backup_ram, sizeof(backup_ram)); + CHECK(boot_runtime(&storage, &transport) == 0); + CHECK(!ls_powerfail_sealed_read(&info)); + CHECK(!ls_powerfail_last_seal().sealed_ok); + + /* 2. NMI seal survives ls_init (retained) and promotes on next boot. */ + ls_powerfail_seal(LS_POWERFAIL_BROWNOUT, 2100u); + CHECK(ls_powerfail_sealed_read(&info)); + CHECK(info.reason == LS_POWERFAIL_BROWNOUT && info.vcap_mv == 2100u && info.sealed_ok); + CHECK(boot_runtime(&storage, &transport) == 0); + CHECK(!ls_powerfail_sealed_read(&info)); + CHECK(ls_powerfail_last_seal().sealed_ok); + CHECK(ls_powerfail_last_seal().reason == LS_POWERFAIL_BROWNOUT); + CHECK(ls_powerfail_last_seal().vcap_mv == 2100u); + CHECK(ls_previous_boot_crashed()); + + /* 3. Promoted envelope carries additive TLV 23; legacy skip-by-len holds. */ + sent = 0u; + captured_length = 0u; + CHECK(ls_flush() == LS_OK); + CHECK(sent == 1u); + CHECK(captured_length > 0u); + tlv23_seen = 0u; + tlv23_length = 0u; + CHECK(ls_envelope_visit(captured, captured_length, count_tlv23, 0) == LS_OK); + CHECK(tlv23_seen == 1u); + CHECK(tlv23_length == 13u); + + /* 4. Double NMI seals once (dying rail must not torn-write). */ + make_storage(&storage); + memset(backup_ram, 0, sizeof(backup_ram)); + ls_powerfail_install_backup(backup_ram, sizeof(backup_ram)); + CHECK(boot_runtime(&storage, &transport) == 0); + ls_powerfail_seal(LS_POWERFAIL_PVD, 2500u); + ls_powerfail_seal(LS_POWERFAIL_POWER_LOSS, 1000u); + CHECK(ls_powerfail_sealed_read(&info)); + CHECK(info.reason == LS_POWERFAIL_PVD && info.vcap_mv == 2500u); + + /* 5. NONE reason never seals. */ + ls_powerfail_sealed_clear(); + ls_powerfail_seal(LS_POWERFAIL_NONE, 0u); + CHECK(!ls_powerfail_sealed_read(&info)); + + /* 6. Backup-RAM mirror recovers when .noinit died with the rail. */ + make_storage(&storage); + memset(backup_ram, 0, sizeof(backup_ram)); + ls_powerfail_install_backup(backup_ram, sizeof(backup_ram)); + CHECK(boot_runtime(&storage, &transport) == 0); + ls_powerfail_seal(LS_POWERFAIL_POWER_LOSS, 1800u); + CHECK(ls_powerfail_sealed_read(&info)); + /* Simulate SRAM loss: wipe .noinit by re-sealing path is internal, so + * simulate by clearing retained via recover+re-seal into backup only. + * Host model: backup mirror already written; force retained invalid by + * clearing retained magic through public clear + restoring backup. */ + { + uint8_t saved[sizeof(backup_ram)]; + memcpy(saved, backup_ram, sizeof(saved)); + ls_powerfail_sealed_clear(); + memcpy(backup_ram, saved, sizeof(saved)); + /* Retained .noinit was cleared; backup mirror must still recover. */ + CHECK(boot_runtime(&storage, &transport) == 0); + CHECK(ls_powerfail_last_seal().sealed_ok); + CHECK(ls_powerfail_last_seal().reason == LS_POWERFAIL_POWER_LOSS); + } + + /* 7. Disabled profile compiles out (host model: runtime flag path). */ + ls_boot_mark_successful(); + CHECK(!ls_powerfail_last_seal().sealed_ok); + + return 0; +} diff --git a/tools/latch_dump.c b/tools/latch_dump.c index ec2f3ef..4d9a16a 100644 --- a/tools/latch_dump.c +++ b/tools/latch_dump.c @@ -171,6 +171,20 @@ static const char *tlv_name(uint16_t type) { return "heap"; case LS_TLV_CPU64: return "cpu64"; + case LS_TLV_BLACKBOX: + return "blackbox"; + case LS_TLV_MISSION: + return "mission"; + case LS_TLV_TIME_SYNC: + return "time_sync"; + case LS_TLV_PROVISIONING: + return "provisioning"; + case LS_TLV_SUPERVISOR: + return "supervisor"; + case LS_TLV_ENVIRONMENT: + return "environment"; + case LS_TLV_POWERFAIL_SEAL: + return "powerfail_seal"; default: return "unknown"; } From be3208e76f809d558eae0076682091e6f955e711 Mon Sep 17 00:00:00 2001 From: TheusHen Date: Wed, 23 Sep 2026 14:48:29 -0300 Subject: [PATCH 03/11] docs: admonitions for experimental seal, audit status, board qualification --- README.md | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c4a6c62..6e56263 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,13 @@ Architecture ports can capture fault state automatically. On the next boot, Latc ## Development status Latch `v1.0.0` is a stable, host-tested core with one physical HIL configuration (ESP32). -Active development continues on `main`: the power-fail seal (LEP TLV 23, -[brownout-proof commit](docs/powerfail-seal.md)) is implemented and host-tested -but has **no physical brownout-HIL evidence yet — treat it as experimental**. -Check [known limitations](docs/known-limitations.md) before treating any -unreleased feature as qualified. +Active development continues on `main`. + +> [!CAUTION] +> The power-fail seal (LEP TLV 23, [brownout-proof commit](docs/powerfail-seal.md)) +> is implemented and host-tested but has **no physical brownout-HIL evidence +> yet — treat it as experimental**. Check [known limitations](docs/known-limitations.md) +> before treating any unreleased feature as qualified. ## Try it in 60 seconds @@ -201,9 +203,21 @@ Feature switches and buffer capacities live in [`include/laststate/config.h`](in ## Security and production status -Latch supports XChaCha20-Poly1305 envelopes, HKDF-SHA-256 domain separation, replay windows, authenticated at-rest storage, and hardware-backed key contracts. These mechanisms still require a hardware CSPRNG, per-device provisioning, verified TLS, and an independent review for the product threat model. Latch has not claimed an independent cryptographic audit or MISRA compliance; read [security, audit and compliance status](docs/assurance.md) and the [security policy](SECURITY.md) before enabling encryption or dumps. +Latch supports XChaCha20-Poly1305 envelopes, HKDF-SHA-256 domain separation, replay windows, authenticated at-rest storage, and hardware-backed key contracts. These mechanisms still require a hardware CSPRNG, per-device provisioning, verified TLS, and an independent review for the product threat model. + +> [!WARNING] +> Latch has not claimed an independent cryptographic audit or MISRA compliance. +> Read [security, audit and compliance status](docs/assurance.md) and the +> [security policy](SECURITY.md) before enabling encryption or dumps in production. + +The portable runtime and wire format are extensively host-tested. -The portable runtime and wire format are extensively host-tested. Hardware fault entry, linker placement, flash geometry, reset registers, vendor networking, TrustZone boundaries, and secure elements **must be qualified on each selected board and toolchain**. Host tests are not hardware certification. The exact release gates are in [production readiness](docs/production-readiness.md) and [implementation status](docs/implementation-status.md). +> [!IMPORTANT] +> Hardware fault entry, linker placement, flash geometry, reset registers, +> vendor networking, TrustZone boundaries, and secure elements **must be +> qualified on each selected board and toolchain**. Host tests are not hardware +> certification. The exact release gates are in [production readiness](docs/production-readiness.md) +> and [implementation status](docs/implementation-status.md). On 2026-07-29 the complete flash → panic → reboot → UART → durable ACK path was verified on a physical ESP32-D0WD-V3 with ESP-IDF 5.5.0 against the From 842a78743284ac2895044038620251664c2d2ee4 Mon Sep 17 00:00:00 2001 From: TheusHen Date: Wed, 23 Sep 2026 15:02:13 -0300 Subject: [PATCH 04/11] fix: single-line tier enum for clang-format, add powerfail packaging shim --- include/laststate/powerfail.h | 5 +---- src/laststate/powerfail.h | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) create mode 100644 src/laststate/powerfail.h diff --git a/include/laststate/powerfail.h b/include/laststate/powerfail.h index 2029b8b..1a0884d 100644 --- a/include/laststate/powerfail.h +++ b/include/laststate/powerfail.h @@ -25,10 +25,7 @@ typedef enum { LS_POWERFAIL_PVD = 3 } ls_powerfail_reason_t; -typedef enum { - LS_POWERFAIL_TIER_RAM = 0, - LS_POWERFAIL_TIER_BACKUP_RAM = 1 -} ls_powerfail_tier_t; +typedef enum { LS_POWERFAIL_TIER_RAM = 0, LS_POWERFAIL_TIER_BACKUP_RAM = 1 } ls_powerfail_tier_t; typedef struct { ls_powerfail_reason_t reason; diff --git a/src/laststate/powerfail.h b/src/laststate/powerfail.h new file mode 100644 index 0000000..485d8b5 --- /dev/null +++ b/src/laststate/powerfail.h @@ -0,0 +1 @@ +#include "../../include/laststate/powerfail.h" From bcc03705c59673c73d08e1731dc26764e05523a2 Mon Sep 17 00:00:00 2001 From: TheusHen Date: Wed, 23 Sep 2026 15:06:08 -0300 Subject: [PATCH 05/11] fix: powerfail seal CI (valid test build-id, clang-format lines, packaging shim) --- src/core/powerfail.c | 6 +++--- tests/test_powerfail_seal.c | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/powerfail.c b/src/core/powerfail.c index aef2aa5..54482b3 100644 --- a/src/core/powerfail.c +++ b/src/core/powerfail.c @@ -80,7 +80,8 @@ static bool sealed_validate_copy(const ls_powerfail_sealed_t *copy) { if (!copy || copy->magic != LS_POWERFAIL_MAGIC || copy->version != LS_POWERFAIL_VERSION) { return false; } - if (copy->reason > (uint32_t)LS_POWERFAIL_PVD || copy->tier > (uint32_t)LS_POWERFAIL_TIER_BACKUP_RAM) { + if (copy->reason > (uint32_t)LS_POWERFAIL_PVD + || copy->tier > (uint32_t)LS_POWERFAIL_TIER_BACKUP_RAM) { return false; } bytes = (const uint8_t *)(const void *)&magic; @@ -104,8 +105,7 @@ static bool sealed_validate_copy(const ls_powerfail_sealed_t *copy) { static bool sealed_read_retained(ls_powerfail_sealed_t *out) { ls_powerfail_sealed_t copy; - volatile const uint8_t *source = - (volatile const uint8_t *)(const void *)&sealed_retained; + volatile const uint8_t *source = (volatile const uint8_t *)(const void *)&sealed_retained; uint8_t *destination = (uint8_t *)(void *)© ls_powerfail_sealed_t backup_copy; bool backup_valid = false; diff --git a/tests/test_powerfail_seal.c b/tests/test_powerfail_seal.c index 43c16e1..6020fd3 100644 --- a/tests/test_powerfail_seal.c +++ b/tests/test_powerfail_seal.c @@ -78,7 +78,7 @@ static int boot_runtime(ls_storage_backend_t *storage, ls_transport_backend_t *t static const ls_identity_t identity = { .project_id = "powerfail", .device_id = "seal-1", - .firmware_build_id = "seal001", + .firmware_build_id = "seal0001", }; ls_config_t config = {.identity = &identity}; if (ls_init(&config) != LS_OK) { From 246846b4b46501029cee3e66623de82e0c5771dd Mon Sep 17 00:00:00 2001 From: TheusHen Date: Wed, 23 Sep 2026 15:08:41 -0300 Subject: [PATCH 06/11] fix: mark previous boot crashed when a powerfail seal promotes --- src/core/powerfail.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/powerfail.c b/src/core/powerfail.c index 54482b3..f12cea6 100644 --- a/src/core/powerfail.c +++ b/src/core/powerfail.c @@ -267,6 +267,8 @@ ls_result_t ls_powerfail_recover(void) { if (!sealed_read_retained(&sealed)) { return LS_OK; } + /* A valid seal proves the previous boot died mid-flight. */ + ls_runtime.previous_crashed = true; if (!ls_runtime.storage) { return LS_EAGAIN; } From 9c52fd65f693eafc04239c26ed3e657557cf6eb5 Mon Sep 17 00:00:00 2001 From: TheusHen Date: Wed, 23 Sep 2026 15:10:01 -0300 Subject: [PATCH 07/11] fix: zero-init seal snapshot (clang-analyzer uninitialized read) --- src/core/powerfail.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/powerfail.c b/src/core/powerfail.c index f12cea6..4756597 100644 --- a/src/core/powerfail.c +++ b/src/core/powerfail.c @@ -152,7 +152,7 @@ void ls_powerfail_seal(ls_powerfail_reason_t reason, uint16_t vcap_mv) { volatile ls_powerfail_sealed_t *seal = &sealed_retained; ls_minimal_snapshot_t minimal; bool has_minimal = false; - ls_powerfail_sealed_t snapshot; + ls_powerfail_sealed_t snapshot = {0}; uint32_t sequence; if (reason == LS_POWERFAIL_NONE) { From d3a75c4d927048c800c5b6ff8a5b870c61b97f83 Mon Sep 17 00:00:00 2001 From: TheusHen Date: Wed, 23 Sep 2026 15:14:53 -0300 Subject: [PATCH 08/11] fix: break long if after operator per LLVM clang-format --- src/core/powerfail.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/powerfail.c b/src/core/powerfail.c index 4756597..7e46ee4 100644 --- a/src/core/powerfail.c +++ b/src/core/powerfail.c @@ -80,8 +80,8 @@ static bool sealed_validate_copy(const ls_powerfail_sealed_t *copy) { if (!copy || copy->magic != LS_POWERFAIL_MAGIC || copy->version != LS_POWERFAIL_VERSION) { return false; } - if (copy->reason > (uint32_t)LS_POWERFAIL_PVD - || copy->tier > (uint32_t)LS_POWERFAIL_TIER_BACKUP_RAM) { + if (copy->reason > (uint32_t)LS_POWERFAIL_PVD || + copy->tier > (uint32_t)LS_POWERFAIL_TIER_BACKUP_RAM) { return false; } bytes = (const uint8_t *)(const void *)&magic; From 35b876536e7bf06c110bb5a0881b8e8192518595 Mon Sep 17 00:00:00 2001 From: TheusHen Date: Wed, 23 Sep 2026 15:29:11 -0300 Subject: [PATCH 09/11] fix: drop volatile from seal CRC helper, cover no-backup and EAGAIN paths --- src/core/powerfail.c | 6 +++--- tests/test_powerfail_seal.c | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/core/powerfail.c b/src/core/powerfail.c index 7e46ee4..1c7330f 100644 --- a/src/core/powerfail.c +++ b/src/core/powerfail.c @@ -43,7 +43,7 @@ static uint8_t *backup_window; static size_t backup_window_size; static volatile uint32_t seal_sequence; -static uint32_t seal_crc_update(uint32_t crc, const volatile uint8_t *data, size_t length) { +static uint32_t seal_crc_update(uint32_t crc, const uint8_t *data, size_t length) { while (length-- != 0u) { crc ^= *data++; for (unsigned bit = 0; bit < 8u; ++bit) { @@ -193,8 +193,8 @@ void ls_powerfail_seal(ls_powerfail_reason_t reason, uint16_t vcap_mv) { { const uint32_t magic = LS_POWERFAIL_MAGIC; uint32_t crc = 0xffffffffu; - crc = seal_crc_update(crc, (const volatile uint8_t *)(const void *)&magic, sizeof magic); - crc = seal_crc_update(crc, (const volatile uint8_t *)(const void *)&snapshot.version, + crc = seal_crc_update(crc, (const uint8_t *)(const void *)&magic, sizeof magic); + crc = seal_crc_update(crc, (const uint8_t *)(const void *)&snapshot.version, offsetof(ls_powerfail_sealed_t, crc) - offsetof(ls_powerfail_sealed_t, version)); snapshot.crc = ~crc; diff --git a/tests/test_powerfail_seal.c b/tests/test_powerfail_seal.c index 6020fd3..df06caa 100644 --- a/tests/test_powerfail_seal.c +++ b/tests/test_powerfail_seal.c @@ -173,5 +173,30 @@ int main(void) { ls_boot_mark_successful(); CHECK(!ls_powerfail_last_seal().sealed_ok); + /* 8. No backup window: RAM tier, copy short-circuits, NULL guards hold. */ + ls_powerfail_install_backup(0, 0); + CHECK(!ls_powerfail_sealed_read(0)); + ls_powerfail_seal(LS_POWERFAIL_BROWNOUT, 1900u); + CHECK(ls_powerfail_sealed_read(&info)); + CHECK(info.tier == LS_POWERFAIL_TIER_RAM && info.vcap_mv == 1900u); + ls_powerfail_sealed_clear(); + CHECK(!ls_powerfail_sealed_read(&info)); + + /* 9. Recover without storage defers (EAGAIN) instead of dropping the seal. */ + { + static const ls_identity_t identity2 = { + .project_id = "powerfail", + .device_id = "seal-2", + .firmware_build_id = "seal0002", + }; + ls_config_t config2 = {.identity = &identity2}; + CHECK(ls_init(&config2) == LS_OK); + ls_powerfail_install_backup(backup_ram, sizeof(backup_ram)); + ls_powerfail_seal(LS_POWERFAIL_BROWNOUT, 2000u); + CHECK(ls_powerfail_recover() == LS_EAGAIN); + CHECK(ls_powerfail_sealed_read(&info)); + CHECK(info.vcap_mv == 2000u); + } + return 0; } From b0a49a900fdeb96b7d0aa6f523394bcfd222879a Mon Sep 17 00:00:00 2001 From: TheusHen Date: Wed, 23 Sep 2026 15:39:21 -0300 Subject: [PATCH 10/11] fix: zero-init minimal snapshot out-param for analyzer --- src/core/powerfail.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/powerfail.c b/src/core/powerfail.c index 1c7330f..06e4806 100644 --- a/src/core/powerfail.c +++ b/src/core/powerfail.c @@ -150,7 +150,7 @@ void ls_powerfail_install_backup(uint8_t *ram, size_t size) { void ls_powerfail_seal(ls_powerfail_reason_t reason, uint16_t vcap_mv) { volatile ls_powerfail_sealed_t *seal = &sealed_retained; - ls_minimal_snapshot_t minimal; + ls_minimal_snapshot_t minimal = {0}; bool has_minimal = false; ls_powerfail_sealed_t snapshot = {0}; uint32_t sequence; From 4f2eec93aca30a84e701b2de64cd50cdfe1e5347 Mon Sep 17 00:00:00 2001 From: TheusHen Date: Wed, 23 Sep 2026 15:46:07 -0300 Subject: [PATCH 11/11] fix: document analyzer false positive on seal commit loop --- src/core/powerfail.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/powerfail.c b/src/core/powerfail.c index 06e4806..27f002e 100644 --- a/src/core/powerfail.c +++ b/src/core/powerfail.c @@ -206,6 +206,11 @@ void ls_powerfail_seal(ls_powerfail_reason_t reason, uint16_t vcap_mv) { volatile uint8_t *dst = (volatile uint8_t *)(void *)&sealed_retained; const uint8_t *src = (const uint8_t *)(const void *)&snapshot; for (size_t index = sizeof snapshot.magic; index < sizeof snapshot; ++index) { + /* False positive: every snapshot byte is defined ({0} plus full + * field assignment; minimal is guarded and zero-initialized). + * Cross-TU init is invisible to the analyzer. The roundtrip is + * CRC-validated in test_powerfail_seal.c. */ + // NOLINTNEXTLINE(clang-analyzer-core.uninitialized.Assign) dst[index] = src[index]; } seal_copy_to_backup(&snapshot);