feat(operation): verify X-Image-Sha256 before painting the panel

Pairs with the server-side header. After streaming the response body to
LittleFS, hash the file with mbedtls/sha256 (hardware-accelerated on
ESP32-S3) and compare against the server's claim. On mismatch:

- Don't update NVS_KEY_IMG_ID, so the next poll reports the old id and
  the server sends 200 again with fresh bytes (natural retry, no extra
  HTTP round-trip in this cycle).
- Don't draw — panel keeps whatever was up before, no garbage on the
  e-ink.
- Raise NVS_KEY_ERR_BORDER so the next healthy 304 paints a clean
  recovery frame with the sync-fail border.

Verification is skipped when the header is absent, so the firmware
stays compatible with any server that hasn't deployed the matching
header yet. mbedtls compiles into a native-test no-op stub (returns
empty hex), so existing native tests don't need a SHA implementation.

Two new tests: FW-17a (mismatch path) and FW-17b (missing header
backward compat). Mock String now has equalsIgnoreCase so the new
comparison compiles in native-test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 19:43:02 -04:00
parent 21871179bd
commit 27d01057e4
3 changed files with 133 additions and 21 deletions
+86 -21
View File
@@ -12,8 +12,46 @@
#else #else
#include "epd.h" #include "epd.h"
#include <esp_sleep.h> #include <esp_sleep.h>
#include <mbedtls/sha256.h>
#endif #endif
// ── SHA-256 of a LittleFS file ────────────────────────────────────────────────
// Used to verify the server-supplied X-Image-Sha256 against what we actually
// wrote to flash. ESP32-S3 has a hardware SHA accelerator; mbedtls picks it up
// automatically. Returns lowercase hex (matches PHP's hash_file('sha256') and
// the X-Image-Sha256 header style). Empty string on failure or in unit tests
// (where mbedtls isn't linked) — callers treat that as "skip verification".
inline String sha256_of_file(const char* path) {
#ifdef UNIT_TEST
(void)path;
return String("");
#else
File f = LittleFS.open(path, "r");
if (!f) return String("");
mbedtls_sha256_context ctx;
mbedtls_sha256_init(&ctx);
mbedtls_sha256_starts(&ctx, 0); // 0 = SHA-256 (1 = SHA-224)
uint8_t buf[1024];
while (f.available()) {
int n = f.read(buf, sizeof(buf));
if (n <= 0) break;
mbedtls_sha256_update(&ctx, buf, (size_t)n);
}
uint8_t digest[32];
mbedtls_sha256_finish(&ctx, digest);
mbedtls_sha256_free(&ctx);
f.close();
char hex[65];
for (int i = 0; i < 32; i++) sprintf(&hex[i * 2], "%02x", digest[i]);
hex[64] = '\0';
return String(hex);
#endif
}
#ifndef UNIT_TEST #ifndef UNIT_TEST
// Defined in main.cpp // Defined in main.cpp
static void show_setup_qr(const String& mac); static void show_setup_qr(const String& mac);
@@ -89,8 +127,8 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre
http.addHeader("X-Current-Image-Id", String(currentImgId)); http.addHeader("X-Current-Image-Id", String(currentImgId));
} }
const char* collectHeaders[] = { "X-Interval-Ms", "X-Image-Id" }; const char* collectHeaders[] = { "X-Interval-Ms", "X-Image-Id", "X-Image-Sha256" };
http.collectHeaders(collectHeaders, 2); http.collectHeaders(collectHeaders, 3);
int code = http.GET(); int code = http.GET();
uint64_t sleepMs = FETCH_INTERVAL_MS; uint64_t sleepMs = FETCH_INTERVAL_MS;
@@ -103,33 +141,60 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre
bool displayInitialized = false; bool displayInitialized = false;
if (code == 200) { if (code == 200) {
String newId = http.header("X-Image-Id"); String newId = http.header("X-Image-Id");
String newSha = http.header("X-Image-Sha256");
File f = LittleFS.open(IMAGE_PATH, "w", true); File f = LittleFS.open(IMAGE_PATH, "w", true);
if (f) { http.writeToStream(&f); f.close(); } if (f) { http.writeToStream(&f); f.close(); }
http.end(); http.end();
// Persist ID and set draw_needed before touching the display. // Verify integrity. If the server sent a SHA-256 and the bytes we
// If the device loses power during the ~20 s refresh, the flag survives // just wrote don't hash to it, the transfer corrupted somewhere
// in NVS so the next boot re-draws from LittleFS instead of looping on 200. // between Imagick and our flash — skip the panel update and leave
if (newId.length() > 0) { // NVS_KEY_IMG_ID alone, so the next poll re-fetches from scratch
prefs.begin(NVS_NAMESPACE, false); // (the device will keep claiming the old image-id, server will see
prefs.putInt(NVS_KEY_DRAW_NEEDED, 1); // the mismatch and send 200 again with fresh bytes). Set the err
prefs.putInt(NVS_KEY_IMG_ID, newId.toInt()); // border so the user sees a sync issue instead of garbage.
prefs.end(); bool integrityOk = true;
if (newSha.length() > 0) {
String actualSha = sha256_of_file(IMAGE_PATH);
if (!newSha.equalsIgnoreCase(actualSha)) {
integrityOk = false;
Serial.println(String("[op] sha256 mismatch: expected=") + newSha +
" actual=" + actualSha + " — discarding image, retry next poll");
}
} }
displayInitialized = true; if (!integrityOk) {
epd_init(); prefs.begin(NVS_NAMESPACE, false);
File r = LittleFS.open(IMAGE_PATH, "r"); prefs.putInt(NVS_KEY_ERR_BORDER, 1);
if (r) { epd_draw_image_from_file(r); r.close(); } prefs.end();
// Don't touch the panel — it keeps whatever was up before.
// displayInitialized stays false; epd_sleep() at the bottom is
// skipped, so no spurious wait_busy timeout on the next cycle.
} else {
// Persist ID and set draw_needed before touching the display.
// If the device loses power during the ~20 s refresh, the flag survives
// in NVS so the next boot re-draws from LittleFS instead of looping on 200.
if (newId.length() > 0) {
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_DRAW_NEEDED, 1);
prefs.putInt(NVS_KEY_IMG_ID, newId.toInt());
prefs.end();
}
// Draw complete — clear pending and error-border flags. The fresh displayInitialized = true;
// image fully overwrites any prior border, so error state is gone. epd_init();
prefs.begin(NVS_NAMESPACE, false); File r = LittleFS.open(IMAGE_PATH, "r");
prefs.putInt(NVS_KEY_DRAW_NEEDED, 0); if (r) { epd_draw_image_from_file(r); r.close(); }
prefs.putInt(NVS_KEY_ERR_BORDER, 0);
prefs.end(); // Draw complete — clear pending and error-border flags. The fresh
// image fully overwrites any prior border, so error state is gone.
prefs.begin(NVS_NAMESPACE, false);
prefs.putInt(NVS_KEY_DRAW_NEEDED, 0);
prefs.putInt(NVS_KEY_ERR_BORDER, 0);
prefs.end();
}
} else if (code == 304) { } else if (code == 304) {
http.end(); http.end();
+8
View File
@@ -46,6 +46,14 @@ struct String {
for (char& c : _s) c = (char)toupper((unsigned char)c); for (char& c : _s) c = (char)toupper((unsigned char)c);
} }
bool equalsIgnoreCase(const String& o) const {
if (_s.size() != o._s.size()) return false;
for (size_t i = 0; i < _s.size(); i++) {
if (tolower((unsigned char)_s[i]) != tolower((unsigned char)o._s[i])) return false;
}
return true;
}
bool operator==(const String& o) const { return _s == o._s; } bool operator==(const String& o) const { return _s == o._s; }
bool operator==(const char* o) const { return _s == o; } bool operator==(const char* o) const { return _s == o; }
bool operator!=(const String& o) const { return _s != o._s; } bool operator!=(const String& o) const { return _s != o._s; }
+39
View File
@@ -322,6 +322,43 @@ void test_fw16_304_with_draw_needed_redraws() {
TEST_ASSERT_EQUAL(0, prefs.getInt("draw", -1)); TEST_ASSERT_EQUAL(0, prefs.getInt("draw", -1));
} }
// FW-17a: 200 response with mismatched X-Image-Sha256 — the bytes were
// corrupted in transit. Don't paint the panel; don't update NVS_KEY_IMG_ID
// (so the next poll re-fetches); raise err_border so the user sees a sync
// issue instead of garbage on the panel.
void test_fw17a_sha256_mismatch_skips_draw_and_keeps_old_img_id() {
g_http_response_headers["X-Image-Id"] = "42";
g_http_response_headers["X-Image-Sha256"] = "deadbeefcafebabedeadbeefcafebabedeadbeefcafebabedeadbeefcafebabe";
g_http_body = "BINDATA";
prefs.ints[NVS_KEY_IMG_ID] = 7; // pre-existing image we want to keep
normal_operation_impl(String("mac"), http, String("url"), prefs);
// Panel must not be touched — the corrupt bytes never reach hardware.
TEST_ASSERT_EQUAL(0, g_epd_init_count);
TEST_ASSERT_EQUAL(0, g_epd_draw_image_count);
// NVS image-id stays at the prior value — next poll mismatches the
// server's currentImage, server sends 200 again, device retries.
TEST_ASSERT_EQUAL(7, prefs.getInt(NVS_KEY_IMG_ID, -1));
// err_border raised so the next healthy 304 will repaint clean.
TEST_ASSERT_EQUAL(1, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
}
// FW-17b: 200 response without an X-Image-Sha256 header (e.g., older server)
// — verification is skipped, the success path runs as it always did. Backward-
// compat guarantee: the firmware still works against pre-checksum servers.
void test_fw17b_missing_sha256_header_skips_verification() {
g_http_response_headers["X-Image-Id"] = "42";
// No X-Image-Sha256 set
g_http_body = "BINDATA";
normal_operation_impl(String("mac"), http, String("url"), prefs);
TEST_ASSERT_EQUAL(1, g_epd_draw_image_count);
TEST_ASSERT_EQUAL(42, prefs.getInt(NVS_KEY_IMG_ID, -1));
TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_ERR_BORDER, -1));
}
// FW-12/13: AP SSID derivation via ap_ssid_from_mac() // FW-12/13: AP SSID derivation via ap_ssid_from_mac()
void test_fw12_ap_ssid_from_mac_aabbcc() { void test_fw12_ap_ssid_from_mac_aabbcc() {
String ssid = ap_ssid_from_mac(String("AA:BB:CC:DD:EE:FF")); String ssid = ap_ssid_from_mac(String("AA:BB:CC:DD:EE:FF"));
@@ -357,5 +394,7 @@ int main(int argc, char** argv) {
RUN_TEST(test_fw14_304_skips_epd_sleep); RUN_TEST(test_fw14_304_skips_epd_sleep);
RUN_TEST(test_fw15_nvs_saved_before_epd_draw_and_flag_cleared); RUN_TEST(test_fw15_nvs_saved_before_epd_draw_and_flag_cleared);
RUN_TEST(test_fw16_304_with_draw_needed_redraws); RUN_TEST(test_fw16_304_with_draw_needed_redraws);
RUN_TEST(test_fw17a_sha256_mismatch_skips_draw_and_keeps_old_img_id);
RUN_TEST(test_fw17b_missing_sha256_header_skips_verification);
return UNITY_END(); return UNITY_END();
} }