Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dea326b
Skeleton for user-less password auth (all users are allowed because w…
brainstorm Feb 24, 2026
8987e30
Simplify password check, place it inline on the event itself
brainstorm Feb 28, 2026
c0450c1
fmt, clippy and gone are the README fat warnings about known issues.
brainstorm Feb 28, 2026
045aad8
[ci skip] Ignore usernames and rename admin_pw/keys for password and …
brainstorm Mar 1, 2026
9093322
Implement storage of Ed25519 pubkeys into SSHStampConfig over ssh env…
brainstorm Mar 3, 2026
68f2df3
[ci skip] add_pubkey() failing on from_openssh() method...
brainstorm Mar 3, 2026
5bb41d9
Merge branch 'main' into auth
brainstorm Mar 5, 2026
c49c995
Cleanup after more stringent imports and vars policy
brainstorm Mar 5, 2026
dfd01ae
Fixed input pubkey validation (from_str instead of from_openssh)... n…
brainstorm Mar 5, 2026
9a9ab6a
Simplify further: remove all password-related logic and ONLY support …
brainstorm Mar 6, 2026
a6ace13
Change env var to SSH_STAMP_PUBKEY /cc @jubeormk1
brainstorm Mar 6, 2026
9de720d
Merge branch 'main' into auth
brainstorm Mar 6, 2026
c38cc7a
Deny password auth overall, allow pubkey_auth selectively
brainstorm Mar 6, 2026
692a852
[ci skip] We're off-by-one on the presented pubkey (see last 89 vs 11…
brainstorm Mar 7, 2026
5a5447d
A few too many software_reset() on the HSM when auth goes south for m…
brainstorm Mar 7, 2026
766bf8a
feature: No unauthenticated Shell or Subsystem
jubeormk1 Mar 9, 2026
fb38bf1
Fixing CI failure: Removing mut in config guard
jubeormk1 Mar 9, 2026
9023eb3
Address @jubeormk1 PR review points, except the HSM ones as they'll r…
brainstorm Mar 9, 2026
39772d2
fix: Splitting channel fixes issue closing the session on channel EOF
jubeormk1 Mar 10, 2026
9ef5d39
First pass at addressing @jubeormk1 PR feedback while https://github.…
brainstorm Mar 10, 2026
9757277
evert "First pass at addressing @jubeormk1 PR feedback while https://…
brainstorm Mar 11, 2026
a583576
Fixing issue in 9ef5d396781e0c3373dbed89b9010ed15ce6e297
jubeormk1 Mar 12, 2026
10b9d0b
Removing unnecessary software_resets keeping one in main
jubeormk1 Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,10 @@ SPDX-License-Identifier: GPL-3.0-or-later

# SSH Stamp

Sponsored by:

![nlnet_zero_commons][nlnet_zero_commons]

# ⚠️ WARNING: Pre-alpha PoC quality, DO NOT use in production. Currently contains highly unsafe business logic auth issues (both password and key management handlers need to be fixed).

# ⚠️ WARNING: Do not file CVEs reports since deficiencies are very much known at this point in time and they'll be worked on soon as part of [this NLNet SSH-Stamp research and development grant][nlnet-grant] ;)

Expect panics, lost bytes on the UART and other tricky UX issues, we are working on it, pull-requests are accepted too!
Your everyday SSH secured serial access.

## Description

Your everyday SSH secured serial access.

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.
Expand Down Expand Up @@ -149,6 +139,10 @@ cargo install cargo-cyclonedx
cargo cyclonedx -f json --manifest-path ./docs/
```

Sponsored by:

![nlnet_zero_commons][nlnet_zero_commons]

[nlnet-grant]: https://nlnet.nl/project/SSH-Stamp/
[openwrt_mediatek_no_monitor]: https://github.com/openwrt/openwrt/issues/16279
[nlnet_zero_commons]: ./docs/nlnet/zero_commons_logo.svg
Expand Down
198 changes: 62 additions & 136 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,36 +1,30 @@
use log::{debug, info};
use log::{debug, info, warn};

use core::net::Ipv4Addr;
#[cfg(feature = "ipv6")]
use core::net::Ipv6Addr;
use core::str::FromStr;
use embassy_net::{Ipv4Cidr, StaticConfigV4};
#[cfg(feature = "ipv6")]
use embassy_net::{Ipv6Cidr, StaticConfigV6};
use heapless::String;

use bcrypt;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;

use sunset::packets::Ed25519PubKey;
use sunset::{KeyType, Result, sshwire};
use sunset::{KeyType, Result};
use sunset::{
SignKey,
sshwire::{SSHDecode, SSHEncode, SSHSink, SSHSource, WireError, WireResult},
};

use crate::errors::Error;
use crate::settings::{DEFAULT_SSID, DEFAULT_UART_RX_PIN, DEFAULT_UART_TX_PIN, KEY_SLOTS};

#[derive(Debug, PartialEq)]
pub struct SSHStampConfig {
//pub first_boot: bool,
pub hostkey: SignKey,

/// Authentication
pub password_authentication: bool,
pub admin_pw: Option<PwHash>,
pub admin_keys: [Option<Ed25519PubKey>; KEY_SLOTS],
/// Authentication: only pubkey-based auth supported
pub pubkeys: [Option<Ed25519PubKey>; KEY_SLOTS],

/// WiFi
pub wifi_ssid: String<32>,
Expand All @@ -46,6 +40,8 @@ pub struct SSHStampConfig {
pub ipv6_static: Option<StaticConfigV6>,
/// UART
pub uart_pins: UartPins,
/// True until the device has been provisioned for the first time.
pub first_boot: bool,
}

#[derive(Debug, PartialEq)]
Expand All @@ -66,15 +62,14 @@ impl Default for UartPins {

impl SSHStampConfig {
/// Bump this when the format changes
pub const CURRENT_VERSION: u8 = 6;
pub const CURRENT_VERSION: u8 = 8;

/// Creates a new config with default parameters.
///
/// Will only fail on RNG failure.
pub fn new() -> Result<Self> {
let hostkey = SignKey::generate(KeyType::Ed25519, None)?;

// TODO: Those env events come from system's std::env / core::env (if any)... so it shouldn't be unsafe()
let wifi_ssid = Self::default_ssid();
let mac = random_mac()?;
let wifi_pw = None;
Expand All @@ -85,43 +80,68 @@ impl SSHStampConfig {
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,
password_authentication: true,
admin_pw: None,
admin_keys: Default::default(),
pubkeys: Default::default(),
wifi_ssid,
wifi_pw,
mac,
ipv4_static: None,
#[cfg(feature = "ipv6")]
ipv6_static: None,
uart_pins,
first_boot: true,
})
}

pub fn set_admin_pw(&mut self, pw: Option<&str>) -> Result<()> {
self.admin_pw = pw.map(PwHash::new).transpose()?;
Ok(())
}

pub fn check_admin_pw(&mut self, pw: &str) -> bool {
if let Some(ref p) = self.admin_pw {
p.check(pw)
} else {
false
}
}
// Password functions removed; pubkey-only auth supported.

pub(crate) fn default_ssid() -> String<32> {
let mut s = String::<32>::new();
s.push_str(DEFAULT_SSID).unwrap();
s
}

// pub fn config_change(&mut self, conf: SSHConfig) -> Result<()> {
// ServEvent::ConfigChange();
// }
pub(crate) fn add_pubkey(&mut self, key_str: &str) -> Result<(), Error> {
// Accept OpenSSH public key format (e.g. "ssh-ed25519 AAAA...") and
// validate it is an Ed25519 key. Insert into the first empty slot or
// overwrite slot 0 if none empty.

info!(
"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");

match openssh.key_data() {
ssh_key::public::KeyData::Ed25519(k) => {
let bytes = k.0; // [u8; 32]
let newk = Ed25519PubKey {
key: sunset::sshwire::Blob(bytes),
};

info!("Parsed Ed25519 public key, adding to config");
for slot in self.pubkeys.iter_mut() {
if slot.is_none() {
*slot = Some(newk);
return Ok(());
}
}

warn!("Public key slots full, overwriting the first one");
// SECURITY: Allow this on FirstAuth ON FIRST BOOT ONLY.
self.pubkeys[0] = Some(newk);
Ok(())
}
_ => Err(Error::BadKey),
}
}
}

fn random_mac() -> Result<[u8; 6]> {
Expand Down Expand Up @@ -253,11 +273,7 @@ impl SSHEncode for SSHStampConfig {
fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> {
enc_signkey(&self.hostkey, s)?;

// Authentication
self.password_authentication.enc(s)?;
enc_option(&self.admin_pw, s)?;

for k in self.admin_keys.iter() {
for k in self.pubkeys.iter() {
enc_option(k, s)?;
}

Expand All @@ -273,6 +289,9 @@ 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)?;

Ok(())
}
}
Expand All @@ -284,11 +303,8 @@ impl<'de> SSHDecode<'de> for SSHStampConfig {
{
let hostkey = dec_signkey(s)?;

// Authentication
let password_authentication = SSHDecode::dec(s)?;
let admin_pw = dec_option(s)?;
let mut admin_keys = [None; KEY_SLOTS];
for k in admin_keys.iter_mut() {
let mut pubkeys = [None; KEY_SLOTS];
for k in pubkeys.iter_mut() {
*k = dec_option(s)?;
}

Expand All @@ -307,109 +323,19 @@ impl<'de> SSHDecode<'de> for SSHStampConfig {
let tx: u8 = SSHDecode::dec(s)?;
let uart_pins = UartPins { rx, tx };

let first_boot = SSHDecode::dec(s)?;

Ok(Self {
hostkey,
password_authentication,
admin_pw,
admin_keys,
pubkeys,
wifi_ssid,
wifi_pw,
mac,
ipv4_static,
#[cfg(feature = "ipv6")]
ipv6_static,
uart_pins,
first_boot,
})
}
}

/// Stores a bcrypt password hash.
///
/// We use bcrypt because it seems the best password hashing option where
/// memory hardness isn't possible (the rp2040 is smaller than CPU or GPU memory).
///
/// The cost is currently set to 6, taking ~500ms on a 125mhz rp2040.
/// Time converges to roughly 8.6ms * 2**cost
///
/// Passwords are pre-hashed to avoid bcrypt's 72 byte limit.
/// rust-bcrypt allows nulls in passwords.
/// We use an hmac rather than plain hash to avoid password shucking
/// (an attacker bcrypts known hashes from some other breach, then
/// brute forces the weaker hash for any that match).
//#[derive(Clone, SSHEncode, SSHDecode, PartialEq)]
#[derive(Clone, PartialEq)]
pub struct PwHash {
salt: [u8; 16],
hash: [u8; 24],
cost: u8,
}

impl PwHash {
const COST: u8 = 6;
/// `pw` must not be empty.
pub fn new(pw: &str) -> Result<Self> {
if pw.is_empty() {
return sunset::error::BadUsage.fail();
}

let mut salt = [0u8; 16];
sunset::random::fill_random(&mut salt)?;
let prehash = Self::prehash(pw, &salt);
let cost = Self::COST;
let hash = bcrypt::bcrypt(cost as u32, salt, &prehash);
Ok(Self { salt, hash, cost })
}

pub fn check(&self, pw: &str) -> bool {
if pw.is_empty() {
return false;
}
let prehash = Self::prehash(pw, &self.salt);
let check_hash = bcrypt::bcrypt(self.cost as u32, self.salt, &prehash);
check_hash.ct_eq(&self.hash).into()
}

fn prehash(pw: &str, salt: &[u8]) -> [u8; 32] {
// OK unwrap: can't fail, accepts any length
// TODO: Generalise, not only Espressif esp_hal
let mut prehash = Hmac::<Sha256>::new_from_slice(salt).unwrap();
prehash.update(pw.as_bytes());
prehash.finalize().into_bytes().into()
}
}

impl core::fmt::Debug for PwHash {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("PwHash").finish_non_exhaustive()
}
}

impl SSHEncode for PwHash {
fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> {
self.salt.enc(s)?;
self.hash.enc(s)?;
self.cost.enc(s)
}
}

impl<'de> SSHDecode<'de> for PwHash {
fn dec<S>(s: &mut S) -> WireResult<Self>
where
S: SSHSource<'de>,
{
let salt = <[u8; 16]>::dec(s)?;
let hash = <[u8; 24]>::dec(s)?;
let cost = u8::dec(s)?;
Ok(PwHash { salt, hash, cost })
}
}

pub fn roundtrip_config() {
Comment thread
brainstorm marked this conversation as resolved.
// default config
let c1 = SSHStampConfig::new().unwrap();
let mut buf = [0u8; 1000];
let l = sshwire::write_ssh(&mut buf, &c1).unwrap();
let v = &buf[..l];
let c2: SSHStampConfig = sshwire::read_ssh(v, None).unwrap();
assert_eq!(c1, c2);
}
8 changes: 8 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,12 @@ pub enum Error {
InvalidPin,
#[snafu(display("Flash storage error"))]
FlashStorageError,
BadKey,
OpenSSHParseError,
}

impl From<ssh_key::Error> for Error {
fn from(_e: ssh_key::Error) -> Error {
Error::OpenSSHParseError
}
}
7 changes: 2 additions & 5 deletions src/espressif/buffered_uart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, pipe::Pipe};
use esp_hal::Async;
use esp_hal::gpio::AnyPin;
use esp_hal::peripherals::UART1;
use esp_hal::system::software_reset;
use esp_hal::uart::{Config, RxConfig, Uart};
use portable_atomic::{AtomicUsize, Ordering};
use static_cell::StaticCell;
Expand Down Expand Up @@ -133,17 +132,15 @@ impl Default for BufferedUart {

pub async fn uart_buffer_disable() -> () {
// disable uart buffer
info!("UART buffer disabled");
info!("UART buffer disabled: WIP");
// TODO: Correctly disable/restart UART buffer and/or send messsage to user over SSH
software_reset();
}
// use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;

pub async fn uart_disable() -> () {
// disable uart
info!("UART disabled");
info!("UART disabled: WIP");
// TODO: Correctly disable/restart UART and/or send messsage to user over SSH
software_reset();
}

/// UART pins for the buffered UART task.
Expand Down
Loading
Loading