Skip to content

SD Card Layout

CJvanSoest edited this page May 25, 2026 · 2 revisions

SD card layout

Chat history (DM and channel) is stored on the microSD card. The slot used is the internal SDMMC slot 0 (slot 1 hosts the WiFi link to the C6).

Mount point: /sd. Driver: esp_vfs_fat_sdmmc_mount with 4-bit SDMMC, internal pull-ups, default frequency, no auto-format.

Directory structure

/sd
└── meshcore/
    ├── channel.bin                       ← public-channel history
    └── dm/
        ├── a3f2c91a8b7e4f5d.bin          ← per-peer DM log (16 hex chars
        ├── 7e29c84d1b0f6a52.bin             = first 8 bytes of peer pubkey)
        └── …

The filename is <first 8 bytes of peer pub-key in hex>.bin (16 chars). With MAX_CONTACTS = 16 the 64-bit prefix collision risk is negligible.

Record format

Each record on disk:

struct __attribute__((packed)) history_rec_hdr_t {
    uint8_t  magic[4];      // "MCR1"
    uint16_t plain_len;     // 1 .. MAX_MSG_TEXT
    uint8_t  flags;         // bit 0 = is_mine
    uint8_t  reserved;
    uint32_t ts_unix;       // seconds, little-endian
    uint8_t  iv[16];        // random IV for this record
};                          // 28 bytes
uint8_t ciphertext[ ceil(plain_len, 16) ];  // AES-128-CBC, PKCS#7

Encryption

  • Cipher: AES-128-CBC with PKCS#7 padding.
  • Key derivation: at history_init(prv_key), key = HMAC-SHA256(prv_key, "mc-history-v1")[0:16] (mbedTLS mbedtls_md_hmac).
  • IV: 16 bytes from esp_fill_random, persisted in the header per record. No IV reuse across records.

The history key is therefore derived from the Ed25519 identity private key. Wiping NVS regenerates the identity → previous on-disk records become unreadable (see "Self-heal" below).

Append path

history_append_dm(peer_pub, text, is_mine) and history_append_channel(text, is_mine) both:

  1. Take the s_mutex (history lock).
  2. Compose the 28-byte header (magic, length, flags, current Unix time, random IV).
  3. PKCS#7-pad and AES-CBC encrypt.
  4. fopen(path, "ab") + fwrite(hdr) + fwrite(ct) + fclose.

The write is fsync'd by fclose semantics on the FAT driver. Power loss mid-write loses at most the in-flight record.

Load path

history_load_dm(peer_pub, add) and history_load_channel(add) read sequentially from start of file. For each record:

  • Verify magic bytes ("MCR1").
  • Verify plain_len in range.
  • Read ciphertext, decrypt, verify PKCS#7 padding matches plain_len.
  • Call the add callback with text and is_mine.

If a record fails the magic / length / padding checks the loop stops (break). Records before the failure are still loaded.

Self-heal on identity change

When the load loop bails out at record 0 with a fatal error (bad magic / bad len / decrypt mismatch), the entire file is removed:

if (fatal && loaded == 0) {
    ESP_LOGW(TAG, "(%s): unreadable from start — removing stale file", path);
    remove(path);
}

This recovers automatically from "identity key changed" (e.g. after a full NVS erase or partition table flash) — the next append starts a fresh log under the new key instead of being shadowed forever by an unreadable old log.

If only the tail of a file is corrupt, the good prefix is loaded and the bad tail is left in place; subsequent appends will simply be added beyond the corruption and only the bad records (and everything after them) are lost on the next load. This is intentional — we avoid destroying readable history on a single bit flip.

Locking

A single s_mutex (FreeRTOS binary semaphore) serialises all SD access within history.c. Holds are kept short (one append or one full load).

Disabled / missing SD

If mount_sd fails the module sets s_status to one of:

Status Cause
ok mounted successfully
no-sd ESP_ERR_NOT_FOUND / ESP_ERR_TIMEOUT
err other mount error
off initial state, before history_init was called

Append/load become no-ops when s_ready == false, so the rest of the app keeps running without persistence.

Clone this wiki locally