mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-30 18:25:21 -04:00
Removed: - MessageStore::addFromPacket and addFromString, superseded by tryAddFromPacket - GeoCoord rangeRadiansToMeters, distanceTo, bearingTo - Router::rawSend, declared virtual with no override and no caller - ContentHandler handleHotspot, handleFs, handleAdminSettings, handleAdminSettingsApply, handleDeleteFsContent and their commented route registrations, plus the now unreachable htmlDeleteDir and the handleUpdateFs declaration that had no definition - ContentHelper replaceAll - OnScreenKeyboardModule popup chain: showPopup, clearPopup, drawPopup, drawPopupOverlay and their state, unreachable since the frame based UI was replaced by baseUI - DebugRenderer drawDebugInfoTrampoline, drawDebugInfoSettingsTrampoline and the orphaned drawFrameSettings - NodeListRenderer calculateMaxScroll, drawColumns and a stale extern haveGlyphs declaration with no definition - UIRenderer::haveGlyphs, Screen::blink, NotificationRenderer::showKeyboardMessagePopupWithTitle, VirtualKeyboard::getInputText - InkHUD touchNavLeft, touchNavRight, Applet::getActiveNodeCount, ThreadedMessageApplet::saveMessagesToFlash - TwoButton::setHandlerUp, TwoButtonExtended setHandlerUp, setJoystickDownHandlers, setJoystickUpHandlers - CannedMessageModule LaunchRepeatDestination, isCharInputAllowed, hasMessages - TrafficManagementModule resetStats, recordRouterHopPreserved, saturatingIncrement - UnitConversions::MetersPerSecondToMilesPerHour - EncryptedStorage getSessionRemainingSeconds - BMI270Sensor::writeRegisters, GPS::hasFlow, FSCommon copyFile, SerialConsole consolePrintf, buzz playLongPressLeadUp, memGet displayPercentHeapFree
284 lines
12 KiB
C++
284 lines
12 KiB
C++
#pragma once
|
|
|
|
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
|
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
|
|
/**
|
|
* Encrypted storage layer for lockdown builds.
|
|
*
|
|
* Key hierarchy:
|
|
* FICR eFuse IDs + passphrase -> SHA-256 -> KEK (16 bytes, never stored)
|
|
* KEK wraps -> DEK (Data Encryption Key, 16 bytes, random, stored in /prefs/.dek)
|
|
* DEK encrypts -> proto files via AES-128-CTR + HMAC-SHA256(DEK)
|
|
* (the DEK file itself is HMAC'd with KEK; only proto files use HMAC(DEK))
|
|
*
|
|
* Ephemeral KEK (FICR-only, no passphrase) -> wraps DEK in the unlock token only.
|
|
* Unlock token (/prefs/.unlock_token) - valid for N boots and/or M hours after provisioning.
|
|
*
|
|
* Boot flow:
|
|
* 1. initLocked() - derive ephemeral KEK, try unlock token
|
|
* 2a. Token valid → UNLOCKED (DEK in RAM, all encrypted files accessible)
|
|
* 2b. No token, no DEK file → NOT PROVISIONED (operator must call provisionPassphrase)
|
|
* 2c. No token, DEK file exists → LOCKED (operator must call unlockWithPassphrase)
|
|
* 3. provisionPassphrase() / unlockWithPassphrase() complete the unlock
|
|
* 4. lockNow() immediately invalidates the token and zeroes the DEK from RAM
|
|
*
|
|
* On-disk formats carry a 4-byte magic but no version byte: this layer has
|
|
* never shipped, so there are no older files to stay compatible with. The
|
|
* magic alone identifies each format; a corrupt or foreign file fails the
|
|
* magic check (and, for the keyed formats, the HMAC).
|
|
*
|
|
* Encrypted proto file format ("MENC"):
|
|
* [4B] Magic 0x4D454E43 ("MENC")
|
|
* [13B] Nonce (random per write)
|
|
* [4B] Original plaintext length (LE uint32)
|
|
* [NB] AES-128-CTR ciphertext
|
|
* [32B] HMAC-SHA256(DEK, magic || nonce || plaintext_len || ciphertext)
|
|
* Total overhead: 53 bytes per file.
|
|
*
|
|
* DEK file format ("MDEK"):
|
|
* [4B] Magic 0x4D44454B ("MDEK")
|
|
* [13B] Nonce (random per write)
|
|
* [16B] AES-128-CTR(KEK, nonce, DEK)
|
|
* [32B] HMAC-SHA256(KEK, "mdek-auth" || nonce || encrypted_DEK)
|
|
* Total: 65 bytes.
|
|
*
|
|
* Unlock token format ("UTOK"):
|
|
* [4B] Magic 0x55544F4B ("UTOK")
|
|
* [13B] Nonce (random per write)
|
|
* [16B] AES-128-CTR(ephemeralKEK, nonce, DEK)
|
|
* [1B] boots_remaining
|
|
* [4B] valid_until_epoch (LE uint32, 0 = no time limit)
|
|
* [4B] session_max_seconds (LE uint32, 0 = no session limit)
|
|
* [4B] monotonic_counter (LE uint32) - see /prefs/.tokmono
|
|
* [32B] HMAC-SHA256(ephemeralKEK, all above fields)
|
|
* Total: 78 bytes.
|
|
*
|
|
* Monotonic counter file (/prefs/.tokmono):
|
|
* [4B] highest counter ever issued (LE uint32)
|
|
* [32B] HMAC-SHA256(ephemeralKEK, "tokmono-auth" || counter)
|
|
* Total: 36 bytes.
|
|
* readAndConsumeToken rejects any token whose body counter is less
|
|
* than the persisted value, defeating a flash-write-only attacker who
|
|
* tries to restore an older (e.g. higher-boot-count) token.
|
|
*
|
|
* Backoff state file (/prefs/.backoff):
|
|
* [1B] attempts
|
|
* [1B] bootsSinceFail
|
|
* [4B] lastFailEpoch (LE uint32)
|
|
* [32B] HMAC-SHA256(ephemeralKEK, "backoff-auth" || body)
|
|
* Total: 38 bytes. Missing / short / MAC-fail are all treated as
|
|
* max-attempts so a tamper-delete can only increase the wait.
|
|
*/
|
|
|
|
namespace EncryptedStorage
|
|
{
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File format constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static constexpr uint32_t MAGIC = 0x4D454E43; // "MENC" - encrypted proto files
|
|
static constexpr size_t NONCE_SIZE = 13;
|
|
static constexpr size_t HMAC_SIZE = 32;
|
|
static constexpr size_t HEADER_SIZE = 4 + NONCE_SIZE + 4; // magic+nonce+plaintext_len
|
|
static constexpr size_t OVERHEAD = HEADER_SIZE + HMAC_SIZE; // 53 bytes
|
|
static constexpr size_t AES_KEY_SIZE = 16;
|
|
static constexpr size_t AES_BLOCK_SIZE = 16;
|
|
|
|
static constexpr uint32_t DEK_MAGIC = 0x4D44454B; // "MDEK"
|
|
static constexpr size_t DEK_SIZE = 4 + NONCE_SIZE + AES_KEY_SIZE + HMAC_SIZE; // 65 bytes
|
|
|
|
static constexpr uint32_t TOKEN_MAGIC = 0x55544F4B; // "UTOK"
|
|
// magic(4) + nonce(NONCE_SIZE=13) + encDek(AES_KEY_SIZE=16)
|
|
// + bootsRemaining(1) + validUntilEpoch(4) + sessionMaxSeconds(4)
|
|
// + monotonicCounter(4) = 46 bytes
|
|
static constexpr size_t TOKEN_BODY_SIZE = 4 + NONCE_SIZE + AES_KEY_SIZE + 1 + 4 + 4 + 4;
|
|
static constexpr size_t TOKEN_TOTAL_SIZE = TOKEN_BODY_SIZE + HMAC_SIZE; // 78 bytes
|
|
|
|
static constexpr uint8_t TOKEN_DEFAULT_BOOTS = 50;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Passphrase-gated boot API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Boot-time init: derive ephemeral KEK and attempt to unlock via the stored token.
|
|
* Sets isUnlocked()=true if the token is present and valid.
|
|
* Must be called after fsInit(), before loadFromDisk().
|
|
*/
|
|
void initLocked();
|
|
|
|
/**
|
|
* First-time provisioning: set the device passphrase, generate a fresh DEK,
|
|
* save it wrapped with the passphrase-mixed KEK, and create an unlock token.
|
|
*
|
|
* @param passphrase Raw passphrase bytes (need not be NUL-terminated)
|
|
* @param passphraseLen Length in bytes (1-32; matches the proto private_key field size)
|
|
* @param bootsRemaining Token valid for this many boots (default TOKEN_DEFAULT_BOOTS)
|
|
* @param validUntilEpoch Absolute Unix timestamp after which token expires (0 = no time limit)
|
|
* @param sessionMaxSeconds Per-boot uptime cap on the unlocked session (0 = no cap).
|
|
* Persists in the token; cold-boot via token inherits the same cap.
|
|
* @return true on success
|
|
*/
|
|
bool provisionPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8_t bootsRemaining = TOKEN_DEFAULT_BOOTS,
|
|
uint32_t validUntilEpoch = 0, uint32_t sessionMaxSeconds = 0);
|
|
|
|
/**
|
|
* Unlock after token expiry (or after lockNow()): derive KEK from passphrase,
|
|
* unwrap the stored DEK, and create a fresh unlock token.
|
|
*
|
|
* @param passphrase Raw passphrase bytes
|
|
* @param passphraseLen Length in bytes (1-32; matches the proto private_key field size)
|
|
* @param bootsRemaining New token valid for this many boots
|
|
* @param validUntilEpoch Absolute Unix timestamp after which token expires (0 = no time limit)
|
|
* @param sessionMaxSeconds Per-boot uptime cap on the unlocked session (0 = no cap).
|
|
* Persists in the new token; reboot starts a fresh session window.
|
|
* @return true if passphrase was correct and DEK is now loaded
|
|
*/
|
|
bool unlockWithPassphrase(const uint8_t *passphrase, size_t passphraseLen, uint8_t bootsRemaining = TOKEN_DEFAULT_BOOTS,
|
|
uint32_t validUntilEpoch = 0, uint32_t sessionMaxSeconds = 0);
|
|
|
|
/**
|
|
* Immediately lock: delete the unlock token and zero the DEK from RAM.
|
|
* The device will require the passphrase on the next boot (or connection).
|
|
*/
|
|
void lockNow();
|
|
|
|
/**
|
|
* Wipe in-RAM key material WITHOUT touching flash. Designed to be called
|
|
* from fault / watchdog handlers before any coredump or RAM-snapshot path
|
|
* runs, so the DEK / KEK / ephemeralKEK don't end up in crash reports.
|
|
*
|
|
* Safe to call from interrupt context: does not take any FreeRTOS locks
|
|
* and does not log. Equivalent to the RAM-wipe half of lockNow() with the
|
|
* token file left intact (so the device can still auto-unlock on the
|
|
* next normal boot via the token).
|
|
*/
|
|
void secureWipeKeys();
|
|
|
|
/** Returns true if the DEK file exists (device has been provisioned). */
|
|
bool isProvisioned();
|
|
|
|
/** Returns true if the DEK is loaded in RAM (device is unlocked). */
|
|
bool isUnlocked();
|
|
|
|
/**
|
|
* Returns true when lockdown is active on this device (== isProvisioned()).
|
|
* The runtime gate for all access-control / redaction / locked-boot
|
|
* behavior. A lockdown-CAPABLE build that has not been provisioned (or has
|
|
* been disabled) returns false here and runs like stock firmware.
|
|
*/
|
|
bool isLockdownActive();
|
|
|
|
/**
|
|
* Decrypt one encrypted file back to plaintext in place (the inverse of
|
|
* migrateFile). Idempotent: a file that is already plaintext returns true
|
|
* without touching it. Requires isUnlocked() (DEK in RAM). Used by the
|
|
* lockdown-disable flow; NodeDB drives the per-file iteration since it owns
|
|
* the proto filenames.
|
|
*
|
|
* @return true on success or if the file was already plaintext.
|
|
*/
|
|
bool migrateFileToPlaintext(const char *filename);
|
|
|
|
/**
|
|
* Final step of disabling lockdown: remove the DEK, unlock token,
|
|
* monotonic-counter, and backoff files, then wipe the in-RAM keys.
|
|
* Call this ONLY after every encrypted file has been reverted to plaintext
|
|
* via migrateFileToPlaintext() - deleting the DEK first would make any
|
|
* remaining encrypted file permanently unreadable. After this returns,
|
|
* isProvisioned()/isLockdownActive() are false. APPROTECT is NOT touched
|
|
* (its lockout is permanent on silicon where it engaged).
|
|
*/
|
|
void removeLockdownArtifacts();
|
|
|
|
/**
|
|
* Returns a short string describing why the device is locked (set during initLocked()).
|
|
* Useful for client-side diagnostics. Examples:
|
|
* "token_missing" - no unlock token file found
|
|
* "token_wrong_size" - token file exists but is corrupt
|
|
* "token_bad_magic" - wrong magic bytes
|
|
* "token_hmac_fail" - HMAC mismatch (tampered or wrong device)
|
|
* "token_boots_zero" - boot count exhausted
|
|
* "token_expired" - TTL expired
|
|
* "token_dek_fail" - DEK decrypt failed
|
|
* "not_provisioned" - no DEK file; needs first provisioning
|
|
* "ok" - unlocked successfully via token
|
|
*/
|
|
const char *getLockReason();
|
|
|
|
/** Boots remaining in the current unlock token (0 if not unlocked or last boot consumed). */
|
|
uint8_t getBootsRemaining();
|
|
|
|
/** Unix epoch at which the current unlock token expires (0 = no time limit or not unlocked). */
|
|
uint32_t getValidUntilEpoch();
|
|
|
|
/** Seconds remaining before next passphrase attempt is allowed (0 = can attempt now). */
|
|
uint32_t getBackoffSecondsRemaining();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Uptime-based session limit
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Independent of the wall-clock and boot-count TTLs on the token. Caps how
|
|
// long a single auto-unlocked session can keep storage unlocked, measured
|
|
// in firmware millis() since the unlock. Reboot resets the counter, so an
|
|
// attacker who power-cycles to dodge the timer still burns a boot count.
|
|
// Combined hard cap: bootsRemaining * sessionMaxSeconds total exposure.
|
|
//
|
|
// Uptime (not wall-clock) by design: an attacker pulling the RTC backup
|
|
// battery and spoofing GPS to roll the clock back cannot defeat this -
|
|
// we never read getValidTime() for session enforcement. The check only
|
|
// engages when sessionMaxSeconds is non-zero, so 0 = unlimited (the
|
|
// existing token-only behavior, suitable for tower/infra nodes).
|
|
|
|
/// Start a session timer. Called after a successful passphrase unlock.
|
|
/// maxSeconds = 0 disables the timer for this session.
|
|
void setSession(uint32_t maxSeconds);
|
|
|
|
/// True if a session timer is set and has elapsed. Idempotent - call
|
|
/// from the main loop on a low-frequency tick.
|
|
bool isSessionExpired();
|
|
|
|
/// Consume one boot from the on-flash token (the rollback ledger) and
|
|
/// re-arm the session timer in place - no reboot. Called from the main
|
|
/// loop when a session expires AND there is still budget. Decrements
|
|
/// bootsRemaining on flash (delete-and-rewrite of the token file, or
|
|
/// outright deletion if the new count is 0). Returns the new boot
|
|
/// count. Caller should check getBootsRemaining() == 0 before this
|
|
/// call: when zero, the budget is exhausted and a hard lock + reboot
|
|
/// should be issued instead.
|
|
uint8_t consumeSessionBoot();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Encrypted file I/O (require isUnlocked())
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Returns true if the file starts with the MENC magic bytes. */
|
|
bool isEncrypted(const char *filename);
|
|
|
|
/**
|
|
* Read and decrypt a file into outBuf.
|
|
* Returns true on success; sets outLen to the plaintext byte count.
|
|
*/
|
|
bool readAndDecrypt(const char *filename, uint8_t *outBuf, size_t outBufSize, size_t &outLen);
|
|
|
|
/**
|
|
* Encrypt plaintext and write to filename.
|
|
* Returns true on success.
|
|
*/
|
|
bool encryptAndWrite(const char *filename, const uint8_t *plaintext, size_t plaintextLen, bool fullAtomic = false);
|
|
|
|
/**
|
|
* Migrate a plaintext proto file to encrypted format in-place.
|
|
* Returns true on success or if already encrypted.
|
|
*/
|
|
bool migrateFile(const char *filename);
|
|
|
|
} // namespace EncryptedStorage
|
|
|
|
#endif // MESHTASTIC_ENCRYPTED_STORAGE
|