Skip to main content

ssh_stamp/
config.rs

1// SPDX-FileCopyrightText: 2026 Roman Valls Guimera <brainstorm@nopcode.org>
2// SPDX-FileCopyrightText: 2026 Julio Beltran Ortega <jubeormk1@gmail.com>
3// SPDX-FileCopyrightText: 2026 Anthony Tambasco <anthony.tambasco@fastmail.com>
4//
5// SPDX-License-Identifier: GPL-3.0-or-later
6
7//! Configuration types and serialization.
8//!
9//! [`SSHStampConfig`] holds all persistent device state: host key, public keys,
10//! `WiFi` credentials, MAC address, UART pins and line parameters, and the
11//! first-login flag. It is serialized to flash via the `sunset` SSH wire format and
12//! deserialized on boot by [`store::load_or_create`](crate::store::load_or_create).
13//!
14//! On first boot, [`SSHStampConfig::new`] generates a random SSID and WPA2
15//! PSK (printed to the serial console).
16
17use log::{debug, warn};
18
19use core::net::Ipv4Addr;
20#[cfg(feature = "ipv6")]
21use core::net::Ipv6Addr;
22use core::str::FromStr;
23use embassy_net::{Ipv4Cidr, StaticConfigV4};
24#[cfg(feature = "ipv6")]
25use embassy_net::{Ipv6Cidr, StaticConfigV6};
26use heapless::String;
27use ssh_key::PublicKey;
28use ssh_key::public::KeyData;
29use ssh_stamp_hal::UartParams;
30
31use sunset::packets::Ed25519PubKey;
32use sunset::{KeyType, Result};
33use sunset::{
34    SignKey,
35    sshwire::{Blob, SSHDecode, SSHEncode, SSHSink, SSHSource, WireError, WireResult},
36};
37
38use crate::errors::Error;
39use crate::settings::{KEY_SLOTS, WIFI_PASSWORD_CHARS};
40
41#[derive(Debug, PartialEq)]
42pub struct SSHStampConfig {
43    pub hostkey: SignKey,
44
45    /// Authentication: only pubkey-based auth supported
46    pub pubkeys: [Option<Ed25519PubKey>; KEY_SLOTS],
47
48    /// `WiFi`
49    /// Access Point Mode
50    pub wifi_ap_ssid: String<32>,
51    pub wifi_ap_pw: String<63>,
52    /// AP band mode (2.4GHz / 5GHz / Auto). Ignored on chips without 5GHz.
53    pub wifi_ap_band: u8,
54    /// Station Mode
55    pub wifi_sta_ssid: String<32>,
56    pub wifi_sta_pw: String<63>,
57    /// Networking
58    /// MAC address. Special values:
59    /// - `[0xFF; 6]`: Generate random MAC on each boot
60    /// - Otherwise: Use the stored MAC (defaults to hardware eFuse MAC)
61    pub mac: [u8; 6],
62    /// `None` for DHCP
63    pub ipv4_static: Option<StaticConfigV4>,
64    #[cfg(feature = "ipv6")]
65    pub ipv6_static: Option<StaticConfigV6>,
66    /// UART
67    pub uart_pins: UartPins,
68    /// UART line parameters (baud, data bits, parity, stop bits) for the
69    /// serial bridge. Settable via the `SSH_STAMP_UART_*` env vars.
70    pub uart_params: UartParams,
71    /// True until a pubkey is provisioned. Further changes require authentication.
72    pub first_login: bool,
73}
74
75/// UART pin assignment.
76///
77/// UART TX and RX pin numbers are target-specific and must be provided
78/// by the port binary (e.g. `ssh-stamp-esp32`). There is no sensible
79/// cross-platform default; `UartPins` is constructed explicitly by the
80/// binary and passed to [`SSHStampConfig::new`].
81#[derive(Debug, PartialEq)]
82pub struct UartPins {
83    pub rx: u8,
84    pub tx: u8,
85}
86
87const MAC_RANDOM_SENTINEL: [u8; 6] = [0xFF; 6];
88
89impl SSHStampConfig {
90    /// Bump this when the format changes
91    pub const CURRENT_VERSION: u8 = 12;
92
93    /// Check if configured for random MAC on each boot
94    #[must_use]
95    pub fn is_mac_random(&self) -> bool {
96        self.mac == MAC_RANDOM_SENTINEL
97    }
98
99    /// Get the MAC address to use (resolves random sentinel)
100    /// # Errors
101    /// Returns an error if the RNG fails
102    pub fn resolve_mac(&self) -> Result<[u8; 6]> {
103        if self.is_mac_random() {
104            random_mac()
105        } else {
106            Ok(self.mac)
107        }
108    }
109
110    /// Creates a new config with default parameters.
111    ///
112    /// `default_mac` is the MAC the platform wants the device to default to
113    /// (typically read from hardware OTP/eFuse). Stored as-is in the config;
114    /// may be overwritten later via the `SSH_STAMP_WIFI_MAC_*` env vars.
115    ///
116    /// `uart_pins` is the TX/RX pin assignment, which is target-specific and
117    /// must be provided by the port binary.
118    ///
119    /// # Errors
120    /// Will only fail on RNG failure.
121    pub fn new(default_mac: [u8; 6], uart_pins: UartPins) -> Result<Self> {
122        let hostkey = SignKey::generate(KeyType::Ed25519, None)?;
123
124        // Wifi Access Point Mode
125        let wifi_ap_ssid = Self::generate_wifi_ssid()?;
126        let wifi_ap_pw = Self::generate_wifi_password()?;
127        let wifi_ap_band = 0; // BandMode::Band2_4G (default)
128        // Wifi Station Mode
129        let wifi_sta_ssid = String::<32>::new();
130        let wifi_sta_pw = String::<63>::new();
131        let mac = default_mac;
132
133        debug!(
134            "SSH Stamp Config new() - RX Pin: {}  TX Pin: {}",
135            uart_pins.rx, uart_pins.tx
136        );
137
138        Ok(SSHStampConfig {
139            hostkey,
140            pubkeys: Default::default(),
141            wifi_ap_ssid,
142            wifi_ap_pw,
143            wifi_ap_band,
144            wifi_sta_ssid,
145            wifi_sta_pw,
146            mac,
147            ipv4_static: None,
148            #[cfg(feature = "ipv6")]
149            ipv6_static: None,
150            uart_pins,
151            uart_params: UartParams::default(),
152            first_login: true,
153        })
154    }
155
156    pub(crate) fn generate_wifi_ssid() -> Result<String<32>> {
157        let mut rnd = [0u8; 16];
158        getrandom::fill(&mut rnd).map_err(|_| sunset::Error::msg("RNG failed"))?;
159        let mut ssid = String::<32>::new();
160        for &byte in &rnd {
161            let _ = ssid.push(WIFI_PASSWORD_CHARS[(byte as usize) % 62] as char);
162        }
163        Ok(ssid)
164    }
165
166    pub(crate) fn generate_wifi_password() -> Result<String<63>> {
167        let mut rnd = [0u8; 24];
168        getrandom::fill(&mut rnd).map_err(|_| sunset::Error::msg("RNG failed"))?;
169        let mut pw = String::<63>::new();
170        for &byte in &rnd {
171            let _ = pw.push(WIFI_PASSWORD_CHARS[(byte as usize) % 62] as char);
172        }
173        Ok(pw)
174    }
175
176    // Password functions removed; pubkey-only auth supported.
177
178    pub(crate) fn add_pubkey(&mut self, key_str: &str) -> Result<(), Error> {
179        // Accept OpenSSH public key format (e.g. "ssh-ed25519 AAAA...") and
180        // validate it is an Ed25519 key. Insert into the first empty slot or
181        // overwrite slot 0 if none empty.
182
183        debug!(
184            "Checking pubkey string passed through ENV: {}",
185            key_str.trim()
186        );
187
188        let openssh = PublicKey::from_str(key_str.trim())?;
189
190        debug!("Public key format valid, continuing to parse");
191
192        match openssh.key_data() {
193            KeyData::Ed25519(k) => {
194                let bytes = k.0; // [u8; 32]
195                let newk = Ed25519PubKey { key: Blob(bytes) };
196
197                debug!("Parsed Ed25519 public key, adding to config");
198                for slot in &mut self.pubkeys {
199                    if slot.is_none() {
200                        *slot = Some(newk);
201                        return Ok(());
202                    }
203                }
204
205                warn!("Public key slots full, overwriting the first one");
206                // SECURITY: Allow this on FirstAuth ON FIRST BOOT ONLY.
207                self.pubkeys[0] = Some(newk);
208                Ok(())
209            }
210            _ => Err(Error::BadKey),
211        }
212    }
213}
214
215fn random_mac() -> Result<[u8; 6]> {
216    let mut mac = [0u8; 6];
217    getrandom::fill(&mut mac).map_err(|_| sunset::Error::msg("RNG failed"))?;
218    // unicast, locally administered
219    mac[0] = (mac[0] & 0xfc) | 0x02;
220    Ok(mac)
221}
222
223// a private encoding specific to demo config, not SSH defined.
224fn enc_signkey(k: &SignKey, s: &mut dyn SSHSink) -> WireResult<()> {
225    // need to add a variant field if we support more key types.
226    match k {
227        SignKey::Ed25519(k) => k.to_bytes().enc(s),
228        SignKey::AgentEd25519(_) => Err(WireError::UnknownVariant),
229    }
230}
231
232fn dec_signkey<'de, S>(s: &mut S) -> WireResult<SignKey>
233where
234    S: SSHSource<'de>,
235{
236    let k: ed25519_dalek::SecretKey = SSHDecode::dec(s)?;
237    let k = ed25519_dalek::SigningKey::from_bytes(&k);
238    Ok(SignKey::Ed25519(k))
239}
240
241// encode Option<T> as a bool then maybe a value
242pub(crate) fn enc_option<T: SSHEncode>(v: Option<&T>, s: &mut dyn SSHSink) -> WireResult<()> {
243    v.is_some().enc(s)?;
244    if let Some(v) = v {
245        v.enc(s)?;
246    }
247    Ok(())
248}
249
250pub(crate) fn dec_option<'de, S, T: SSHDecode<'de>>(s: &mut S) -> WireResult<Option<T>>
251where
252    S: SSHSource<'de>,
253{
254    bool::dec(s)?.then(|| SSHDecode::dec(s)).transpose()
255}
256
257fn enc_ipv4_config(v: Option<&StaticConfigV4>, s: &mut dyn SSHSink) -> WireResult<()> {
258    v.is_some().enc(s)?;
259    if let Some(v) = v {
260        v.address.address().to_bits().enc(s)?;
261        debug!("enc_ipv4_config: prefix = {}", v.address.prefix_len());
262        v.address.prefix_len().enc(s)?;
263        // to u32
264        let gw = v.gateway.as_ref().map(|g| g.to_bits());
265        enc_option(gw.as_ref(), s)?;
266    }
267    Ok(())
268}
269
270#[cfg(feature = "ipv6")]
271fn enc_ipv6_config(v: Option<&StaticConfigV6>, s: &mut dyn SSHSink) -> WireResult<()> {
272    v.is_some().enc(s)?;
273    if let Some(v) = v {
274        v.address.address().octets().enc(s)?;
275        v.address.prefix_len().enc(s)?;
276        let gw = v.gateway.as_ref().map(core::net::Ipv6Addr::octets);
277        enc_option(gw.as_ref(), s)?;
278    }
279    Ok(())
280}
281
282fn dec_ipv4_config<'de, S>(s: &mut S) -> WireResult<Option<StaticConfigV4>>
283where
284    S: SSHSource<'de>,
285{
286    let opt = bool::dec(s)?;
287    opt.then(|| {
288        let ad: u32 = SSHDecode::dec(s)?;
289        let ad = Ipv4Addr::from_bits(ad);
290        let prefix: u8 = SSHDecode::dec(s)?;
291        if prefix > 32 {
292            // embassy panics, so test it here
293            return Err(WireError::PacketWrong);
294        }
295        let gw: Option<u32> = dec_option(s)?;
296        let gateway = gw.map(Ipv4Addr::from_bits);
297        Ok(StaticConfigV4 {
298            address: Ipv4Cidr::new(ad, prefix),
299            gateway,
300            // The embassy-net heapless version is different so `Default::default()` must be
301            // used here.
302            dns_servers: Default::default(),
303        })
304    })
305    .transpose()
306}
307
308#[cfg(feature = "ipv6")]
309fn dec_ipv6_config<'de, S>(s: &mut S) -> WireResult<Option<StaticConfigV6>>
310where
311    S: SSHSource<'de>,
312{
313    let opt = bool::dec(s)?;
314    opt.then(|| {
315        let ad: [u8; 16] = SSHDecode::dec(s)?;
316        let ad = Ipv6Addr::from(ad);
317        let prefix = SSHDecode::dec(s)?;
318        if prefix > 32 {
319            // embassy panics, so test it here
320            return Err(WireError::PacketWrong);
321        }
322        let gw: Option<[u8; 16]> = dec_option(s)?;
323        let gateway = gw.map(Ipv6Addr::from);
324        Ok(StaticConfigV6 {
325            address: Ipv6Cidr::new(ad, prefix),
326            gateway,
327            dns_servers: Default::default(),
328        })
329    })
330    .transpose()
331}
332
333impl SSHEncode for SSHStampConfig {
334    fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> {
335        enc_signkey(&self.hostkey, s)?;
336
337        for k in &self.pubkeys {
338            enc_option(k.as_ref(), s)?;
339        }
340
341        // Wifi Access Point Mode
342        self.wifi_ap_ssid.as_str().enc(s)?;
343        self.wifi_ap_pw.as_str().enc(s)?;
344        self.wifi_ap_band.enc(s)?;
345        // Wifi Station Mode
346        self.wifi_sta_ssid.as_str().enc(s)?;
347        self.wifi_sta_pw.as_str().enc(s)?;
348        self.mac.enc(s)?;
349
350        enc_ipv4_config(self.ipv4_static.as_ref(), s)?;
351        #[cfg(feature = "ipv6")]
352        enc_ipv6_config(self.ipv6_static.as_ref(), s)?;
353
354        // Encode UartPins
355        self.uart_pins.rx.enc(s)?;
356        self.uart_pins.tx.enc(s)?;
357
358        // Encode UartParams
359        self.uart_params.baud.enc(s)?;
360        self.uart_params.data_bits.enc(s)?;
361        (self.uart_params.parity as u8).enc(s)?;
362        self.uart_params.stop_bits.enc(s)?;
363
364        // Persist first-login marker
365        self.first_login.enc(s)?;
366
367        Ok(())
368    }
369}
370
371impl<'de> SSHDecode<'de> for SSHStampConfig {
372    fn dec<S>(s: &mut S) -> WireResult<Self>
373    where
374        S: SSHSource<'de>,
375    {
376        let hostkey = dec_signkey(s)?;
377
378        let mut pubkeys = [None; KEY_SLOTS];
379        for k in &mut pubkeys {
380            *k = dec_option(s)?;
381        }
382
383        // Wifi Access Point Mode
384        let wifi_ap_ssid_str: &str = SSHDecode::dec(s)?;
385        let wifi_ap_ssid = String::try_from(wifi_ap_ssid_str).map_err(|_| WireError::BadString)?;
386        let wifi_ap_pw_str: &str = SSHDecode::dec(s)?;
387        let wifi_ap_pw = String::try_from(wifi_ap_pw_str).map_err(|_| WireError::BadString)?;
388        let wifi_ap_band: u8 = SSHDecode::dec(s)?;
389        // Wifi Station Mode
390        let wifi_sta_ssid_str: &str = SSHDecode::dec(s)?;
391        let wifi_sta_ssid =
392            String::try_from(wifi_sta_ssid_str).map_err(|_| WireError::BadString)?;
393        let wifi_sta_pw_str: &str = SSHDecode::dec(s)?;
394        let wifi_sta_pw = String::try_from(wifi_sta_pw_str).map_err(|_| WireError::BadString)?;
395
396        let mac = SSHDecode::dec(s)?;
397
398        let ipv4_static = dec_ipv4_config(s)?;
399        #[cfg(feature = "ipv6")]
400        let ipv6_static = dec_ipv6_config(s)?;
401
402        // Not supported by sshwire-derive nor virtue (no Option<u8> support)
403        // let uart_pins = SSHDecode::dec(s)?;
404        let rx: u8 = SSHDecode::dec(s)?;
405        let tx: u8 = SSHDecode::dec(s)?;
406        let uart_pins = UartPins { rx, tx };
407
408        // Decode UartParams
409        let baud: u32 = SSHDecode::dec(s)?;
410        let data_bits: u8 = SSHDecode::dec(s)?;
411        let parity: u8 = SSHDecode::dec(s)?;
412        let stop_bits: u8 = SSHDecode::dec(s)?;
413        let uart_params = UartParams {
414            baud,
415            data_bits,
416            parity: parity.into(),
417            stop_bits,
418        };
419
420        let first_login = SSHDecode::dec(s)?;
421
422        Ok(Self {
423            hostkey,
424            pubkeys,
425            wifi_ap_ssid,
426            wifi_ap_pw,
427            wifi_ap_band,
428            wifi_sta_ssid,
429            wifi_sta_pw,
430            mac,
431            ipv4_static,
432            #[cfg(feature = "ipv6")]
433            ipv6_static,
434            uart_pins,
435            uart_params,
436            first_login,
437        })
438    }
439}