diff --git a/CMakeLists.txt b/CMakeLists.txt index 6c78574..91ff5b6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -123,4 +123,16 @@ target_link_libraries(hslock pico_enable_stdio_usb(hslock 1) pico_enable_stdio_uart(hslock 0) +# H7 (part A): close the no-firmware-cooperation USB reset-to-BOOTSEL paths. +# pico_stdio_usb otherwise lets a host drop the chip into the BOOTSEL mass- +# storage bootloader with `stty ... 1200` or `picotool reboot -u` and then +# `picotool save` the whole image (every seed + the WiFi password). Disabling +# both reset interfaces means extraction now requires holding BOOTSEL / SWD, +# not just the port. (BOOTSEL-on-power-up and SWD remain — see H7 part B for +# at-rest encryption and the verify-on-hw note.) +target_compile_definitions(hslock PRIVATE + PICO_STDIO_USB_ENABLE_RESET_VIA_BAUD_RATE=0 + PICO_STDIO_USB_ENABLE_RESET_VIA_VENDOR_INTERFACE=0 +) + pico_add_extra_outputs(hslock) \ No newline at end of file diff --git a/mbedtls_config.h b/mbedtls_config.h index c8638af..3c681c6 100644 --- a/mbedtls_config.h +++ b/mbedtls_config.h @@ -11,4 +11,10 @@ #define MBEDTLS_SHA1_C #define MBEDTLS_MD_C +// H7: at-rest encryption of secrets in storage (AES-256-GCM under a device-bound +// KEK derived via HMAC-SHA256). GCM needs AES + the cipher layer. +#define MBEDTLS_AES_C +#define MBEDTLS_CIPHER_C +#define MBEDTLS_GCM_C + #endif \ No newline at end of file diff --git a/serial/commands_system.c b/serial/commands_system.c index 4b92f9e..bdd3cac 100644 --- a/serial/commands_system.c +++ b/serial/commands_system.c @@ -64,11 +64,13 @@ void cmd_status(int argc, char **argv) { printf("ntp: not synced\r\n"); } - // Keys (admin only: key inventory is target-selection data) + // Keys (admin only: key inventory is target-selection data). Declared at + // function scope so the scrub below always runs, even on the non-admin path + // where the array stays zero-initialised. + static key_record_t keys[BACKUP_MAX_KEYS]; if (commands_is_admin()) { - static key_record_t keys[BACKUP_MAX_KEYS]; - int count = storage_key_list(keys, BACKUP_MAX_KEYS); - int enabled = 0, corrupt = 0; + int count = storage_key_list(keys, BACKUP_MAX_KEYS); + int enabled = 0, corrupt = 0; for (int i = 0; i < count; i++) { if (!keys[i].is_checksum_valid) corrupt++; diff --git a/storage/storage.c b/storage/storage.c index 454e32f..a7679c7 100644 --- a/storage/storage.c +++ b/storage/storage.c @@ -5,9 +5,14 @@ #include "lfs_util.h" #include "pico/stdlib.h" #include "pico/flash.h" +#include "pico/rand.h" +#include "pico/unique_id.h" #include "hardware/flash.h" #include "hardware/sync.h" +#include "mbedtls/gcm.h" +#include "mbedtls/md.h" + #include #include #include @@ -61,18 +66,13 @@ static uint32_t key_checksum(const key_record_stored_t *key) { return crc; } -// Fill via an out-pointer rather than returning by value so the secret never -// lives in a transient helper-frame copy that would outlast this call. -static void to_stored(const key_record_t *k, key_record_stored_t *s) { - s->id = k->id; - s->is_enabled = k->is_enabled; - s->is_admin = k->is_admin; - s->created_at = k->created_at; - memcpy(s->name, k->name, sizeof(s->name)); - memcpy(s->secret, k->secret, sizeof(s->secret)); - s->checksum = key_checksum(s); -} - +// Legacy plaintext record -> logic model. Retained ONLY for reading records +// written by firmware that predates at-rest encryption (H7); new records are +// always written encrypted (key_record_enc_t below) and read via enc_to_key, +// and a legacy record is migrated to the encrypted format the next time the +// key is saved. Fill via an out-pointer rather than returning by value so the +// secret never lives in a transient helper-frame copy that would outlast this +// call. static void to_record(const key_record_stored_t *s, key_record_t *k) { k->id = s->id; k->is_enabled = s->is_enabled; @@ -84,6 +84,193 @@ static void to_record(const key_record_stored_t *s, key_record_t *k) { memcpy(k->secret, s->secret, sizeof(k->secret)); } +// --------------------------------------------------------------------------- +// At-rest encryption (H7) +// --------------------------------------------------------------------------- +// Secrets (TOTP seeds and the WiFi password) are encrypted at rest under a +// device-bound key-encryption key (KEK): HMAC-SHA256(compile-time secret, +// per-board unique id). AES-256-GCM provides confidentiality plus integrity — +// the 16-byte tag authenticates both the ciphertext and the cleartext metadata +// header (passed as AAD), so it replaces the old CRC for encrypted records. +// +// The RP2040 has no secure boot / flash encryption / readback protection, so +// this only raises the bar from "read the flash" to "read the flash AND the +// firmware" (the compile-time secret lives in the image). It is defence in +// depth, not a root of trust; RP2350 (OTP + secure boot) is the real fix. The +// board id is not secret, so the compile-time secret SHOULD be overridden per +// build via -DHSLOCK_STORAGE_KEK_SECRET=... rather than shipping this default. +#ifndef HSLOCK_STORAGE_KEK_SECRET +#define HSLOCK_STORAGE_KEK_SECRET "hslock-storage-kek-v1-override-at-build-time" +#endif + +#define KEK_LEN 32 // AES-256 +#define REC_IV_LEN 12 // GCM nonce +#define REC_TAG_LEN 16 // GCM tag + +// Derive the 32-byte device-bound KEK. Deterministic per board, so an encrypted +// record round-trips on the same device across reboots. +static void derive_kek(uint8_t kek_out[KEK_LEN]) { + pico_unique_board_id_t board; + pico_get_unique_board_id(&board); + const mbedtls_md_info_t *info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + mbedtls_md_hmac(info, (const uint8_t *)HSLOCK_STORAGE_KEK_SECRET, + strlen(HSLOCK_STORAGE_KEK_SECRET), board.id, sizeof(board.id), kek_out); +} + +// AES-256-GCM seal: random per-record IV, AAD authenticated but not encrypted. +static bool gcm_seal(const uint8_t *aad, size_t aad_len, const uint8_t *pt, size_t pt_len, + uint8_t *iv_out, uint8_t *ct_out, uint8_t *tag_out) { + // Fresh random 96-bit IV per write — the KEK is device-fixed, so IV reuse + // under one key would be catastrophic for GCM. Draw from the RP2040 RNG. + for (size_t i = 0; i < REC_IV_LEN;) { + uint64_t r = get_rand_64(); + size_t chunk = (REC_IV_LEN - i) < sizeof(r) ? (REC_IV_LEN - i) : sizeof(r); + memcpy(iv_out + i, &r, chunk); + i += chunk; + } + + uint8_t kek[KEK_LEN]; + derive_kek(kek); + mbedtls_gcm_context ctx; + mbedtls_gcm_init(&ctx); + int rc = mbedtls_gcm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, kek, KEK_LEN * 8); + if (rc == 0) + rc = mbedtls_gcm_crypt_and_tag(&ctx, MBEDTLS_GCM_ENCRYPT, pt_len, iv_out, REC_IV_LEN, aad, + aad_len, pt, ct_out, REC_TAG_LEN, tag_out); + mbedtls_gcm_free(&ctx); + secure_wipe(kek, sizeof(kek)); + return rc == 0; +} + +// AES-256-GCM open: verifies the tag over (AAD, ciphertext). Returns false on +// any authentication failure — corruption, tampering, or a wrong/rotated KEK. +static bool gcm_open(const uint8_t *aad, size_t aad_len, const uint8_t *iv, const uint8_t *ct, + size_t ct_len, const uint8_t *tag, uint8_t *pt_out) { + uint8_t kek[KEK_LEN]; + derive_kek(kek); + mbedtls_gcm_context ctx; + mbedtls_gcm_init(&ctx); + int rc = mbedtls_gcm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, kek, KEK_LEN * 8); + if (rc == 0) + rc = mbedtls_gcm_auth_decrypt(&ctx, ct_len, iv, REC_IV_LEN, aad, aad_len, tag, REC_TAG_LEN, + ct, pt_out); + mbedtls_gcm_free(&ctx); + secure_wipe(kek, sizeof(kek)); + return rc == 0; +} + +// --------------------------------------------------------------------------- +// Encrypted key record (on-flash format v2) +// --------------------------------------------------------------------------- +// The cleartext header (magic..created_at) is stored in the clear AND fed to +// GCM as AAD, so any tampering with it fails the tag. Only `secret` is +// encrypted. sizeof(key_record_enc_t) differs from sizeof(key_record_stored_t), +// which is how a read distinguishes the two formats (see storage_key_get). + +#define KEY_REC_MAGIC 0x324B5348u // "HSK2" +#define KEY_REC_VERSION 2 + +typedef struct { + uint32_t magic; + uint16_t version; + uint16_t id; + char name[KEY_NAME_MAX]; + uint8_t is_enabled; + uint8_t is_admin; + uint32_t created_at; + uint8_t iv[REC_IV_LEN]; + uint8_t tag[REC_TAG_LEN]; + uint8_t secret_enc[KEY_SECRET_LEN]; +} key_record_enc_t; + +// Serialise the authenticated metadata into a packed buffer (struct padding is +// never fed to GCM, so seal and open agree byte-for-byte). +static size_t build_key_aad(uint8_t *aad, const key_record_enc_t *e) { + size_t o = 0; + uint32_t magic = e->magic; + uint16_t ver = e->version; + uint16_t id = e->id; + uint32_t cat = e->created_at; + memcpy(aad + o, &magic, sizeof(magic)); + o += sizeof(magic); + memcpy(aad + o, &ver, sizeof(ver)); + o += sizeof(ver); + memcpy(aad + o, &id, sizeof(id)); + o += sizeof(id); + memcpy(aad + o, e->name, KEY_NAME_MAX); + o += KEY_NAME_MAX; + aad[o++] = e->is_enabled; + aad[o++] = e->is_admin; + memcpy(aad + o, &cat, sizeof(cat)); + o += sizeof(cat); + return o; +} +#define KEY_AAD_LEN (4 + 2 + 2 + KEY_NAME_MAX + 1 + 1 + 4) + +static bool key_to_enc(const key_record_t *k, key_record_enc_t *e) { + memset(e, 0, sizeof(*e)); + e->magic = KEY_REC_MAGIC; + e->version = KEY_REC_VERSION; + e->id = k->id; + e->is_enabled = k->is_enabled ? 1 : 0; + e->is_admin = k->is_admin ? 1 : 0; + e->created_at = k->created_at; + memcpy(e->name, k->name, sizeof(e->name)); + + uint8_t aad[KEY_AAD_LEN]; + size_t aad_len = build_key_aad(aad, e); + return gcm_seal(aad, aad_len, k->secret, KEY_SECRET_LEN, e->iv, e->secret_enc, e->tag); +} + +static key_record_t enc_to_key(const key_record_enc_t *e) { + key_record_t k; + k.id = e->id; + k.is_enabled = (e->is_enabled != 0); + k.is_admin = (e->is_admin != 0); + k.created_at = e->created_at; + memcpy(k.name, e->name, sizeof(k.name)); + k.name[KEY_NAME_MAX - 1] = '\0'; + + uint8_t aad[KEY_AAD_LEN]; + size_t aad_len = build_key_aad(aad, e); + k.is_checksum_valid = + gcm_open(aad, aad_len, e->iv, e->secret_enc, KEY_SECRET_LEN, e->tag, k.secret); + if (!k.is_checksum_valid) + memset(k.secret, 0, sizeof(k.secret)); // never surface undecryptable bytes + return k; +} + +// --------------------------------------------------------------------------- +// Encrypted WiFi record (on-flash format v2) +// --------------------------------------------------------------------------- +// The whole wifi_config_t (ssid + password) is encrypted. sizeof differs from +// sizeof(wifi_config_t), the legacy plaintext size, so a read can tell them +// apart and migrate. + +#define WIFI_REC_MAGIC 0x32575348u // "HSW2" +#define WIFI_REC_VERSION 2 + +typedef struct { + uint32_t magic; + uint16_t version; + uint16_t _reserved; + uint8_t iv[REC_IV_LEN]; + uint8_t tag[REC_TAG_LEN]; + uint8_t ct[sizeof(wifi_config_t)]; +} wifi_record_enc_t; + +static size_t build_wifi_aad(uint8_t *aad, const wifi_record_enc_t *w) { + size_t o = 0; + uint32_t magic = w->magic; + uint16_t ver = w->version; + memcpy(aad + o, &magic, sizeof(magic)); + o += sizeof(magic); + memcpy(aad + o, &ver, sizeof(ver)); + o += sizeof(ver); + return o; +} +#define WIFI_AAD_LEN (4 + 2) + // --------------------------------------------------------------------------- // Flash block device callbacks // --------------------------------------------------------------------------- @@ -239,32 +426,68 @@ bool storage_wifi_get(wifi_config_t *out) { if (lfs_file_opencfg(&lfs, &f, FILE_WIFI, LFS_O_RDONLY, &LFS_FILE_CFG) < 0) return false; - lfs_ssize_t n = lfs_file_read(&lfs, &f, out, sizeof(wifi_config_t)); + union { + wifi_record_enc_t enc; + wifi_config_t legacy; + uint8_t raw[sizeof(wifi_record_enc_t)]; + } buf; + lfs_ssize_t n = lfs_file_read(&lfs, &f, &buf, sizeof(buf)); lfs_file_close(&lfs, &f); - if (n != (lfs_ssize_t)sizeof(wifi_config_t)) - return false; - // Defense-in-depth: never trust flash to be NUL-terminated. Both fields - // are used directly as C strings (printf("%s"), cyw43 connect), so force a + // Defense-in-depth: never trust flash to be NUL-terminated. Both fields are + // used directly as C strings (printf("%s"), cyw43 connect), so force a // terminator at the last byte to prevent an over-read past the field. - out->ssid[WIFI_SSID_MAX - 1] = '\0'; - out->password[WIFI_PASSWORD_MAX - 1] = '\0'; - return true; + if (n == (lfs_ssize_t)sizeof(wifi_record_enc_t) && buf.enc.magic == WIFI_REC_MAGIC && + buf.enc.version == WIFI_REC_VERSION) { + uint8_t aad[WIFI_AAD_LEN]; + size_t aad_len = build_wifi_aad(aad, &buf.enc); + bool ok = gcm_open(aad, aad_len, buf.enc.iv, buf.enc.ct, sizeof(wifi_config_t), buf.enc.tag, + (uint8_t *)out); + if (!ok) { + printf("[storage] wifi decrypt failed (tamper or wrong device)\r\n"); + secure_wipe(out, sizeof(*out)); + return false; + } + out->ssid[WIFI_SSID_MAX - 1] = '\0'; + out->password[WIFI_PASSWORD_MAX - 1] = '\0'; + return true; + } + + // Legacy plaintext record: pass it through (migrated to encrypted on the + // next storage_wifi_set). + if (n == (lfs_ssize_t)sizeof(wifi_config_t)) { + memcpy(out, &buf.legacy, sizeof(wifi_config_t)); + out->ssid[WIFI_SSID_MAX - 1] = '\0'; + out->password[WIFI_PASSWORD_MAX - 1] = '\0'; + return true; + } + + return false; } bool storage_wifi_set(const wifi_config_t *cfg) { if (!mounted) return false; + wifi_record_enc_t rec; + memset(&rec, 0, sizeof(rec)); + rec.magic = WIFI_REC_MAGIC; + rec.version = WIFI_REC_VERSION; + uint8_t aad[WIFI_AAD_LEN]; + size_t aad_len = build_wifi_aad(aad, &rec); + if (!gcm_seal(aad, aad_len, (const uint8_t *)cfg, sizeof(wifi_config_t), rec.iv, rec.ct, + rec.tag)) + return false; + lfs_file_t f; int flags = LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC; int rc = lfs_file_opencfg(&lfs, &f, FILE_WIFI, flags, &LFS_FILE_CFG); if (rc < 0) return false; - lfs_ssize_t n = lfs_file_write(&lfs, &f, cfg, sizeof(wifi_config_t)); + lfs_ssize_t n = lfs_file_write(&lfs, &f, &rec, sizeof(rec)); lfs_file_close(&lfs, &f); - return n == (lfs_ssize_t)sizeof(wifi_config_t); + return n == (lfs_ssize_t)sizeof(rec); } bool storage_wifi_clear(void) { @@ -296,18 +519,28 @@ bool storage_key_get(uint16_t id, key_record_t *out) { if (lfs_file_opencfg(&lfs, &f, path, LFS_O_RDONLY, &LFS_FILE_CFG) < 0) return false; - key_record_stored_t stored; - lfs_ssize_t n = lfs_file_read(&lfs, &f, &stored, sizeof(stored)); + union { + key_record_enc_t enc; + key_record_stored_t legacy; + uint8_t raw[sizeof(key_record_enc_t)]; + } buf; + lfs_ssize_t n = lfs_file_read(&lfs, &f, &buf, sizeof(buf)); lfs_file_close(&lfs, &f); - if (n != (lfs_ssize_t)sizeof(stored)) { - secure_wipe(&stored, sizeof(stored)); + + if (n == (lfs_ssize_t)sizeof(key_record_enc_t) && buf.enc.magic == KEY_REC_MAGIC && + buf.enc.version == KEY_REC_VERSION) { + *out = enc_to_key(&buf.enc); + } else if (n == (lfs_ssize_t)sizeof(key_record_stored_t)) { + // Legacy plaintext record (pre-H7). Read it so the key keeps working; + // it is migrated to the encrypted format the next time it is saved. + to_record(&buf.legacy, out); + } else { + secure_wipe(&buf, sizeof(buf)); return false; } - - to_record(&stored, out); - // The stored record (secret included) is no longer needed: scrub it so the - // seed doesn't linger on the stack after this read. - secure_wipe(&stored, sizeof(stored)); + // The raw record (secret / ciphertext included) is no longer needed: scrub + // it so nothing lingers on the stack after this read. + secure_wipe(&buf, sizeof(buf)); if (!out->is_checksum_valid) printf("[storage] key %u checksum mismatch\r\n", id); @@ -323,21 +556,22 @@ bool storage_key_save(const key_record_t *key) { char path[40]; key_path(key->id, path, sizeof(path)); - key_record_stored_t stored; - to_stored(key, &stored); // checksum computed here + key_record_enc_t enc; // AES-256-GCM seal computed here (H7) + if (!key_to_enc(key, &enc)) + return false; lfs_file_t f; int flags = LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC; if (lfs_file_opencfg(&lfs, &f, path, flags, &LFS_FILE_CFG) < 0) { - secure_wipe(&stored, sizeof(stored)); + secure_wipe(&enc, sizeof(enc)); return false; } - lfs_ssize_t n = lfs_file_write(&lfs, &f, &stored, sizeof(stored)); + lfs_ssize_t n = lfs_file_write(&lfs, &f, &enc, sizeof(enc)); lfs_file_close(&lfs, &f); - bool ok = n == (lfs_ssize_t)sizeof(stored); - // Scrub the serialised record (secret included) from the stack. - secure_wipe(&stored, sizeof(stored)); + bool ok = n == (lfs_ssize_t)sizeof(enc); + // Scrub the serialised record from the stack. + secure_wipe(&enc, sizeof(enc)); return ok; } diff --git a/test/Makefile b/test/Makefile index 0c35e66..c185289 100644 --- a/test/Makefile +++ b/test/Makefile @@ -61,6 +61,15 @@ TOTP_LIBS := -lmbedcrypto STORAGE_SRCS := harness_storage.c $(ROOT)/storage/storage.c $(ROOT)/storage/backup.c \ $(ROOT)/libs/littlefs/lfs.c $(ROOT)/libs/littlefs/lfs_util.c STORAGE_DEFS := -DLFS_NO_MALLOC -DLFS_NO_DEBUG +# storage.c now encrypts secrets at rest with AES-256-GCM (H7), so the harness +# links the REAL mbedtls (system libmbedcrypto) like the TOTP harness. `-idirafter +# stub` keeps the pico/hardware shims reachable while letting the real +# + win over the compile-only stubs. CI: the test +# job already apt-installs `libmbedtls-dev` for the TOTP harness. +STORAGE_INCLUDES := -I$(ROOT) -I$(ROOT)/hardware -I$(ROOT)/network -I$(ROOT)/serial \ + -I$(ROOT)/storage -I$(SHARED) -I$(BASE32) -I$(BASE64) \ + -I$(QRCODEGEN) -I$(ROOT)/libs/littlefs -idirafter stub +STORAGE_LIBS := -lmbedcrypto # Commands harness: links serial/commands.c (the dispatcher) against SPY handler # stubs + a stub buzzer, all inside harness_commands.c. No real key/system/ @@ -143,7 +152,8 @@ $(BUILD)/asan_totp: $(TOTP_SRCS) | $(BUILD) $(CC) $(CSTD) $(WARN) $(ASAN_FLAGS) $(TOTP_INCLUDES) $(TOTP_SRCS) $(TOTP_LIBS) -o $@ $(BUILD)/asan_storage: $(STORAGE_SRCS) | $(BUILD) - $(CC) $(CSTD) $(WARN) $(ASAN_FLAGS) $(STORAGE_DEFS) $(INCLUDES) $(STORAGE_SRCS) -o $@ + $(CC) $(CSTD) $(WARN) $(ASAN_FLAGS) $(STORAGE_DEFS) $(STORAGE_INCLUDES) $(STORAGE_SRCS) \ + $(STORAGE_LIBS) -o $@ $(BUILD)/asan_commands: $(COMMANDS_SRCS) | $(BUILD) $(CC) $(CSTD) $(WARN) $(ASAN_FLAGS) $(INCLUDES) $(COMMANDS_SRCS) -o $@ @@ -169,7 +179,8 @@ $(BUILD)/vg_totp: $(TOTP_SRCS) | $(BUILD) $(CC) $(CSTD) $(WARN) $(VG_FLAGS) $(TOTP_INCLUDES) $(TOTP_SRCS) $(TOTP_LIBS) -o $@ $(BUILD)/vg_storage: $(STORAGE_SRCS) | $(BUILD) - $(CC) $(CSTD) $(WARN) $(VG_FLAGS) $(STORAGE_DEFS) $(INCLUDES) $(STORAGE_SRCS) -o $@ + $(CC) $(CSTD) $(WARN) $(VG_FLAGS) $(STORAGE_DEFS) $(STORAGE_INCLUDES) $(STORAGE_SRCS) \ + $(STORAGE_LIBS) -o $@ $(BUILD)/vg_commands: $(COMMANDS_SRCS) | $(BUILD) $(CC) $(CSTD) $(WARN) $(VG_FLAGS) $(INCLUDES) $(COMMANDS_SRCS) -o $@ @@ -191,8 +202,8 @@ coverage: check-submodules | $(COV_DIR)/obj -o $(COV_DIR)/obj/cov_base64 $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(TOTP_INCLUDES) $(TOTP_SRCS) $(TOTP_LIBS) \ -o $(COV_DIR)/obj/cov_totp - $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(STORAGE_DEFS) $(INCLUDES) $(STORAGE_SRCS) \ - -o $(COV_DIR)/obj/cov_storage + $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(STORAGE_DEFS) $(STORAGE_INCLUDES) $(STORAGE_SRCS) \ + $(STORAGE_LIBS) -o $(COV_DIR)/obj/cov_storage $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(INCLUDES) $(COMMANDS_SRCS) \ -o $(COV_DIR)/obj/cov_commands @echo "== coverage: running instrumented harnesses ==" diff --git a/test/harness_storage.c b/test/harness_storage.c index 45ed6df..72ec95e 100644 --- a/test/harness_storage.c +++ b/test/harness_storage.c @@ -28,7 +28,10 @@ #include "pico/stdlib.h" /* PICO_FLASH_SIZE_BYTES, PICO_OK */ #include "backup.h" -#include "lfs_util.h" /* lfs_crc: matches backup.c's whole-backup checksum */ +#include "lfs.h" /* second lfs mount, used to plant a legacy plaintext record */ +#include "lfs_util.h" /* lfs_crc: matches backup.c's whole-backup checksum */ +#include "pico/rand.h" /* get_rand_64: IV source for storage.c's GCM (H7) */ +#include "pico/unique_id.h" /* board id: storage.c's device-bound KEK (H7) */ #include "storage.h" /* Must match storage.c's private layout constants. */ @@ -87,6 +90,111 @@ static void flash_ram_map(void) { memset(base, 0xFF, STORAGE_SIZE_BYTES); /* fresh, fully-erased flash */ } +/* --- device doubles storage.c's at-rest crypto (H7) needs ----------------- */ + +/* GCM IV source. storage.c draws a fresh 96-bit IV per write via get_rand_64; + * a monotonic counter gives distinct, reproducible IVs. */ +uint64_t get_rand_64(void) { + static uint64_t ctr = 0x0123456789ABCDEFull; + ctr += 0x9E3779B97F4A7C15ull; + return ctr; +} + +/* Per-board unique id feeding storage.c's KEK. Mutable so a test can simulate a + * different device (rotated KEK) and confirm an encrypted record no longer + * decrypts. */ +static uint8_t g_board_id[PICO_UNIQUE_BOARD_ID_SIZE_BYTES] = {0xA0, 0xA1, 0xA2, 0xA3, + 0xA4, 0xA5, 0xA6, 0xA7}; + +void pico_get_unique_board_id(pico_unique_board_id_t *id_out) { + memcpy(id_out->id, g_board_id, PICO_UNIQUE_BOARD_ID_SIZE_BYTES); +} + +/* --- second littlefs mount: plant a legacy plaintext key record ------------ */ +/* storage.c writes encrypted records now, so to exercise the legacy-plaintext + * migration/passthrough path we format the same RAM window with an independent + * lfs mount and write one pre-H7 record by hand, then let storage.c mount it. */ + +/* Must match storage.c's PRIVATE key_record_stored_t byte-for-byte. */ +typedef struct { + uint16_t id; + char name[KEY_NAME_MAX]; + uint8_t secret[KEY_SECRET_LEN]; + uint8_t is_enabled; + uint8_t is_admin; + uint32_t created_at; + uint32_t checksum; +} legacy_stored_t; + +/* Must match storage.c's PRIVATE key_checksum() field-by-field. */ +static uint32_t legacy_checksum(const legacy_stored_t *k) { + uint32_t crc = 0xFFFFFFFF; + crc = lfs_crc(crc, &k->id, sizeof(k->id)); + crc = lfs_crc(crc, k->name, sizeof(k->name)); + crc = lfs_crc(crc, k->secret, sizeof(k->secret)); + crc = lfs_crc(crc, &k->is_enabled, sizeof(k->is_enabled)); + crc = lfs_crc(crc, &k->is_admin, sizeof(k->is_admin)); + crc = lfs_crc(crc, &k->created_at, sizeof(k->created_at)); + return crc; +} + +/* littlefs block-device callbacks over the SAME RAM window storage.c uses. */ +static int h2_read(const struct lfs_config *c, lfs_block_t b, lfs_off_t o, void *buf, + lfs_size_t sz) { + (void)c; + memcpy(buf, flash_ptr(STORAGE_FLASH_OFFSET + b * FLASH_SECTOR_SIZE + o), sz); + return 0; +} +static int h2_prog(const struct lfs_config *c, lfs_block_t b, lfs_off_t o, const void *buf, + lfs_size_t sz) { + (void)c; + flash_range_program(STORAGE_FLASH_OFFSET + b * FLASH_SECTOR_SIZE + o, buf, sz); + return 0; +} +static int h2_erase(const struct lfs_config *c, lfs_block_t b) { + (void)c; + flash_range_erase(STORAGE_FLASH_OFFSET + b * FLASH_SECTOR_SIZE, FLASH_SECTOR_SIZE); + return 0; +} +static int h2_sync(const struct lfs_config *c) { + (void)c; + return 0; +} + +/* Format the window and write ONE legacy plaintext record at /keys/. */ +static void plant_legacy_key(const legacy_stored_t *rec) { + static uint8_t rbuf[256], pbuf[256], lbuf[8], fbuf[256]; + struct lfs_config cfg = { + .read = h2_read, + .prog = h2_prog, + .erase = h2_erase, + .sync = h2_sync, + .read_size = 256, + .prog_size = 256, + .block_size = FLASH_SECTOR_SIZE, + .block_count = STORAGE_SIZE_BYTES / FLASH_SECTOR_SIZE, + .cache_size = 256, + .lookahead_size = sizeof(lbuf), + .block_cycles = 500, + .read_buffer = rbuf, + .prog_buffer = pbuf, + .lookahead_buffer = lbuf, + }; + const struct lfs_file_config fcfg = {.buffer = fbuf}; + + lfs_t l; + assert(lfs_format(&l, &cfg) == 0); + assert(lfs_mount(&l, &cfg) == 0); + assert(lfs_mkdir(&l, "/keys") == 0); + char path[40]; + snprintf(path, sizeof path, "/keys/%05u", rec->id); + lfs_file_t f; + assert(lfs_file_opencfg(&l, &f, path, LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC, &fcfg) == 0); + assert(lfs_file_write(&l, &f, rec, sizeof(*rec)) == (lfs_ssize_t)sizeof(*rec)); + assert(lfs_file_close(&l, &f) == 0); + assert(lfs_unmount(&l) == 0); +} + /* Scan the whole storage window for `pat` — used to prove a secret is NOT * sitting in cleartext anywhere in flash. */ static bool window_contains(const uint8_t *pat, size_t n) { @@ -408,7 +516,11 @@ int main(void) { key_record_t vk = make_key(42, "victim", true, false, 1700111111u, 0); memcpy(vk.secret, del_seed, sizeof del_seed); assert(storage_key_save(&vk) == true); - assert(window_contains(del_seed, sizeof del_seed) == true); /* seed on flash */ + /* H7 encrypts secrets at rest, so the plaintext seed never lands on + * flash; the (encrypted) record still exists and must be scrubbed on + * delete — that scrub is what this test verifies. */ + assert(window_contains(del_seed, sizeof del_seed) == false); + assert(storage_key_exists(42) == true); g_del_max_zero_run = 0; g_delete_active = 1; @@ -422,6 +534,114 @@ int main(void) { assert(storage_key_get(42, &tmp) == false); } + /* --- H7: secrets are encrypted at rest -------------------------------- */ + /* A saved key's seed must NOT appear in cleartext anywhere in the window, + * yet must decrypt back exactly on this device. Use a distinctive seed + * pattern so the window scan can't collide with unrelated bytes. */ + { + uint8_t distinct[KEY_SECRET_LEN]; + for (int i = 0; i < KEY_SECRET_LEN; i++) + distinct[i] = (uint8_t)(0xC0 ^ (i * 7 + 3)); + assert(window_contains(distinct, sizeof distinct) == false); /* not there yet */ + + key_record_t ek = make_key(11, "enc", true, true, 1700009999u, 0); + memcpy(ek.secret, distinct, sizeof distinct); + assert(storage_key_save(&ek) == true); + + /* confidentiality: plaintext seed is nowhere in flash */ + assert(window_contains(distinct, sizeof distinct) == false); + + /* round-trip: same device decrypts to the exact seed, tag verifies */ + key_record_t eg; + assert(storage_key_get(11, &eg) == true); + assert(eg.is_checksum_valid == true); + assert(memcmp(eg.secret, distinct, sizeof distinct) == 0); + assert(keys_equal(&ek, &eg)); + + /* wifi password likewise never lands in cleartext, round-trips */ + wifi_config_t ewc = {0}; + snprintf(ewc.ssid, sizeof ewc.ssid, "net"); + snprintf(ewc.password, sizeof ewc.password, "PLAINTEXT-WIFI-PW-MARKER-XYZ"); + assert(storage_wifi_set(&ewc) == true); + assert(window_contains((const uint8_t *)"PLAINTEXT-WIFI-PW-MARKER-XYZ", 28) == false); + wifi_config_t ewg = {0}; + assert(storage_wifi_get(&ewg) == true); + assert(strcmp(ewg.password, ewc.password) == 0); + assert(strcmp(ewg.ssid, ewc.ssid) == 0); + assert(storage_wifi_clear() == true); + + /* --- device binding: a rotated/foreign KEK must NOT decrypt -------- */ + g_board_id[0] ^= 0xFF; /* pretend this is a different board */ + key_record_t wrong; + assert(storage_key_get(11, &wrong) == true); /* record still present */ + assert(wrong.is_checksum_valid == false); /* tag fails under wrong KEK */ + uint8_t zero[KEY_SECRET_LEN] = {0}; + assert(memcmp(wrong.secret, zero, sizeof zero) == 0); /* seed not surfaced */ + g_board_id[0] ^= 0xFF; /* restore true device */ + assert(storage_key_get(11, &eg) == true); + assert(eg.is_checksum_valid == true); /* decrypts again */ + assert(memcmp(eg.secret, distinct, sizeof distinct) == 0); + + assert(storage_key_delete(11) == true); + } + + /* --- H7: legacy plaintext record migrates safely ---------------------- */ + /* A device flashed before H7 holds plaintext key_record_stored_t records. + * storage.c must still read them (decrypt-or-passthrough), and re-encrypt + * them on the next save. Plant one via an independent lfs mount, then let + * storage.c mount the same media. */ + { + flash_ram_map(); /* fresh window; storage re-mounts below */ + legacy_stored_t leg = {0}; + leg.id = 7; + leg.is_enabled = 1; + leg.is_admin = 1; + leg.created_at = 1699999999u; + snprintf(leg.name, sizeof leg.name, "legacy"); + for (int i = 0; i < KEY_SECRET_LEN; i++) + leg.secret[i] = (uint8_t)(0x5A + i); + leg.checksum = legacy_checksum(&leg); + plant_legacy_key(&leg); + + assert(storage_init() == true); /* mounts the planted filesystem */ + + /* passthrough: legacy record reads back with its plaintext seed */ + key_record_t lg; + assert(storage_key_get(7, &lg) == true); + assert(lg.is_checksum_valid == true); + assert(lg.is_admin == true && lg.is_enabled == true); + assert(strcmp(lg.name, "legacy") == 0); + for (int i = 0; i < KEY_SECRET_LEN; i++) + assert(lg.secret[i] == (uint8_t)(0x5A + i)); + + /* the on-flash legacy record IS plaintext (that is the risk H7 closes), + * and being plaintext it decrypts independent of the KEK — flip the + * board id and it STILL reads (no crypto binds it). */ + assert(window_contains(leg.secret, KEY_SECRET_LEN) == true); + g_board_id[0] ^= 0xFF; + key_record_t lg_wrongdev; + assert(storage_key_get(7, &lg_wrongdev) == true); + assert(lg_wrongdev.is_checksum_valid == true); /* CRC, not KEK-bound */ + g_board_id[0] ^= 0xFF; + + /* migrate: re-save upgrades it to the encrypted format. (The stale + * plaintext copy may linger in flash until a block is reused/formatted + * — that residue is M13's concern, not H7's; do not assert it gone.) */ + assert(storage_key_save(&lg) == true); + key_record_t mg; + assert(storage_key_get(7, &mg) == true); + assert(mg.is_checksum_valid == true); + assert(keys_equal(&lg, &mg)); + + /* proof of upgrade: the record is NOW KEK-bound, so a foreign device + * can no longer decrypt it (a still-plaintext record would have). */ + g_board_id[0] ^= 0xFF; + key_record_t mg_wrongdev; + assert(storage_key_get(7, &mg_wrongdev) == true); + assert(mg_wrongdev.is_checksum_valid == false); /* encrypted now */ + g_board_id[0] ^= 0xFF; + } + printf("storage OK\n"); return 0; } diff --git a/test/stub/mbedtls/gcm.h b/test/stub/mbedtls/gcm.h new file mode 100644 index 0000000..da309fa --- /dev/null +++ b/test/stub/mbedtls/gcm.h @@ -0,0 +1,43 @@ +#ifndef STUB_MBEDTLS_GCM_H +#define STUB_MBEDTLS_GCM_H + +/* + * Host stub for . + * + * This exists ONLY so storage.c compiles in contexts that use the shared + * -Istub include path (the coverage target's compile-only pass over the whole + * first-party tree). The harnesses that actually RUN storage.c's crypto + * (asan_storage / vg_storage / cov_storage) are built with `-idirafter stub` + * and link the real libmbedcrypto, so the system wins there and + * this file is never used for execution. The signatures mirror mbedTLS 3.x so + * storage.c type-checks either way. + */ + +#include +#include + +typedef enum { + MBEDTLS_CIPHER_ID_AES = 2, +} mbedtls_cipher_id_t; + +#define MBEDTLS_GCM_ENCRYPT 1 +#define MBEDTLS_GCM_DECRYPT 0 + +typedef struct { + unsigned char opaque[512]; +} mbedtls_gcm_context; + +void mbedtls_gcm_init(mbedtls_gcm_context *ctx); +void mbedtls_gcm_free(mbedtls_gcm_context *ctx); +int mbedtls_gcm_setkey(mbedtls_gcm_context *ctx, mbedtls_cipher_id_t cipher, + const unsigned char *key, unsigned int keybits); +int mbedtls_gcm_crypt_and_tag(mbedtls_gcm_context *ctx, int mode, size_t length, + const unsigned char *iv, size_t iv_len, const unsigned char *add, + size_t add_len, const unsigned char *input, unsigned char *output, + size_t tag_len, unsigned char *tag); +int mbedtls_gcm_auth_decrypt(mbedtls_gcm_context *ctx, size_t length, const unsigned char *iv, + size_t iv_len, const unsigned char *add, size_t add_len, + const unsigned char *tag, size_t tag_len, const unsigned char *input, + unsigned char *output); + +#endif diff --git a/test/stub/mbedtls/md.h b/test/stub/mbedtls/md.h index dc0b25a..b08ce7e 100644 --- a/test/stub/mbedtls/md.h +++ b/test/stub/mbedtls/md.h @@ -1,13 +1,14 @@ #ifndef STUB_MBEDTLS_MD_H #define STUB_MBEDTLS_MD_H -/* Host stub for : only the HMAC path totp.c uses. */ +/* Host stub for : the HMAC path totp.c and storage.c use. */ #include #include typedef enum { - MBEDTLS_MD_SHA1 = 4, + MBEDTLS_MD_SHA1 = 4, + MBEDTLS_MD_SHA256 = 9, /* storage.c's KEK derivation (H7) */ } mbedtls_md_type_t; typedef struct mbedtls_md_info_t mbedtls_md_info_t;