diff --git a/README.md b/README.md index c369180..bdd7c9a 100644 --- a/README.md +++ b/README.md @@ -10,104 +10,94 @@ Your everyday SSH secured serial access. ## Description -The **SSH Stamp** is a secure wireless to UART bridge -implemented in Rust (no_std, no_alloc and no_unsafe whenever possible) -with simplicity and robustness as its main design tenets. - -The firmware runs on a microcontroller running Secure SHell Protocol -(RFC 4253 and related IETF standards series). This firmware can be -used for multiple purposes, conveniently avoiding physical -tethering and securely tunneling traffic via SSH by default: easily -add telemetry to a (moving) robot, monitor and operate any (domestic) -appliance remotely, conduct remote cybersecurity audits on -network gear of a company, reverse engineer hardware and software for -right to repair purposes, just to name a few examples. +The **SSH Stamp** is a secure wireless to UART bridge implemented in Rust (no_std, no_alloc and no_unsafe whenever possible) with simplicity and robustness as its main design tenets. + +The firmware runs on a microcontroller running Secure SHell Protocol (RFC 4253 and related IETF standards series). This firmware can be used for multiple purposes, conveniently avoiding physical tethering and securely tunneling traffic via SSH by default: easily add telemetry to a (moving) robot, monitor and operate any (domestic) appliance remotely, conduct remote cybersecurity audits on network gear of a company, reverse engineer hardware and software for right to repair purposes, just to name a few examples. A "low level to SSH Swiss army knife". # Building -Rust versions are controlled via `rust-toolchain.toml` and the equivalent defined on the CI workflow. - -On a fresh system the following should be enough to build and run on the relevant ESP32 dev boards. +Tooling is controlled by `rust-toolchain.toml`. On a fresh host you'll typically need the Rust source component and a flasher (we use `espflash` below as an example): -## Required for all targets: ``` rustup toolchain install stable --component rust-src cargo install espflash --locked ``` -## ESP32-C6 +Build/flash for your board using the short command pattern (replace `` with the concrete chip you have): +| Machine target | Rust toolchain target | +| --- | --- | +| `esp32` | `xtensa-esp32-none-elf` | +| `esp32c2` | `riscv32imc-unknown-none-elf` | +| `esp32c3` | `riscv32imc-unknown-none-elf` | +| `esp32c5` | `riscv32imac-unknown-none-elf` | +| `esp32c6` | `riscv32imac-unknown-none-elf` | +| `esp32c61` | `riscv32imac-unknown-none-elf` | +| `esp32s2` | `xtensa-esp32s2-none-elf` | +| `esp32s3` | `xtensa-esp32s3-none-elf` | ``` -rustup target add riscv32imac-unknown-none-elf -cargo build-esp32c6 -cargo run-esp32c6 +rustup target add +cargo build- # e.g. cargo build-esp32c6, cargo build-esp32c3, cargo build-esp32 +cargo run- # convenience helper (if supported) that builds + flashes ``` -## ESP32-C2 / ESP32-C3 -``` -rustup target add riscv32imc-unknown-none-elf -``` -### ESP32-C2 -``` -cargo build-esp32c2 -cargo run-esp32c2 -``` -### ESP32-C3 +Xtensa targets (ESP32/ESP32-S2/S3) do require `espup` in addition to the `rustup` command above: + ``` -cargo build-esp32c2 -cargo run-esp32c3 +cargo install espup +espup install +source $HOME/export-esp.sh ``` +## First boot & provisioning -## ESP32 / ESP32-S2 / ESP32-S3 (Xtensa Cores) -Install esp toolchain first: https://github.com/esp-rs/espup -``` -cargo install espup -espup install -source $HOME/export-esp.sh -``` +1. Flash the firmware and open the serial console (example): -### ESP32 -``` -cargo +esp build-esp32 -cargo +esp run-esp32 -``` -### ESP32-S2 -``` -cargo +esp build-esp32s2 -cargo +esp run-esp32s2 -``` -### ESP32-S3 ``` -cargo +esp build-esp32s3 -cargo +esp run-esp32s3 +# build & flash (example for esp32c6) +cargo build-esp32c6 --release +cargo run-esp32c6 ``` -### Using rustup toolchain override (Doesn't require `+esp`) -To set rustup override: -``` -rustup override set esp -``` -To remove rustup override: +1. On first boot the device generates a random WPA2 PSK and prints it to the serial console with the following (or similar) info messages: + ``` -cargo override unset +(...) +INFO - WIFI PSK: +INFO - Connect to the AP `ssh-stamp` as a DHCP client with IP: 192.168.4.1 ``` -Build: + +2. Connect a laptop/phone to the `ssh-stamp` AP using the printed PSK, then SSH into the device at `root@192.168.4.1`. + +3. Provisioning via SSH environment variables + +You can provision the device by sending these environment variables with your SSH client. Examples below use OpenSSH and `SendEnv` to forward local environment variables to the device. + +- Add your public key (first-boot only): + ``` -cargo build-esp32 -cargo build-esp32s2 -cargo build-esp32s3 +export SSH_STAMP_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)" +ssh -o SendEnv=SSH_STAMP_PUBKEY root@192.168.4.1 ``` -Run: + +- Set a custom SSID and WPA2 PSK (allowed on first-boot or any authenticated session): + ``` -cargo run-esp32 -cargo run-esp32s2 -cargo run-esp32s3 +export SSH_STAMP_WIFI_SSID="MyHomeSSID" +export SSH_STAMP_WIFI_PSK="my-super-secret-psk" +ssh -o SendEnv=SSH_STAMP_WIFI_SSID -o SendEnv=SSH_STAMP_WIFI_PSK root@192.168.4.1 ``` +Notes: +- `SSH_STAMP_PUBKEY` is accepted on first-boot to add the initial admin key. +- `SSH_STAMP_WIFI_SSID` and `SSH_STAMP_WIFI_PSK` may be applied while authenticated via pubkey (or on first-boot). After a successful change the device persists the settings and performs a software reset so the new WiFi settings take effect. +- If you prefer a single-step provisioning, export all three env vars locally and forward them with `SendEnv` in the same SSH invocation. + +If your SSH client doesn't forward environment variables by default, use the `-o SendEnv=VAR` option as shown above or configure `SendEnv` in your SSH client config. + # Default UART Pins | Target | RX | TX | | ---- | -- | -- | diff --git a/src/config.rs b/src/config.rs index 8a8559f..bdb9268 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use log::{debug, info, warn}; +use log::{debug, warn}; use core::net::Ipv4Addr; #[cfg(feature = "ipv6")] @@ -17,7 +17,9 @@ use sunset::{ }; use crate::errors::Error; -use crate::settings::{DEFAULT_SSID, DEFAULT_UART_RX_PIN, DEFAULT_UART_TX_PIN, KEY_SLOTS}; +use crate::settings::{ + DEFAULT_SSID, DEFAULT_UART_RX_PIN, DEFAULT_UART_TX_PIN, KEY_SLOTS, WIFI_PASSWORD_CHARS, +}; #[derive(Debug, PartialEq)] pub struct SSHStampConfig { @@ -40,8 +42,8 @@ pub struct SSHStampConfig { pub ipv6_static: Option, /// UART pub uart_pins: UartPins, - /// True until the device has been provisioned for the first time. - pub first_boot: bool, + /// True until a pubkey is provisioned. Further changes require authentication. + pub first_login: bool, } #[derive(Debug, PartialEq)] @@ -72,17 +74,14 @@ impl SSHStampConfig { let wifi_ssid = Self::default_ssid(); let mac = random_mac()?; - let wifi_pw = None; + let wifi_pw = Some(Self::generate_wifi_password()?); let uart_pins = UartPins::default(); - info!( + debug!( "SSH Stamp Config new() - RX Pin: {} TX Pin: {}", uart_pins.rx, uart_pins.tx ); - // No password config fields nor logic: only pubkey auth supported. - // Leave password fields out (except wifi one). - Ok(SSHStampConfig { hostkey, pubkeys: Default::default(), @@ -93,10 +92,20 @@ impl SSHStampConfig { #[cfg(feature = "ipv6")] ipv6_static: None, uart_pins, - first_boot: true, + first_login: true, }) } + fn generate_wifi_password() -> Result> { + let mut rnd = [0u8; 24]; + sunset::random::fill_random(&mut rnd)?; + let mut pw = String::<63>::new(); + for &byte in rnd.iter() { + let _ = pw.push(WIFI_PASSWORD_CHARS[(byte as usize) % 62] as char); + } + Ok(pw) + } + // Password functions removed; pubkey-only auth supported. pub(crate) fn default_ssid() -> String<32> { @@ -110,14 +119,14 @@ impl SSHStampConfig { // validate it is an Ed25519 key. Insert into the first empty slot or // overwrite slot 0 if none empty. - info!( + debug!( "Checking pubkey string passed through ENV: {}", key_str.trim() ); let openssh = ssh_key::PublicKey::from_str(key_str.trim())?; - info!("Public key format valid, continuing to parse"); + debug!("Public key format valid, continuing to parse"); match openssh.key_data() { ssh_key::public::KeyData::Ed25519(k) => { @@ -126,7 +135,7 @@ impl SSHStampConfig { key: sunset::sshwire::Blob(bytes), }; - info!("Parsed Ed25519 public key, adding to config"); + debug!("Parsed Ed25519 public key, adding to config"); for slot in self.pubkeys.iter_mut() { if slot.is_none() { *slot = Some(newk); @@ -289,8 +298,8 @@ impl SSHEncode for SSHStampConfig { self.uart_pins.rx.enc(s)?; self.uart_pins.tx.enc(s)?; - // Persist first-boot marker - self.first_boot.enc(s)?; + // Persist first-login marker + self.first_login.enc(s)?; Ok(()) } @@ -323,7 +332,7 @@ impl<'de> SSHDecode<'de> for SSHStampConfig { let tx: u8 = SSHDecode::dec(s)?; let uart_pins = UartPins { rx, tx }; - let first_boot = SSHDecode::dec(s)?; + let first_login = SSHDecode::dec(s)?; Ok(Self { hostkey, @@ -335,7 +344,7 @@ impl<'de> SSHDecode<'de> for SSHStampConfig { #[cfg(feature = "ipv6")] ipv6_static, uart_pins, - first_boot, + first_login, }) } } diff --git a/src/espressif/buffered_uart.rs b/src/espressif/buffered_uart.rs index 84231cb..48b58cc 100644 --- a/src/espressif/buffered_uart.rs +++ b/src/espressif/buffered_uart.rs @@ -21,7 +21,7 @@ use portable_atomic::{AtomicUsize, Ordering}; use static_cell::StaticCell; use sunset_async::SunsetMutex; -use log::info; +use log::debug; // Sizes of the software buffers. Inward is more // important as an overrun here drops bytes. A full outward @@ -132,14 +132,14 @@ impl Default for BufferedUart { pub async fn uart_buffer_disable() -> () { // disable uart buffer - info!("UART buffer disabled: WIP"); + debug!("UART buffer disabled: WIP"); // TODO: Correctly disable/restart UART buffer and/or send messsage to user over SSH } // use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; pub async fn uart_disable() -> () { // disable uart - info!("UART disabled: WIP"); + debug!("UART disabled: WIP"); // TODO: Correctly disable/restart UART and/or send messsage to user over SSH } diff --git a/src/espressif/net.rs b/src/espressif/net.rs index 0aa136e..9498da8 100644 --- a/src/espressif/net.rs +++ b/src/espressif/net.rs @@ -5,7 +5,7 @@ use log::{debug, error, info, warn}; use crate::config::SSHStampConfig; -use crate::settings::{DEFAULT_IP, DEFAULT_SSID}; +use crate::settings::{DEFAULT_IP, DEFAULT_SSID, WIFI_PASSWORD_CHARS}; use core::net::Ipv4Addr; use core::net::SocketAddrV4; use edge_dhcp; @@ -23,9 +23,16 @@ use esp_hal::peripherals::WIFI; use esp_hal::rng::Rng; use esp_radio::Controller; use esp_radio::wifi::WifiEvent; -use esp_radio::wifi::{AccessPointConfig, ModeConfig, WifiApState, WifiController}; +use esp_radio::wifi::{ + AccessPointConfig, AuthMethod::Wpa2Wpa3Personal, ModeConfig, WifiApState, WifiController, +}; use heapless::String; +extern crate alloc; +use crate::store; +use alloc::string::String as AllocString; +use storage::flash; use sunset_async::SunsetMutex; + // When you are okay with using a nightly compiler it's better to use https://docs.rs/static_cell/2.1.0/static_cell/macro.make_static.html macro_rules! mk_static { ($t:ty,$val:expr) => {{ @@ -48,10 +55,42 @@ pub async fn if_up( esp_radio::wifi::new(wifi_init, wifi, Default::default()) .map_err(|_| sunset::error::BadUsage.build())?; - let ap_config = - ModeConfig::AccessPoint(AccessPointConfig::default().with_ssid(DEFAULT_SSID.into())); + // Ensure WiFi PSK exists before applying AP config to avoid esp_wifi_set_config errors + { + let mut guard = config.lock().await; + if guard.wifi_pw.is_none() { + let mut rnd = [0u8; 24]; + for chunk in rnd.chunks_exact_mut(4) { + chunk.copy_from_slice(&rng.random().to_le_bytes()); + } + + let mut pw = String::<63>::new(); + for &byte in rnd.iter() { + let _ = pw.push(WIFI_PASSWORD_CHARS[(byte as usize) % 62] as char); + } + + warn!("wifi_pw missing from config, generated new password"); + guard.wifi_pw = Some(pw); + + let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { + panic!("Flash storage not initialized; cannot persist wifi password"); + }; + let mut flash_storage = flash_storage_guard.lock().await; + if let Err(e) = store::save(&mut flash_storage, &guard).await { + panic!("Failed to persist generated wifi password: {:?}", e); + } + } + info!("WIFI PSK: {}", guard.wifi_pw.as_ref().unwrap()); + } + + let ap_config = ModeConfig::AccessPoint( + AccessPointConfig::default() + .with_ssid(AllocString::from(wifi_ssid(config).await.as_str())) + .with_auth_method(Wpa2Wpa3Personal) + .with_password(AllocString::from(wifi_password(config).await.as_str())), + ); let res = wifi_controller.set_config(&ap_config); - info!("wifi_set_configuration returned {:?}", res); + debug!("wifi_set_configuration returned {:?}", res); let gw_ip_addr_ipv4 = *DEFAULT_IP; @@ -83,7 +122,6 @@ pub async fn if_up( Timer::after(Duration::from_millis(500)).await; } - // TODO: Use wifi_manager instead? info!( "Connect to the AP `ssh-stamp` as a DHCP client with IP: {}", gw_ip_addr_ipv4 @@ -94,13 +132,13 @@ pub async fn if_up( pub async fn ap_stack_disable() -> () { // drop ap_stack - info!("AP Stack disabled: WIP"); + debug!("AP Stack disabled: WIP"); // TODO: Correctly disable/restart AP Stack and/or send messsage to user over SSH } pub async fn tcp_socket_disable() -> () { // drop tcp stack - info!("TCP socket disabled: WIP"); + debug!("TCP socket disabled: WIP"); // TODO: Correctly disable/restart tcp socket and/or send messsage to user over SSH } @@ -111,7 +149,7 @@ pub async fn accept_requests<'a>( ) -> TcpSocket<'a> { let mut tcp_socket = TcpSocket::new(tcp_stack, rx_buffer, tx_buffer); - info!("Waiting for SSH client..."); + debug!("Waiting for SSH client..."); if let Err(e) = tcp_socket .accept(IpListenEndpoint { addr: None, @@ -123,55 +161,78 @@ pub async fn accept_requests<'a>( // continue; tcp_socket_disable().await; } - info!("Connected, port 22"); + debug!("Connected, port 22"); tcp_socket } +/// Returns the configured WiFi SSID from the config, or the default SSID if not set. +pub async fn wifi_ssid(config: &'static SunsetMutex) -> String<63> { + let guard = config.lock().await; + let ssid_src = if !guard.wifi_ssid.is_empty() { + guard.wifi_ssid.as_str() + } else { + DEFAULT_SSID + }; + + String::<63>::try_from(ssid_src).unwrap_or_else(|_| { + let mut fallback = String::<63>::new(); + fallback.push_str(DEFAULT_SSID).ok(); + fallback + }) +} + +/// Returns the WiFi password from the config. +/// Panics if wifi_pw is not set in the config. +pub async fn wifi_password(config: &'static SunsetMutex) -> String<63> { + let guard = config.lock().await; + match &guard.wifi_pw { + Some(pw) => String::<63>::try_from(pw.as_str()).unwrap_or_else(|_| { + panic!("wifi_pw stored value exceeds 63 characters"); + }), + None => panic!("wifi_pw must be set before calling wifi_password()"), + } +} + +/// Manages the WiFi access point lifecycle. +/// Starts the AP with the configured SSID and password from the config. +/// Handles reconnection if the AP stops. #[embassy_executor::task] pub async fn wifi_up( mut wifi_controller: WifiController<'static>, config: &'static SunsetMutex, ) { - info!("Device capabilities: {:?}", wifi_controller.capabilities()); - let wifi_ssid = { - let guard = config.lock().await; - guard.wifi_ssid.clone() - // drop guard - }; - // TODO: No wifi password(s) yet... - //let wifi_password = config.lock().await.wifi_pw; + debug!("Device capabilities: {:?}", wifi_controller.capabilities()); debug!("Starting wifi"); - let ssid_string = String::<63>::try_from(wifi_ssid.as_str()) - .map(|s| s.to_ascii_lowercase()) - .unwrap_or_else(|_| { - warn!("SSID too long, using default"); - DEFAULT_SSID.into() - }); - let client_config = - ModeConfig::AccessPoint(AccessPointConfig::default().with_ssid(ssid_string)); - loop { + let ssid_string = wifi_ssid(config).await; + let pw_string = wifi_password(config).await; + let client_config = ModeConfig::AccessPoint( + AccessPointConfig::default() + .with_ssid(AllocString::from(ssid_string.as_str())) + .with_auth_method(Wpa2Wpa3Personal) + .with_password(AllocString::from(pw_string.as_str())), + ); + if esp_radio::wifi::ap_state() == WifiApState::Started { - // wait until we're no longer connected wifi_controller.wait_for_event(WifiEvent::ApStop).await; Timer::after(Duration::from_millis(5000)).await } if !matches!(wifi_controller.is_started(), Ok(true)) { if let Err(e) = wifi_controller.set_config(&client_config) { - info!("Failed to set wifi config: {:?}", e); + debug!("Failed to set wifi config: {:?}", e); Timer::after(Duration::from_millis(1000)).await; continue; } - info!("Starting wifi"); + debug!("Starting wifi"); if let Err(e) = wifi_controller.start_async().await { - info!("Failed to start wifi: {:?}", e); + debug!("Failed to start wifi: {:?}", e); Timer::after(Duration::from_millis(1000)).await; continue; } - info!("Wifi started!"); + debug!("Wifi started!"); } Timer::after(Duration::from_millis(10)).await; } @@ -183,14 +244,14 @@ pub async fn wifi_controller_disable() -> () { // drop wifi controller // esp_wifi::deinit_unchecked() // wifi_controller.deinit_unchecked() - info!("Disabling wifi: WIP"); + debug!("Disabling wifi: WIP"); //software_reset(); } use esp_radio::wifi::WifiDevice; #[embassy_executor::task] async fn net_up(mut runner: Runner<'static, WifiDevice<'static>>) { - info!("Bringing up network stack...\n"); + debug!("Bringing up network stack...\n"); runner.run().await } diff --git a/src/main.rs b/src/main.rs index 272d68c..0a16760 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ // // SPDX-License-Identifier: GPL-3.0-or-later +use log::{debug, error, warn}; use ssh_stamp::{ config::SSHStampConfig, espressif::{ @@ -21,11 +22,10 @@ use storage::flash; use sunset_async::{SSHServer, SunsetMutex}; +use core::future::Future; use core::result::Result; use core::result::Result::Err; use core::result::Result::Ok; -// use core::error::Error; -use core::future::Future; use embassy_executor::Spawner; use embassy_futures::select::{Either3, select3}; use embassy_net::{Stack, tcp::TcpSocket}; @@ -35,7 +35,6 @@ use esp_hal::system::software_reset; use esp_hal::{peripherals::WIFI, rng::Rng}; use esp_println::logger; -use log::info; use esp_radio::Controller; use esp_rtos::embassy::InterruptExecutor; @@ -44,13 +43,14 @@ use static_cell::StaticCell; cfg_if::cfg_if! { if #[cfg(feature = "esp32")] { + use log::info; use esp_hal::timer::timg::TimerGroup; } } pub async fn peripherals_disable() -> () { // drop peripherals - info!("Disabling peripherals: WIP"); + debug!("Disabling peripherals: WIP"); } pub struct SshStampInit<'a> { @@ -63,7 +63,7 @@ pub struct SshStampInit<'a> { static INT_EXECUTOR: StaticCell> = StaticCell::new(); // 0 is used for esp_rtos #[esp_rtos::main] async fn main(spawner: Spawner) -> ! { - info!("HSM: main"); + debug!("HSM: main"); cfg_if::cfg_if!( if #[cfg(feature = "esp32s2")] { // TODO: This heap size will crash at runtime (only for the ESP32S2), we need to fix this @@ -75,14 +75,14 @@ async fn main(spawner: Spawner) -> ! { ); esp_bootloader_esp_idf::esp_app_desc!(); logger::init_logger_from_env(); - info!("HSM: Initialising peripherals "); + debug!("HSM: Initialising peripherals "); // System init let peripherals = esp_hal::init(esp_hal::Config::default()); let rng = Rng::new(); rng::register_custom_rng(rng); - info!("Initialising flash "); + debug!("Initialising flash "); flash::init(peripherals.FLASH); #[cfg(feature = "sftp-ota")] @@ -92,7 +92,7 @@ async fn main(spawner: Spawner) -> ! { .expect("Failed to validate the current ota partition"); } // Read SSH configuration from Flash (if it exists) - info!("Loading config "); + debug!("Loading config "); let flash_config = { let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { panic!("Could not acquire flash storage lock"); @@ -102,11 +102,11 @@ async fn main(spawner: Spawner) -> ! { } .expect("Could not load or create SSHStampConfig"); - info!("Initialising config "); + debug!("Initialising config "); static CONFIG: StaticCell> = StaticCell::new(); let config: &SunsetMutex = CONFIG.init(SunsetMutex::new(flash_config)); - info!("Initialising gpio "); + debug!("Initialising gpio "); // Only certain GPIO are available for each target. // Pins are selected at compile time based on the target chip. cfg_if::cfg_if!( @@ -134,7 +134,7 @@ async fn main(spawner: Spawner) -> ! { } ); - info!("Initialising timers "); + debug!("Initialising timers "); let sw_int = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); cfg_if::cfg_if! { if #[cfg(feature = "esp32")] { @@ -183,13 +183,13 @@ async fn main(spawner: Spawner) -> ! { match peripherals_enabled(peripherals_enabled_struct).await { Ok(_) => (), Err(e) => { - info!("Peripheral error: {}", e); + error!("Peripheral error: {}", e); } } peripherals_disable().await; // loop {} - log::warn!("End of Main... Reset!!"); + warn!("End of Main... Reset!!"); software_reset(); } @@ -203,7 +203,7 @@ pub struct PeripheralsEnabled<'a> { } async fn peripherals_enabled(s: SshStampInit<'static>) -> Result<(), sunset::Error> { - info!("HSM: peripherals_enabled"); + debug!("HSM: peripherals_enabled"); let controller = esp_radio::init().map_err(|_| sunset::error::BadUsage.build())?; let peripherals_enabled_struct = PeripheralsEnabled { @@ -217,7 +217,7 @@ async fn peripherals_enabled(s: SshStampInit<'static>) -> Result<(), sunset::Err match wifi_controller_enabled(peripherals_enabled_struct).await { Ok(_) => (), Err(e) => { - info!("Wifi controller error: {}", e); + error!("Wifi controller error: {}", e); } } @@ -233,7 +233,7 @@ pub struct WifiControllerEnabled<'a> { } pub async fn wifi_controller_enabled(s: PeripheralsEnabled<'static>) -> Result<(), sunset::Error> { - info!("HSM: wifi_controller_enabled"); + debug!("HSM: wifi_controller_enabled"); let tcp_stack = net::if_up(s.spawner, s.controller, s.wifi, s.rng, s.config).await?; let wifi_controller_enabled_stack = WifiControllerEnabled { @@ -245,7 +245,7 @@ pub async fn wifi_controller_enabled(s: PeripheralsEnabled<'static>) -> Result<( match tcp_enabled(wifi_controller_enabled_stack).await { Ok(_) => (), Err(e) => { - info!("AP Stack error: {}", e); + error!("AP Stack error: {}", e); } } net::ap_stack_disable().await; @@ -260,7 +260,7 @@ pub struct TCPEnabled<'a> { cfg_if::cfg_if!(if #[cfg(feature = "esp32")] {use embassy_net::IpListenEndpoint;}); async fn tcp_enabled<'a>(s: WifiControllerEnabled<'a>) -> Result<(), sunset::Error> { - info!("HSM: tcp_enabled"); + debug!("HSM: tcp_enabled"); let mut rx_buffer = [0u8; 1536]; let mut tx_buffer = [0u8; 1536]; @@ -294,7 +294,7 @@ async fn tcp_enabled<'a>(s: WifiControllerEnabled<'a>) -> Result<(), sunset::Err match socket_enabled(tcp_enabled_struct).await { Ok(_) => (), Err(e) => { - info!("TCP socket error: {}", e); + error!("TCP socket error: {}", e); } } net::tcp_socket_disable().await; @@ -310,14 +310,14 @@ pub struct SocketEnabled<'a> { } async fn socket_enabled<'a>(s: TCPEnabled<'a>) -> Result<(), sunset::Error> { - info!("HSM: socket_enabled"); + debug!("HSM: socket_enabled"); // loop { // Spawn network tasks to handle incoming connections with demo_common::session() let mut inbuf = [0u8; UART_BUFFER_SIZE]; let mut outbuf = [0u8; UART_BUFFER_SIZE]; - info!("HSM: Starting ssh_server"); + debug!("HSM: Starting ssh_server"); let ssh_server = serve::ssh_wait_for_initialisation(&mut inbuf, &mut outbuf).await; - info!("HSM: Started ssh_server"); + debug!("HSM: Started ssh_server"); let socket_enabled_struct = SocketEnabled { config: s.config, @@ -328,7 +328,7 @@ async fn socket_enabled<'a>(s: TCPEnabled<'a>) -> Result<(), sunset::Error> { match ssh_enabled(socket_enabled_struct).await { Ok(_) => (), Err(e) => { - info!("SSH server error: {}", e); + error!("SSH server error: {}", e); } } @@ -350,13 +350,13 @@ where } async fn ssh_enabled<'a>(s: SocketEnabled<'a>) -> Result<(), sunset::Error> { - info!("HSM: ssh_enabled"); + debug!("HSM: ssh_enabled"); // loop { - info!("HSM: Starting channel pipe"); + debug!("HSM: Starting channel pipe"); let chan_pipe = Channel::::new(); - info!("HSM: Started channel pipe. Calling connection_loop from ssh_enabled"); + debug!("HSM: Started channel pipe. Calling connection_loop from ssh_enabled"); let connection = serve::connection_loop(&s.ssh_server, &chan_pipe, s.config); - info!("HSM: Started connection loop"); + debug!("HSM: Started connection loop"); let ssh_enabled_struct = SshEnabled { tcp_socket: s.tcp_socket, @@ -369,7 +369,7 @@ async fn ssh_enabled<'a>(s: SocketEnabled<'a>) -> Result<(), sunset::Error> { match client_connected(ssh_enabled_struct).await { Ok(_) => (), Err(e) => { - info!("Client connection error: {}", e); + error!("Client connection error: {}", e); } } @@ -394,10 +394,9 @@ where CL: Future>, 'a: 'b, { - info!("HSM: client_connected"); + debug!("HSM: client_connected"); - // // loop { - info!("HSM: Setting up serial bridge"); + debug!("HSM: Setting up serial bridge"); let bridge = serve::handle_ssh_client(s.uart_buf, s.ssh_server, s.chan_pipe); let uart_enabled_struct = ClientConnected { @@ -409,14 +408,13 @@ where match bridge_connected(uart_enabled_struct).await { Ok(_) => (), Err(e) => { - info!("Bridge error: {}", e); + debug!("Bridge error: {}", e); } } serve::bridge_disable().await; - // } - Ok(()) // todo!() return relevant value + Ok(()) } async fn bridge_connected<'a, 'b, CL, BR>( @@ -427,20 +425,19 @@ where BR: Future>, 'a: 'b, { - info!("HSM: bridge_connected"); + debug!("HSM: bridge_connected"); let mut tcp_socket = s.tcp_socket; let (mut rsock, mut wsock) = tcp_socket.split(); - info!("HSM: Running server from bridge_connected()"); + debug!("HSM: Running server from bridge_connected()"); let server = s.ssh_server.run(&mut rsock, &mut wsock); let connection_loop = s.connection_loop; let bridge = s.bridge; - info!("HSM: Main select() in bridge_connected()"); + debug!("HSM: Main select() in bridge_connected()"); match select3(server, connection_loop, bridge).await { Either3::First(r) => r, Either3::Second(r) => r, Either3::Third(r) => r, - }?; - Result::Ok(()) + } } #[panic_handler] diff --git a/src/serial.rs b/src/serial.rs index 11995d9..a447f93 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -4,7 +4,7 @@ use embassy_futures::select::select; use embedded_io_async::{Read, Write}; -use log::{info, warn}; +use log::{debug, info, warn}; // Espressif specific crates use crate::espressif::buffered_uart::BufferedUart; @@ -17,9 +17,8 @@ pub async fn serial_bridge( uart: &BufferedUart, ) -> Result<(), sunset::Error> { info!("Starting serial <--> SSH bridge"); - select(uart_to_ssh(uart, chanw), ssh_to_uart(chanr, uart)).await; - info!("Stopping serial <--> SSH bridge"); + debug!("Stopping serial <--> SSH bridge"); Ok(()) } diff --git a/src/serve.rs b/src/serve.rs index 9fe41fa..37cd2bf 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -8,6 +8,8 @@ use crate::config::SSHStampConfig; use crate::espressif::buffered_uart::UART_SIGNAL; use crate::settings::UART_BUFFER_SIZE; use crate::store; +use esp_hal::system::software_reset; +use heapless::String; use storage::flash; use core::fmt::Debug; @@ -39,6 +41,7 @@ pub async fn connection_loop( debug!("Entering connection_loop and prog_loop is next..."); let mut config_changed: bool = false; + let mut needs_reset: bool = false; // Will be set in `ev` PubkeyAuth is accepted and cleared once the channel is sent down chan_pipe let mut auth_checked = false; @@ -50,7 +53,7 @@ pub async fn connection_loop( trace!("{:?}", &ev); match ev { ServEvent::SessionSubsystem(a) => { - info!("ServEvent::SessionSubsystem"); + debug!("ServEvent::SessionSubsystem"); if !auth_checked { warn!("Unauthenticated SessionSubsystem rejected"); @@ -62,10 +65,10 @@ pub async fn connection_loop( #[cfg(feature = "sftp-ota")] { a.succeed()?; - info!("We got SFTP subsystem"); + debug!("We got SFTP subsystem"); match chan_pipe.try_send(SessionType::Sftp(ch)) { Ok(_) => auth_checked = false, - Err(e) => error!("Could not send the channel: {:?}", e), + Err(e) => log::error!("Could not send the channel: {:?}", e), }; } #[cfg(not(feature = "sftp-ota"))] @@ -79,30 +82,32 @@ pub async fn connection_loop( } } ServEvent::SessionShell(a) => { - info!("ServEvent::SessionShell"); + debug!("ServEvent::SessionShell"); if !auth_checked { warn!("Unauthenticated SessionShell rejected"); a.fail()?; - // TODO: Handle this gracefully - // TODO: Provide a message back to the client and the close the session? } else if let Some(ch) = session.take() { // Save config after connection successful (SessionEnv completed) if config_changed { - config_changed = false; // TODO: Avoid unnecessary "does not neet to be mutable" warnings for now + config_changed = false; let config_guard = config.lock().await; let Some(flash_storage_guard) = flash::get_flash_n_buffer() else { panic!("Could not acquire flash storage lock"); }; let mut flash_storage = flash_storage_guard.lock().await; let _result = store::save(&mut flash_storage, &config_guard).await; + drop(config_guard); + if needs_reset { + info!("Configuration saved. Rebooting to apply WiFi changes..."); + software_reset(); + } } debug_assert!(ch.num() == a.channel()); a.succeed()?; - info!("We got shell"); - // Signal for uart task to configure pins and run. Value is irrelevant. + debug!("We got shell"); UART_SIGNAL.signal(1); - info!("Connection loop: UART_SIGNAL sent"); + debug!("Connection loop: UART_SIGNAL sent"); match chan_pipe.try_send(SessionType::Bridge(ch)) { Ok(_) => auth_checked = false, Err(e) => log::error!("Could not send the channel: {:?}", e), @@ -112,31 +117,24 @@ pub async fn connection_loop( } } ServEvent::FirstAuth(mut a) => { - info!("ServEvent::FirstAuth"); - // Allow the "first auth" behaviour only on first-boot-like configs. - // Consider the device in first-boot state when there is no password - // and no stored client pubkeys. + debug!("ServEvent::FirstAuth"); let config_guard = config.lock().await; - // Disable password auth method regardless. a.enable_password_auth(false)?; - // SECURITY: We have no users; enable pubkey auth so the - // provisioner can add a key. a.enable_pubkey_auth(true)?; - if config_guard.first_boot { - a.allow()?; // SECURITY: Controversial (but necessary to provision?) + if config_guard.first_login { + a.allow()?; } else { - // Not first boot: do not auto-allow; reject the first-auth helper. - info!( - "FirstAuth received but not first-boot, allowing pubkey auth but rejecting + debug!( + "FirstAuth received but not first-login, allowing pubkey auth but rejecting additions of new public keys on already provisioned device" ); a.reject()?; } } ServEvent::Hostkeys(h) => { - info!("ServEvent::Hostkeys"); + debug!("ServEvent::Hostkeys"); let config_guard = config.lock().await; // Just take it from config as private hostkey is generated on first boot. h.hostkeys(&[&config_guard.hostkey])?; @@ -146,7 +144,7 @@ pub async fn connection_loop( a.reject()?; } ServEvent::PubkeyAuth(a) => { - info!("ServEvent::PubkeyAuth"); + debug!("ServEvent::PubkeyAuth"); let config_guard = config.lock().await; let client_pubkey = a.pubkey()?; @@ -161,7 +159,7 @@ pub async fn connection_loop( a.allow()?; auth_checked = true; } else { - info!("No matching pubkey slot found"); + debug!("No matching pubkey slot found"); a.reject()?; } } @@ -172,7 +170,7 @@ pub async fn connection_loop( } } ServEvent::OpenSession(a) => { - info!("ServEvent::OpenSession"); + debug!("ServEvent::OpenSession"); match session { Some(_) => { todo!("Can't have two sessions"); @@ -196,18 +194,14 @@ pub async fn connection_loop( } "SSH_STAMP_PUBKEY" => { let mut config_guard = config.lock().await; - // Only allow adding a pubkey via ENV on first-boot-like configs. - if !config_guard.first_boot { - warn!("SSH_STAMP_PUBKEY env received but not first-boot; rejecting"); + if !config_guard.first_login { + warn!("SSH_STAMP_PUBKEY env received but not first-login; rejecting"); a.fail()?; - break Ok(()); // TODO: Do better HSM-flow-wise } else if config_guard.add_pubkey(a.value()?).is_ok() { - info!("Added new pubkey from ENV"); + debug!("Added new pubkey from ENV"); a.succeed()?; - // Mark that config has changed and clear first_boot so - // future connections are not treated as first-boot. - config_guard.first_boot = false; + config_guard.first_login = false; config_changed = true; auth_checked = true; } else { @@ -215,20 +209,71 @@ pub async fn connection_loop( a.fail()?; } } + "SSH_STAMP_WIFI_SSID" => { + let mut config_guard = config.lock().await; + if !(auth_checked || config_guard.first_login) { + warn!( + "SSH_STAMP_WIFI_SSID env received but not authenticated; rejecting" + ); + a.fail()?; + } else { + let mut s = String::<32>::new(); + if s.push_str(a.value()?).is_ok() { + config_guard.wifi_ssid = s; + debug!("Set wifi SSID from ENV"); + a.succeed()?; + config_changed = true; + needs_reset = true; + } else { + warn!("SSH_STAMP_WIFI_SSID too long"); + a.fail()?; + } + } + } + "SSH_STAMP_WIFI_PSK" => { + let mut config_guard = config.lock().await; + if !(auth_checked || config_guard.first_login) { + warn!( + "SSH_STAMP_WIFI_PSK env received but not authenticated; rejecting" + ); + a.fail()?; + } else { + let value = a.value()?; + if value.len() < 8 { + warn!("SSH_STAMP_WIFI_PSK too short (min 8 characters)"); + a.fail()?; + } else if value.len() > 63 { + warn!("SSH_STAMP_WIFI_PSK too long (max 63 characters)"); + a.fail()?; + } else { + let mut s = String::<63>::new(); + if s.push_str(value).is_ok() { + config_guard.wifi_pw = Some(s); + debug!("Set WIFI PSK from ENV"); + a.succeed()?; + config_changed = true; + needs_reset = true; + } else { + warn!("SSH_STAMP_WIFI_PSK push_str failed unexpectedly"); + a.fail()?; + } + } + } + } _ => { - warn!("Unsupported environment variable"); - a.fail()?; + debug!("Ignoring unknown environment variable: {}", a.name()?); + a.succeed()?; } } } ServEvent::SessionPty(a) => { - let first_boot = { config.lock().await.first_boot }; + let first_login = { config.lock().await.first_login }; - if auth_checked || first_boot { - info!("ServEvent::SessionPty: Session granted"); + if auth_checked || first_login { + debug!("ServEvent::SessionPty: Session granted"); a.succeed()?; } else { - info!("ServEvent::SessionPty: No auth not session"); + debug!("ServEvent::SessionPty: No auth not session"); a.fail()?; } } @@ -236,18 +281,16 @@ pub async fn connection_loop( a.fail()?; } ServEvent::Defunct => { - info!("Expected caller to handle event"); + debug!("Expected caller to handle event"); error::BadUsage.fail()? } - ServEvent::PollAgain => { - // info!("ServEvent::PollAgain"); - } + ServEvent::PollAgain => {} } } } pub async fn connection_disable() -> () { - info!("Connection loop disabled: WIP"); + debug!("Connection loop disabled: WIP"); // TODO: Correctly disable/restart Conection loop and/or send messsage to user over SSH } @@ -259,7 +302,7 @@ pub async fn ssh_wait_for_initialisation<'server>( } pub async fn ssh_disable() -> () { - info!("SSH Server disabled: WIP"); + debug!("SSH Server disabled: WIP"); // TODO: Correctly disable/restart SSH Server and/or send messsage to user over SSH } @@ -272,9 +315,9 @@ pub async fn handle_ssh_client<'a, 'b>( ssh_server: &'b SSHServer<'a>, chan_pipe: &'b Channel, ) -> Result<(), sunset::Error> { - info!("Preparing bridge"); + debug!("Preparing bridge"); let session_type = chan_pipe.receive().await; - info!("Checking bridge session type"); + debug!("Checking bridge session type"); match session_type { SessionType::Bridge(ch) => { info!("Handling bridge session"); @@ -286,7 +329,7 @@ pub async fn handle_ssh_client<'a, 'b>( #[cfg(feature = "sftp-ota")] SessionType::Sftp(ch) => { { - info!("Handling SFTP session"); + debug!("Handling SFTP session"); let stdio = ssh_server.stdio(ch).await?; // TODO: Use a configuration flag to select the hardware specific OtaActions implementer let ota_writer = storage::esp_ota::OtaWriter::new(); @@ -299,7 +342,7 @@ pub async fn handle_ssh_client<'a, 'b>( pub async fn bridge_disable() -> () { // disable bridge - info!("Bridge disabled: WIP"); + debug!("Bridge disabled: WIP"); // TODO: Correctly disable/restart bridge and/or send message to user over SSH // software_reset(); } diff --git a/src/settings.rs b/src/settings.rs index e904078..f871e59 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -5,11 +5,16 @@ use core::net::Ipv4Addr; // SSH server settings //pub(crate) const MTU: usize = 1536; //pub(crate) const PORT: u16 = 22; +// Default WiFi SSID used when none configured (via ENV vars) pub(crate) const DEFAULT_SSID: &str = "ssh-stamp"; //pub(crate) const SSH_SERVER_ID: &str = "SSH-2.0-ssh-stamp-0.1"; pub(crate) const KEY_SLOTS: usize = 1; // TODO: Document whether this a "reasonable default"? Justify why? pub(crate) const DEFAULT_IP: &Ipv4Addr = &Ipv4Addr::new(192, 168, 4, 1); +// WiFi password generation +pub(crate) const WIFI_PASSWORD_CHARS: &[u8; 62] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + // UART settings //pub(crate) const BAUD_RATE: u32 = 115200; //pub(crate) const UART_SETTINGS: &str = "8N1"; diff --git a/src/store.rs b/src/store.rs index 1236bad..91dc790 100644 --- a/src/store.rs +++ b/src/store.rs @@ -5,7 +5,7 @@ use esp_bootloader_esp_idf::partitions; use pretty_hex::PrettyHex; use sha2::Digest; -use log::{debug, error, info}; +use log::{debug, error}; use crate::errors::Error as SSHStampError; use sunset::error::Error as SunsetError; @@ -37,8 +37,8 @@ impl FlashConfig<'_> { // TODO: Rework Error mapping with esp_storage errors /// Finds the NVS partitions and retrieves information about it. pub fn find_config_partition(fb: &mut FlashBuffer) -> Result<(), SSHStampError> { - info!("Flash size = {} Mb", fb.flash.capacity() / (1024 * 1024)); - info!("Flash storage : {:?}", fb.flash); + debug!("Flash size = {} Mb", fb.flash.capacity() / (1024 * 1024)); + debug!("Flash storage : {:?}", fb.flash); let pt = partitions::read_partition_table( &mut fb.flash, &mut fb.buf[..esp_bootloader_esp_idf::partitions::PARTITION_TABLE_MAX_LEN], @@ -60,8 +60,8 @@ impl FlashConfig<'_> { let nvs_partition = nvs.as_embedded_storage(&mut fb.flash); - info!("NVS partition size = {}", nvs_partition.capacity()); - info!("NVS partition offset = 0x{:x}", nvs.offset()); + debug!("NVS partition size = {}", nvs_partition.capacity()); + debug!("NVS partition offset = 0x{:x}", nvs.offset()); Ok(()) } @@ -77,10 +77,10 @@ fn config_hash(config: &SSHStampConfig) -> Result<[u8; 32], SunsetError> { pub async fn load_or_create(flash: &mut FlashBuffer<'_>) -> Result { match load(flash).await { Ok(c) => { - info!("Good existing config"); + debug!("Good existing config"); return Ok(c); } - Err(e) => info!("Existing config bad, making new. {e}"), + Err(e) => debug!("Existing config bad, making new. {e}"), } create(flash).await @@ -89,7 +89,7 @@ pub async fn load_or_create(flash: &mut FlashBuffer<'_>) -> Result) -> Result { let c = SSHStampConfig::new()?; save(flash, &c).await?; - info!("Created new config: {:?}", &c); + debug!("Created new config: {:?}", &c); Ok(c) } @@ -149,7 +149,7 @@ pub async fn save(fl: &mut FlashBuffer<'_>, config: &SSHStampConfig) -> Result<( CONFIG_OFFSET + FlashConfig::BUF_SIZE ); - info!("Erasing flash"); + debug!("Erasing flash"); const { assert!(CONFIG_AREA_SIZE > FlashConfig::BUF_SIZE) }; @@ -168,6 +168,6 @@ pub async fn save(fl: &mut FlashBuffer<'_>, config: &SSHStampConfig) -> Result<( SunsetError::msg("flash write error") })?; - info!("flash save done"); + debug!("flash save done"); Ok(()) }