Skip to main content

ssh_stamp/
store.rs

1// SPDX-FileCopyrightText: 2026 Roman Valls Guimera <brainstorm@nopcode.org>
2// SPDX-FileCopyrightText: 2026 pancake <pancake@nopcode.org>
3// SPDX-FileCopyrightText: 2026 Anthony Tambasco <anthony.tambasco@fastmail.com>
4// SPDX-FileCopyrightText: 2026 Marko Malenic <mmalenic1@gmail.com>
5//
6// SPDX-License-Identifier: GPL-3.0-or-later
7
8use embedded_storage::nor_flash::{NorFlash, ReadNorFlash};
9
10use ssh_key::sha2::Digest;
11
12use log::{debug, error};
13
14use sunset::error::Error as SunsetError;
15
16use crate::config::{SSHStampConfig, UartPins};
17
18use sunset::sshwire::{self, OwnOrBorrow};
19use sunset_sshwire_derive::{SSHDecode, SSHEncode};
20
21// TODO: [Nice to have] Read the right partition and write there instead of hardcoding offset and size.
22pub const CONFIG_VERSION_SIZE: usize = 4;
23pub const CONFIG_HASH_SIZE: usize = 32;
24pub const CONFIG_AREA_SIZE: usize = 4096;
25pub const CONFIG_OFFSET: usize = 0x9000;
26
27// SSHConfig::CURRENT_VERSION must be bumped if any of this struct
28#[derive(SSHEncode, SSHDecode)]
29struct FlashConfig<'a> {
30    version: u8,
31    config: OwnOrBorrow<'a, SSHStampConfig>,
32    /// sha256 hash of config
33    hash: [u8; 32],
34}
35
36impl FlashConfig<'_> {
37    const BUF_SIZE: usize = 460; // Must be enough to hold the whole config
38}
39
40fn config_hash(config: &SSHStampConfig) -> Result<[u8; 32], SunsetError> {
41    let mut h = ssh_key::sha2::Sha256::new();
42    sshwire::hash_ser(&mut h, config)?;
43    Ok(h.finalize().into())
44}
45
46/// Loads a `SSHStampConfig` from flash, or creates a new one if none exists.
47///
48/// `default_mac` is used only when a new config has to be minted (e.g. first
49/// boot); the platform reads this from hardware and passes it in.
50///
51/// `default_uart_pins` is the target-specific UART pin assignment, used when
52/// creating a new config. On subsequent boots the pins are loaded from flash.
53///
54/// # Errors
55/// Returns an error if config creation or flash write fails.
56pub fn load_or_create<F>(
57    flash: &mut F,
58    buf: &mut [u8],
59    default_mac: [u8; 6],
60    default_uart_pins: UartPins,
61) -> Result<SSHStampConfig, SunsetError>
62where
63    F: NorFlash,
64{
65    match load_checked(flash, buf) {
66        Ok(mut c) => {
67            debug!("Good existing config");
68            if c.wifi_ap_ssid.as_str() == "ssh-stamp" {
69                debug!("Migrating insecure default Access Point SSID, regenerating randomly");
70                c.wifi_ap_ssid = SSHStampConfig::generate_wifi_ssid()?;
71                if c.wifi_ap_pw.is_empty() {
72                    c.wifi_ap_pw = SSHStampConfig::generate_wifi_password()?;
73                }
74                save(flash, buf, &c)?;
75            }
76            Ok(c)
77        }
78        // A config exists but failed the version or integrity check (or the
79        // flash read errored). Recreating here would silently wipe the stored
80        // pubkeys, regenerate the host key, and reopen the unauthenticated
81        // first-login window, so refuse rather than fail open.
82        Err(LoadError::Invalid(e)) => {
83            error!("Existing config present but invalid; refusing to overwrite it: {e}");
84            Err(e)
85        }
86        // No decodable config at all (blank/erased flash on first boot). This
87        // is the only case where minting a fresh config is the right thing.
88        Err(LoadError::Absent) => {
89            debug!("No existing config found, creating a new one");
90            create(flash, buf, default_mac, default_uart_pins)
91        }
92    }
93}
94
95/// Creates a new `SSHStampConfig` and saves it to flash.
96///
97/// # Errors
98/// Returns an error if config creation or flash write fails.
99pub fn create<F>(
100    flash: &mut F,
101    buf: &mut [u8],
102    default_mac: [u8; 6],
103    default_uart_pins: UartPins,
104) -> Result<SSHStampConfig, SunsetError>
105where
106    F: NorFlash,
107{
108    let c = SSHStampConfig::new(default_mac, default_uart_pins)?;
109    save(flash, buf, &c)?;
110    // Don't Debug-print the config: it contains the Ed25519 host private key.
111    debug!("Created new config");
112
113    Ok(c)
114}
115
116/// Why an existing config could not be loaded from flash.
117///
118/// Kept as the error half of a `Result` rather than a three-way enum so the
119/// large `SSHStampConfig` stays in the `Ok` arm.
120enum LoadError {
121    /// No decodable config was present (blank/erased flash, e.g. first boot).
122    /// This is the only outcome for which minting a fresh config is correct.
123    Absent,
124    /// A config was structurally present but failed the version or hash check,
125    /// or the flash read itself errored. The caller must not overwrite it.
126    Invalid(SunsetError),
127}
128
129/// Reads and validates the config from flash, distinguishing "no config yet"
130/// from "a config is present but invalid" so callers can avoid silently
131/// wiping stored keys on the latter.
132fn load_checked<F>(flash: &mut F, buf: &mut [u8]) -> Result<SSHStampConfig, LoadError>
133where
134    F: ReadNorFlash,
135{
136    // If at some point you target a 64bit arch these can truncate and cause
137    // corruption of the bootloader or the ota partition.
138    let Ok(offset) = u32::try_from(CONFIG_OFFSET) else {
139        return Err(LoadError::Invalid(SunsetError::msg(
140            "CONFIG_OFFSET overflow",
141        )));
142    };
143
144    if flash.read(offset, buf).is_err() {
145        error!("flash read error 0x{CONFIG_OFFSET:x}");
146        // A transient read error is not proof the config is gone; do not wipe.
147        return Err(LoadError::Invalid(SunsetError::msg("flash error")));
148    }
149
150    // Undecodable bytes mean no config has been written yet (or the region is
151    // erased). This is the only path allowed to fall through to create().
152    let Ok((flash_config, _used)) = sshwire::read_ssh::<FlashConfig>(buf, None) else {
153        return Err(LoadError::Absent);
154    };
155
156    if flash_config.version != SSHStampConfig::CURRENT_VERSION {
157        error!("wrong config version on decode: {}", flash_config.version);
158        return Err(LoadError::Invalid(SunsetError::msg("wrong config version")));
159    }
160
161    // OwnOrBorrow::Own is the only variant that can be decoded from bytes
162    let OwnOrBorrow::Own(config) = flash_config.config else {
163        return Err(LoadError::Invalid(SunsetError::msg(
164            "unexpected borrowed config",
165        )));
166    };
167
168    let calc_hash = config_hash(&config).map_err(LoadError::Invalid)?;
169
170    if calc_hash != flash_config.hash {
171        return Err(LoadError::Invalid(SunsetError::msg("bad config hash")));
172    }
173
174    Ok(config)
175}
176
177/// Loads `SSHStampConfig` from flash.
178///
179/// # Errors
180/// Returns an error if flash read fails, config is absent, invalid, or the
181/// hash mismatches.
182pub fn load<F>(flash: &mut F, buf: &mut [u8]) -> Result<SSHStampConfig, SunsetError>
183where
184    F: ReadNorFlash,
185{
186    match load_checked(flash, buf) {
187        Ok(c) => Ok(c),
188        Err(LoadError::Absent) => Err(SunsetError::msg("failed to decode flash config")),
189        Err(LoadError::Invalid(e)) => Err(e),
190    }
191}
192
193/// Saves `SSHStampConfig` to flash.
194///
195/// # Errors
196/// Returns an error if flash write fails or config serialization fails.
197pub fn save<F>(flash: &mut F, buf: &mut [u8], config: &SSHStampConfig) -> Result<(), SunsetError>
198where
199    F: NorFlash,
200{
201    let sc = FlashConfig {
202        version: SSHStampConfig::CURRENT_VERSION,
203        config: OwnOrBorrow::Borrow(config),
204        hash: config_hash(config)?,
205    };
206
207    // NB: do not hex_dump `buf` (or the hash) here — the serialized config
208    // begins with the Ed25519 host private key and contains the WiFi passwords.
209    let l = sshwire::write_ssh(buf, &sc)?;
210
211    debug!("Erasing flash");
212
213    const { assert!(CONFIG_AREA_SIZE > FlashConfig::BUF_SIZE) };
214
215    // Write only the encoded config, rounded up to the flash write
216    // granularity, instead of the entire caller buffer. Writing the whole
217    // buffer persisted stale trailing RAM to flash and, for a buffer larger
218    // than the config area, would write past the erased region into the
219    // adjacent partition (NVS/PHY on ESP32).
220    let write_len = l
221        .checked_next_multiple_of(F::WRITE_SIZE)
222        .filter(|n| *n <= buf.len() && *n <= CONFIG_AREA_SIZE)
223        .ok_or_else(|| SunsetError::msg("encoded config too large for flash area"))?;
224
225    let offset =
226        u32::try_from(CONFIG_OFFSET).map_err(|_| SunsetError::msg("CONFIG_OFFSET overflow"))?;
227    let area_size = u32::try_from(CONFIG_AREA_SIZE)
228        .map_err(|_| SunsetError::msg("CONFIG_AREA_SIZE overflow"))?;
229
230    flash.erase(offset, offset + area_size).map_err(|_e| {
231        error!("flash erase error");
232        SunsetError::msg("flash erase error")
233    })?;
234
235    flash.write(offset, &buf[..write_len]).map_err(|_e| {
236        error!("flash write error");
237        SunsetError::msg("flash write error")
238    })?;
239
240    debug!("flash save done");
241    Ok(())
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::settings::KEY_SLOTS;
248    use core::str::FromStr;
249    use embedded_storage::nor_flash::{ErrorType, NorFlashErrorKind, ReadNorFlash};
250    use embedded_storage_inmemory::MemFlash;
251    use heapless::String;
252    use sunset::packets::Ed25519PubKey;
253    use sunset::sshwire::Blob;
254
255    type TestFlash = MemFlash<{ CONFIG_OFFSET + CONFIG_AREA_SIZE }, CONFIG_AREA_SIZE, 4>;
256
257    fn round_trip(config: &SSHStampConfig) {
258        let mut flash = TestFlash::new(0);
259        let mut buf = [0u8; CONFIG_AREA_SIZE];
260        save(&mut flash, &mut buf, config).unwrap();
261        let loaded = load(&mut flash, &mut buf).unwrap();
262        assert_eq!(&loaded, config);
263    }
264
265    fn test_config() -> SSHStampConfig {
266        SSHStampConfig::new([0x02; 6], UartPins { rx: 10, tx: 11 }).unwrap()
267    }
268
269    #[test]
270    fn config_with_round_trip() {
271        round_trip(&test_config());
272    }
273
274    #[test]
275    fn config_with_wifi_round_trip() {
276        let mut config = test_config();
277        config.pubkeys = [Some(Ed25519PubKey {
278            key: Blob([0x5a; 32]),
279        }); KEY_SLOTS];
280        config.wifi_sta_ssid = String::from_str(&"s".repeat(32)).unwrap();
281        config.wifi_sta_pw = String::from_str(&"p".repeat(63)).unwrap();
282
283        round_trip(&config);
284
285        let mut buf = [0u8; CONFIG_AREA_SIZE];
286        let written = sshwire::write_ssh(
287            &mut buf,
288            &FlashConfig {
289                version: SSHStampConfig::CURRENT_VERSION,
290                config: OwnOrBorrow::Borrow(&config),
291                hash: config_hash(&config).unwrap(),
292            },
293        )
294        .unwrap();
295        assert!(written <= FlashConfig::BUF_SIZE);
296        assert!(written <= CONFIG_AREA_SIZE);
297    }
298
299    const MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x01];
300    /// Matches esp-storage's word-sized writes.
301    const WRITE_GRANULARITY: usize = 4;
302    /// Enough to cover the config area plus the region that follows it, so a
303    /// write running past the erased area is visible to the assertions.
304    const FLASH_LEN: usize = CONFIG_OFFSET + 4 * CONFIG_AREA_SIZE;
305    /// Stand-in for stale RAM left in the shared flash buffer by an earlier
306    /// read; must never reach flash.
307    const STALE: u8 = 0xAA;
308
309    fn pins() -> UartPins {
310        UartPins { rx: 4, tx: 5 }
311    }
312
313    /// NOR-flash stand-in. Erased cells read `0xFF` and a write can only clear
314    /// bits (`&=`), as on real NOR, so writing over an unerased cell shows up
315    /// as corruption instead of silently succeeding.
316    struct MockFlash {
317        cells: std::vec::Vec<u8>,
318    }
319
320    impl MockFlash {
321        fn erased() -> Self {
322            Self {
323                cells: std::vec![0xFF; FLASH_LEN],
324            }
325        }
326
327        fn config_area(&self) -> &[u8] {
328            &self.cells[CONFIG_OFFSET..CONFIG_OFFSET + CONFIG_AREA_SIZE]
329        }
330
331        fn past_config_area(&self) -> &[u8] {
332            &self.cells[CONFIG_OFFSET + CONFIG_AREA_SIZE..]
333        }
334
335        fn bounds(&self, offset: u32, len: usize) -> Result<(usize, usize), NorFlashErrorKind> {
336            let start = offset as usize;
337            let end = start
338                .checked_add(len)
339                .ok_or(NorFlashErrorKind::OutOfBounds)?;
340            if end > self.cells.len() {
341                return Err(NorFlashErrorKind::OutOfBounds);
342            }
343            Ok((start, end))
344        }
345    }
346
347    impl ErrorType for MockFlash {
348        type Error = NorFlashErrorKind;
349    }
350
351    impl ReadNorFlash for MockFlash {
352        const READ_SIZE: usize = 1;
353
354        fn read(&mut self, offset: u32, bytes: &mut [u8]) -> Result<(), Self::Error> {
355            let (start, end) = self.bounds(offset, bytes.len())?;
356            bytes.copy_from_slice(&self.cells[start..end]);
357            Ok(())
358        }
359
360        fn capacity(&self) -> usize {
361            self.cells.len()
362        }
363    }
364
365    impl NorFlash for MockFlash {
366        const WRITE_SIZE: usize = WRITE_GRANULARITY;
367        const ERASE_SIZE: usize = CONFIG_AREA_SIZE;
368
369        fn erase(&mut self, from: u32, to: u32) -> Result<(), Self::Error> {
370            let (start, end) = self.bounds(from, (to - from) as usize)?;
371            self.cells[start..end].fill(0xFF);
372            Ok(())
373        }
374
375        fn write(&mut self, offset: u32, bytes: &[u8]) -> Result<(), Self::Error> {
376            if !(offset as usize).is_multiple_of(Self::WRITE_SIZE)
377                || !bytes.len().is_multiple_of(Self::WRITE_SIZE)
378            {
379                return Err(NorFlashErrorKind::NotAligned);
380            }
381            let (start, end) = self.bounds(offset, bytes.len())?;
382            for (cell, byte) in self.cells[start..end].iter_mut().zip(bytes) {
383                *cell &= byte;
384            }
385            Ok(())
386        }
387    }
388
389    /// Length of the encoded `FlashConfig` for `config`, so tests can poke at
390    /// the trailing hash without guessing where it lands.
391    fn encoded_len(config: &SSHStampConfig) -> usize {
392        let sc = FlashConfig {
393            version: SSHStampConfig::CURRENT_VERSION,
394            config: OwnOrBorrow::Borrow(config),
395            hash: config_hash(config).unwrap(),
396        };
397        let mut probe = [0u8; CONFIG_AREA_SIZE];
398        sshwire::write_ssh(&mut probe, &sc).unwrap()
399    }
400
401    /// Blank flash must still mint a config: classifying an erased region as
402    /// `Invalid` rather than `Absent` would leave a fresh device unable to boot.
403    #[test]
404    fn blank_flash_mints_a_config() {
405        let mut flash = MockFlash::erased();
406        let mut buf = [0u8; CONFIG_AREA_SIZE];
407
408        let created = load_or_create(&mut flash, &mut buf, MAC, pins())
409            .expect("first boot on erased flash must create a config");
410        assert!(created.first_login);
411
412        let mut buf = [0u8; CONFIG_AREA_SIZE];
413        let reloaded = load(&mut flash, &mut buf).expect("the config just written must load back");
414        assert_eq!(created, reloaded);
415    }
416
417    /// A config whose version does not match (e.g. an OTA bumping
418    /// `CURRENT_VERSION`) must not be recreated: that would regenerate the host
419    /// key and reopen the unauthenticated first-login window.
420    #[test]
421    fn wrong_version_is_refused_without_overwriting() {
422        let mut flash = MockFlash::erased();
423        let mut buf = [0u8; CONFIG_AREA_SIZE];
424        load_or_create(&mut flash, &mut buf, MAC, pins()).unwrap();
425
426        // The version byte leads the encoded FlashConfig.
427        flash.cells[CONFIG_OFFSET] = SSHStampConfig::CURRENT_VERSION.wrapping_add(1);
428        let before = flash.cells.clone();
429
430        let mut buf = [0u8; CONFIG_AREA_SIZE];
431        assert!(
432            load_or_create(&mut flash, &mut buf, MAC, pins()).is_err(),
433            "a version mismatch must fail closed, not mint a new config"
434        );
435        assert_eq!(flash.cells, before, "flash must be left untouched");
436    }
437
438    /// Same for a config that fails its integrity check.
439    #[test]
440    fn bad_hash_is_refused_without_overwriting() {
441        let mut flash = MockFlash::erased();
442        let mut buf = [0u8; CONFIG_AREA_SIZE];
443        let config = load_or_create(&mut flash, &mut buf, MAC, pins()).unwrap();
444
445        // The sha256 occupies the last CONFIG_HASH_SIZE bytes of the record.
446        let hash_start = CONFIG_OFFSET + encoded_len(&config) - CONFIG_HASH_SIZE;
447        flash.cells[hash_start] ^= 0xFF;
448        let before = flash.cells.clone();
449
450        let mut buf = [0u8; CONFIG_AREA_SIZE];
451        assert!(
452            load_or_create(&mut flash, &mut buf, MAC, pins()).is_err(),
453            "a hash mismatch must fail closed, not mint a new config"
454        );
455        assert_eq!(flash.cells, before, "flash must be left untouched");
456    }
457
458    /// `save` must persist only the encoded config, not the rest of the shared
459    /// flash buffer, which carries whatever the last read left behind.
460    #[test]
461    fn save_does_not_persist_stale_buffer_bytes() {
462        let mut flash = MockFlash::erased();
463        let mut buf = [STALE; CONFIG_AREA_SIZE];
464        let config = SSHStampConfig::new(MAC, pins()).unwrap();
465
466        save(&mut flash, &mut buf, &config).unwrap();
467
468        let written = encoded_len(&config).next_multiple_of(WRITE_GRANULARITY);
469        assert!(
470            flash.config_area()[written..].iter().all(|&b| b == 0xFF),
471            "bytes past the encoded config were written to flash"
472        );
473        // Sanity: the config really is there, so the assertion above is not
474        // passing on an empty write.
475        let mut buf = [0u8; CONFIG_AREA_SIZE];
476        assert_eq!(load(&mut flash, &mut buf).unwrap(), config);
477    }
478
479    /// A buffer larger than the config area must not spill past the erased
480    /// region into the neighbouring partition.
481    #[test]
482    fn save_stays_within_the_config_area() {
483        let mut flash = MockFlash::erased();
484        let mut buf = [STALE; CONFIG_AREA_SIZE * 2];
485        let config = SSHStampConfig::new(MAC, pins()).unwrap();
486
487        save(&mut flash, &mut buf, &config).unwrap();
488
489        assert!(
490            flash.past_config_area().iter().all(|&b| b == 0xFF),
491            "save wrote past CONFIG_AREA_SIZE into the adjacent partition"
492        );
493    }
494}