Skip to main content

SimpleGo Security Architecture - Hardware Class 1

Runtime Memory Protection

Document version: Session 44 | March 2026 Applies to: All SimpleGo Hardware Classes (Class 1 implementation, concepts extend to Class 2/3) Copyright: 2025-2026 Sascha Daemgen, IT and More Systems, Recklinghausen License: AGPL-3.0 (Software) | CERN-OHL-W-2.0 (Hardware)


The Principle: What Is Not in Memory Cannot Be Stolen

The HMAC vault (Pillar 1) protects keys at rest in flash storage. But during normal operation, decrypted messages and derived key material exist in volatile RAM. An attacker who can read RAM contents - via JTAG debugging, cold boot attack, or a firmware vulnerability - bypasses the flash encryption entirely.

Runtime memory protection is the discipline of minimizing the time that sensitive data exists in RAM and ensuring it is securely overwritten when no longer needed. This is not optional hardening. It is the second of the three required pillars for Hardware Class 1 security.


Current Vulnerabilities (Session 43 Status)

SEC-01: Decrypted Messages in PSRAM (CRITICAL)

The s_msg_cache in ui/screens/ui_chat.c holds up to 30 decrypted messages in PSRAM, totaling approximately 120 KB. These messages are loaded from the AES-256-GCM encrypted SD card for display in the chat sliding window. The cache is never zeroed - not on chat screen exit, not on contact switch, not on display timeout, not on shutdown.

An attacker with JTAG access to a powered device can scan the PSRAM region and read all 30 cached messages in plaintext. If the device has multiple contacts, switching between them leaves the previous contact's messages in the cache alongside the new contact's messages until the cache slots are overwritten by new data.

SEC-04: No Memory Wipe on Display Timeout (HIGH)

When the display times out (backlight off, apparent "lock" state), no memory clearing occurs. The device appears locked but retains plaintext messages in PSRAM and LVGL label memory. An attacker who gains physical access to a "locked" device has the same access to cached messages as an unlocked device.


Sensitive Data Locations in the ESP32-S3 Memory Map

Understanding where sensitive data lives is prerequisite to protecting it:

LocationSizeContainsCurrent Protection
s_msg_cache (PSRAM)~120 KB (30 messages)Decrypted message text, sender info, timestampsNone (SEC-01)
LVGL label buffers (internal pool)Variable (within 64 KB pool)Currently displayed message text (5 bubbles)None (SEC-04)
ratchet_state_t array (PSRAM)~68 KB (128 contacts)Active ratchet keys, chain keys, DH keysOverwritten on ratchet advance
NVS read buffers (stack)VariableRatchet keys during load from NVSStack frame reuse (unreliable)
TLS session context (internal SRAM)~40 KBTLS session keys, negotiation stateManaged by mbedTLS
Frame pool (PSRAM)64 KB (4 x 16 KB)Raw SMP frames during processingsodium_memzero() on release (correct)
tx_ring_buffer / rx_ring_bufferVariableEncrypted SMP frames in transitRing buffer overwrites (unreliable)

The frame pool is the only sensitive buffer that is currently correctly zeroed on release, using sodium_memzero(). This was implemented correctly from the beginning.


The Fix: sodium_memzero() at Every Transition

What sodium_memzero() Does

sodium_memzero(void *ptr, size_t len) is libsodium's secure memory zeroing function. Unlike memset(), it is guaranteed not to be optimized away by the compiler (CWE-14: Compiler Removal of Code to Clear Buffers). It uses volatile writes or platform-specific barriers to ensure the zeroing actually happens.

SimpleGo already links libsodium (used for NaCl encryption layers 2 and 3). No additional dependency is needed. The function is available in every source file via #include <sodium.h>.

SEC-01 Fix: Zeroing s_msg_cache

The fix adds sodium_memzero() calls at four transition points in ui/screens/ui_chat.c:

On chat screen cleanup (existing function ui_chat_cleanup()):

void ui_chat_cleanup(void) {
// Zero all cached messages before freeing
if (s_msg_cache != NULL) {
sodium_memzero(s_msg_cache, sizeof(msg_cache_entry_t) * MSG_CACHE_SIZE);
}
// Null static LVGL pointers (existing code)
s_chat_container = NULL;
s_input_textarea = NULL;
// ... other pointer nulling
}

On contact switch (when loading a different conversation):

static void load_contact_messages(int contact_slot) {
// Zero previous contact's cached messages first
if (s_msg_cache != NULL) {
sodium_memzero(s_msg_cache, sizeof(msg_cache_entry_t) * MSG_CACHE_SIZE);
}
s_msg_cache_count = 0;
// Load new contact's messages from encrypted SD card
// ...
}

