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:
+86
-21
@@ -12,8 +12,46 @@
|
||||
#else
|
||||
#include "epd.h"
|
||||
#include <esp_sleep.h>
|
||||
#include <mbedtls/sha256.h>
|
||||
#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
|
||||
// Defined in main.cpp
|
||||
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));
|
||||
}
|
||||
|
||||
const char* collectHeaders[] = { "X-Interval-Ms", "X-Image-Id" };
|
||||
http.collectHeaders(collectHeaders, 2);
|
||||
const char* collectHeaders[] = { "X-Interval-Ms", "X-Image-Id", "X-Image-Sha256" };
|
||||
http.collectHeaders(collectHeaders, 3);
|
||||
int code = http.GET();
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
if (f) { http.writeToStream(&f); f.close(); }
|
||||
http.end();
|
||||
|
||||
// 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();
|
||||
// Verify integrity. If the server sent a SHA-256 and the bytes we
|
||||
// just wrote don't hash to it, the transfer corrupted somewhere
|
||||
// between Imagick and our flash — skip the panel update and leave
|
||||
// NVS_KEY_IMG_ID alone, so the next poll re-fetches from scratch
|
||||
// (the device will keep claiming the old image-id, server will see
|
||||
// the mismatch and send 200 again with fresh bytes). Set the err
|
||||
// border so the user sees a sync issue instead of garbage.
|
||||
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;
|
||||
epd_init();
|
||||
File r = LittleFS.open(IMAGE_PATH, "r");
|
||||
if (r) { epd_draw_image_from_file(r); r.close(); }
|
||||
if (!integrityOk) {
|
||||
prefs.begin(NVS_NAMESPACE, false);
|
||||
prefs.putInt(NVS_KEY_ERR_BORDER, 1);
|
||||
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
|
||||
// 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();
|
||||
displayInitialized = true;
|
||||
epd_init();
|
||||
File r = LittleFS.open(IMAGE_PATH, "r");
|
||||
if (r) { epd_draw_image_from_file(r); r.close(); }
|
||||
|
||||
// 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) {
|
||||
http.end();
|
||||
|
||||
Reference in New Issue
Block a user