diff --git a/.gitignore b/.gitignore index 9df0ee07..8c7b8bf2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .vscode tmp path_store* +.DS_Store \ No newline at end of file diff --git a/examples/identity_vault/README.md b/examples/identity_vault/README.md new file mode 100644 index 00000000..4e4184de --- /dev/null +++ b/examples/identity_vault/README.md @@ -0,0 +1,19 @@ +# simple encryption test + +This allows encryption-at-rest for sent-messages and identity. + +I only have a CardputerADV for testing, so I do it like this: + +```sh +# compile and upload +pio run -t upload -e cardputeradv + +# connect to serial +pio device monitor -e cardputeradv +``` + +Basic idea is you create a password-protected identity on SD card, then on reboot it will ask you for that password and still work with same identity. Various tests are performed to validate that idenity is working. The initial password-check is pretty slow to help defend against offline brute-force attacks, but it's hash-verified, and after that your identity is used (from memory) for full-speed operation, as it normally would be. + +I also included [identity_tool](./identity_tool.py) for offline testing with the files. + +The password for test files is `1234`. \ No newline at end of file diff --git a/examples/identity_vault/identity_tool.py b/examples/identity_vault/identity_tool.py new file mode 100755 index 00000000..5d384ec2 --- /dev/null +++ b/examples/identity_vault/identity_tool.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +""" +identity_tool.py — Inspect microReticulum identity and message files on a PC. + +Decrypts files written by lib/password (identity) and lib/encrypted_store +(messages) using the same algorithms as the device, so you can verify files +off-device without needing the hardware. + +Usage: + python identity_tool.py identity.bin + python identity_tool.py identity.bin /msgs/last.enc [more_files ...] + +Install dependencies: + pip install cryptography + +Inspect identity only + ./identity_tool.py test/identity.bin + +Inspect identity + decrypt message files + ./identity_tool.py test/identity.bin test/msgs/last.enc + +Also print raw key bytes (careful) + ./identity_tool.py test/identity.bin --show-keys +""" + +import sys +import os +import hashlib +import hmac as _hmac +import getpass +import argparse + +try: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +except ImportError: + print("Missing dependency. Run: pip install cryptography", file=sys.stderr) + sys.exit(1) + + +# ── constants (must match password.h and encrypted_store.h) ────────────────── + +PBKDF2_ITERATIONS = 100000 +PASSWORD_FILE_OVERHEAD = 65 # version(1) + salt(16) + IV(16) + HMAC(32) +ENCSTORE_FILE_OVERHEAD = 49 # version(1) + IV(16) + HMAC(32) + + +# ── primitives ──────────────────────────────────────────────────────────────── + +def _pbkdf2_64(password: str, salt: bytes) -> bytes: + """ + Derive 64 bytes via PBKDF2-HMAC-SHA256 with PBKDF2_ITERATIONS iterations. + Python's pbkdf2_hmac produces two back-to-back 32-byte blocks (counter 1 + and counter 2), matching the C++ pbkdf2_block() calls in password.cpp. + returned[0:32] → AES-256-CTR key + returned[32:64] → HMAC-SHA256 key + """ + return hashlib.pbkdf2_hmac( + 'sha256', + password.encode('utf-8'), + salt, + PBKDF2_ITERATIONS, + dklen=64, + ) + + +def _aes_ctr(key: bytes, iv: bytes, data: bytes) -> bytes: + """AES-256-CTR — same operation for encrypt and decrypt.""" + cipher = Cipher(algorithms.AES(key), modes.CTR(iv)) + ctx = cipher.decryptor() + return ctx.update(data) + ctx.finalize() + + +def _hmac_sha256(key: bytes, *parts: bytes) -> bytes: + """HMAC-SHA256 over the concatenation of all parts.""" + h = _hmac.new(key, digestmod='sha256') + for p in parts: + h.update(p) + return h.digest() + + +def _ct_equal(a: bytes, b: bytes) -> bool: + """Constant-time comparison — avoids timing leaks on HMAC verification.""" + return _hmac.compare_digest(a, b) + + +# ── identity file (lib/password) ────────────────────────────────────────────── + +def open_identity(path: str, password: str) -> bytes: + """ + Decrypt a lib/password identity file. + + File layout: version(1) + salt(16) + IV(16) + ciphertext(N) + HMAC-SHA256(32) + + Returns the raw plaintext bytes (64-byte Reticulum private key) on success. + Raises ValueError if the file is missing content, uses an unknown version, + or fails HMAC verification (wrong password or corrupted/tampered file). + """ + raw = open(path, 'rb').read() + + if len(raw) <= PASSWORD_FILE_OVERHEAD: + raise ValueError(f"File is too small ({len(raw)} bytes) to be a valid identity file") + + plaintext_len = len(raw) - PASSWORD_FILE_OVERHEAD + + version = raw[0:1] + salt = raw[1:17] + iv = raw[17:33] + ct = raw[33:33 + plaintext_len] + mac_stored = raw[33 + plaintext_len:] + + if version[0] != 0x01: + raise ValueError(f"Unsupported file version {version[0]:#04x}") + + keys = _pbkdf2_64(password, salt) + aes_key = keys[0:32] + hmac_key = keys[32:64] + + # HMAC covers the entire file except the tag itself + mac_expected = _hmac_sha256(hmac_key, version, salt, iv, ct) + if not _ct_equal(mac_expected, mac_stored): + raise ValueError("Authentication failed — wrong password or corrupted file") + + return _aes_ctr(aes_key, iv, ct) + + +# ── identity hash (matches Identity::truncated_hash in Identity.h) ──────────── + +def compute_identity_hash(prv_bytes: bytes) -> bytes: + """ + Derive the 16-byte Reticulum identity hash from a 64-byte private key. + + Replicates: + truncated_hash(get_public_key()) + = SHA256(x25519_pub || ed25519_pub)[:16] + """ + x_pub = X25519PrivateKey.from_private_bytes(prv_bytes[0:32]) \ + .public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + ed_pub = Ed25519PrivateKey.from_private_bytes(prv_bytes[32:64]) \ + .public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + return hashlib.sha256(x_pub + ed_pub).digest()[:16] + + +# ── message file (lib/encrypted_store) ─────────────────────────────────────── + +def open_message(path: str, prv_bytes: bytes) -> bytes: + """ + Decrypt a lib/encrypted_store message file. + + File layout: version(1) + IV(16) + ciphertext(N) + HMAC-SHA256(32) + + Key derivation mirrors encrypted_store.cpp: + HKDF-SHA256(ikm=prv_bytes, salt=identity_hash, info=b'', length=64) + keys[0:32] → AES-256-CTR key + keys[32:64] → HMAC-SHA256 key + + Returns plaintext bytes on success. + Raises ValueError on wrong identity, corruption, or tampered file. + """ + raw = open(path, 'rb').read() + + if len(raw) <= ENCSTORE_FILE_OVERHEAD: + raise ValueError(f"File is too small ({len(raw)} bytes) to be a valid message file") + + plaintext_len = len(raw) - ENCSTORE_FILE_OVERHEAD + + version = raw[0:1] + iv = raw[1:17] + ct = raw[17:17 + plaintext_len] + mac_stored = raw[17 + plaintext_len:] + + if version[0] != 0x01: + raise ValueError(f"Unsupported file version {version[0]:#04x}") + + # HKDF with identity hash as salt, empty info — matches C++ hkdf() call. + # IKM is only the X25519 encryption key (first 32 bytes), matching C++: + # identity.encryptionPrivateKey() → _prv_bytes (X25519 only, not Ed25519) + id_hash = compute_identity_hash(prv_bytes) + hkdf = HKDF(algorithm=hashes.SHA256(), length=64, salt=id_hash, info=b'') + keys = hkdf.derive(prv_bytes[0:32]) + aes_key = keys[0:32] + hmac_key = keys[32:64] + + mac_expected = _hmac_sha256(hmac_key, version, iv, ct) + if not _ct_equal(mac_expected, mac_stored): + raise ValueError("Authentication failed — wrong identity or corrupted/tampered file") + + return _aes_ctr(aes_key, iv, ct) + + +# ── CLI ─────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Decrypt microReticulum lib/password identity files and " + "lib/encrypted_store message files.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python identity_tool.py identity.bin + python identity_tool.py identity.bin /Volumes/SD/msgs/last.enc + """, + ) + parser.add_argument('identity', + help="Path to the identity file created by lib/password") + parser.add_argument('messages', nargs='*', + help="Optional lib/encrypted_store message files to decrypt") + parser.add_argument('--show-keys', action='store_true', + help="Print raw private key bytes (handle with care)") + args = parser.parse_args() + + # ── load identity ───────────────────────────────────────────────────────── + password = getpass.getpass("Password: ") + try: + prv_bytes = open_identity(args.identity, password) + except (ValueError, OSError) as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + finally: + del password # drop from memory as soon as possible + + id_hash = compute_identity_hash(prv_bytes) + print(f"\nIdentity file: {args.identity}") + print(f" Status: OK") + print(f" Identity hash: {id_hash.hex()}") + print(f" Private key: {len(prv_bytes)} bytes") + if args.show_keys: + print(f" X25519 prv: {prv_bytes[0:32].hex()}") + print(f" Ed25519 prv: {prv_bytes[32:64].hex()}") + + # ── decrypt message files ───────────────────────────────────────────────── + for msg_path in args.messages: + print(f"\nMessage file: {msg_path}") + try: + plaintext = open_message(msg_path, prv_bytes) + print(f" Status: OK") + print(f" Size: {len(plaintext)} bytes") + try: + text = plaintext.decode('utf-8') + print(f" Content: {text!r}") + except UnicodeDecodeError: + print(f" Content: (binary) {plaintext.hex()}") + except (ValueError, OSError) as e: + print(f" Status: FAIL — {e}") + + # ── wipe key material ───────────────────────────────────────────────────── + # Python doesn't guarantee memory wiping, but this at least removes the + # reference so the GC can reclaim the memory sooner. + del prv_bytes + + +if __name__ == '__main__': + main() diff --git a/examples/identity_vault/platformio.ini b/examples/identity_vault/platformio.ini new file mode 100644 index 00000000..f3b5e753 --- /dev/null +++ b/examples/identity_vault/platformio.ini @@ -0,0 +1,94 @@ +; PlatformIO Project Configuration File +; +; encrypted_announce — password-protected identity + encrypted message storage +; +; Requires an SD card connected to the MCU (see SD_CS_PIN in main.cpp). +; This example is Arduino / ESP32 only — it depends on the SD, AES, CTR, +; SHA256, and RNG libraries from the Arduino ecosystem. + +[env] +monitor_speed = 115200 +upload_speed = 460800 +build_type = debug +build_flags = + -Wall + -Wno-missing-field-initializers + -Wno-format + -DUSTORE_USE_UNIVERSALFS +lib_deps = + ArduinoJson@^7.4.2 + MsgPack@^0.4.2 + https://github.com/attermann/Crypto.git + https://github.com/attermann/microStore.git + ;microStore=symlink://../../../microStore + microReticulum=symlink://../.. + password=symlink://../../lib/password + encrypted_store=symlink://../../lib/encrypted_store + +[env:ttgo-t-beam] +framework = arduino +platform = espressif32 +board = ttgo-t-beam +board_build.partitions = no_ota.csv +build_flags = + ${env.build_flags} + -Wextra + -DBOARD_ESP32 + -DMSGPACK_USE_BOOST=OFF +lib_deps = + ${env.lib_deps} +monitor_filters = esp32_exception_decoder + +[env:lilygo_tbeam_supreme] +framework = arduino +platform = espressif32 +board = t-beams3-supreme +board_build.partitions = no_ota.csv +build_flags = + ${env.build_flags} + -Wextra + -DBOARD_ESP32 + -DMSGPACK_USE_BOOST=OFF +lib_deps = + ${env.lib_deps} +monitor_filters = esp32_exception_decoder + +[env:ttgo-lora32-v21] +framework = arduino +platform = espressif32 +board = ttgo-lora32-v21 +board_build.partitions = no_ota.csv +build_flags = + ${env.build_flags} + -Wextra + -DBOARD_ESP32 + -DMSGPACK_USE_BOOST=OFF +lib_deps = + ${env.lib_deps} +monitor_filters = esp32_exception_decoder + +; this is what I have for testing +[env:cardputeradv] +platform = espressif32@6.7.0 +board = esp32-s3-devkitc-1 +board_build.partitions = no_ota.csv +framework = arduino +upload_protocol = esp-builtin +build_flags = + ${env.build_flags} + -DESP32S3 + -DBOARD_ESP32 + -DARDUINO_USB_MODE=1 + -DARDUINO_USB_CDC_ON_BOOT=1 + -Wextra + -DMSGPACK_USE_BOOST=OFF + -DSD_CS_PIN=12 + -DBUTTON_PIN=0 + -DSPI_SCK=40 + -DSPI_MISO=39 + -DSPI_MOSI=14 + -DLORA_CS=5 + -DSD_SPEED=40000000 +lib_deps = + ${env.lib_deps} +monitor_filters = esp32_exception_decoder diff --git a/examples/identity_vault/src/main.cpp b/examples/identity_vault/src/main.cpp new file mode 100644 index 00000000..536bef87 --- /dev/null +++ b/examples/identity_vault/src/main.cpp @@ -0,0 +1,337 @@ +// encrypted_announce example +// +// Offline test for password-protected identity and encrypted message storage. +// No network interface required. +// +// On first run (no /identity.bin on SD): +// - A new Reticulum identity is generated. +// - You are prompted for a password; the identity is saved to /identity.bin +// encrypted with that password via PBKDF2 + AES-256-CTR. +// +// On subsequent runs: +// - Enter your password; the same identity is restored from SD. +// +// After loading, three offline tests run automatically: +// 1. Announce — create an announce packet and validate it +// 2. Packet — encrypt a packet to the destination, decrypt it, verify payload +// 3. Storage — write a message encrypted to SD, read it back, verify it +// +// Button / 'r' over serial: re-run all tests. + +#include +#include +#ifdef SPI_SCK +#include +#endif + +#include "password.h" +#include "encrypted_store.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef BUTTON_PIN +#define BUTTON_PIN 38 +#endif + +#ifndef SD_CS_PIN +#define SD_CS_PIN 5 +#endif + +#ifndef SD_SPEED +#define SD_SPEED 4000000 +#endif + +const char* APP_NAME = "example_utilities"; +const char* IDENTITY_PATH = "/identity.bin"; +const char* MSG_PATH = "/msgs/last.enc"; + +// identity blob: prv_bytes(64) +#define IDENTITY_BLOB_LEN 64 +#define PASSWORD_BUF_LEN 64 + +const char* TEST_PAYLOAD = "The quick brown fox jumps over the lazy dog"; + +volatile bool run_tests_flag = false; + +// Shared state for the packet test callback +static RNS::Bytes _test_packet_data; +static bool _test_packet_received = false; +static void testPacketCallback(const RNS::Bytes& data, const RNS::Packet&) { + _test_packet_data = data; + _test_packet_received = true; +} + +microStore::FileSystem filesystem{microStore::Adapters::UniversalFileSystem()}; + +RNS::Reticulum reticulum({RNS::Type::NONE}); +RNS::Identity identity({RNS::Type::NONE}); +RNS::Destination destination({RNS::Type::NONE}); + +// ── helpers ─────────────────────────────────────────────────────────────────── + +static void pass(const char* label) { + Serial.print(" PASS "); + Serial.println(label); +} + +static void fail(const char* label, const char* reason = nullptr) { + Serial.print(" FAIL "); + Serial.print(label); + if (reason) { Serial.print(" — "); Serial.print(reason); } + Serial.println(); +} + +// Read a line from serial into `out` (max `maxlen-1` chars), echoing '*'. +// Returns true when at least one character was entered. +static bool readPasswordSerial(const char* prompt, char* out, size_t maxlen) { + Serial.print(prompt); + size_t len = 0; + for (;;) { + if (Serial.available()) { + char c = (char)Serial.read(); + if (c == '\n' || c == '\r') { + if (len > 0) break; + } else if (len < maxlen - 1) { + out[len++] = c; + Serial.print('*'); + } + } + } + out[len] = '\0'; + Serial.println(); + return len > 0; +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +// 1. Announce: sign an announce packet with the identity's Ed25519 key and +// verify the signature + destination hash binding are correct. +// This proves the identity keypair is intact and signing works. +static void test_announce() { + Serial.println("[ 1/3 announce signature ]"); + Serial.print(" identity: "); Serial.println(identity.hexhash().c_str()); + Serial.print(" destination: "); Serial.println(destination.hash().toHex().c_str()); + try { + RNS::Packet announce_packet = destination.announce( + RNS::bytesFromString("test"), false, {RNS::Type::NONE}, {}, false); + announce_packet.pack(); + Serial.print(" announce packet size: "); Serial.print(announce_packet.raw().size()); Serial.println(" bytes"); + if (RNS::Identity::validate_announce(announce_packet)) { + pass("Ed25519 signature + destination hash binding valid"); + } else { + fail("Ed25519 signature + destination hash binding valid", "signature check failed"); + } + } + catch (const std::exception& e) { + fail("announce", e.what()); + } +} + +// 2. Packet encrypt/decrypt: encrypt a packet with the destination's X25519 +// public key, reconstruct it from raw bytes (simulating receipt over the +// air), decrypt with the private key, and verify the plaintext is intact. +static void test_packet() { + Serial.println("[ 2/3 packet X25519 encrypt/decrypt ]"); + Serial.print(" plaintext ("); Serial.print(strlen(TEST_PAYLOAD)); Serial.println(" bytes):"); + Serial.print(" \""); Serial.print(TEST_PAYLOAD); Serial.println("\""); + _test_packet_received = false; + _test_packet_data = RNS::Bytes(); + destination.set_packet_callback(testPacketCallback); + try { + RNS::Packet send_packet(destination, RNS::bytesFromString(TEST_PAYLOAD)); + send_packet.pack(); + Serial.print(" encrypted packet size: "); Serial.print(send_packet.raw().size()); Serial.println(" bytes"); + + RNS::Packet recv_packet(send_packet.raw()); + recv_packet.unpack(); + destination.receive(recv_packet); // decrypts and fires callback + + if (_test_packet_received && + _test_packet_data.size() == strlen(TEST_PAYLOAD) && + memcmp(_test_packet_data.data(), TEST_PAYLOAD, _test_packet_data.size()) == 0) { + Serial.println(" decrypted payload matches original"); + pass("X25519 encrypt → decrypt round-trip"); + } else { + fail("X25519 encrypt → decrypt round-trip", + _test_packet_received ? "payload mismatch" : "callback not called"); + } + } + catch (const std::exception& e) { + fail("packet", e.what()); + } + destination.set_packet_callback(nullptr); +} + +// 3. SD encrypted storage: encrypt a message with the identity's X25519 +// private key via AES-256-CTR, write it to SD, read it back, and verify +// the plaintext matches. The file on SD is unreadable without the password. +static void test_storage() { + Serial.println("[ 3/3 SD encrypted storage ]"); + Serial.print(" writing to: "); Serial.println(MSG_PATH); + + if (!RNS::Utilities::OS::directory_exists("/msgs")) RNS::Utilities::OS::create_directory("/msgs"); + + const uint8_t* plain = (const uint8_t*)TEST_PAYLOAD; + size_t len = strlen(TEST_PAYLOAD); + + if (!encstore_write(MSG_PATH, identity, plain, len)) { + fail("write encrypted file", "encstore_write returned false"); + return; + } + Serial.print(" file size on SD: "); Serial.print(encstore_size(MSG_PATH) + 16); + Serial.println(" bytes (16-byte IV + ciphertext)"); + + uint8_t* buf = (uint8_t*)malloc(len); + if (!buf) { fail("read encrypted file", "malloc failed"); return; } + + bool ok = encstore_read(MSG_PATH, identity, buf, len); + if (ok && memcmp(buf, plain, len) == 0) { + Serial.println(" decrypted payload matches original"); + pass("AES-256-CTR write → read round-trip"); + } else { + fail("AES-256-CTR write → read round-trip", + ok ? "payload mismatch" : "encstore_read returned false"); + } + free(buf); +} + +static void run_all_tests() { + Serial.println("\n=== security tests ==="); + Serial.println(" payload: \"" + String(TEST_PAYLOAD) + "\""); + test_announce(); + test_packet(); + test_storage(); + Serial.println("=== done ===\n"); +} + +// ── RNS setup ───────────────────────────────────────────────────────────────── + +static void reticulum_setup() { + try { + reticulum = RNS::Reticulum(); + reticulum.transport_enabled(false); + reticulum.start(); + + destination = RNS::Destination(identity, RNS::Type::Destination::IN, + RNS::Type::Destination::SINGLE, + APP_NAME, "announcesample"); + destination.set_proof_strategy(RNS::Type::Destination::PROVE_ALL); + + INFOF("Identity hash: %s", identity.hexhash().c_str()); + } + catch (const std::exception& e) { + ERRORF("reticulum_setup: %s", e.what()); + } +} + +// ── Arduino entry points ────────────────────────────────────────────────────── + +void IRAM_ATTR userKey() { + run_tests_flag = true; +} + +void setup() { + Serial.begin(115200); + // Wait up to 3s for terminal; don't block forever on USB-CDC boards. + for (int i = 0; i < 300 && !Serial; i++) { delay(10); } + Serial.println("\nencrypted_announce offline test"); + + // ── SPI / SD hardware setup ─────────────────────────────────────────────── + // On boards where SD shares a SPI bus with other peripherals (e.g. LoRa), + // the bus and CS pins must be configured before microStore initialises SD. + // microStore::UniversalFileSystem::init() handles SD.begin() for standard + // pin layouts, so no explicit SD.begin() is needed in that case. +#ifdef LORA_CS + pinMode(LORA_CS, OUTPUT); digitalWrite(LORA_CS, HIGH); +#endif +#ifdef SPI_SCK + // Custom SPI bus — bring it up manually so SD.begin() can use the right pins. + pinMode(SD_CS_PIN, OUTPUT); digitalWrite(SD_CS_PIN, HIGH); + SPIClass spi(HSPI); + spi.begin(SPI_SCK, SPI_MISO, SPI_MOSI); + if (!SD.begin(SD_CS_PIN, spi, SD_SPEED)) { + Serial.println("SD init failed — check wiring and SD_CS_PIN"); + while (true) { delay(1000); } + } +#endif + + // ── filesystem init (must happen before any OS:: calls) ─────────────────── + filesystem.init(); + RNS::Utilities::OS::register_filesystem(filesystem); + + // DEBUG: wipe identity and stored messages so you can re-enrol with a new password. + // Comment these out once everything is working. + // RNS::Utilities::OS::remove_file(IDENTITY_PATH); + // RNS::Utilities::OS::remove_file(MSG_PATH); + // Serial.println("DEBUG: wiped identity and messages."); + + // ── button ──────────────────────────────────────────────────────────────── + pinMode(BUTTON_PIN, INPUT); + attachInterrupt(BUTTON_PIN, userKey, FALLING); + + RNS::loglevel(RNS::LOG_TRACE); + + // ── identity load / create ──────────────────────────────────────────────── + char password[PASSWORD_BUF_LEN] = {0}; + + uint8_t id_blob[IDENTITY_BLOB_LEN]; + + if (!RNS::Utilities::OS::file_exists(IDENTITY_PATH)) { + Serial.println("No identity file — generating new identity..."); + readPasswordSerial("Password: ", password, sizeof(password)); + RNS::Identity new_id; + memcpy(id_blob, new_id.get_private_key().data(), 64); + + if (!password_protect(IDENTITY_PATH, password, id_blob, sizeof(id_blob))) { + Serial.println("Failed to save identity."); + memset(password, 0, sizeof(password)); + while (true) { delay(1000); } + } + Serial.print("Identity saved to "); + Serial.println(IDENTITY_PATH); + identity = new_id; + } else { + while (true) { + readPasswordSerial("Password: ", password, sizeof(password)); + if (password_open(IDENTITY_PATH, password, id_blob, sizeof(id_blob))) { + break; + } + Serial.println("Wrong password or corrupt identity file."); + memset(password, 0, sizeof(password)); + } + identity = RNS::Identity(false); + identity.load_private_key(RNS::Bytes(id_blob, 64)); + Serial.println("Identity loaded."); + } + + memset(password, 0, sizeof(password)); + memset(id_blob, 0, sizeof(id_blob)); + + reticulum_setup(); + run_all_tests(); +} + +void loop() { + reticulum.loop(); + + if (run_tests_flag) { + run_all_tests(); + run_tests_flag = false; + } + + if (Serial.available()) { + char c = (char)Serial.read(); + if (c == 'r') run_all_tests(); + } +} diff --git a/examples/identity_vault/test/identity.bin b/examples/identity_vault/test/identity.bin new file mode 100755 index 00000000..ceaad775 Binary files /dev/null and b/examples/identity_vault/test/identity.bin differ diff --git a/examples/identity_vault/test/msgs/last.enc b/examples/identity_vault/test/msgs/last.enc new file mode 100755 index 00000000..9a7f9107 --- /dev/null +++ b/examples/identity_vault/test/msgs/last.enc @@ -0,0 +1 @@ +]/1+fV"Gp#IfVJs:u{ 开D{Ԝזo?J6.FI9]kF77tD_~);_>Rh \ No newline at end of file diff --git a/examples/lora_announce/variants/.DS_Store b/examples/lora_announce/variants/.DS_Store deleted file mode 100644 index 9aa8a6e5..00000000 Binary files a/examples/lora_announce/variants/.DS_Store and /dev/null differ diff --git a/examples/lora_transport/variants/.DS_Store b/examples/lora_transport/variants/.DS_Store deleted file mode 100644 index 9aa8a6e5..00000000 Binary files a/examples/lora_transport/variants/.DS_Store and /dev/null differ diff --git a/lib/encrypted_store/README.md b/lib/encrypted_store/README.md new file mode 100644 index 00000000..52e03c58 --- /dev/null +++ b/lib/encrypted_store/README.md @@ -0,0 +1,102 @@ +# encrypted_store + +Authenticated encrypted file storage for microReticulum messages on Arduino/ESP32 with an SD card. + +Encrypts a message blob using an identity's private key and writes it to a file. Reading back requires the same identity. Designed to protect sent/received Reticulum messages at rest — announcements and peer records are public and do not need this treatment. + +## Security design + +### Key derivation — HKDF-SHA256 + +Two independent 32-byte keys are derived from the identity's X25519 encryption private key using HKDF-SHA256 (the same key derivation primitive Reticulum uses for packet encryption): + +- **keys[0:32]** → AES-256-CTR encryption key +- **keys[32:64]** → HMAC-SHA256 authentication key + +The identity's **hash** is used as the HKDF salt, which binds the derived keys to this specific identity. A different identity — even one with a similarly shaped key — produces completely different derived keys. + +### Authenticated encryption + +The HMAC-SHA256 tag covers the version byte, IV, and ciphertext. It is verified with a **constant-time comparison** before any decryption happens. A wrong identity, corrupted file, or tampered ciphertext all fail here. No plaintext is ever produced from an unauthenticated file. + +### IV generation + +The 16-byte IV is generated via `Cryptography::random()` — the same entropy source Reticulum uses for all its own IV and nonce generation. + +### File format + +``` +┌──────────┬──────────┬──────────────┬─────────────────┐ +│ version │ IV │ ciphertext │ HMAC-SHA256 │ +│ 1 byte │ 16 bytes │ N bytes │ 32 bytes │ +└──────────┴──────────┴──────────────┴─────────────────┘ +Total overhead: 49 bytes beyond the plaintext. +``` + +- **version** — always `0x01`; allows future format changes to be detected +- **IV** — fresh random 16 bytes per write; AES-CTR initialisation vector +- **ciphertext** — AES-256-CTR encrypted plaintext +- **HMAC** — authenticates version + IV + ciphertext + +## API + +```cpp +#include "encrypted_store.h" + +// Encrypt `data` (len bytes) and write to `path` using `identity`'s key. +// Returns true on success. +bool encstore_write(const char* path, const RNS::Identity& identity, + const uint8_t* data, size_t len); + +// Decrypt the file at `path` into `data` (must be encstore_size(path) bytes). +// Returns true on success. +// Returns false if the file is missing, wrong size, or authentication fails +// (wrong identity or corrupted file). No plaintext is written on failure. +bool encstore_read(const char* path, const RNS::Identity& identity, + uint8_t* data, size_t len); + +// Returns the plaintext byte count stored at `path` (file_size - 49), +// or 0 if the file does not exist or is too small to be valid. +size_t encstore_size(const char* path); +``` + +The identity must have its **private key loaded** before calling these functions (use `password_open` from `lib/password` to restore a saved identity). + +## Usage example + +```cpp +#include "encrypted_store.h" + +// After loading identity via lib/password ... + +// Store an encrypted message +const char* msg = "hello reticulum"; +encstore_write("/msgs/001.enc", identity, + (const uint8_t*)msg, strlen(msg)); + +// Read it back +size_t sz = encstore_size("/msgs/001.enc"); +uint8_t* buf = (uint8_t*)malloc(sz + 1); +if (encstore_read("/msgs/001.enc", identity, buf, sz)) { + buf[sz] = '\0'; + Serial.println((char*)buf); +} +free(buf); +``` + +## Relationship to lib/password + +These two libraries cover different layers: + +| Layer | Library | Key source | Purpose | +|---|---|---|---| +| Identity file | `lib/password` | User password → PBKDF2 | Protect the private key on SD | +| Message files | `lib/encrypted_store` | Identity private key → HKDF | Protect messages on SD | + +The typical flow is: `password_open` → load identity → use `encstore_*` for messages. + +## Dependencies + +- `microReticulum` — `Identity`, `Cryptography::hkdf`, `Cryptography::HMAC`, `Cryptography::random` +- `ArduinoCryptography` (attermann/Crypto) — AES256, CTR (transitive via microReticulum) +- `SD` — file I/O (Arduino built-in) diff --git a/lib/encrypted_store/encrypted_store.cpp b/lib/encrypted_store/encrypted_store.cpp new file mode 100644 index 00000000..68a9715e --- /dev/null +++ b/lib/encrypted_store/encrypted_store.cpp @@ -0,0 +1,116 @@ +#include "encrypted_store.h" +#include +#include +#include +#include +#include + +// ── constant-time comparison ────────────────────────────────────────────────── +static bool ct_equal(const uint8_t* a, const uint8_t* b, size_t len) { + uint8_t diff = 0; + for (size_t i = 0; i < len; i++) diff |= a[i] ^ b[i]; + return diff == 0; +} + +// ── key derivation ──────────────────────────────────────────────────────────── +// Derives 64 bytes from the identity's X25519 private key via HKDF-SHA256. +// The identity hash is used as salt, binding the derived keys to this specific +// identity (not just the raw key bytes). +// out[0:32] → AES-256-CTR encryption key +// out[32:64] → HMAC-SHA256 authentication key +static void derive_keys(const RNS::Identity& identity, uint8_t out[64]) { + RNS::Bytes k = RNS::Cryptography::hkdf(64, + identity.encryptionPrivateKey(), + identity.hash()); + memcpy(out, k.data(), 64); +} + +// ── HMAC helper ─────────────────────────────────────────────────────────────── +// Computes HMAC-SHA256 over: version(1) + IV(16) + ciphertext(N) +static RNS::Bytes compute_mac(const uint8_t* mac_key, + uint8_t version, + const uint8_t* iv, + const uint8_t* ct, size_t ctlen) { + RNS::Bytes key_bytes(mac_key, 32); + RNS::Cryptography::HMAC hmac(key_bytes); + uint8_t ver = version; + hmac.update(RNS::Bytes(&ver, 1)); + hmac.update(RNS::Bytes(iv, 16)); + hmac.update(RNS::Bytes(ct, ctlen)); + return hmac.digest(); +} + +// ── public API ──────────────────────────────────────────────────────────────── +// File layout: version(1) + IV(16) + ciphertext(N) + HMAC-SHA256(32) + +bool encstore_write(const char* path, const RNS::Identity& identity, + const uint8_t* data, size_t len) { + uint8_t keys[64]; + derive_keys(identity, keys); + + // Fresh random IV via Cryptography::random() for consistent entropy source + RNS::Bytes iv_bytes = RNS::Cryptography::random(16); + const uint8_t* iv = iv_bytes.data(); + + uint8_t* ct = (uint8_t*)malloc(len); + if (!ct) { memset(keys, 0, 64); return false; } + + CTR ctr; + ctr.setKey(keys, 32); + ctr.setIV(iv, 16); + ctr.encrypt(ct, data, len); + + RNS::Bytes mac = compute_mac(keys + 32, ENCSTORE_FILE_VERSION, iv, ct, len); + + // Assemble: version(1) + IV(16) + ciphertext(len) + HMAC(32) + size_t file_len = ENCSTORE_FILE_OVERHEAD + len; + RNS::Bytes file_data; + uint8_t* fbuf = file_data.writable(file_len); + fbuf[0] = (uint8_t)ENCSTORE_FILE_VERSION; + memcpy(fbuf + 1, iv, 16); + memcpy(fbuf + 17, ct, len); + memcpy(fbuf + 17 + len, mac.data(), 32); + file_data.resize(file_len); + + free(ct); + memset(keys, 0, 64); + return RNS::Utilities::OS::write_file(path, file_data) == file_len; +} + +bool encstore_read(const char* path, const RNS::Identity& identity, + uint8_t* data, size_t len) { + RNS::Bytes file_data; + size_t read = RNS::Utilities::OS::read_file(path, file_data); + if (read != len + ENCSTORE_FILE_OVERHEAD) return false; + + const uint8_t* fbuf = file_data.data(); + uint8_t version = fbuf[0]; + const uint8_t* iv = fbuf + 1; + const uint8_t* ct = fbuf + 17; + const uint8_t* mac_stored = fbuf + 17 + len; + + uint8_t keys[64]; + derive_keys(identity, keys); + + // Verify HMAC before decrypting — wrong identity or tampered file detected here + RNS::Bytes mac_expected = compute_mac(keys + 32, version, iv, ct, len); + if (!ct_equal(mac_expected.data(), mac_stored, 32)) { + memset(keys, 0, 64); + return false; + } + + CTR ctr; + ctr.setKey(keys, 32); + ctr.setIV(iv, 16); + ctr.decrypt(data, ct, len); + + memset(keys, 0, 64); + return true; +} + +size_t encstore_size(const char* path) { + microStore::File f = RNS::Utilities::OS::open_file(path, microStore::File::ModeRead); + if (!f) return 0; + size_t sz = f.size(); + return (sz > ENCSTORE_FILE_OVERHEAD) ? (sz - ENCSTORE_FILE_OVERHEAD) : 0; +} diff --git a/lib/encrypted_store/encrypted_store.h b/lib/encrypted_store/encrypted_store.h new file mode 100644 index 00000000..128b988d --- /dev/null +++ b/lib/encrypted_store/encrypted_store.h @@ -0,0 +1,48 @@ +#pragma once +#include +#include +#include +#include "Identity.h" + +// Authenticated encrypted message storage using the Reticulum identity's +// X25519 encryption private key. +// +// Announcements and peers are public — this module is for sent/received +// message blobs that must be protected at rest. +// +// Key derivation: +// Two 32-byte keys are derived from the identity's private key via HKDF +// (the same primitive Reticulum uses for packet encryption): +// keys[0:32] → AES-256-CTR encryption key +// keys[32:64] → HMAC-SHA256 authentication key +// The identity hash is used as the HKDF salt so keys are bound to the +// specific identity, not just the raw key bytes. +// +// File layout: version(1) + IV(16) + ciphertext(N) + HMAC-SHA256(32) +// Total overhead per file: 49 bytes. +// +// The HMAC covers version + IV + ciphertext, providing authenticated +// encryption: tampering, corruption, or a wrong identity are all detected +// before any plaintext is produced. +// +// Load the identity first (e.g. via password_open from lib/password) so +// the private key is available before calling these functions. + +#define ENCSTORE_FILE_VERSION 0x01 +#define ENCSTORE_FILE_OVERHEAD 49 // 1 (version) + 16 (IV) + 32 (HMAC) + +// Write `len` bytes from `data` to `path` encrypted and authenticated with +// `identity`'s key. Returns true on success. +bool encstore_write(const char* path, const RNS::Identity& identity, + const uint8_t* data, size_t len); + +// Read and authenticate the message stored at `path`, then decrypt into +// `data` (must be at least encstore_size(path) bytes). +// Returns true on success; false means file missing, wrong identity, or +// corruption — no plaintext is written in that case. +bool encstore_read(const char* path, const RNS::Identity& identity, + uint8_t* data, size_t len); + +// Returns the plaintext byte count for the file at `path` (file_size - 49), +// or 0 if the file is missing or too small to be valid. +size_t encstore_size(const char* path); diff --git a/lib/encrypted_store/library.json b/lib/encrypted_store/library.json new file mode 100644 index 00000000..4692798f --- /dev/null +++ b/lib/encrypted_store/library.json @@ -0,0 +1,19 @@ +{ + "name": "encrypted_store", + "version": "1.0.0", + "description": "AES-256-CTR encrypted file storage for microReticulum messages using the identity encryption key", + "keywords": "reticulum, encryption, aes, storage, identity", + "license": "Apache-2.0", + "dependencies": [ + { + "name": "Crypto", + "url": "https://github.com/attermann/Crypto.git" + }, + { + "name": "microReticulum", + "url": "https://github.com/attermann/microReticulum.git" + } + ], + "frameworks": "arduino", + "platforms": "*" +} diff --git a/lib/password/README.md b/lib/password/README.md new file mode 100644 index 00000000..c7f985f6 --- /dev/null +++ b/lib/password/README.md @@ -0,0 +1,136 @@ +# password + +Password-protected file storage for microReticulum identity keys on Arduino/ESP32 with an SD card. + +Encrypts a blob of bytes with a password and writes it to a file. Reading back requires the same password. Designed for storing a Reticulum identity's 64-byte private key so the device can be powered off safely. + +## Security design + +### Key derivation — PBKDF2-HMAC-SHA256 + +Two independent 32-byte keys are derived from the password and a random 16-byte salt using PBKDF2 with 100,000 iterations: + +- **Block 1** → AES-256-CTR encryption key +- **Block 2** → HMAC-SHA256 authentication key + +Running two full iteration chains means an attacker must compute 200,000 PBKDF2 iterations per password guess. At ~500 ms per 10,000 iterations on an ESP32, guessing a single password takes roughly **10 seconds on the device's own hardware**. A GPU can go faster, but the cost scales linearly with iterations. + +The iteration count is `PBKDF2_ITERATIONS` in `password.h` and can be tuned for your hardware. The initial unlock is a one-time operation, so a high value is preferred. + +### Authenticated encryption + +The HMAC-SHA256 tag covers the entire file (version + salt + IV + ciphertext). It is verified with a **constant-time comparison** before any decryption happens. A wrong password or a tampered file always produces an authentication failure — there is no plaintext oracle and no timing leak on the comparison. + +### File format + +``` +┌──────────┬──────────┬──────────┬──────────────┬─────────────────┐ +│ version │ salt │ IV │ ciphertext │ HMAC-SHA256 │ +│ 1 byte │ 16 bytes │ 16 bytes │ N bytes │ 32 bytes │ +└──────────┴──────────┴──────────┴──────────────┴─────────────────┘ +Total overhead: 65 bytes beyond the plaintext. +``` + +- **version** — always `0x01`; allows future format changes to be detected +- **salt** — random per file; ensures two files protected with the same password have different keys +- **IV** — random per file; AES-CTR initialisation vector +- **ciphertext** — AES-256-CTR encrypted plaintext +- **HMAC** — authenticates everything above it + +## Brute-force resistance + +### What an attacker needs + +To crack the password an attacker must obtain the identity file (the SD card, or a copy of it) and then guess passwords offline. Each guess requires computing 200,000 HMAC-SHA256 operations — two full PBKDF2 chains. The HMAC at the end of the file tells them immediately whether the guess was right, so they can automate this at scale. + +### How fast can a modern machine guess? + +SHA-256 is GPU-friendly, so a dedicated cracking rig can outpace the device significantly: + +| Hardware | Guesses / second | +|---|---| +| ESP32 (the device itself) | ~0.1 | +| Modern laptop CPU | ~50–200 | +| RTX 4090 (high-end GPU) | ~2,000–5,000 | +| 100× RTX 4090 cloud cluster | ~200,000–500,000 | + +### How long does cracking take? + +These are **average** times (half the keyspace), assuming the attacker has the identity file: + +| Password type | Example | Combinations | Single RTX 4090 | 100-GPU cluster | +|---|---|---|---|---| +| 6 random lowercase | `kxqmbt` | 3 × 10⁸ | < 1 minute | < 1 second | +| 8 random alphanumeric | `aB3mK9xQ` | 2 × 10¹⁴ | ~14 years | ~7 weeks | +| 4-word diceware | `coral-lamp-fence-drum` | 3.6 × 10¹⁵ | ~228 years | ~2 years | +| 12 random alphanumeric | `aB3mK9xQpL2w` | 3 × 10²¹ | ~100 billion years | ~1 billion years | +| 5-word diceware | `coral-lamp-fence-drum-river` | 2.8 × 10¹⁹ | ~1.7 billion years | ~17 million years | + +### Known limitation: PBKDF2 is GPU-friendly + +PBKDF2-SHA256 is the best option available on constrained hardware, but it is not memory-hard. A purpose-built GPU cluster can attack it far faster than the device can defend. A modern memory-hard function like **Argon2id** would be 100–1,000× more expensive for a GPU attacker at the same device-side cost — but Argon2id requires ~64 MB of working memory per operation, which rules it out on an ESP32. + +### Recommendations + +**Do:** +- Use at least a 4-word [diceware](https://diceware.dmuth.org/) passphrase or 10+ random characters. +- Generate your password with a proper random source (dice, a password manager, etc.), not a memorable phrase. +- Treat the SD card with the same care as the device itself — physical possession of the card enables offline attacks. + +**Avoid:** +- Dictionary words, names, dates, or any phrase you can remember easily. +- Passwords shorter than 10 characters. +- Reusing a password from another service. + +**Increasing `PBKDF2_ITERATIONS`:** If your hardware is faster or your application tolerates a longer unlock delay, raising this value directly multiplies the attacker's cost. Doubling it halves the number of guesses per second. The identity file only needs to be unlocked once per boot, so a 10–30 second delay is often acceptable. + +## API + +```cpp +#include "password.h" + +// Encrypt `data` (len bytes) and write to `path`. +// Returns true on success. +bool password_protect(const char* path, const char* password, + const uint8_t* data, size_t len); + +// Decrypt a file written by password_protect into `data` (must be len bytes). +// Returns true on success. +// Returns false if the file is missing, wrong size, or authentication fails +// (wrong password or corrupted file). No plaintext is written on failure. +bool password_open(const char* path, const char* password, + uint8_t* data, size_t len); +``` + +The `password` parameter is a plain `const char*`. **Zero the password buffer** with `memset` as soon as you are done with it. + +## Usage example + +```cpp +#include +#include "password.h" + +// Store a 64-byte identity private key +uint8_t prv[64]; // fill with key material +char pw[64] = {0}; +// ... read pw from serial ... + +password_protect("/identity.bin", pw, prv, sizeof(prv)); +memset(pw, 0, sizeof(pw)); + +// Later: load it back +uint8_t prv_loaded[64]; +char pw2[64] = {0}; +// ... read pw2 from serial ... + +if (password_open("/identity.bin", pw2, prv_loaded, sizeof(prv_loaded))) { + // use prv_loaded ... +} +memset(pw2, 0, sizeof(pw2)); +memset(prv_loaded, 0, sizeof(prv_loaded)); +``` + +## Dependencies + +- `ArduinoCryptography` (attermann/Crypto) — SHA256, AES256, CTR, RNG +- `SD` — file I/O (Arduino built-in) diff --git a/lib/password/library.json b/lib/password/library.json new file mode 100644 index 00000000..df83cb9b --- /dev/null +++ b/lib/password/library.json @@ -0,0 +1,19 @@ +{ + "name": "password", + "version": "1.0.0", + "description": "PBKDF2 + AES-256-CTR password-protected file storage for microReticulum identities", + "keywords": "reticulum, password, aes, encryption, identity", + "license": "Apache-2.0", + "dependencies": [ + { + "name": "Crypto", + "url": "https://github.com/attermann/Crypto.git" + }, + { + "name": "microReticulum", + "url": "https://github.com/attermann/microReticulum.git" + } + ], + "frameworks": "arduino", + "platforms": "*" +} diff --git a/lib/password/password.cpp b/lib/password/password.cpp new file mode 100644 index 00000000..e4885fa3 --- /dev/null +++ b/lib/password/password.cpp @@ -0,0 +1,152 @@ +#include "password.h" +#include +#include + +// ── constant-time comparison ────────────────────────────────────────────────── +// Always iterates all `len` bytes so timing doesn't reveal where the first +// mismatch is, preventing side-channel leaks on HMAC comparison. +static bool ct_equal(const uint8_t* a, const uint8_t* b, size_t len) { + uint8_t diff = 0; + for (size_t i = 0; i < len; i++) diff |= a[i] ^ b[i]; + return diff == 0; +} + +// ── PBKDF2-HMAC-SHA256 ──────────────────────────────────────────────────────── +// Computes one 32-byte output block of PBKDF2 (RFC 2898 §5.2). +// `block_num` is 1-indexed; block 1 = AES key, block 2 = HMAC key. +static void pbkdf2_block(const uint8_t* pw, size_t pwlen, + const uint8_t* salt, uint32_t block_num, + uint8_t* out32) { + SHA256 sha; + // U1 = HMAC(password, salt || block_num_be) + sha.resetHMAC(pw, pwlen); + sha.update(salt, 16); + uint8_t blk[4] = { + (uint8_t)(block_num >> 24), (uint8_t)(block_num >> 16), + (uint8_t)(block_num >> 8), (uint8_t)(block_num) + }; + sha.update(blk, 4); + uint8_t u[32]; + sha.finalizeHMAC(pw, pwlen, u, 32); + memcpy(out32, u, 32); + // Ui = HMAC(password, U_{i-1}), XOR into accumulator + for (uint32_t i = 1; i < PBKDF2_ITERATIONS; i++) { + sha.resetHMAC(pw, pwlen); + sha.update(u, 32); + sha.finalizeHMAC(pw, pwlen, u, 32); + for (int j = 0; j < 32; j++) out32[j] ^= u[j]; + } +} + +// Derives 64 bytes from password + salt via two independent PBKDF2 blocks: +// out64[0:32] → AES-256-CTR encryption key +// out64[32:64] → HMAC-SHA256 authentication key +// Running two full chains means an attacker must compute 2×PBKDF2_ITERATIONS +// iterations per password guess. +static void pbkdf2_64(const char* password, const uint8_t* salt, uint8_t* out64) { + const uint8_t* pw = (const uint8_t*)password; + size_t pwlen = strlen(password); + pbkdf2_block(pw, pwlen, salt, 1, out64); + pbkdf2_block(pw, pwlen, salt, 2, out64 + 32); +} + +// ── HMAC-SHA256 helper ──────────────────────────────────────────────────────── +static void hmac_sha256(const uint8_t* key, const uint8_t* msg, size_t msglen, + uint8_t* out32) { + SHA256 sha; + sha.resetHMAC(key, 32); + sha.update(msg, msglen); + sha.finalizeHMAC(key, 32, out32, 32); +} + +// ── public API ──────────────────────────────────────────────────────────────── +// File layout: version(1) + salt(16) + IV(16) + ciphertext(N) + HMAC-SHA256(32) + +bool password_protect(const char* path, const char* password, + const uint8_t* data, size_t len) { + uint8_t salt[16], iv[16]; + RNG.rand(salt, 16); + RNG.rand(iv, 16); + + uint8_t keys[64]; + pbkdf2_64(password, salt, keys); + + uint8_t* ct = (uint8_t*)malloc(len); + if (!ct) { memset(keys, 0, 64); return false; } + + CTR ctr; + ctr.setKey(keys, 32); + ctr.setIV(iv, 16); + ctr.encrypt(ct, data, len); + + // HMAC over version + salt + IV + ciphertext + size_t auth_len = 1 + 16 + 16 + len; + uint8_t* auth = (uint8_t*)malloc(auth_len); + if (!auth) { free(ct); memset(keys, 0, 64); return false; } + auth[0] = PASSWORD_FILE_VERSION; + memcpy(auth + 1, salt, 16); + memcpy(auth + 17, iv, 16); + memcpy(auth + 33, ct, len); + + uint8_t mac[32]; + hmac_sha256(keys + 32, auth, auth_len, mac); + free(auth); + + // Assemble: version(1) + salt(16) + IV(16) + ciphertext(len) + HMAC(32) + size_t file_len = PASSWORD_FILE_OVERHEAD + len; + RNS::Bytes file_data; + uint8_t* fbuf = file_data.writable(file_len); + fbuf[0] = (uint8_t)PASSWORD_FILE_VERSION; + memcpy(fbuf + 1, salt, 16); + memcpy(fbuf + 17, iv, 16); + memcpy(fbuf + 33, ct, len); + memcpy(fbuf + 33 + len, mac, 32); + file_data.resize(file_len); + + free(ct); + memset(keys, 0, 64); + return RNS::Utilities::OS::write_file(path, file_data) == file_len; +} + +bool password_open(const char* path, const char* password, + uint8_t* data, size_t len) { + RNS::Bytes file_data; + size_t read = RNS::Utilities::OS::read_file(path, file_data); + if (read != len + PASSWORD_FILE_OVERHEAD) return false; + + const uint8_t* fbuf = file_data.data(); + uint8_t version = fbuf[0]; + const uint8_t* salt = fbuf + 1; + const uint8_t* iv = fbuf + 17; + const uint8_t* ct = fbuf + 33; + const uint8_t* mac_stored = fbuf + 33 + len; + + uint8_t keys[64]; + pbkdf2_64(password, salt, keys); + + // Verify HMAC before decrypting — avoids decryption oracle attacks + size_t auth_len = 1 + 16 + 16 + len; + uint8_t* auth = (uint8_t*)malloc(auth_len); + if (!auth) { memset(keys, 0, 64); return false; } + auth[0] = version; + memcpy(auth + 1, salt, 16); + memcpy(auth + 17, iv, 16); + memcpy(auth + 33, ct, len); + + uint8_t mac_expected[32]; + hmac_sha256(keys + 32, auth, auth_len, mac_expected); + free(auth); + + if (!ct_equal(mac_expected, mac_stored, 32)) { + memset(keys, 0, 64); + return false; // wrong password or tampered file + } + + CTR ctr; + ctr.setKey(keys, 32); + ctr.setIV(iv, 16); + ctr.decrypt(data, ct, len); + + memset(keys, 0, 64); + return true; +} diff --git a/lib/password/password.h b/lib/password/password.h new file mode 100644 index 00000000..50584b86 --- /dev/null +++ b/lib/password/password.h @@ -0,0 +1,35 @@ +#pragma once +#include +#include +#include +#include +#include + +// PBKDF2 iteration count. +// Higher = slower brute-force at the cost of slower unlock. +// Initial identity load is a one-time operation, so a high value is fine. +// ~500ms per 10,000 iterations on ESP32 → 100,000 ≈ 5 seconds. +#define PBKDF2_ITERATIONS 100000 + +// File format version stored as the first byte of every protected file. +// Increment if the format ever changes so old files can be detected. +#define PASSWORD_FILE_VERSION 0x01 + +// File layout: version(1) + salt(16) + IV(16) + ciphertext(N) + HMAC-SHA256(32) +// Total per-file overhead beyond the plaintext: 65 bytes. +#define PASSWORD_FILE_OVERHEAD 65 + +// Encrypt `data` of `len` bytes and write to `path`. +// Two 32-byte keys are derived from `password` via PBKDF2 (PBKDF2_ITERATIONS): +// key[0:32] → AES-256-CTR encryption +// key[32:64] → HMAC-SHA256 over (version + salt + IV + ciphertext) +// The HMAC is appended to the file and verified on open, catching both +// wrong passwords and file corruption without any plaintext oracle. +// Returns true on success. +bool password_protect(const char* path, const char* password, const uint8_t* data, size_t len); + +// Decrypt a file written by password_protect. +// Returns true on success. +// Returns false if the file is missing, too small, or the HMAC does not match +// (wrong password or corrupted file) — no plaintext is written in that case. +bool password_open(const char* path, const char* password, uint8_t* data, size_t len); diff --git a/variants/.DS_Store b/variants/.DS_Store deleted file mode 100644 index 9aa8a6e5..00000000 Binary files a/variants/.DS_Store and /dev/null differ