From 75e45710634d026b6ab0c4d621fdf6bebcbfbfae Mon Sep 17 00:00:00 2001 From: Tim Date: Wed, 14 Oct 2026 07:55:34 -0700 Subject: [PATCH 1/4] gen_gtable: default output path relative to the script, not a drive Co-Authored-By: Claude Opus 5.5 --- vendor/tools/gen_gtable.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vendor/tools/gen_gtable.py b/vendor/tools/gen_gtable.py index be35e76..ddeade5 100644 --- a/vendor/tools/gen_gtable.py +++ b/vendor/tools/gen_gtable.py @@ -77,8 +77,8 @@ def mont_hex(v): out.append('];') import os import sys -dst = sys.argv[1] if len(sys.argv) > 1 else 'F:/coding/janus/rusty_esp_mid/vendor/p256/src/arithmetic/generator_table.rs' -# never open(dst, 'w') on F: (a truncation there once lost a file) +dst = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'p256', 'src', 'arithmetic', 'generator_table.rs') +# never open(dst, 'w') directly (a truncation on F: once lost a file) open(dst + '.tmp', 'w', encoding='utf-8', newline='\n').write('\n'.join(out) + '\n') os.replace(dst + '.tmp', dst) print('rows', len(rows), 'entries', sum(len(r) for r in rows)) From 99fd84d4a37b0cdb2a5ebd4dd5ad686b78f07dcd Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 15 Oct 2026 12:45:54 -0700 Subject: [PATCH 2/4] seal: secrets at rest -- SealedKv seals every value (AES-256-GCM, bound to its key name, a fresh nonce per write) under a key the chip derives and never stores; hal::seal_key takes it from the HMAC peripheral and an eFuse key block (feature seal-hmac). A board provisioned in the clear is sealed where it lies; with no key nothing pretends. partitions: the table where the build puts it (ESP_BOOTLOADER_ESP_IDF_CONFIG_PARTITION_TABLE_OFFSET, the setting esp-bootloader-esp-idf reads), read through the cache under flash encryption -- the secure-boot bootloader does not fit under 0x8000 on the C6. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 102 ++++++ Cargo.toml | 2 + crates/rusty_esp_mid-core/Cargo.toml | 1 + crates/rusty_esp_mid-core/src/lib.rs | 1 + crates/rusty_esp_mid-core/src/seal.rs | 299 ++++++++++++++++++ crates/rusty_esp_mid-esp/Cargo.toml | 5 + crates/rusty_esp_mid-esp/src/hal/mod.rs | 7 + .../rusty_esp_mid-esp/src/hal/partitions.rs | 66 +++- crates/rusty_esp_mid-esp/src/hal/seal.rs | 35 ++ 9 files changed, 508 insertions(+), 10 deletions(-) create mode 100644 crates/rusty_esp_mid-core/src/seal.rs create mode 100644 crates/rusty_esp_mid-esp/src/hal/seal.rs diff --git a/Cargo.lock b/Cargo.lock index d975a0e..86b3db9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -296,6 +331,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout", +] + [[package]] name = "clang-sys" version = "1.9.1" @@ -429,6 +474,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "cvt" version = "0.1.2" @@ -1226,6 +1280,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "glob" version = "0.3.4" @@ -1615,6 +1679,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instability" version = "0.3.14" @@ -1940,6 +2013,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "p256" version = "0.13.2" @@ -1989,6 +2068,18 @@ dependencies = [ "spki", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2377,6 +2468,7 @@ dependencies = [ name = "rusty_esp_mid-core" version = "0.1.2" dependencies = [ + "aes-gcm", "base64", "bs58", "ecdsa", @@ -2997,6 +3089,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index dafd4b6..981f84d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,8 @@ rusty_esp_core = { version = "0.1.2", git = "https://github.com/Remade-With-Rust # is for rusty_esp_signal's session keys. No `std` here: the core is no_std. p256 = { version = "0.13", default-features = false, features = ["ecdsa", "ecdh"] } sha2 = { version = "0.10", default-features = false } +# Sealing secrets at rest (seal.rs): AES-256-GCM, the house cipher. +aes-gcm = { version = "0.10", default-features = false, features = ["aes"] } # The RFC 6979 signing primitive over a bare scalar (round 2, R7): the # version p256 0.13 already brings, with the parts `SigningKey` uses. ecdsa = { version = "0.16", default-features = false, features = ["hazmat", "rfc6979"] } diff --git a/crates/rusty_esp_mid-core/Cargo.toml b/crates/rusty_esp_mid-core/Cargo.toml index fae3f8a..8fe6837 100644 --- a/crates/rusty_esp_mid-core/Cargo.toml +++ b/crates/rusty_esp_mid-core/Cargo.toml @@ -56,6 +56,7 @@ p256 = { workspace = true } # public key on construction: a scalar multiplication per boot (R7). ecdsa = { workspace = true } sha2 = { workspace = true } +aes-gcm = { workspace = true } bs58 = { workspace = true } base64 = { workspace = true } serde = { workspace = true, optional = true } diff --git a/crates/rusty_esp_mid-core/src/lib.rs b/crates/rusty_esp_mid-core/src/lib.rs index 9a5e894..26e44da 100644 --- a/crates/rusty_esp_mid-core/src/lib.rs +++ b/crates/rusty_esp_mid-core/src/lib.rs @@ -44,6 +44,7 @@ pub mod key; pub mod kms; pub mod manifest; pub mod nonce; +pub mod seal; pub mod setup; pub mod signer; diff --git a/crates/rusty_esp_mid-core/src/seal.rs b/crates/rusty_esp_mid-core/src/seal.rs new file mode 100644 index 0000000..18cb463 --- /dev/null +++ b/crates/rusty_esp_mid-core/src/seal.rs @@ -0,0 +1,299 @@ +//! Secrets at rest: a [`Kv`] whose values are sealed under a device key. +//! +//! **Why.** Flash encryption protects the app slots, but a settings or +//! identity partition in NVS format is not encrypted with them (ESP-IDF's own +//! NVS takes a separate key for that), so the device key and the network +//! passphrase sit in the clear on a dumped flash. [`SealedKv`] seals every +//! value it stores with AES-256-GCM under a key the chip derives and never +//! stores: on the ESP32-C6, an HMAC over a fixed label with an eFuse key +//! block that software cannot read back (`rusty_esp_mid-esp`'s +//! `seal_key`). Anyone holding the flash holds ciphertext. +//! +//! **The format.** `MAGIC (4) || nonce (12) || ciphertext || tag (16)`. The +//! nonce is fresh from the [`Rng`] on every write, and the value's own key +//! name is the additional data, so a sealed value moved under another name +//! does not open. `MAGIC` begins with a NUL, which no setting string holds: +//! a value written in the clear before sealing existed (a board provisioned +//! from a computer, say) still reads, and [`SealedKv::seal_in_place`] seals +//! it where it lies. +//! +//! **No key, no sealing.** Built with `None` -- the chip has no key burned +//! -- it passes every value through unchanged, and [`SealedKv::sealing`] +//! says so, so a firmware can say so too. Nothing pretends. + +use aes_gcm::aead::{AeadInPlace, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce, Tag}; +use rusty_esp_core::error::{Error, Result}; +use rusty_esp_core::hal::{Kv, Rng}; + +/// What marks a sealed value. The NUL first: no setting string holds one. +pub const MAGIC: [u8; 4] = [0, b'J', b'S', 1]; +const NONCE_LEN: usize = 12; +const TAG_LEN: usize = 16; +/// Bytes a sealed value takes beyond its plaintext. +pub const OVERHEAD: usize = MAGIC.len() + NONCE_LEN + TAG_LEN; +/// The longest value sealed: settings and keys, not files. +pub const MAX_VALUE: usize = 256; + +/// The label the device's sealing key is derived under; a new label is a +/// new key, and every sealed value would need sealing again. +pub const KEY_LABEL: &[u8] = b"janus-seal-v1"; + +/// A [`Kv`] that seals what it stores (see the module docs). +pub struct SealedKv { + inner: K, + rng: R, + cipher: Option, +} + +impl core::fmt::Debug for SealedKv { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("SealedKv") + .field("sealing", &self.cipher.is_some()) + .finish_non_exhaustive() + } +} + +impl SealedKv { + /// Over `inner`, sealing under `key`; with `None`, values pass through + /// in the clear. + pub fn new(inner: K, rng: R, key: Option<&[u8; 32]>) -> Self { + Self { + inner, + rng, + cipher: key.map(|k| Aes256Gcm::new(k.into())), + } + } + + /// Whether values are sealed (a key was given). + #[must_use] + pub const fn sealing(&self) -> bool { + self.cipher.is_some() + } + + /// The store underneath. + pub fn into_inner(self) -> K { + self.inner + } + + /// Whether `key`'s stored value is sealed; `None` when it is absent. + pub fn is_sealed(&self, key: &str) -> Result> { + let mut raw = [0u8; MAX_VALUE + OVERHEAD]; + match self.inner.get(key, &mut raw) { + Ok(Some(n)) => Ok(Some(raw[..n].starts_with(&MAGIC))), + Ok(None) => Ok(None), + // too long to be one of ours + Err(Error::BufferTooSmall { .. }) => Ok(Some(false)), + Err(e) => Err(e), + } + } + + /// Seal, where it lies, each of `keys` still stored in the clear. + /// Returns how many were sealed. Without a key it seals nothing. + pub fn seal_in_place(&mut self, keys: &[&str]) -> Result { + if self.cipher.is_none() { + return Ok(0); + } + let mut sealed = 0; + for key in keys { + if self.is_sealed(key)? == Some(false) { + let mut plain = [0u8; MAX_VALUE]; + let Some(n) = self.inner.get(key, &mut plain)? else { + continue; + }; + self.put(key, &plain[..n])?; + plain.fill(0); + sealed += 1; + } + } + Ok(sealed) + } +} + +impl Kv for SealedKv { + fn get(&self, key: &str, out: &mut [u8]) -> Result> { + let mut raw = [0u8; MAX_VALUE + OVERHEAD]; + let n = match self.inner.get(key, &mut raw) { + Ok(Some(n)) => n, + Ok(None) => return Ok(None), + // a value too long to be sealed is one stored in the clear + Err(Error::BufferTooSmall { .. }) => return self.inner.get(key, out), + Err(e) => return Err(e), + }; + let raw = &mut raw[..n]; + if !raw.starts_with(&MAGIC) { + // stored in the clear, before sealing (or with no key) + let dst = out + .get_mut(..n) + .ok_or(Error::BufferTooSmall { needed: n })?; + dst.copy_from_slice(raw); + return Ok(Some(n)); + } + // a sealed value with no key to open it: say so, never guess + let cipher = self.cipher.as_ref().ok_or(Error::Crypto)?; + if n < OVERHEAD { + return Err(Error::Corrupt); + } + let (head, rest) = raw.split_at_mut(MAGIC.len() + NONCE_LEN); + let nonce: [u8; NONCE_LEN] = head[MAGIC.len()..].try_into().map_err(|_| Error::Corrupt)?; + let (body, tag) = rest.split_at_mut(rest.len() - TAG_LEN); + let tag: [u8; TAG_LEN] = (&*tag).try_into().map_err(|_| Error::Corrupt)?; + cipher + .decrypt_in_place_detached(&Nonce::from(nonce), key.as_bytes(), body, &Tag::from(tag)) + .map_err(|_| Error::Crypto)?; + let len = body.len(); + let result = match out.get_mut(..len) { + Some(dst) => { + dst.copy_from_slice(body); + Ok(Some(len)) + } + None => Err(Error::BufferTooSmall { needed: len }), + }; + body.fill(0); + result + } + + fn put(&mut self, key: &str, value: &[u8]) -> Result<()> { + let Some(cipher) = self.cipher.as_ref() else { + return self.inner.put(key, value); + }; + if value.len() > MAX_VALUE { + return Err(Error::Unsupported); + } + let mut raw = [0u8; MAX_VALUE + OVERHEAD]; + raw[..MAGIC.len()].copy_from_slice(&MAGIC); + let mut nonce = [0u8; NONCE_LEN]; + self.rng.fill(&mut nonce)?; + raw[MAGIC.len()..MAGIC.len() + NONCE_LEN].copy_from_slice(&nonce); + let start = MAGIC.len() + NONCE_LEN; + let end = start + value.len(); + raw[start..end].copy_from_slice(value); + let tag = cipher + .encrypt_in_place_detached(&Nonce::from(nonce), key.as_bytes(), &mut raw[start..end]) + .map_err(|_| Error::Crypto)?; + raw[end..end + TAG_LEN].copy_from_slice(&tag); + self.inner.put(key, &raw[..end + TAG_LEN]) + } + + fn remove(&mut self, key: &str) -> Result { + self.inner.remove(key) + } +} + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + use rusty_esp_core::hal::host::{InsecureTestRng, MemoryKv}; + + const KEY: [u8; 32] = [7; 32]; + + fn sealed() -> SealedKv { + SealedKv::new(MemoryKv::default(), InsecureTestRng::seeded(1), Some(&KEY)) + } + + fn raw(kv: &SealedKv, key: &str) -> std::vec::Vec { + let mut buf = [0u8; 512]; + let n = kv.inner.get(key, &mut buf).unwrap().unwrap(); + buf[..n].to_vec() + } + + fn get(kv: &impl Kv, key: &str) -> Result>> { + let mut buf = [0u8; 512]; + Ok(kv.get(key, &mut buf)?.map(|n| buf[..n].to_vec())) + } + + #[test] + fn a_value_reads_back_and_is_not_on_the_flash_in_the_clear() { + let mut kv = sealed(); + kv.put("wifi.psk", b"correct horse battery").unwrap(); + assert_eq!(get(&kv, "wifi.psk").unwrap().unwrap(), b"correct horse battery"); + let stored = raw(&kv, "wifi.psk"); + assert!(stored.starts_with(&MAGIC)); + assert_eq!(stored.len(), 21 + OVERHEAD); + assert!(!stored.windows(5).any(|w| w == b"horse"), "no plaintext at rest"); + assert_eq!(kv.is_sealed("wifi.psk").unwrap(), Some(true)); + assert_eq!(kv.is_sealed("absent").unwrap(), None); + assert!(get(&kv, "absent").unwrap().is_none()); + } + + #[test] + fn the_same_value_twice_is_two_different_ciphertexts() { + let mut kv = sealed(); + kv.put("a", b"same").unwrap(); + let first = raw(&kv, "a"); + kv.put("a", b"same").unwrap(); + assert_ne!(first, raw(&kv, "a"), "a fresh nonce every write"); + } + + #[test] + fn a_flipped_bit_a_moved_value_or_the_wrong_key_does_not_open() { + let mut kv = sealed(); + kv.put("wifi.psk", b"secret").unwrap(); + let good = raw(&kv, "wifi.psk"); + for i in [MAGIC.len(), MAGIC.len() + NONCE_LEN, good.len() - 1] { + let mut bad = good.clone(); + bad[i] ^= 1; + kv.inner.put("wifi.psk", &bad).unwrap(); + assert_eq!(get(&kv, "wifi.psk"), Err(Error::Crypto), "byte {i} flipped"); + } + // the same sealed bytes filed under another name + kv.inner.put("name", &good).unwrap(); + assert_eq!(get(&kv, "name"), Err(Error::Crypto), "the name is bound in"); + // the right name, another device's key + let mut other = SealedKv::new(MemoryKv::default(), InsecureTestRng::seeded(2), Some(&[8; 32])); + other.inner.put("wifi.psk", &good).unwrap(); + assert_eq!(get(&other, "wifi.psk"), Err(Error::Crypto)); + // and a sealed value with no key at all is refused, not returned raw + let mut keyless = SealedKv::new(MemoryKv::default(), InsecureTestRng::seeded(3), None); + keyless.inner.put("wifi.psk", &good).unwrap(); + assert_eq!(get(&keyless, "wifi.psk"), Err(Error::Crypto)); + // a truncated one is corrupt + kv.inner.put("wifi.psk", &good[..OVERHEAD - 1]).unwrap(); + assert_eq!(get(&kv, "wifi.psk"), Err(Error::Corrupt)); + } + + #[test] + fn a_board_provisioned_in_the_clear_is_sealed_where_it_lies() { + let mut kv = sealed(); + kv.inner.put("wifi.ssid", b"home").unwrap(); + kv.inner.put("wifi.psk", b"hunter22").unwrap(); + assert_eq!(get(&kv, "wifi.psk").unwrap().unwrap(), b"hunter22", "still reads"); + assert_eq!(kv.is_sealed("wifi.psk").unwrap(), Some(false)); + assert_eq!(kv.seal_in_place(&["wifi.ssid", "wifi.psk", "absent"]).unwrap(), 2); + assert_eq!(kv.is_sealed("wifi.psk").unwrap(), Some(true)); + assert_eq!(get(&kv, "wifi.psk").unwrap().unwrap(), b"hunter22"); + assert_eq!(get(&kv, "wifi.ssid").unwrap().unwrap(), b"home"); + assert_eq!(kv.seal_in_place(&["wifi.ssid", "wifi.psk"]).unwrap(), 0, "once"); + } + + #[test] + fn with_no_key_nothing_is_sealed_and_it_says_so() { + let mut kv = SealedKv::new(MemoryKv::default(), InsecureTestRng::seeded(1), None); + assert!(!kv.sealing()); + kv.put("wifi.psk", b"plain").unwrap(); + assert_eq!(raw_of(&kv), b"plain"); + assert_eq!(kv.seal_in_place(&["wifi.psk"]).unwrap(), 0); + assert!(sealed().sealing()); + + fn raw_of(kv: &SealedKv) -> std::vec::Vec { + let mut buf = [0u8; 64]; + let n = kv.inner.get("wifi.psk", &mut buf).unwrap().unwrap(); + buf[..n].to_vec() + } + } + + #[test] + fn sizes_are_refused_not_truncated() { + let mut kv = sealed(); + assert_eq!(kv.put("big", &[1u8; MAX_VALUE + 1]), Err(Error::Unsupported)); + kv.put("max", &[2u8; MAX_VALUE]).unwrap(); + assert_eq!(get(&kv, "max").unwrap().unwrap().len(), MAX_VALUE); + kv.put("k", b"twelve bytes").unwrap(); + let mut small = [0u8; 4]; + assert_eq!(kv.get("k", &mut small), Err(Error::BufferTooSmall { needed: 12 })); + // a value stored in the clear, longer than any sealed one, still reads + kv.inner.put("legacy", &[3u8; 400]).unwrap(); + let mut big = [0u8; 400]; + assert_eq!(kv.get("legacy", &mut big), Ok(Some(400))); + } +} diff --git a/crates/rusty_esp_mid-esp/Cargo.toml b/crates/rusty_esp_mid-esp/Cargo.toml index 64d4134..03a479b 100644 --- a/crates/rusty_esp_mid-esp/Cargo.toml +++ b/crates/rusty_esp_mid-esp/Cargo.toml @@ -22,6 +22,9 @@ alloc = ["rusty_esp_mid-core/alloc"] # (`esp-storage/esp32s3` and the like); no `alloc` is needed for them, so # the core-only rung stays measurable underneath. esp-hal = ["dep:esp-hal", "dep:esp-storage"] +# The sealing key from the HMAC peripheral (`hal::seal_key`): chips that have +# one (C3, C6, H2, S2, S3). The firmware turns it on with its chip. +seal-hmac = ["esp-hal", "dep:nb"] # Track A — std on ESP-IDF via esp-idf-svc: `idf::{EspNvsKv, EspRng}`. esp-idf = ["std", "dep:esp-idf-svc"] # Development only: let `EspNvsKv::open` accept a plaintext NVS partition. @@ -35,6 +38,8 @@ esp-idf-svc = { version = "0.52", optional = true } # esp-storage 0.10.0 today) and selects the chip. esp-hal = { version = "1.2", default-features = false, features = ["unstable"], optional = true } esp-storage = { version = "0.10", optional = true } +# esp-hal's HMAC driver answers in `nb` results +nb = { version = "1.1", optional = true } [build-dependencies] # Re-emits esp-idf-sys's cfgs to this crate (build.rs); a no-op on the host. diff --git a/crates/rusty_esp_mid-esp/src/hal/mod.rs b/crates/rusty_esp_mid-esp/src/hal/mod.rs index 5aff231..27af41c 100644 --- a/crates/rusty_esp_mid-esp/src/hal/mod.rs +++ b/crates/rusty_esp_mid-esp/src/hal/mod.rs @@ -7,6 +7,9 @@ //! too. The two tracks read each other's partitions. //! - [`find_partition`]: the partition table at `0x8000`, by label. //! - [`EspHalRng`]: the chip's hardware generator behind `Rng`. +//! - `seal_key` (feature `seal-hmac`): the key a +//! [`SealedKv`](rusty_esp_mid_core::seal::SealedKv) seals secrets under, +//! from the HMAC peripheral and an eFuse key block. //! - [`SharedPartition`] / [`open_shared`] / [`Store`]: several partitions //! (the owner's settings, the identity) open at once on one flash, as the //! setup session holds them. @@ -19,9 +22,13 @@ pub mod flash; pub mod partitions; pub mod rng; +#[cfg(feature = "seal-hmac")] +pub mod seal; pub mod shared; pub use flash::PartitionFlash; pub use partitions::{Partition, find_partition}; pub use rng::EspHalRng; +#[cfg(feature = "seal-hmac")] +pub use seal::seal_key; pub use shared::{SharedFlash, SharedNvs, SharedPartition, Store, open_shared}; diff --git a/crates/rusty_esp_mid-esp/src/hal/partitions.rs b/crates/rusty_esp_mid-esp/src/hal/partitions.rs index 82cbccb..ffe3698 100644 --- a/crates/rusty_esp_mid-esp/src/hal/partitions.rs +++ b/crates/rusty_esp_mid-esp/src/hal/partitions.rs @@ -1,13 +1,56 @@ -//! The ESP-IDF partition table at `0x8000`: 32-byte entries — a magic, -//! a type and subtype, an offset, a length, a 16-byte label, flags — ended -//! by an MD5 entry or blank flash. Read through `esp-storage`, one entry -//! at a time, to find a label. +//! The ESP-IDF partition table: 32-byte entries — a magic, a type and +//! subtype, an offset, a length, a 16-byte label, flags — ended by an MD5 +//! entry or blank flash. Read through `esp-storage` in one piece to find a +//! label. +//! +//! **Where.** `0x8000` unless the build moves it with +//! `ESP_BOOTLOADER_ESP_IDF_CONFIG_PARTITION_TABLE_OFFSET`, the setting +//! `esp-bootloader-esp-idf` reads, so the two never disagree. A bootloader +//! with secure boot and flash encryption does not fit under `0x8000` on the +//! C6 (36,800 B), and its table moves to `0x10000`. +//! +//! **Encrypted.** Under flash encryption the table is ciphertext on the +//! flash; it is read through the cache, which decrypts it. use esp_storage::FlashStorage; use rusty_esp_mid_core::esp_core::error::{Error, Result}; /// Where the bootloader keeps the table. -pub const TABLE_OFFSET: u32 = 0x8000; +pub const TABLE_OFFSET: u32 = + match option_env!("ESP_BOOTLOADER_ESP_IDF_CONFIG_PARTITION_TABLE_OFFSET") { + Some(v) => parse_u32(v), + None => 0x8000, + }; + +/// A decimal or `0x` hexadecimal `u32`, at compile time: a bad setting is a +/// build error, never a table looked for in the wrong place. +const fn parse_u32(s: &str) -> u32 { + let b = s.as_bytes(); + let (mut i, radix) = if b.len() > 2 && b[0] == b'0' && (b[1] == b'x' || b[1] == b'X') { + (2, 16) + } else { + (0, 10) + }; + assert!(i < b.len(), "an empty partition table offset"); + let mut v: u32 = 0; + while i < b.len() { + let d = match b[i] { + c @ b'0'..=b'9' => (c - b'0') as u32, + c @ b'a'..=b'f' if radix == 16 => (c - b'a' + 10) as u32, + c @ b'A'..=b'F' if radix == 16 => (c - b'A' + 10) as u32, + _ => panic!("the partition table offset is not a number"), + }; + v = match v.checked_mul(radix) { + Some(m) => match m.checked_add(d) { + Some(n) => n, + None => panic!("the partition table offset overflows"), + }, + None => panic!("the partition table offset overflows"), + }; + i += 1; + } + v +} /// Entries the table can hold in its 3 KB (the last is the MD5). pub const MAX_ENTRIES: u32 = 95; @@ -56,11 +99,14 @@ impl core::fmt::Debug for Partition { /// The partition labelled `label`, or `Ok(None)`. `Corrupt` when an entry /// carries neither the entry magic nor the end marks. pub fn find_partition(storage: &mut FlashStorage<'_>, label: &str) -> Result> { - let mut entry = [0u8; 32]; - for i in 0..MAX_ENTRIES { - storage - .read_nor(TABLE_OFFSET + i * 32, &mut entry) - .map_err(|_| Error::Hardware)?; + let mut table = [0u8; MAX_ENTRIES as usize * 32]; + if esp_storage::flash_encryption() { + storage.read_encrypted(TABLE_OFFSET, &mut table) + } else { + storage.read_nor(TABLE_OFFSET, &mut table) + } + .map_err(|_| Error::Hardware)?; + for entry in table.chunks_exact(32) { if entry[..2] == MD5_MAGIC || entry[..2] == [0xFF, 0xFF] { return Ok(None); } diff --git a/crates/rusty_esp_mid-esp/src/hal/seal.rs b/crates/rusty_esp_mid-esp/src/hal/seal.rs new file mode 100644 index 0000000..f14601f --- /dev/null +++ b/crates/rusty_esp_mid-esp/src/hal/seal.rs @@ -0,0 +1,35 @@ +//! The sealing key for [`SealedKv`](rusty_esp_mid_core::seal::SealedKv): +//! HMAC-SHA-256 over [`KEY_LABEL`] under an eFuse key block burned with the +//! `HMAC_UP` purpose, computed by the chip's HMAC peripheral. +//! +//! The eFuse key is read-protected when it is burned: software can ask the +//! peripheral for an HMAC under it, never for the key itself. So the sealing +//! key exists only while the firmware runs, is the same on every boot, and +//! differs on every chip whose key block was burned with its own random key. +//! A flash read off one board opens nowhere. +//! +//! Burned at the factory (`espefuse.py burn_key BLOCK_KEYn key.bin +//! HMAC_UP`, a fresh random key per chip). A chip without one answers +//! `None`, and a firmware runs with its secrets in the clear and says so. +//! +//! Chips with an HMAC peripheral only (the ESP32-C3, -C6, -H2, -S2 and -S3); +//! the `seal-hmac` feature is the firmware's to turn on. + +use esp_hal::hmac::{Hmac, HmacPurpose, KeyId}; +use esp_hal::peripherals::HMAC; +use rusty_esp_mid_core::seal::KEY_LABEL; + +/// The sealing key under eFuse key block `block`, or `None` when that block +/// holds no key burned for `HMAC_UP`. +pub fn seal_key(hmac: HMAC<'_>, block: KeyId) -> Option<[u8; 32]> { + let mut h = Hmac::new(hmac); + h.init(); + nb::block!(h.configure(HmacPurpose::ToUser, block)).ok()?; + let mut rest = KEY_LABEL; + while !rest.is_empty() { + rest = nb::block!(h.update(rest)).ok()?; + } + let mut key = [0u8; 32]; + nb::block!(h.finalize(&mut key)).ok()?; + Some(key) +} From 1e2b4af88709db011685649401290f9a4b54136d Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 15 Oct 2026 12:46:12 -0700 Subject: [PATCH 3/4] seal: rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/rusty_esp_mid-core/src/seal.rs | 44 ++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/rusty_esp_mid-core/src/seal.rs b/crates/rusty_esp_mid-core/src/seal.rs index 18cb463..36233eb 100644 --- a/crates/rusty_esp_mid-core/src/seal.rs +++ b/crates/rusty_esp_mid-core/src/seal.rs @@ -206,11 +206,17 @@ mod tests { fn a_value_reads_back_and_is_not_on_the_flash_in_the_clear() { let mut kv = sealed(); kv.put("wifi.psk", b"correct horse battery").unwrap(); - assert_eq!(get(&kv, "wifi.psk").unwrap().unwrap(), b"correct horse battery"); + assert_eq!( + get(&kv, "wifi.psk").unwrap().unwrap(), + b"correct horse battery" + ); let stored = raw(&kv, "wifi.psk"); assert!(stored.starts_with(&MAGIC)); assert_eq!(stored.len(), 21 + OVERHEAD); - assert!(!stored.windows(5).any(|w| w == b"horse"), "no plaintext at rest"); + assert!( + !stored.windows(5).any(|w| w == b"horse"), + "no plaintext at rest" + ); assert_eq!(kv.is_sealed("wifi.psk").unwrap(), Some(true)); assert_eq!(kv.is_sealed("absent").unwrap(), None); assert!(get(&kv, "absent").unwrap().is_none()); @@ -240,7 +246,11 @@ mod tests { kv.inner.put("name", &good).unwrap(); assert_eq!(get(&kv, "name"), Err(Error::Crypto), "the name is bound in"); // the right name, another device's key - let mut other = SealedKv::new(MemoryKv::default(), InsecureTestRng::seeded(2), Some(&[8; 32])); + let mut other = SealedKv::new( + MemoryKv::default(), + InsecureTestRng::seeded(2), + Some(&[8; 32]), + ); other.inner.put("wifi.psk", &good).unwrap(); assert_eq!(get(&other, "wifi.psk"), Err(Error::Crypto)); // and a sealed value with no key at all is refused, not returned raw @@ -257,13 +267,25 @@ mod tests { let mut kv = sealed(); kv.inner.put("wifi.ssid", b"home").unwrap(); kv.inner.put("wifi.psk", b"hunter22").unwrap(); - assert_eq!(get(&kv, "wifi.psk").unwrap().unwrap(), b"hunter22", "still reads"); + assert_eq!( + get(&kv, "wifi.psk").unwrap().unwrap(), + b"hunter22", + "still reads" + ); assert_eq!(kv.is_sealed("wifi.psk").unwrap(), Some(false)); - assert_eq!(kv.seal_in_place(&["wifi.ssid", "wifi.psk", "absent"]).unwrap(), 2); + assert_eq!( + kv.seal_in_place(&["wifi.ssid", "wifi.psk", "absent"]) + .unwrap(), + 2 + ); assert_eq!(kv.is_sealed("wifi.psk").unwrap(), Some(true)); assert_eq!(get(&kv, "wifi.psk").unwrap().unwrap(), b"hunter22"); assert_eq!(get(&kv, "wifi.ssid").unwrap().unwrap(), b"home"); - assert_eq!(kv.seal_in_place(&["wifi.ssid", "wifi.psk"]).unwrap(), 0, "once"); + assert_eq!( + kv.seal_in_place(&["wifi.ssid", "wifi.psk"]).unwrap(), + 0, + "once" + ); } #[test] @@ -285,12 +307,18 @@ mod tests { #[test] fn sizes_are_refused_not_truncated() { let mut kv = sealed(); - assert_eq!(kv.put("big", &[1u8; MAX_VALUE + 1]), Err(Error::Unsupported)); + assert_eq!( + kv.put("big", &[1u8; MAX_VALUE + 1]), + Err(Error::Unsupported) + ); kv.put("max", &[2u8; MAX_VALUE]).unwrap(); assert_eq!(get(&kv, "max").unwrap().unwrap().len(), MAX_VALUE); kv.put("k", b"twelve bytes").unwrap(); let mut small = [0u8; 4]; - assert_eq!(kv.get("k", &mut small), Err(Error::BufferTooSmall { needed: 12 })); + assert_eq!( + kv.get("k", &mut small), + Err(Error::BufferTooSmall { needed: 12 }) + ); // a value stored in the clear, longer than any sealed one, still reads kv.inner.put("legacy", &[3u8; 400]).unwrap(); let mut big = [0u8; 400]; From 47e4a563195eab0fba7c327fc91d9bfc4ac7bb90 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 15 Oct 2026 15:28:37 -0700 Subject: [PATCH 4/4] seal: SealedKv::inner, so a firmware can scrub the store after sealing in place (NVS keeps a rewritten value's old bytes until its page is erased) Co-Authored-By: Claude Opus 5.5 --- crates/rusty_esp_mid-core/src/seal.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/rusty_esp_mid-core/src/seal.rs b/crates/rusty_esp_mid-core/src/seal.rs index 36233eb..9e2dc26 100644 --- a/crates/rusty_esp_mid-core/src/seal.rs +++ b/crates/rusty_esp_mid-core/src/seal.rs @@ -71,6 +71,12 @@ impl SealedKv { self.cipher.is_some() } + /// The store underneath, borrowed (to scrub it after sealing in place: + /// NVS keeps a rewritten value's old bytes until its page is erased). + pub const fn inner(&self) -> &K { + &self.inner + } + /// The store underneath. pub fn into_inner(self) -> K { self.inner