diff --git a/src/operation.h b/src/operation.h index da507b0..2ec7ea7 100644 --- a/src/operation.h +++ b/src/operation.h @@ -12,8 +12,46 @@ #else #include "epd.h" #include +#include #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(); diff --git a/test/mocks/Arduino.h b/test/mocks/Arduino.h index def181a..d0cc83e 100644 --- a/test/mocks/Arduino.h +++ b/test/mocks/Arduino.h @@ -46,6 +46,14 @@ struct String { 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 char* o) const { return _s == o; } bool operator!=(const String& o) const { return _s != o._s; } diff --git a/test/test_normal_operation/test_main.cpp b/test/test_normal_operation/test_main.cpp index c5f11db..735ba58 100644 --- a/test/test_normal_operation/test_main.cpp +++ b/test/test_normal_operation/test_main.cpp @@ -322,6 +322,43 @@ void test_fw16_304_with_draw_needed_redraws() { 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() void test_fw12_ap_ssid_from_mac_aabbcc() { 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_fw15_nvs_saved_before_epd_draw_and_flag_cleared); 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(); }