On display timeout (screen lock trigger):

void simplego_screen_lock(void) {
// Zero message cache
if (s_msg_cache != NULL) {
sodium_memzero(s_msg_cache, sizeof(msg_cache_entry_t) * MSG_CACHE_SIZE);
}
s_msg_cache_count = 0;
// Clear LVGL labels (see SEC-04 fix below)
// Navigate to lock screen
ui_manager_navigate(SCREEN_LOCK);
}

On device shutdown / deep sleep:

void simplego_secure_shutdown(void) {
// Zero ALL sensitive RAM before power down
if (s_msg_cache != NULL) {
sodium_memzero(s_msg_cache, sizeof(msg_cache_entry_t) * MSG_CACHE_SIZE);
}
// Zero ratchet state array
extern ratchet_state_t *ratchet_states;
if (ratchet_states != NULL) {
sodium_memzero(ratchet_states, sizeof(ratchet_state_t) * 128);
}
// Zero frame pool
// ... (already done on individual frame release, belt-and-suspenders here)
// Enter deep sleep or power off
}

SEC-04 Fix: LVGL Label Clearing

LVGL labels containing message text must be explicitly cleared before screen navigation. Simply destroying the LVGL screen is not sufficient because LVGL may reuse the memory pool region without zeroing it.

static void clear_chat_bubbles(void) {
for (int i = 0; i < VISIBLE_BUBBLE_COUNT; i++) {
if (s_bubble_labels[i] != NULL) {
lv_label_set_text(s_bubble_labels[i], ""); // Overwrite label text buffer
// Note: LVGL internally reallocs the text buffer.
// The old buffer content remains in the LVGL pool until reused.
// This is a known LVGL limitation - no way to securely zero freed pool memory.
}
}
}

LVGL pool limitation: LVGL's internal memory pool does not support secure zeroing of freed allocations. When a label's text is changed, the old text buffer is freed back to the pool but not zeroed. The text content persists until the pool region is reused by another allocation. For complete protection, the entire LVGL pool would need to be zeroed on screen lock, which would require reinitializing LVGL - a disruptive operation. For Class 1, the combination of label text clearing (overwrites the live buffer) and screen destruction (releases pool memory) provides reasonable protection. Full pool zeroing is planned for Class 3 (Vault model with supercapacitor-backed zeroization).

Verification Test

After implementation, this test procedure verifies the fix:

1. Send/receive 10+ messages in a conversation
2. Trigger display timeout (or manually call simplego_screen_lock())
3. Connect JTAG debugger to powered device
4. Dump PSRAM region: dump memory 0x3C000000 0x800000
5. Search dump for any sent/received message text
6. Expected result: ZERO occurrences of message text in PSRAM
7. Search LVGL pool region for message text
8. Expected result: ZERO occurrences (or only partial fragments from pool reuse)

Additional Zeroing Points

Beyond SEC-01 and SEC-04, the following zeroing operations should be implemented for defense-in-depth:

NVS key material after load

When ratchet keys are loaded from NVS into the ratchet state array, the intermediate buffers used during the NVS read should be zeroed:

// After loading ratchet state from NVS
uint8_t key_buffer[32];
nvs_get_blob(handle, "ratchet_key", key_buffer, &length);
memcpy(&ratchet_states[slot].chain_key, key_buffer, 32);
mbedtls_platform_zeroize(key_buffer, sizeof(key_buffer)); // Zero the temp buffer

Decrypted SMP frame payload

After an SMP frame is decrypted (all three layers) and the JSON payload is parsed, the decrypted plaintext buffer should be zeroed before the frame is returned to the pool:

// After parsing the decrypted message content
extract_message_text(decrypted_payload, &parsed_message);
sodium_memzero(decrypted_payload, payload_length); // Zero plaintext
// Frame pool release already calls sodium_memzero (existing, correct)

WiFi credentials in transit

WiFi passwords are passed as strings to esp_wifi_connect(). After the connection is established, any local copies should be zeroed. Note that the WiFi driver internally stores credentials - SimpleGo cannot zero the driver's internal copy, but can zero its own buffers.


Future Class 2/3 Features: Duress PIN and Dead Man's Switch

The following features were discussed in Session 27 and Session 36 for the Vault model. They extend the runtime memory protection concept from "zero on transition" to "zero on demand" and "zero on timeout."

Duress PIN

A secondary unlock code that appears to function normally but triggers immediate and irreversible key destruction:

