Skip to content

fix(H6): authenticated-encrypted backups (AES-GCM + PBKDF2) - #18

Closed
d33mobile wants to merge 2 commits into
hakierspejs:masterfrom
d33mobile:fix/h6-backup-crypto
Closed

fix(H6): authenticated-encrypted backups (AES-GCM + PBKDF2)#18
d33mobile wants to merge 2 commits into
hakierspejs:masterfrom
d33mobile:fix/h6-backup-crypto

Conversation

@d33mobile

Copy link
Copy Markdown

Finding (ISSUES.md H6 — Backups are neither encrypted nor authenticated; CRC-32 is not a MAC)

storage/backup.h, storage/backup.c, serial/commands_backup.c. The blob is plaintext backup_key_t records — secret[20] is the raw HMAC-SHA1 seed — protected only by a CRC any attacker can recompute.

  1. Confidentiality. cmd_export_keys prints every seed for every key. Anyone obtaining a blob derives valid codes for every key including admin keys, indefinitely.
  2. Integrity. An attacker who gets a blob accepted sets is_admin = 1 on a record whose secret they chose, fixes the CRC, and gains permanent admin. backup_import applies no privilege check on is_admin — import is the only way to set that bit without set-key-admin.
  • Encrypt-then-MAC the payload (PBKDF2/HKDF + AES-GCM or ChaCha20-Poly1305 via the already-linked mbedTLS); reject on MAC failure before any parsing. Keep the CRC only as a paste-corruption hint.
  • At minimum, refuse to import is_admin = 1 records without per-key operator re-confirmation.

Fix