User enters Duress PIN on lock screen
--> Device appears to unlock normally
--> In background: sodium_memzero() on ALL sensitive RAM
--> NVS partition erased and reinitialized (empty)
--> SD card encryption keys zeroed (history becomes unrecoverable)
--> Ratchet states destroyed (all contacts must be re-established)
--> Device shows empty contact list (plausible: "new device" appearance)

The Duress PIN is stored as a separate hash in NVS (not in plaintext). The device gives no visual indication that the Duress PIN was entered rather than the real PIN. Forensic analysis of the device afterward shows a legitimately empty device with a functioning vault - indistinguishable from a freshly provisioned unit.

Dead Man's Switch

Automatic key destruction after a configurable period of inactivity:

Last successful unlock: timestamp stored in NVS
Boot sequence checks: (current_time - last_unlock) > configured_timeout?
If YES:
--> Same destruction sequence as Duress PIN
--> Device boots to "welcome / setup" screen
If NO:
--> Normal boot

The timeout is configurable (24 hours to 30 days). The timer resets on every successful unlock with the real PIN. The Dead Man's Switch protects against the scenario where a device is seized and held in evidence storage without being powered on - the first boot after the timeout period triggers destruction.

Implementation note: This requires reliable timekeeping across power cycles. The ESP32-S3's RTC (Real-Time Clock) maintains time during deep sleep if the RTC power domain is active. For Class 3, a coin cell battery backup for the RTC is planned to ensure time continuity even during complete power removal.

Wipe Code

A separate code (distinct from both the real PIN and the Duress PIN) that triggers a full device wipe including firmware re-flash to factory state. Unlike the Duress PIN which tries to be invisible, the Wipe Code is an explicit "destroy everything" action for when the user knows the device is compromised and does not need plausible deniability.

Supercapacitor-Backed Zeroization (Class 3 Only)

For Class 3 (Vault), a supercapacitor provides enough energy to complete a full RAM zeroing sequence even if external power is suddenly removed. The target is sub-100-nanosecond zeroization of all sensitive RAM regions. This protects against the "unplug and freeze" cold boot attack variant where the attacker removes power and immediately cools the RAM chips to slow data decay.

The ESP32-S3's SRAM uses static RAM cells (not DRAM), which have faster data decay than DRAM but are still readable for a brief window after power removal (milliseconds at room temperature, longer if cooled). The supercapacitor ensures the zeroing code executes before this window expires.


The EncroChat Lesson

In Session 36, we explicitly discussed the EncroChat forensic failure. When Dutch and French law enforcement compromised EncroChat's servers in 2020, they were able to recover "deleted" data from seized devices because the deletion was not cryptographically secure - data was marked as deleted in the filesystem but not actually overwritten. Forensic tools recovered message content, contact lists, and media that users believed they had deleted.

SimpleGo's approach is the opposite: sodium_memzero() physically overwrites every byte with zeros. NVS erase followed by reinitialization creates a fresh encrypted partition. SD card encryption keys, once zeroed from RAM, render the encrypted history permanently unrecoverable (the AES-256-GCM encrypted data on the SD card becomes indistinguishable from random noise without the key).

The difference between "deleted" and "destroyed" is the difference between EncroChat's failure and SimpleGo's design goal.


Implementation Priority

TaskSEC IDPriorityDependencyEstimated Effort
sodium_memzero() on s_msg_cache at all transitionsSEC-01CriticalNone2-3 hours
LVGL label clearing on screen lockSEC-04HighSEC-011-2 hours
NVS temp buffer zeroingNewMediumNone1 hour
Decrypted payload zeroingNewMediumNone1 hour
Screen lock with navigation to lock screenSEC-04HighSEC-013-4 hours
Duress PINFutureFor Class 2/3Screen lock implemented1-2 days
Dead Man's SwitchFutureFor Class 2/3RTC battery backup1-2 days
Supercapacitor zeroizationFutureFor Class 3 onlyCustom PCBHardware design

SEC-01 and SEC-04 are Session 44 deliverables. The remaining items are documented here for completeness and will be implemented in future sessions as their dependencies are met.


References

SourceDescription
libsodium documentation: sodium_memzeroSecure memory zeroing guarantees
CWE-14Compiler Removal of Code to Clear Buffers
Session 42 fix (SEC-03)mbedtls_platform_zeroize replacing memset in smp_storage.c
Session 27 discussionRAM protection options, Duress PIN concept
Session 36 discussionSecure deletion, EncroChat comparison, Dead Man's Switch
EncroChat takedown (2020)Europol: Operation Venetic forensic recovery

SimpleGo - IT and More Systems, Recklinghausen First native C implementation of the SimpleX Messaging Protocol AGPL-3.0 (Software) | CERN-OHL-W-2.0 (Hardware)