A backup's whole purpose is to live off-device, so its raw seeds must be useless to anyone who obtains the blob, and a tampered blob must never be trusted. The payload is now encrypted-then-MAC'd under an operator passphrase (a backup is portable, so the key cannot be device-bound — device-bound at-rest secrecy is H7's concern).

New wire format v2

Cleartext header followed by AES-256-GCM ciphertext of the serialised records:

field size notes
magic 4 HSLL
version 4 2
key_count 4 authenticated (AAD)
salt 16 PBKDF2 salt, authenticated (AAD)
iv 12 GCM nonce, authenticated (AAD)
tag 16 GCM auth tag = the MAC
checksum 4 CRC-32 of ciphertext — paste-corruption hint only, not a trust boundary
ciphertext key_count * sizeof(backup_key_t) AES-256-GCM of the key records

Crypto

  • Key derivation: mbedtls_pkcs5_pbkdf2_hmac_ext(MBEDTLS_MD_SHA256, passphrase, salt, 100000 iters) → 32-byte key.
  • AEAD: AES-256-GCM (mbedtls_gcm_crypt_and_tag / mbedtls_gcm_auth_decrypt). The header prefix through the IV is passed as associated data, so magic/version/key_count/salt/iv are authenticated. With GCM the tag is the MAC of ciphertext + AAD — encrypt-then-MAC by construction.
  • backup_export takes the passphrase, derives the key, encrypts, emits the v2 blob (salt/IV from the platform CSPRNG, get_rand_64). backup_import parses the cleartext header, derives the key from the blob's salt, GCM-verifies the tag, and rejects before parsing any record on failure. Wrong passphrase and any tamper (header / ciphertext / tag) all fail the tag check.
  • The CRC is retained only to hint at paste corruption; the GCM tag is the sole trust boundary.

is_admin backstop (defense-in-depth)

Even with a valid MAC, each is_admin record is gated through a per-key operator confirmation callback (backup_admin_confirm_fn) before any destructive write; a denied record aborts the whole import with existing keys untouched. A NULL callback denies all admin records. commands_backup.c prompts for the passphrase over the console (export + import) and drives the admin-confirm prompt.

mbedtls config

mbedtls_config.h enables MBEDTLS_AES_C, MBEDTLS_CIPHER_C, MBEDTLS_GCM_C, MBEDTLS_PKCS5_C (previously only MD/SHA-1/SHA-256). mbedtls_pkcs5_pbkdf2_hmac_ext is a mbedTLS 3.x API — matches pico-sdk master (docs/BUILD.md). verify-on-hw: firmware image-size impact of the added mbedtls modules, on-device PBKDF2 timing (100k iters, HW-accelerated SHA-256 when LIB_PICO_SHA256), and the console passphrase-entry UX.

Host-side verification

test/harness_storage.c links the real first-party backup.c + storage.c + littlefs against system mbedTLS and tests the round-trip end to end:

  • export → import round-trip with the right passphrase restores every key (including an admin key via the confirm callback);
  • the ciphertext contains no cleartext seed (searches for the raw seed bytes);
  • wrong passphrase → rejected, store untouched;
  • tampered GCM tag (CRC still valid) → MAC failure → rejected;
  • tampered ciphertext → MAC failure → rejected;
  • tampered AAD (key_count altered within range) → MAC failure → rejected;
  • malformed headers (bad magic/version, over-large key_count, truncated, too-small) → rejected pre-decrypt;
  • is_admin backstop: a validly-encrypted forged admin record is rejected when the operator declines and when the callback is NULL, and imported only when confirmed — directly exercising the H6 escalation path;
  • UBSan regression: a forged valid blob with a non-bool flag byte imports and reads back canonicalised.

The test forges validly-authenticated blobs with the same PBKDF2+GCM construction (the "passphrase holder crafts a malicious payload" model), so the is_admin / non-bool paths are exercised through genuine ciphertext, not plaintext pokes.

CI host gates all green locally: make -C test asan = 0, valgrind = 0, coverage = 0, ./ci --action=check = 0. The libFuzzer fuzz_*.c harnesses are out of scope (clang-only, not in CI) and untouched.

Scoped to follow-up

  • Import atomicity (stage to a temp key set, swap only on full success) is M6, not changed here — backup_import still deletes-then-writes, but the admin backstop runs before any delete.
  • Removing the pre-input safety-backup double-emit is M15; this PR emits the safety backup once (after the passphrase prompt, before overwrite).
  • base64_decode bounds hardening is M5; buffer sizes here are derived from sizeof(backup_header_t)/record counts and track the larger v2 header correctly.

Claude and others added 2 commits July 30, 2026 01:05
…uth failure

Backups exist to live off-device, so their raw HMAC seeds must be useless to
anyone who obtains a blob, and a tampered blob must never be trusted. The v1
format was plaintext backup_key_t records guarded only by a CRC-32 (not a MAC):
a leaked blob exposed every seed forever, and an attacker could set is_admin=1
on a chosen-secret record, fix the CRC, and import to gain permanent admin.

New wire format v2: cleartext header (magic, version=2, key_count, 16B salt,
12B IV, 16B GCM tag, CRC-32 hint) followed by AES-256-GCM ciphertext of the
serialised records. The AEAD key is PBKDF2-HMAC-SHA256(passphrase, salt) - a
backup is portable so the key must come from an operator secret, not a
device-bound key. The header prefix (through the IV) is fed to GCM as AAD, so
magic/version/key_count/salt/IV are authenticated. With GCM the tag IS the MAC
of ciphertext+AAD (encrypt-then-MAC by construction).

backup_import derives the key from the blob's salt, GCM-verifies the tag, and
REJECTS before parsing any record on failure - wrong passphrase and any tamper
(header/ciphertext/tag) all fail the tag check. The CRC is kept only as a
paste-corruption hint, not a trust boundary. Defense-in-depth: even an
authenticated is_admin record is gated through a per-key operator confirmation
callback before any destructive write; a denied record aborts the whole import.

commands_backup.c prompts for the passphrase over the console (export + import)
and drives the admin-confirm prompt. mbedtls_config.h enables AES/CIPHER/GCM/
PKCS5 (verify firmware image size on device).

Host-verified: harness_storage.c exercises export->import roundtrip (incl. an
admin key), wrong-passphrase reject, tag/ciphertext/AAD tamper reject, the
is_admin backstop (deny + NULL-callback deny + confirm), a ciphertext
no-cleartext-seed check, and the non-bool-flag UBSan path via a forged valid
blob. make -C test asan/valgrind/coverage and ./ci --action=check all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CI host's mbedTLS predates 3.6 and lacks mbedtls_pkcs5_pbkdf2_hmac_ext,
which broke the host link. Select the API by MBEDTLS_VERSION_NUMBER: the _ext
form on 3.6+ (firmware / pico-sdk mbedtls), the context-based
mbedtls_pkcs5_pbkdf2_hmac on older builds. Warning-clean on both (the context
form is deprecated on 3.6+, the _ext form is absent pre-3.6). Applied in both
backup.c and the forge helper in harness_storage.c.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants