From 71f14aba841580af41df5f6dc75265af711de54f Mon Sep 17 00:00:00 2001
From: Matt Johnston <matt@ucc.asn.au>
Date: Sun, 28 May 2023 22:14:29 +0800
Subject: [PATCH] Password auth, reset, bcrypt hash

Better echo handling
auth_password and auth_pubkey server behaviour methods are now async
---
 Cargo.toml                             |   3 +
 embassy/demos/common/Cargo.toml        |  12 +-
 embassy/demos/common/src/config.rs     | 284 ++++++++++++++++++----
 embassy/demos/common/src/lib.rs        |   7 +-
 embassy/demos/common/src/menu.rs       |  16 +-
 embassy/demos/common/src/server.rs     |  59 ++++-
 embassy/demos/picow/Cargo.lock         | 118 ++++++---
 embassy/demos/picow/Cargo.toml         |  22 +-
 embassy/demos/picow/src/flashconfig.rs |  41 +++-
 embassy/demos/picow/src/main.rs        | 162 ++++++++-----
 embassy/demos/picow/src/picowmenu.rs   | 321 ++++++++++++++++++++++---
 embassy/demos/picow/src/takepipe.rs    |   4 +
 embassy/demos/picow/src/usbserial.rs   |  50 ++--
 embassy/demos/picow/src/wifi.rs        |  12 +-
 embassy/demos/std/Cargo.lock           |  32 +++
 embassy/demos/std/src/main.rs          |   2 +-
 src/behaviour.rs                       |   4 +-
 src/kex.rs                             |   4 +-
 src/servauth.rs                        |  33 ++-
 src/sign.rs                            |   2 +-
 src/sshwire.rs                         |  15 +-
 testing/ci.sh                          |   5 +
 22 files changed, 973 insertions(+), 235 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index b995e80..16ddef4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -64,6 +64,8 @@ pretty-hex = { version = "0.3", default-features = false }
 # for non_async
 futures = { version = "0.3", default-features = false }
 
+defmt = { version  = "0.3", optional = true }
+
 [features]
 std = ["snafu/std", "snafu/backtraces"]
 rsa = ["dep:rsa", "ssh-key/rsa"]
@@ -71,6 +73,7 @@ rsa = ["dep:rsa", "ssh-key/rsa"]
 openssh-key = ["ssh-key"]
 # implements embedded_io::Error for sunset::Error
 embedded-io = ["dep:embedded-io"]
+defmt = ["dep:defmt"]
 
 [dev-dependencies]
 # examples want std::error
diff --git a/embassy/demos/common/Cargo.toml b/embassy/demos/common/Cargo.toml
index cbdc68c..f4b791f 100644
--- a/embassy/demos/common/Cargo.toml
+++ b/embassy/demos/common/Cargo.toml
@@ -22,10 +22,20 @@ heapless = "0.7.15"
 # using local fork
 # menu = "0.3"
 embedded-io = { version = "0.4", features = ["async"] }
+sha2 = { version = "0.10", default-features = false }
+hmac = { version = "0.12", default-features = false }
+# TODO: has zeroize
+bcrypt = { version = "0.14", default-features = false }
 
 defmt = { version = "0.3", optional = true }
 log = "0.4"
+pretty-hex = { version = "0.3", default-features = false }
 
 [features]
-defmt = ["dep:defmt", "embassy-net/defmt", "embedded-io/defmt"]
+defmt = ["dep:defmt", "embedded-io/defmt"]
 log = ["embassy-net/log"]
+
+[patch.crates-io]
+embassy-futures = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
+embassy-net = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
+embassy-net-driver = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
diff --git a/embassy/demos/common/src/config.rs b/embassy/demos/common/src/config.rs
index 1d7e7e1..034febc 100644
--- a/embassy/demos/common/src/config.rs
+++ b/embassy/demos/common/src/config.rs
@@ -1,37 +1,51 @@
 #[allow(unused_imports)]
-use {
-    sunset::error::{Error, Result, TrapBug},
-};
+use sunset::error::{Error, Result, TrapBug};
 
 #[allow(unused_imports)]
 #[cfg(not(feature = "defmt"))]
-use {
-    log::{debug, error, info, log, trace, warn},
-};
+use log::{debug, error, info, log, trace, warn};
 
 #[allow(unused)]
 #[cfg(feature = "defmt")]
-use defmt::{debug, info, warn, panic, error, trace};
+use defmt::{debug, error, info, panic, trace, warn};
+
+use hmac::{Hmac, Mac};
+use sha2::Sha256;
 
 use heapless::{String, Vec};
 
 use sunset_sshwire_derive::*;
+
 use sunset::sshwire;
-use sunset::sshwire::{BinString, SSHEncode, SSHDecode, WireResult, SSHSource, SSHSink, WireError};
+use sunset::sshwire::{
+    BinString, SSHDecode, SSHEncode, SSHSink, SSHSource, WireError, WireResult,
+};
 
-use sunset::{SignKey, KeyType};
 use sunset::packets::Ed25519PubKey;
+use sunset::{KeyType, SignKey};
+
+pub const KEY_SLOTS: usize = 3;
 
-// Be sure to bump picow flash_config::CURRENT_VERSION
+// Be sure to bump CURRENT_VERSION
 // if this struct changes (or encode/decode impls).
-#[derive(Debug, Clone)]
+// BUF_SIZE will probably also need updating.
+#[derive(Debug, Clone, PartialEq)]
 pub struct SSHConfig {
     pub hostkey: SignKey,
 
-    /// login password
-    pub pw_hash: Option<[u8; 32]>,
-    // 3 slots
-    pub auth_keys: [Option<Ed25519PubKey>; 3],
+    /// login password for serial
+    pub console_pw: Option<PwHash>,
+    pub console_keys: [Option<Ed25519PubKey>; KEY_SLOTS],
+    pub console_noauth: bool,
+
+    /// For serial admin interface, or ssh
+    ///
+    /// If unset then serial logins are allowed without a password.
+    /// SSH logins are never allowed without a password. TODO add a flag
+    /// to disable all SSH password logins.
+    pub admin_pw: Option<PwHash>,
+    /// for ssh admin
+    pub admin_keys: [Option<Ed25519PubKey>; KEY_SLOTS],
 
     /// SSID
     pub wifi_net: String<32>,
@@ -40,6 +54,13 @@ pub struct SSHConfig {
 }
 
 impl SSHConfig {
+    /// Bump this when the format changes
+    pub const CURRENT_VERSION: u8 = 4;
+    /// A buffer this large will fit any SSHConfig.
+    // It can be updated by looking at
+    // `cargo test -- roundtrip_config --show-output`
+    pub const BUF_SIZE: usize = 443;
+
     /// Creates a new config with default parameters.
     ///
     /// Will only fail on RNG failure.
@@ -50,12 +71,41 @@ impl SSHConfig {
         let wifi_pw = option_env!("WIFI_PW").map(|p| p.into());
         Ok(SSHConfig {
             hostkey,
-            pw_hash: None,
-            auth_keys: Default::default(),
+            console_pw: None,
+            console_keys: Default::default(),
+            console_noauth: false,
+            admin_pw: None,
+            admin_keys: Default::default(),
             wifi_net,
             wifi_pw,
         })
     }
+
+    pub fn set_console_pw(&mut self, pw: Option<&str>) -> Result<()> {
+        self.console_pw = pw.map(|p| PwHash::new(p)).transpose()?;
+        Ok(())
+    }
+
+    pub fn check_console_pw(&mut self, pw: &str) -> bool {
+        if let Some(ref p) = self.console_pw {
+            p.check(pw)
+        } else {
+            false
+        }
+    }
+
+    pub fn set_admin_pw(&mut self, pw: Option<&str>) -> Result<()> {
+        self.admin_pw = pw.map(|p| PwHash::new(p)).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
+        }
+    }
 }
 
 // a private encoding specific to demo config, not SSH defined.
@@ -67,62 +117,208 @@ fn enc_signkey(k: &SignKey, s: &mut dyn SSHSink) -> WireResult<()> {
     }
 }
 
-fn dec_signkey<'de, S>(s: &mut S) -> WireResult<SignKey> where S: SSHSource<'de> {
+fn dec_signkey<'de, S>(s: &mut S) -> WireResult<SignKey>
+where
+    S: SSHSource<'de>,
+{
     Ok(SignKey::Ed25519(SSHDecode::dec(s)?))
 }
 
+// encode Option<T> as a bool then maybe a value
+fn enc_option<T: SSHEncode>(v: &Option<T>, s: &mut dyn SSHSink) -> WireResult<()> {
+    v.is_some().enc(s)?;
+    v.enc(s)
+}
+
+fn dec_option<'de, S, T: SSHDecode<'de>>(s: &mut S) -> WireResult<Option<T>>
+where
+    S: SSHSource<'de>,
+{
+    bool::dec(s)?.then(|| SSHDecode::dec(s)).transpose()
+}
+
 impl SSHEncode for SSHConfig {
     fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> {
+        info!("enc si");
         enc_signkey(&self.hostkey, s)?;
 
-        self.pw_hash.is_some().enc(s)?;
-        self.pw_hash.enc(s)?;
+        info!("enc pw");
+        enc_option(&self.console_pw, s)?;
 
-        for k in self.auth_keys.iter() {
-            k.is_some().enc(s)?;
-            k.enc(s)?;
+        for k in self.console_keys.iter() {
+            info!("enc k");
+            enc_option(k, s)?;
         }
 
-        self.wifi_net.as_str().enc(s)?;
+        self.console_noauth.enc(s)?;
+
+        info!("enc ad");
+        enc_option(&self.admin_pw, s)?;
 
-        self.wifi_pw.is_some().enc(s)?;
-        if let Some(ref p) = self.wifi_pw {
-            p.as_str().enc(s)?;
+        for k in self.admin_keys.iter() {
+            info!("enc ke");
+            enc_option(k, s)?;
         }
+
+        info!("enc net");
+        self.wifi_net.as_str().enc(s)?;
+        info!("enc netpw");
+        enc_option(&self.wifi_pw, s)?;
         Ok(())
     }
 }
 
 impl<'de> SSHDecode<'de> for SSHConfig {
-    fn dec<S>(s: &mut S) -> WireResult<Self> where S: SSHSource<'de> {
+    fn dec<S>(s: &mut S) -> WireResult<Self>
+    where
+        S: SSHSource<'de>,
+    {
+        info!("dec si");
         let hostkey = dec_signkey(s)?;
 
-        let have_pw_hash = bool::dec(s)?;
-        let pw_hash = have_pw_hash.then(|| SSHDecode::dec(s)).transpose()?;
+        info!("dec pw");
+        let console_pw = dec_option(s)?;
 
-        let mut auth_keys = [None, None, None];
-        for k in auth_keys.iter_mut() {
-            if bool::dec(s)? {
-                *k = Some(SSHDecode::dec(s)).transpose()?;
-            }
+        let mut console_keys = [None, None, None];
+        for k in console_keys.iter_mut() {
+            info!("dec k");
+            *k = dec_option(s)?;
         }
 
-        let wifi_net = <&str>::dec(s)?.into();
-        let have_wifi_pw = bool::dec(s)?;
+        let console_noauth = SSHDecode::dec(s)?;
+
+        info!("dec ad");
+        let admin_pw = dec_option(s)?;
+
+        let mut admin_keys = [None, None, None];
+        for k in admin_keys.iter_mut() {
+            info!("dec adk");
+            *k = dec_option(s)?;
+        }
+
+        info!("dec wn");
+        let wifi_net = SSHDecode::dec(s)?;
+        info!("dec wp");
+        let wifi_pw = dec_option(s)?;
 
-        let wifi_pw = have_wifi_pw.then(|| {
-            let p: &str = SSHDecode::dec(s)?;
-            Ok(p.into())
-        })
-        .transpose()?;
         Ok(Self {
             hostkey,
-            pw_hash,
-            auth_keys,
+            console_pw,
+            console_keys,
+            console_noauth,
+            admin_pw,
+            admin_keys,
             wifi_net,
             wifi_pw,
         })
     }
 }
 
+/// 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)]
+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.clone(), &prehash);
+        check_hash == self.hash
+    }
+
+    fn prehash(pw: &str, salt: &[u8]) -> [u8; 32] {
+        // OK unwrap: can't fail, accepts any length
+        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()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::*;
+    use config::PwHash;
+    use sunset::packets::Ed25519PubKey;
+    use sunset::sshwire::{self, Blob};
+
+    #[test]
+    fn roundtrip_config() {
+        // default config
+        let c1 = SSHConfig::new().unwrap();
+        let mut buf = [0u8; 1000];
+        let l = sshwire::write_ssh(&mut buf, &c1).unwrap();
+        let v = &buf[..l];
+        let c2: SSHConfig = sshwire::read_ssh(&buf, None).unwrap();
+        assert_eq!(c1, c2);
 
+        // All the fruit, to check BUF_SIZE.
+        // Variable length fields are all max size.
+        let mut c1 = SSHConfig {
+            hostkey: c1.hostkey,
+            console_pw: Some(PwHash::new("zong").unwrap()),
+            console_keys: [
+                Some(Ed25519PubKey { key: Blob([14u8; 32]) }),
+                Some(Ed25519PubKey { key: Blob([24u8; 32]) }),
+                Some(Ed25519PubKey { key: Blob([34u8; 32]) }),
+            ],
+            console_noauth: true,
+            admin_pw: Some(PwHash::new("f").unwrap()),
+            admin_keys: [
+                Some(Ed25519PubKey { key: Blob([19u8; 32]) }),
+                Some(Ed25519PubKey { key: Blob([29u8; 32]) }),
+                Some(Ed25519PubKey { key: Blob([39u8; 32]) }),
+            ],
+            wifi_net: core::str::from_utf8([b'a'; 32].as_slice()).unwrap().into(),
+            wifi_pw: Some(
+                core::str::from_utf8([b'f'; 63].as_slice()).unwrap().into(),
+            ),
+        };
+
+        let mut buf = [0u8; SSHConfig::BUF_SIZE];
+        let l = sshwire::write_ssh(&mut buf, &c1).unwrap();
+        println!("BUF_SIZE must be at least {}", l);
+        let v = &buf[..l];
+        let c2: SSHConfig = sshwire::read_ssh(&buf, None).unwrap();
+        assert_eq!(c1, c2);
+    }
+}
diff --git a/embassy/demos/common/src/lib.rs b/embassy/demos/common/src/lib.rs
index 7cdcecb..afb4084 100644
--- a/embassy/demos/common/src/lib.rs
+++ b/embassy/demos/common/src/lib.rs
@@ -1,15 +1,18 @@
-#![no_std]
+#![cfg_attr(not(any(feature = "std", test)), no_std)]
 
 #![feature(type_alias_impl_trait)]
 #![feature(async_fn_in_trait)]
 // #![allow(incomplete_features)]
 
-mod config;
 mod server;
 
+pub mod config;
 pub mod menu;
 pub mod demo_menu;
 
 pub use server::{Shell, listener};
 pub use config::SSHConfig;
 pub use demo_menu::BufOutput;
+
+// needed for derive
+use sunset::sshwire;
diff --git a/embassy/demos/common/src/menu.rs b/embassy/demos/common/src/menu.rs
index 4c7afa5..5c4dd5c 100644
--- a/embassy/demos/common/src/menu.rs
+++ b/embassy/demos/common/src/menu.rs
@@ -113,6 +113,7 @@ where
     /// Maximum four levels deep
     menus: [Option<&'a Menu<'a, T>>; 4],
     depth: usize,
+    echo: bool,
     /// The context object the `Runner` carries around.
     pub context: T,
 }
@@ -242,7 +243,7 @@ where
     /// buffer that the `Runner` can use. Feel free to pass anything as the
     /// `context` type - the only requirement is that the `Runner` can
     /// `write!` to the context, which it will do for all text output.
-    pub fn new(menu: &'a Menu<'a, T>, buffer: &'a mut [u8], mut context: T) -> Runner<'a, T> {
+    pub fn new(menu: &'a Menu<'a, T>, buffer: &'a mut [u8], echo: bool, mut context: T) -> Runner<'a, T> {
         if let Some(cb_fn) = menu.entry {
             cb_fn(&mut context);
         }
@@ -251,6 +252,7 @@ where
             depth: 0,
             buffer,
             used: 0,
+            echo,
             context,
         };
         r.prompt(true);
@@ -303,6 +305,13 @@ where
             return;
         }
         let outcome = if input == 0x0D {
+            if !self.echo {
+                // Echo the command
+                write!(self.context, "\r").unwrap();
+                if let Ok(s) = core::str::from_utf8(&self.buffer[0..self.used]) {
+                    write!(self.context, "{}", s).unwrap();
+                }
+            }
             // Handle the command
             self.process_command();
             Outcome::CommandProcessed
@@ -332,8 +341,7 @@ where
             self.buffer[self.used] = input;
             self.used += 1;
 
-            // #[cfg(feature = "echo")]
-            {
+            if self.echo {
                 // We have to do this song and dance because `self.prompt()` needs
                 // a mutable reference to self, and we can't have that while
                 // holding a reference to the buffer at the same time.
@@ -442,7 +450,7 @@ where
                     }
                 }
             } else {
-                writeln!(self.context, "Input was empty?").unwrap();
+                // writeln!(self.context, "Input was empty?").unwrap();
             }
         } else {
             // Hmm ..  we did not have a valid string
diff --git a/embassy/demos/common/src/server.rs b/embassy/demos/common/src/server.rs
index ab116b0..7f24db8 100644
--- a/embassy/demos/common/src/server.rs
+++ b/embassy/demos/common/src/server.rs
@@ -9,6 +9,9 @@ use {
 #[cfg(feature = "defmt")]
 use defmt::{debug, info, warn, panic, error, trace};
 
+use core::fmt::Write as _;
+use pretty_hex::PrettyHex;
+
 use embassy_sync::mutex::Mutex;
 use embassy_sync::blocking_mutex::raw::NoopRawMutex;
 use embassy_net::tcp::TcpSocket;
@@ -126,6 +129,8 @@ struct DemoServer<'a, S: Shell> {
 }
 
 impl<'a, S: Shell> DemoServer<'a, S> {
+    const ADMIN_USER: &'static str = "config";
+
     fn new(shell: &'a S, config: SSHConfig) -> Result<Self> {
 
         Ok(Self {
@@ -135,18 +140,61 @@ impl<'a, S: Shell> DemoServer<'a, S> {
             shell,
         })
     }
+
+    fn is_admin(&self, username: TextString) -> bool {
+        username.as_str().unwrap_or_default() == Self::ADMIN_USER
+    }
 }
 
 impl<'a, S: Shell> ServBehaviour for DemoServer<'a, S> {
+
     fn hostkeys(&mut self) -> BhResult<heapless::Vec<&SignKey, 2>> {
         // OK unwrap: only one element
         Ok(heapless::Vec::from_slice(&[&self.config.hostkey]).unwrap())
     }
 
     async fn auth_unchallenged(&mut self, username: TextString<'_>) -> bool {
-        info!("Allowing auth for user {}", username.as_str().unwrap_or("bad"));
-        self.shell.authed(username.as_str().unwrap_or("")).await;
-        true
+        if !self.is_admin(username) && self.config.console_noauth {
+            info!("Allowing auth for user {}", username.as_str().unwrap_or("bad"));
+            self.shell.authed(username.as_str().unwrap_or("")).await;
+            true
+        } else {
+            false
+        }
+    }
+
+    async fn auth_password(&mut self, username: TextString<'_>, password: TextString<'_>) -> bool {
+        let p = if self.is_admin(username) {
+            &self.config.admin_pw
+        } else {
+            &self.config.console_pw
+        };
+
+        if let Some(ref p) = p {
+            if let (Ok(user), Ok(pw)) = (username.as_str(), password.as_str()) {
+                if p.check(pw) {
+                    self.shell.authed(user).await;
+                    return true
+                }
+            }
+        }
+        false
+    }
+
+    fn have_auth_password(&self, username: TextString) -> bool {
+        if self.is_admin(username) {
+            self.config.admin_pw.is_some()
+        } else {
+            self.config.console_pw.is_some()
+        }
+    }
+
+    fn have_auth_pubkey(&self, username: TextString) -> bool {
+        if self.is_admin(username) {
+            self.config.admin_keys.iter().any(|k| k.is_some())
+        } else {
+            self.config.console_keys.iter().any(|k| k.is_some())
+        }
     }
 
     fn open_session(&mut self, chan: ChanHandle) -> ChanOpened {
@@ -205,14 +253,15 @@ pub trait Shell {
 #[derive(Default)]
 pub struct BufOutput {
     /// Sufficient to hold output produced from a single keystroke input. Further output will be discarded
-    s: heapless::String<300>,
+    // pub s: String<300>,
+    // todo
+    pub s: String<3000>,
 }
 
 impl BufOutput {
     pub async fn flush<W>(&mut self, w: &mut W) -> Result<()>
     where W: asynch::Write + embedded_io::Io<Error = sunset::Error>
     {
-
         let mut b = self.s.as_str().as_bytes();
         while b.len() > 0 {
             let l = w.write(b).await?;
diff --git a/embassy/demos/picow/Cargo.lock b/embassy/demos/picow/Cargo.lock
index 7252ddb..d076e64 100644
--- a/embassy/demos/picow/Cargo.lock
+++ b/embassy/demos/picow/Cargo.lock
@@ -122,6 +122,22 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f8fe8f5a8a398345e52358e18ff07cc17a568fbca5c6f73873d3a62056309603"
 
+[[package]]
+name = "base64"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d"
+
+[[package]]
+name = "bcrypt"
+version = "0.14.0"
+dependencies = [
+ "base64",
+ "blowfish",
+ "getrandom",
+ "subtle",
+]
+
 [[package]]
 name = "bit-set"
 version = "0.5.3"
@@ -158,6 +174,16 @@ dependencies = [
  "generic-array 0.14.6",
 ]
 
+[[package]]
+name = "blowfish"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
+dependencies = [
+ "byteorder",
+ "cipher",
+]
+
 [[package]]
 name = "bytemuck"
 version = "1.13.1"
@@ -321,10 +347,10 @@ dependencies = [
  "cortex-m",
  "cortex-m-rt",
  "defmt",
- "embassy-futures",
- "embassy-net-driver-channel",
+ "embassy-futures 0.1.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
+ "embassy-net-driver-channel 0.1.0",
  "embassy-sync 0.1.0",
- "embassy-time 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
+ "embassy-time 0.1.1 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
  "embedded-hal 1.0.0-alpha.10",
  "futures",
  "num_enum",
@@ -484,7 +510,7 @@ dependencies = [
  "cortex-m",
  "critical-section 1.1.1",
  "embassy-executor",
- "embassy-hal-common",
+ "embassy-hal-common 0.1.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
  "embassy-macros",
  "embassy-sync 0.2.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
 ]
@@ -518,11 +544,22 @@ dependencies = [
  "static_cell",
 ]
 
+[[package]]
+name = "embassy-futures"
+version = "0.1.0"
+
 [[package]]
 name = "embassy-futures"
 version = "0.1.0"
 source = "git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e#3e730aa8b06401003202bf9e21a9c83ec6b21b0e"
 
+[[package]]
+name = "embassy-hal-common"
+version = "0.1.0"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "embassy-hal-common"
 version = "0.1.0"
@@ -546,16 +583,14 @@ dependencies = [
 [[package]]
 name = "embassy-net"
 version = "0.1.0"
-source = "git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e#3e730aa8b06401003202bf9e21a9c83ec6b21b0e"
 dependencies = [
  "as-slice 0.2.1",
  "atomic-polyfill 1.0.1",
  "atomic-pool",
- "defmt",
- "embassy-hal-common",
- "embassy-net-driver",
- "embassy-sync 0.2.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
- "embassy-time 0.1.1 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
+ "embassy-hal-common 0.1.0",
+ "embassy-net-driver 0.1.0",
+ "embassy-sync 0.2.0",
+ "embassy-time 0.1.1",
  "embedded-io 0.4.0",
  "embedded-nal-async",
  "futures",
@@ -566,12 +601,22 @@ dependencies = [
  "stable_deref_trait",
 ]
 
+[[package]]
+name = "embassy-net-driver"
+version = "0.1.0"
+
 [[package]]
 name = "embassy-net-driver"
 version = "0.1.0"
 source = "git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e#3e730aa8b06401003202bf9e21a9c83ec6b21b0e"
+
+[[package]]
+name = "embassy-net-driver-channel"
+version = "0.1.0"
 dependencies = [
- "defmt",
+ "embassy-futures 0.1.0",
+ "embassy-net-driver 0.1.0",
+ "embassy-sync 0.2.0",
 ]
 
 [[package]]
@@ -579,8 +624,8 @@ name = "embassy-net-driver-channel"
 version = "0.1.0"
 source = "git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e#3e730aa8b06401003202bf9e21a9c83ec6b21b0e"
 dependencies = [
- "embassy-futures",
- "embassy-net-driver",
+ "embassy-futures 0.1.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
+ "embassy-net-driver 0.1.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
  "embassy-sync 0.2.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
 ]
 
@@ -598,8 +643,8 @@ dependencies = [
  "embassy-cortex-m",
  "embassy-embedded-hal",
  "embassy-executor",
- "embassy-futures",
- "embassy-hal-common",
+ "embassy-futures 0.1.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
+ "embassy-hal-common 0.1.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
  "embassy-sync 0.2.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
  "embassy-time 0.1.1 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
  "embassy-usb-driver",
@@ -633,6 +678,17 @@ dependencies = [
  "heapless",
 ]
 
+[[package]]
+name = "embassy-sync"
+version = "0.2.0"
+dependencies = [
+ "cfg-if",
+ "critical-section 1.1.1",
+ "embedded-io 0.4.0",
+ "futures-util",
+ "heapless",
+]
+
 [[package]]
 name = "embassy-sync"
 version = "0.2.0"
@@ -661,13 +717,10 @@ dependencies = [
 [[package]]
 name = "embassy-time"
 version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd403e218939bba4a1fe4b58c6f81bf0818852bdd824147f95e6dc4ff4166ac4"
 dependencies = [
  "atomic-polyfill 1.0.1",
  "cfg-if",
  "critical-section 1.1.1",
- "defmt",
  "embedded-hal 0.2.7",
  "futures-util",
  "heapless",
@@ -681,6 +734,7 @@ dependencies = [
  "atomic-polyfill 1.0.1",
  "cfg-if",
  "critical-section 1.1.1",
+ "defmt",
  "embedded-hal 0.2.7",
  "futures-util",
  "heapless",
@@ -692,8 +746,8 @@ version = "0.1.0"
 source = "git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e#3e730aa8b06401003202bf9e21a9c83ec6b21b0e"
 dependencies = [
  "defmt",
- "embassy-futures",
- "embassy-net-driver-channel",
+ "embassy-futures 0.1.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
+ "embassy-net-driver-channel 0.1.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
  "embassy-sync 0.2.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
  "embassy-usb-driver",
  "heapless",
@@ -1613,8 +1667,6 @@ checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
 [[package]]
 name = "smoltcp"
 version = "0.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e9786ac45091b96f946693e05bfa4d8ca93e2d3341237d97a380107a6b38dea"
 dependencies = [
  "bitflags",
  "byteorder",
@@ -1714,6 +1766,7 @@ dependencies = [
  "chacha20",
  "cipher",
  "ctr",
+ "defmt",
  "digest",
  "embedded-io 0.4.0",
  "futures",
@@ -1737,15 +1790,19 @@ dependencies = [
 name = "sunset-demo-embassy-common"
 version = "0.1.0"
 dependencies = [
+ "bcrypt",
  "defmt",
- "embassy-futures",
+ "embassy-futures 0.1.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
  "embassy-net",
- "embassy-net-driver",
+ "embassy-net-driver 0.1.0",
  "embassy-sync 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
- "embassy-time 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
+ "embassy-time 0.1.1 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
  "embedded-io 0.4.0",
  "heapless",
+ "hmac",
  "log",
+ "pretty-hex",
+ "sha2",
  "sunset",
  "sunset-embassy",
  "sunset-sshwire-derive",
@@ -1765,12 +1822,12 @@ dependencies = [
  "defmt",
  "defmt-rtt",
  "embassy-executor",
- "embassy-futures",
+ "embassy-futures 0.1.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
  "embassy-net",
- "embassy-net-driver",
+ "embassy-net-driver 0.1.0",
  "embassy-rp",
  "embassy-sync 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
- "embassy-time 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
+ "embassy-time 0.1.1 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
  "embassy-usb",
  "embassy-usb-driver",
  "embedded-hal 1.0.0-alpha.10",
@@ -1782,8 +1839,11 @@ dependencies = [
  "log",
  "panic-probe",
  "pin-utils",
+ "pretty-hex",
  "rand",
  "sha2",
+ "smoltcp",
+ "snafu",
  "static_cell",
  "sunset",
  "sunset-demo-embassy-common",
@@ -1796,7 +1856,7 @@ name = "sunset-embassy"
 version = "0.2.0-alpha"
 dependencies = [
  "atomic-polyfill 1.0.1",
- "embassy-futures",
+ "embassy-futures 0.1.0 (git+https://github.com/embassy-rs/embassy?rev=3e730aa8b06401003202bf9e21a9c83ec6b21b0e)",
  "embassy-sync 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
  "embedded-io 0.4.0",
  "log",
diff --git a/embassy/demos/picow/Cargo.toml b/embassy/demos/picow/Cargo.toml
index 5786c99..eaeee18 100644
--- a/embassy/demos/picow/Cargo.toml
+++ b/embassy/demos/picow/Cargo.toml
@@ -33,9 +33,12 @@ static_cell = "1.0"
 defmt = { version  = "0.3", optional = true }
 defmt-rtt = "0.3"
 panic-probe = { version = "0.3", features = ["print-defmt"] }
+pretty-hex = { version = "0.3", default-features = false }
 log = { version = "0.4" }
 futures = { version = "0.3", default-features = false }
 
+snafu = { version = "0.7", default-features = false, features = ["rust_1_61"] }
+
 cortex-m = { version = "0.7.6", features = ["critical-section-single-core"]}
 cortex-m-rt = "0.7.0"
 
@@ -56,9 +59,12 @@ critical-section = "1.1"
 rand = { version = "0.8", default-features = false, features = ["getrandom"] }
 sha2 = { version = "0.10", default-features = false }
 
+# for defmt feature
+smoltcp = { default-features = false }
+
 [features]
 default = ["defmt", "sunset-demo-embassy-common/defmt", "embassy-usb/defmt"]
-defmt = ["dep:defmt"]
+defmt = ["dep:defmt", "sunset/defmt", "smoltcp/defmt"]
 
 # Use cyw43 firmware already on flash. This saves time when developing.
 # probe-rs-cli download firmware/43439A0.bin --format bin --chip RP2040 --base-address 0x10100000
@@ -69,14 +75,20 @@ romfw = []
 embassy-executor = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
 embassy-futures = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
 embassy-rp = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
-embassy-net = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
+# embassy-net = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
 embassy-usb = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
+embassy-time = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
 embassy-usb-driver = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
 # for cyw43
-embassy-net-driver-channel = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
-embassy-net-driver = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
+# embassy-net-driver-channel = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
+# embassy-net-driver = { git = "https://github.com/embassy-rs/embassy", rev = "3e730aa8b06401003202bf9e21a9c83ec6b21b0e" }
+
+embassy-net = { path = "/home/matt/3rd/rs/embassy/embassy-net" }
+embassy-net-driver = { path = "/home/matt/3rd/rs/embassy/embassy-net-driver" }
+embassy-net-driver-channel = { path = "/home/matt/3rd/rs/embassy/embassy-net-driver-channel" }
+smoltcp = { path = "/home/matt/3rd/rs/smoltcp" }
 
-# embedded-io = { path = "/home/matt/3rd/rs/embedded-io" }
+bcrypt = { path = "/home/matt/3rd/rs/bcrypt" }
 
 [profile.dev]
 debug = 2
diff --git a/embassy/demos/picow/src/flashconfig.rs b/embassy/demos/picow/src/flashconfig.rs
index ccf9d93..afd5989 100644
--- a/embassy/demos/picow/src/flashconfig.rs
+++ b/embassy/demos/picow/src/flashconfig.rs
@@ -24,15 +24,14 @@ use sunset::sshwire;
 use sunset::sshwire::{BinString, SSHEncode, SSHDecode, WireResult, SSHSource, SSHSink, WireError};
 use sunset::sshwire::OwnOrBorrow;
 
-use crate::demo_common::SSHConfig;
-
-// bump this when the format changes
-const CURRENT_VERSION: u8 = 2;
+use crate::demo_common;
+use demo_common::SSHConfig;
 
 // TODO: unify offsets with wifi's romfw feature
 const CONFIG_OFFSET: u32 = 0x150000;
 pub const FLASH_SIZE: usize = 2*1024*1024;
 
+// SSHConfig::CURRENT_VERSION must be bumped if any of this struct changes
 #[derive(SSHEncode, SSHDecode)]
 struct FlashConfig<'a> {
     version: u8,
@@ -41,6 +40,10 @@ struct FlashConfig<'a> {
     hash: [u8; 32],
 }
 
+impl FlashConfig<'_> {
+    const BUF_SIZE: usize = 1 + SSHConfig::BUF_SIZE + 32;
+}
+
 fn config_hash(config: &SSHConfig) -> Result<[u8; 32]> {
     let mut h = sha2::Sha256::new();
     sshwire::hash_ser(&mut h, config, None)?;
@@ -49,13 +52,15 @@ fn config_hash(config: &SSHConfig) -> Result<[u8; 32]> {
 
 /// Loads a SSHConfig at startup. Good for persisting hostkeys.
 pub fn load_or_create(flash: &mut Flash<'_, FLASH, FLASH_SIZE>) -> Result<SSHConfig> {
+    use snafu::Error;
     let c = load(flash);
     match load(flash) {
         Ok(c) => {
             info!("Good existing config");
             return Ok(c)
         }
-        Err(c) => info!("Existing config bad, making new"),
+        // Err(sunset::Error::Custom(msg: msg)) => info!("Existing config bad, making new. {}", msg),
+        Err(e) => info!("Existing config bad, making new. {}", e.description()),
     }
 
     create(flash)
@@ -70,11 +75,19 @@ pub fn create(flash: &mut Flash<'_, FLASH, FLASH_SIZE>) -> Result<SSHConfig> {
 }
 
 pub fn load(flash: &mut Flash<'_, FLASH, FLASH_SIZE>) -> Result<SSHConfig> {
-    let mut buf = [0u8; ERASE_SIZE];
+    // let mut buf = [0u8; ERASE_SIZE];
+    let mut buf = [0u8; FlashConfig::BUF_SIZE];
     flash.read(CONFIG_OFFSET, &mut buf).map_err(|_| Error::msg("flash error"))?;
+
+    use pretty_hex::PrettyHex;
+    use core::fmt::Write;
+    let mut b = demo_common::BufOutput::default();
+    writeln!(b, "load {:?}", buf.hex_dump());
+    info!("{}", &b.s);
+
     let s: FlashConfig = sshwire::read_ssh(&buf, None)?;
 
-    if s.version != CURRENT_VERSION {
+    if s.version != SSHConfig::CURRENT_VERSION {
         return Err(Error::msg("wrong config version"))
     }
 
@@ -94,11 +107,19 @@ pub fn load(flash: &mut Flash<'_, FLASH, FLASH_SIZE>) -> Result<SSHConfig> {
 pub fn save(flash: &mut Flash<'_, FLASH, FLASH_SIZE>, config: &SSHConfig) -> Result<()> {
     let mut buf = [0u8; ERASE_SIZE];
     let sc = FlashConfig {
-        version: CURRENT_VERSION,
+        version: SSHConfig::CURRENT_VERSION,
         config: OwnOrBorrow::Borrow(&config),
         hash: config_hash(&config)?,
     };
-    sshwire::write_ssh(&mut buf, &sc)?;
+    let l = sshwire::write_ssh(&mut buf, &sc)?;
+    let buf = &buf[..l];
+
+    use pretty_hex::PrettyHex;
+    use core::fmt::Write;
+    let mut b = demo_common::BufOutput::default();
+    writeln!(b, "save {:?}", buf.hex_dump());
+    info!("{}", &b.s);
+
     trace!("flash erase");
     flash.erase(CONFIG_OFFSET, CONFIG_OFFSET + ERASE_SIZE as u32)
     .map_err(|_| Error::msg("flash erase error"))?;
@@ -107,7 +128,7 @@ pub fn save(flash: &mut Flash<'_, FLASH, FLASH_SIZE>, config: &SSHConfig) -> Res
     flash.write(CONFIG_OFFSET, &buf)
     .map_err(|_| Error::msg("flash write error"))?;
 
-    trace!("save done");
+    info!("flash save done");
     Ok(())
 }
 
diff --git a/embassy/demos/picow/src/main.rs b/embassy/demos/picow/src/main.rs
index c888403..a294d07 100644
--- a/embassy/demos/picow/src/main.rs
+++ b/embassy/demos/picow/src/main.rs
@@ -6,56 +6,55 @@
 
 #[allow(unused_imports)]
 #[cfg(not(feature = "defmt"))]
-pub use {
-    log::{debug, error, info, log, trace, warn},
-};
+pub use log::{debug, error, info, log, trace, warn};
 
 #[allow(unused_imports)]
 #[cfg(feature = "defmt")]
-pub use defmt::{debug, info, warn, panic, error, trace};
+pub use defmt::{debug, error, info, panic, trace, warn};
 
 use {defmt_rtt as _, panic_probe as _};
 
 use core::fmt::Write as _;
-
+use pretty_hex::PrettyHex;
 
 use embassy_executor::Spawner;
-use embassy_net::Stack;
 use embassy_futures::join::join;
 use embassy_futures::select::select;
-use embassy_rp::{pio::PioPeripheral, interrupt};
+use embassy_net::Stack;
 use embassy_rp::peripherals::FLASH;
-use embedded_io::{asynch, Io};
+use embassy_rp::{interrupt, pio::PioPeripheral};
+use embassy_time::Duration;
 use embedded_io::asynch::Write as _;
+use embedded_io::{asynch, Io};
 
 use heapless::{String, Vec};
 
 use static_cell::StaticCell;
 
 use demo_common::menu::Runner as MenuRunner;
-use embedded_io::asynch::Read;
-use embassy_sync::signal::Signal;
 use embassy_sync::blocking_mutex::raw::NoopRawMutex;
+use embassy_sync::signal::Signal;
+use embedded_io::asynch::Read;
 
 use sunset::*;
 use sunset_embassy::{SSHServer, SunsetMutex};
 
-pub(crate) use sunset_demo_embassy_common as demo_common;
 use crate::demo_common::singleton;
+pub(crate) use sunset_demo_embassy_common as demo_common;
 
 mod flashconfig;
-mod wifi;
-mod usbserial;
 mod picowmenu;
 mod takepipe;
+mod usbserial;
+mod wifi;
 
-use demo_common::{SSHConfig, demo_menu, Shell};
+use demo_common::{demo_menu, SSHConfig, Shell};
 
 use takepipe::TakeBase;
 
 const NUM_LISTENERS: usize = 4;
 // +1 for dhcp. referenced directly by wifi_stack() function
-pub(crate) const NUM_SOCKETS: usize = NUM_LISTENERS+1;
+pub(crate) const NUM_SOCKETS: usize = NUM_LISTENERS + 1;
 
 #[embassy_executor::main]
 async fn main(spawner: Spawner) {
@@ -74,14 +73,9 @@ async fn main(spawner: Spawner) {
         flashconfig::load_or_create(&mut flash).unwrap()
     };
 
-    let flash = &*singleton!(
-        SunsetMutex::new(flash)
-    );
-
-    let config = &*singleton!(
-        SunsetMutex::new(config)
-    );
+    let flash = &*singleton!(SunsetMutex::new(flash));
 
+    let config = &*singleton!(SunsetMutex::new(config));
 
     let (wifi_net, wifi_pw) = {
         let c = config.lock().await;
@@ -89,8 +83,11 @@ async fn main(spawner: Spawner) {
     };
     // spawn the wifi stack
     let (_, sm, _, _, _) = p.PIO0.split();
-    let (stack, wifi_control) = wifi::wifi_stack(&spawner, p.PIN_23, p.PIN_24, p.PIN_25, p.PIN_29, p.DMA_CH0, sm,
-        wifi_net, wifi_pw).await;
+    let (stack, wifi_control) = wifi::wifi_stack(
+        &spawner, p.PIN_23, p.PIN_24, p.PIN_25, p.PIN_29, p.DMA_CH0, sm, wifi_net,
+        wifi_pw,
+    )
+    .await;
     let stack = &*singleton!(stack);
     let wifi_control = singleton!(SunsetMutex::new(wifi_control));
     spawner.spawn(net_task(&stack)).unwrap();
@@ -98,12 +95,11 @@ async fn main(spawner: Spawner) {
     let usb_pipe = singleton!(takepipe::TakePipe::new());
     let usb_pipe = singleton!(usb_pipe.base());
 
-    let state = GlobalState {
-        usb_pipe,
-        wifi_control,
-        config,
-        flash,
-    };
+    let watchdog = singleton!(SunsetMutex::new(
+        embassy_rp::watchdog::Watchdog::new(p.WATCHDOG)
+    ));
+
+    let state = GlobalState { usb_pipe, wifi_control, config, flash, watchdog };
     let state = singleton!(state);
 
     let usb_irq = interrupt::take!(USBCTRL_IRQ);
@@ -116,20 +112,23 @@ async fn main(spawner: Spawner) {
 
 // TODO: pool_size should be NUM_LISTENERS but needs a literal
 #[embassy_executor::task(pool_size = 4)]
-async fn listener(stack: &'static Stack<cyw43::NetDriver<'static>>,
+async fn listener(
+    stack: &'static Stack<cyw43::NetDriver<'static>>,
     config: &'static SunsetMutex<SSHConfig>,
-    ctx: &'static GlobalState) -> ! {
+    ctx: &'static GlobalState,
+) -> ! {
     demo_common::listener::<_, DemoShell>(stack, config, ctx).await
 }
 
 pub(crate) struct GlobalState {
     // If taking multiple mutexes, lock in the order below avoid inversion.
-
     pub usb_pipe: &'static TakeBase<'static>,
     pub wifi_control: &'static SunsetMutex<cyw43::Control<'static>>,
     pub config: &'static SunsetMutex<SSHConfig>,
-    pub flash: &'static SunsetMutex<embassy_rp::flash::Flash<'static,
-    FLASH, { flashconfig::FLASH_SIZE }>>,
+    pub flash: &'static SunsetMutex<
+        embassy_rp::flash::Flash<'static, FLASH, { flashconfig::FLASH_SIZE }>,
+    >,
+    pub watchdog: &'static SunsetMutex<embassy_rp::watchdog::Watchdog>,
 }
 
 struct DemoShell {
@@ -141,29 +140,42 @@ struct DemoShell {
 }
 
 // `local` is set for usb serial menus which require different auth
-async fn menu<R, W>(mut chanr: R, mut chanw: W,
+async fn menu<R, W>(
+    mut chanr: R,
+    mut chanw: W,
     local: bool,
-    state: &'static GlobalState) -> Result<()>
-    where R: asynch::Read+Io<Error=sunset::Error>,
-        W: asynch::Write+Io<Error=sunset::Error> {
+    state: &'static GlobalState,
+) -> Result<()>
+where
+    R: asynch::Read + Io<Error = sunset::Error>,
+    W: asynch::Write + Io<Error = sunset::Error>,
+{
     let mut menu_buf = [0u8; 64];
     let menu_ctx = picowmenu::MenuCtx::new(state);
 
-    let mut menu = MenuRunner::new(&picowmenu::SETUP_MENU, &mut menu_buf, menu_ctx);
-
-    // bodge
-    for c in "help\r\n".bytes() {
-        menu.input_byte(c);
+    // let echo = !local;
+    let echo = true;
+    let mut menu =
+        MenuRunner::new(&picowmenu::SETUP_MENU, &mut menu_buf, echo, menu_ctx);
+
+    // Bodge. Isn't safe for local serial either since Linux would reply to those
+    // bytes with echo (a terminal emulator isn't attached yet), and then we get
+    // confused by it.
+    if !local {
+        for c in "help\r\n".bytes() {
+            menu.input_byte(c);
+        }
+        menu.context.out.flush(&mut chanw).await?;
     }
-    menu.context.out.flush(&mut chanw).await?;
 
     'io: loop {
         let mut b = [0u8; 20];
         let lr = chanr.read(&mut b).await?;
         if lr == 0 {
-            break
+            break;
         }
         let b = &mut b[..lr];
+
         for c in b.iter() {
             menu.input_byte(*c);
             menu.context.out.flush(&mut chanw).await?;
@@ -174,6 +186,14 @@ async fn menu<R, W>(mut chanr: R, mut chanw: W,
                 if local {
                     writeln!(menu.context.out, "serial can't loop");
                 } else {
+                    if state.usb_pipe.is_in_use() {
+                        writeln!(
+                            menu.context.out,
+                            "Opening usb1, stealing existing session"
+                        );
+                    } else {
+                        writeln!(menu.context.out, "Opening usb1");
+                    }
                     serial(chanr, chanw, state).await?;
                     // TODO we could return to the menu on serial error?
                     break 'io;
@@ -181,6 +201,7 @@ async fn menu<R, W>(mut chanr: R, mut chanw: W,
             }
 
             if menu.context.need_save {
+                info!("needs save");
                 // clear regardless of success, don't want a tight loop.
                 menu.context.need_save = false;
 
@@ -191,20 +212,39 @@ async fn menu<R, W>(mut chanr: R, mut chanw: W,
                 }
             }
 
+            if menu.context.logout {
+                break 'io;
+            }
+
+            if menu.context.reset {
+                let _ = chanw.write_all(b"Resetting\r\n").await;
+                let mut wd = state.watchdog.lock().await;
+                wd.start(Duration::from_millis(200));
+                loop {
+                    embassy_time::Timer::after(Duration::from_secs(1)).await;
+                }
+            }
+
+            // messages from handling
             menu.context.out.flush(&mut chanw).await?;
         }
     }
     Ok(())
 }
 
-async fn serial<R, W>(mut chanr: R, mut chanw: W, state: &'static GlobalState) -> Result<()>
-    where R: asynch::Read+Io<Error=sunset::Error>,
-        W: asynch::Write+Io<Error=sunset::Error> {
-
+async fn serial<R, W>(
+    mut chanr: R,
+    mut chanw: W,
+    state: &'static GlobalState,
+) -> Result<()>
+where
+    R: asynch::Read + Io<Error = sunset::Error>,
+    W: asynch::Write + Io<Error = sunset::Error>,
+{
     let (mut rx, mut tx) = state.usb_pipe.take().await;
     let r = async {
         // TODO: could have a single buffer to translate in-place.
-        const DOUBLE: usize = 2*takepipe::READ_SIZE;
+        const DOUBLE: usize = 2 * takepipe::READ_SIZE;
         let mut b = [0u8; takepipe::READ_SIZE];
         let mut btrans = Vec::<u8, DOUBLE>::new();
         loop {
@@ -264,21 +304,24 @@ impl Shell for DemoShell {
     }
 
     async fn authed(&self, username: &str) {
+        info!("authed for {}", username);
         let mut u = self.username.lock().await;
         *u = username.try_into().unwrap_or(String::new());
     }
 
-    async fn run<'f, S: ServBehaviour>(&self, serv: &'f SSHServer<'f, S>) -> Result<()>
-    {
+    async fn run<'f, S: ServBehaviour>(
+        &self,
+        serv: &'f SSHServer<'f, S>,
+    ) -> Result<()> {
         let session = async {
             // wait for a shell to start
             let chan_handle = self.notify.wait().await;
             let stdio = serv.stdio(chan_handle).await?;
 
-            if *self.username.lock().await == "serial" {
-                serial(stdio.clone(), stdio, self.ctx).await
-            } else {
+            if *self.username.lock().await == "config" {
                 menu(stdio.clone(), stdio, false, self.ctx).await
+            } else {
+                serial(stdio.clone(), stdio, self.ctx).await
             }
         };
 
@@ -292,12 +335,11 @@ async fn net_task(stack: &'static Stack<cyw43::NetDriver<'static>>) -> ! {
 }
 
 #[embassy_executor::task]
-async fn usb_serial_task(usb: embassy_rp::peripherals::USB,
+async fn usb_serial_task(
+    usb: embassy_rp::peripherals::USB,
     irq: embassy_rp::interrupt::USBCTRL_IRQ,
     global: &'static GlobalState,
-    ) -> ! {
-
+) -> ! {
     usbserial::usb_serial(usb, irq, global).await;
     todo!("shoudln't exit");
 }
-
diff --git a/embassy/demos/picow/src/picowmenu.rs b/embassy/demos/picow/src/picowmenu.rs
index 1856c46..304497b 100644
--- a/embassy/demos/picow/src/picowmenu.rs
+++ b/embassy/demos/picow/src/picowmenu.rs
@@ -1,17 +1,26 @@
 use core::fmt::Write;
 use core::future::{poll_fn, Future};
-use core::sync::atomic::Ordering::{Relaxed, SeqCst};
 use core::ops::DerefMut;
+use core::sync::atomic::Ordering::{Relaxed, SeqCst};
 
+use embedded_io::asynch;
+use embedded_io::asynch::Write as _;
 
 use embassy_sync::waitqueue::MultiWakerRegistration;
 
+use heapless::{String, Vec};
+
 use crate::demo_common;
 use crate::GlobalState;
 use demo_common::{BufOutput, SSHConfig};
 
 use demo_common::menu::*;
 
+use sunset::packets::Ed25519PubKey;
+
+// arbitrary in bytes, for sizing buffers
+const MAX_PW_LEN: usize = 50;
+
 pub(crate) struct MenuCtx {
     pub out: BufOutput,
     pub state: &'static GlobalState,
@@ -19,16 +28,27 @@ pub(crate) struct MenuCtx {
     // flags to be handled by the calling async loop
     pub switch_usb1: bool,
     pub need_save: bool,
+
+    pub logout: bool,
+    pub reset: bool,
 }
 
 impl MenuCtx {
     pub fn new(state: &'static GlobalState) -> Self {
-        Self { state, out: Default::default(), switch_usb1: false, need_save: false }
+        Self {
+            state,
+            out: Default::default(),
+            switch_usb1: false,
+            need_save: false,
+            logout: false,
+            reset: false,
+        }
     }
 
     fn with_config<F>(&mut self, f: F) -> bool
-        where F: FnOnce(&mut SSHConfig, &mut BufOutput)
-        {
+    where
+        F: FnOnce(&mut SSHConfig, &mut BufOutput),
+    {
         let mut c = match self.state.config.try_lock() {
             Ok(c) => c,
             Err(e) => {
@@ -50,6 +70,11 @@ impl core::fmt::Write for MenuCtx {
 pub(crate) const SETUP_MENU: Menu<MenuCtx> = Menu {
     label: "setup",
     items: &[
+        &Item {
+            command: "logout",
+            help: None,
+            item_type: ItemType::Callback { function: do_logout, parameters: &[] },
+        },
         &AUTH_ITEM,
         &GPIO_ITEM,
         &SERIAL_ITEM,
@@ -76,7 +101,7 @@ pub(crate) const SETUP_MENU: Menu<MenuCtx> = Menu {
             help: None,
         },
     ],
-    entry: Some(enter_top),
+    entry: None,
     exit: None,
 };
 
@@ -93,6 +118,17 @@ const AUTH_ITEM: Item<MenuCtx> = Item {
                 },
                 help: None,
             },
+            &Item {
+                command: "console-noauth",
+                item_type: ItemType::Callback {
+                    parameters: &[Parameter::Mandatory {
+                        parameter_name: "yesno",
+                        help: Some("Set yes for SSH to serial with no auth. Take care!"),
+                    }],
+                    function: do_console_noauth,
+                },
+                help: None,
+            },
             &Item {
                 command: "key",
                 item_type: ItemType::Callback {
@@ -115,7 +151,18 @@ const AUTH_ITEM: Item<MenuCtx> = Item {
                     //     "An OpenSSH style ed25519 key, eg
                     //     key ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AA...",
                     // ),
-                    function: do_auth_key,
+                    function: do_key,
+                },
+                help: None,
+            },
+            &Item {
+                command: "clear-key",
+                item_type: ItemType::Callback {
+                    parameters: &[Parameter::Mandatory {
+                        parameter_name: "slot",
+                        help: None,
+                    }],
+                    function: do_clear_key,
                 },
                 help: None,
             },
@@ -126,7 +173,71 @@ const AUTH_ITEM: Item<MenuCtx> = Item {
                         parameter_name: "pw",
                         help: None,
                     }],
-                    function: do_auth_pw,
+                    function: do_console_pw,
+                },
+                help: None,
+            },
+            &Item {
+                command: "disable-password",
+                item_type: ItemType::Callback {
+                    parameters: &[],
+                    function: do_console_clear_pw,
+                },
+                help: None,
+            },
+            &Item {
+                command: "admin-key",
+                item_type: ItemType::Callback {
+                    parameters: &[
+                        Parameter::Mandatory { parameter_name: "slot", help: None },
+                        Parameter::Mandatory {
+                            parameter_name: "ssh-ed25519",
+                            help: None,
+                        },
+                        Parameter::Mandatory {
+                            parameter_name: "base64",
+                            help: None,
+                        },
+                        Parameter::Optional {
+                            parameter_name: "comment",
+                            help: None,
+                        },
+                    ],
+                    // help: Some(
+                    //     "An OpenSSH style ed25519 key, eg
+                    //     key ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AA...",
+                    // ),
+                    function: do_admin_key,
+                },
+                help: None,
+            },
+            &Item {
+                command: "clear-admin-key",
+                item_type: ItemType::Callback {
+                    parameters: &[Parameter::Mandatory {
+                        parameter_name: "slot",
+                        help: None,
+                    }],
+                    function: do_admin_clear_key,
+                },
+                help: None,
+            },
+            &Item {
+                command: "admin-password",
+                item_type: ItemType::Callback {
+                    parameters: &[Parameter::Mandatory {
+                        parameter_name: "pw",
+                        help: None,
+                    }],
+                    function: do_admin_pw,
+                },
+                help: Some("Password for serial or config@. 'None' to clear"),
+            },
+            &Item {
+                command: "clear-admin-password",
+                item_type: ItemType::Callback {
+                    parameters: &[],
+                    function: do_admin_clear_pw,
                 },
                 help: None,
             },
@@ -146,8 +257,14 @@ const WIFI_ITEM: Item<MenuCtx> = Item {
                 command: "wpa2",
                 item_type: ItemType::Callback {
                     parameters: &[
-                        Parameter::Mandatory { parameter_name: "net", help: Some("ssid") },
-                        Parameter::Mandatory { parameter_name: "password", help: None },
+                        Parameter::Mandatory {
+                            parameter_name: "net",
+                            help: Some("ssid"),
+                        },
+                        Parameter::Mandatory {
+                            parameter_name: "password",
+                            help: None,
+                        },
                     ],
                     function: do_wifi_wpa2,
                 },
@@ -156,9 +273,10 @@ const WIFI_ITEM: Item<MenuCtx> = Item {
             &Item {
                 command: "open",
                 item_type: ItemType::Callback {
-                    parameters: &[
-                        Parameter::Mandatory { parameter_name: "net", help: Some("ssid") },
-                    ],
+                    parameters: &[Parameter::Mandatory {
+                        parameter_name: "net",
+                        help: Some("ssid"),
+                    }],
                     function: do_wifi_open,
                 },
                 help: None,
@@ -204,45 +322,149 @@ const GPIO_ITEM: Item<MenuCtx> = Item {
     help: Some("GPIO, todo"),
 };
 
-
 const SERIAL_ITEM: Item<MenuCtx> = Item {
     command: "serial",
     item_type: ItemType::Menu(&Menu {
         label: "serial",
-        items: &[
-            &Item {
-                command: "usb0",
-                item_type: ItemType::Callback {
-                    parameters: &[],
-                    function: do_usb1,
-                },
-                help: Some("Connect to if00 serial port. Disconnect to exit."),
-            },
-        ],
+        items: &[&Item {
+            command: "usb0",
+            item_type: ItemType::Callback { parameters: &[], function: do_usb1 },
+            help: Some("Connect to if00 serial port. Disconnect to exit."),
+        }],
         entry: None,
         exit: None,
     }),
     help: Some("Passwords and Keys."),
 };
 
-fn enter_top(context: &mut MenuCtx) {
-    writeln!(context, "In setup menu").unwrap();
-}
-
 fn enter_auth(context: &mut MenuCtx) {
     writeln!(context, "In auth menu").unwrap();
 }
 
+fn endis(v: bool) -> &'static str {
+    if v {
+        "enabled"
+    } else {
+        "disabled"
+    }
+}
+
+fn prkey(context: &mut dyn Write, name: &str, k: &Option<Ed25519PubKey>) {
+    if let Some(k) = k {
+        writeln!(context, "{} ed25519 todo", name);
+    } else {
+        writeln!(context, "{} disabled", name);
+    }
+}
+
 fn do_auth_show(_item: &Item<MenuCtx>, _args: &[&str], context: &mut MenuCtx) {
-    writeln!(context, "auth key");
+    context.with_config(|c, out| {
+        write!(out, "Console password ");
+        if c.console_noauth {
+            writeln!(out, "not required");
+        } else {
+            writeln!(out, "{}", endis(c.console_pw.is_some()));
+        }
+        writeln!(out, "Console password {}", endis(c.console_pw.is_some()));
+        prkey(out, "Console key1", &c.console_keys[0]);
+        prkey(out, "Console key2", &c.console_keys[1]);
+        prkey(out, "Console key3", &c.console_keys[2]);
+        writeln!(out, "Admin password {}", endis(c.admin_pw.is_some()));
+        prkey(out, "Admin key1", &c.admin_keys[0]);
+        prkey(out, "Admin key2", &c.admin_keys[1]);
+        prkey(out, "Admin key3", &c.admin_keys[2]);
+    });
 }
 
-fn do_auth_key(_item: &Item<MenuCtx>, _args: &[&str], context: &mut MenuCtx) {
-    writeln!(context, "auth key");
+fn do_key(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {
+    let slot: usize = match args[0].parse() {
+        Err(e) => {
+            writeln!(context, "Bad slot");
+            return;
+        }
+        Ok(s) => s,
+    };
+    if slot == 0 || slot > demo_common::config::KEY_SLOTS {
+        writeln!(context, "Bad slot");
+        return;
+    }
+    context.need_save = true;
+
+    writeln!(context, "todo openssh key parsing");
 }
 
-fn do_auth_pw(_item: &Item<MenuCtx>, _args: &[&str], context: &mut MenuCtx) {
-    writeln!(context, "this is auth pw");
+fn do_clear_key(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {
+    writeln!(context, "todo");
+    context.need_save = true;
+}
+
+fn do_console_pw(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {
+    let pw = args[0];
+    if pw.as_bytes().len() > MAX_PW_LEN {
+        writeln!(context, "Too long");
+        return;
+    }
+    context.with_config(|c, out| {
+        match c.set_console_pw(Some(pw)) {
+            Ok(()) => writeln!(out, "Set console password"),
+            Err(e) => writeln!(out, "Failed setting, {}", e),
+        };
+    });
+    context.need_save = true;
+}
+
+// TODO: this is a bit hazardous with the takepipe kickoff mechanism
+fn do_console_noauth(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {
+    context.with_config(|c, out| {
+        c.console_noauth = args[0] == "yes";
+        let _ = writeln!(out, "Set console noauth {}", if c.console_noauth {
+            "yes"
+        } else {
+            "no"
+        });
+    });
+    context.need_save = true;
+}
+
+fn do_admin_key(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {
+    writeln!(context, "todo");
+    context.need_save = true;
+}
+
+fn do_admin_clear_key(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {
+    writeln!(context, "todo");
+    context.need_save = true;
+}
+
+fn do_console_clear_pw(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {
+    context.with_config(|c, out| {
+        let _ = c.set_console_pw(None);
+        writeln!(out, "Disabled console password");
+    });
+    context.need_save = true;
+}
+
+fn do_admin_pw(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {
+    let pw = args[0];
+    if pw.as_bytes().len() > MAX_PW_LEN {
+        writeln!(context, "Too long");
+        return;
+    }
+    context.with_config(|c, out| {
+        match c.set_admin_pw(Some(pw)) {
+            Ok(()) => writeln!(out, "Set admin password"),
+            Err(e) => writeln!(out, "Failed setting, {}", e),
+        };
+    });
+    context.need_save = true;
+}
+
+fn do_admin_clear_pw(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {
+    context.with_config(|c, out| {
+        let _ = c.set_admin_pw(None);
+        writeln!(out, "Disabled admin password");
+    });
+    context.need_save = true;
 }
 
 fn do_gpio_show(_item: &Item<MenuCtx>, _args: &[&str], context: &mut MenuCtx) {
@@ -251,13 +473,21 @@ fn do_gpio_show(_item: &Item<MenuCtx>, _args: &[&str], context: &mut MenuCtx) {
 
 fn do_gpio_set(_item: &Item<MenuCtx>, _args: &[&str], context: &mut MenuCtx) {}
 
-fn do_erase_config(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {
+fn do_erase_config(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {}
+
+fn do_logout(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {
+    context.logout = true;
 }
 
-fn do_reset(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {}
+fn do_reset(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {
+    context.reset = true;
+}
 
 fn do_about(_item: &Item<MenuCtx>, _args: &[&str], context: &mut MenuCtx) {
-    let _ = writeln!(context, "Sunset SSH, USB serial\nMatt Johnston <matt@ucc.asn.au>\n");
+    let _ = writeln!(
+        context,
+        "Sunset SSH, USB serial\nMatt Johnston <matt@ucc.asn.au>\n"
+    );
 }
 
 fn do_usb1(_item: &Item<MenuCtx>, _args: &[&str], context: &mut MenuCtx) {
@@ -307,3 +537,24 @@ fn do_wifi_open(_item: &Item<MenuCtx>, args: &[&str], context: &mut MenuCtx) {
     context.need_save = true;
     wifi_entry(context);
 }
+
+// Returns an error on EOF etc.
+pub(crate) async fn request_pw<E>(
+    tx: &mut impl asynch::Write<Error = E>,
+    rx: &mut impl asynch::Read<Error = E>,
+) -> Result<String<MAX_PW_LEN>, ()> {
+    tx.write_all(b"\r\nEnter Password: ").await.map_err(|_| ())?;
+    let mut pw = Vec::<u8, MAX_PW_LEN>::new();
+    loop {
+        let mut c = [0u8];
+        rx.read_exact(&mut c).await.map_err(|_| ())?;
+        let c = c[0];
+        if c == b'\r' || c == b'\n' {
+            break;
+        }
+        pw.push(c).map_err(|_| ())?;
+    }
+
+    let pw = core::str::from_utf8(&pw).map_err(|_| ())?;
+    return Ok(pw.into());
+}
diff --git a/embassy/demos/picow/src/takepipe.rs b/embassy/demos/picow/src/takepipe.rs
index 98f8f82..c2bd835 100644
--- a/embassy/demos/picow/src/takepipe.rs
+++ b/embassy/demos/picow/src/takepipe.rs
@@ -101,6 +101,10 @@ impl<'a> TakeBase<'a> {
         (r, w)
     }
 
+    pub fn is_in_use(&self) -> bool {
+        self.shared_read.try_lock().is_err()
+    }
+
     pub fn split(&'a self) -> (TakeBaseRead<'a>, TakeBaseWrite<'a>) {
         let r = TakeBaseRead {
             pipe: self.pipe,
diff --git a/embassy/demos/picow/src/usbserial.rs b/embassy/demos/picow/src/usbserial.rs
index 5780265..02ee0c9 100644
--- a/embassy/demos/picow/src/usbserial.rs
+++ b/embassy/demos/picow/src/usbserial.rs
@@ -20,6 +20,7 @@ use sunset::*;
 use sunset_embassy::*;
 
 use crate::*;
+use picowmenu::request_pw;
 
 pub(crate) async fn usb_serial(
     usb: embassy_rp::peripherals::USB,
@@ -27,8 +28,6 @@ pub(crate) async fn usb_serial(
     global: &'static GlobalState,
 )
 {
-    info!("usb_serial top");
-
     let driver = embassy_rp::usb::Driver::new(usb, irq);
 
     let mut config = embassy_usb::Config::new(0xf055, 0x6053);
@@ -78,14 +77,15 @@ pub(crate) async fn usb_serial(
     // Run the USB device.
     let usb_fut = usb.run();
 
+    // console via SSH on if00
     let io0 = async {
         let (mut chan_rx, mut chan_tx) = global.usb_pipe.split();
         let chan_rx = &mut chan_rx;
         let chan_tx = &mut chan_tx;
         loop {
-            info!("usb waiting");
+            info!("USB waiting");
             cdc0_rx.wait_connection().await;
-            info!("Connected");
+            info!("USB connected");
             let mut cdc0_tx = CDCWrite::new(&mut cdc0_tx);
             let mut cdc0_rx = CDCRead::new(&mut cdc0_rx);
 
@@ -93,25 +93,44 @@ pub(crate) async fn usb_serial(
             let io_rx = io_copy::<64, _, _>(chan_rx, &mut cdc0_tx);
 
             let _ = join(io_rx, io_tx).await;
-            info!("Disconnected");
+            info!("USB disconnected");
         }
     };
 
+    // Admin menu on if02
     let setup = async {
-        loop {
-            info!("usb waiting");
+        'usb: loop {
             cdc2_rx.wait_connection().await;
-            info!("Connected");
-            let cdc2_tx = CDCWrite::new(&mut cdc2_tx);
-            let cdc2_rx = CDCRead::new(&mut cdc2_rx);
+            let mut cdc2_tx = CDCWrite::new(&mut cdc2_tx);
+            let mut cdc2_rx = CDCRead::new(&mut cdc2_rx);
+
+            // wait for a keystroke before writing anything.
+            let mut c = [0u8];
+            let _ = cdc2_rx.read_exact(&mut c).await;
+            
+            let p = {
+                let c = global.config.lock().await;
+                c.admin_pw.clone()
+            };
+
+            if let Some(p) = p {
+                'pw: loop {
+                    match request_pw(&mut cdc2_tx, &mut cdc2_rx).await {
+                        Ok(pw) => {
+                            if p.check(&pw) {
+                                let _ = cdc2_tx.write_all(b"Good\r\n").await;
+                                break 'pw
+                            }
+                        }
+                        Err(_) => continue 'usb
+                    }
+                }
+            }
 
             let _ = menu(cdc2_rx, cdc2_tx, true, global).await;
-
-            info!("Disconnected");
         }
     };
 
-    info!("usb join");
     join3(usb_fut, io0, setup).await;
 }
 
@@ -141,13 +160,11 @@ impl<'a, D: Driver<'a>> asynch::Read for CDCRead<'a, '_, D> {
                 .read_packet(ret)
                 .await
                 .map_err(|_| sunset::Error::ChannelEOF)?;
-            info!("direct read_packet {:?}", &ret[..n]);
             return Ok(n)
         }
 
         let b = self.fill_buf().await?;
         let n = ret.len().min(b.len());
-        info!("buf read {:?}, rl {} bl {}", &b[..n], ret.len(), b.len());
         (&mut ret[..n]).copy_from_slice(&b[..n]);
         self.consume(n);
         return Ok(n)
@@ -165,12 +182,10 @@ impl<'a, D: Driver<'a>> asynch::BufRead for CDCRead<'a, '_, D> {
                 .read_packet(self.buf.as_mut())
                 .await
                 .map_err(|_| sunset::Error::ChannelEOF)?;
-            info!("buf read_packet {:?}", &self.buf[..n]);
 
             self.end = n;
         }
         debug_assert!(self.end > 0);
-        info!("fill {}..{}", self.start, self.end);
 
         return Ok(&self.buf[self.start..self.end]);
     }
@@ -185,7 +200,6 @@ impl<'a, D: Driver<'a>> asynch::BufRead for CDCRead<'a, '_, D> {
             self.start = 0;
             self.end = 0;
         }
-        info!("consumed {},  {}..{}", amt, self.start, self.end);
     }
 }
 
diff --git a/embassy/demos/picow/src/wifi.rs b/embassy/demos/picow/src/wifi.rs
index 9851d3f..ca39d9f 100644
--- a/embassy/demos/picow/src/wifi.rs
+++ b/embassy/demos/picow/src/wifi.rs
@@ -63,8 +63,10 @@ pub(crate) async fn wifi_stack(spawner: &Spawner,
     // control.set_power_management(cyw43::PowerManagementMode::None).await;
     // control.set_power_management(cyw43::PowerManagementMode::Performance).await;
 
+    // TODO: this should move out of the critical path, run in the bg.
+    // just return control before joining.
     let mut status = Ok(());
-    for i in 0..5 {
+    for i in 0..2 {
         status = if let Some(ref pw) = wpa_password {
             info!("wifi net {} wpa2 {}", wifi_net, &pw);
             control.join_wpa2(&wifi_net, &pw).await
@@ -79,10 +81,10 @@ pub(crate) async fn wifi_stack(spawner: &Spawner,
         }
     }
 
-    if let Err(e) = status {
-        // wait forever
-        let () = futures::future::pending().await;
-    }
+    // if let Err(e) = status {
+    //     // wait forever
+    //     let () = futures::future::pending().await;
+    // }
 
     let config = embassy_net::Config::Dhcp(Default::default());
 
diff --git a/embassy/demos/std/Cargo.lock b/embassy/demos/std/Cargo.lock
index 18d98ca..a5e023f 100644
--- a/embassy/demos/std/Cargo.lock
+++ b/embassy/demos/std/Cargo.lock
@@ -127,6 +127,24 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
 
+[[package]]
+name = "base64"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d"
+
+[[package]]
+name = "bcrypt"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9df288bec72232f78c1ec5fe4e8f1d108aa0265476e93097593c803c8c02062a"
+dependencies = [
+ "base64",
+ "blowfish",
+ "getrandom",
+ "subtle",
+]
+
 [[package]]
 name = "bitflags"
 version = "1.3.2"
@@ -142,6 +160,16 @@ dependencies = [
  "generic-array 0.14.6",
 ]
 
+[[package]]
+name = "blowfish"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
+dependencies = [
+ "byteorder",
+ "cipher",
+]
+
 [[package]]
 name = "byteorder"
 version = "1.4.3"
@@ -1050,6 +1078,7 @@ dependencies = [
 name = "sunset-demo-embassy-common"
 version = "0.1.0"
 dependencies = [
+ "bcrypt",
  "embassy-futures",
  "embassy-net",
  "embassy-net-driver",
@@ -1057,7 +1086,10 @@ dependencies = [
  "embassy-time 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
  "embedded-io",
  "heapless",
+ "hmac",
  "log",
+ "pretty-hex",
+ "sha2",
  "sunset",
  "sunset-embassy",
  "sunset-sshwire-derive",
diff --git a/embassy/demos/std/src/main.rs b/embassy/demos/std/src/main.rs
index d8869a0..ccefa82 100644
--- a/embassy/demos/std/src/main.rs
+++ b/embassy/demos/std/src/main.rs
@@ -100,7 +100,7 @@ impl Shell for DemoShell {
             let mut menu_buf = [0u8; 150];
             let menu_out = demo_menu::BufOutput::default();
 
-            let mut menu = MenuRunner::new(&setupmenu::SETUP_MENU, &mut menu_buf, menu_out);
+            let mut menu = MenuRunner::new(&setupmenu::SETUP_MENU, &mut menu_buf, true, menu_out);
 
             // bodge
             for c in "help\r\n".bytes() {
diff --git a/src/behaviour.rs b/src/behaviour.rs
index 471e7b5..c9a48ff 100644
--- a/src/behaviour.rs
+++ b/src/behaviour.rs
@@ -238,7 +238,7 @@ pub trait ServBehaviour {
     ///
     /// Implementations may need to take care to avoid leaking user existence
     /// based on timing.
-    fn auth_password(&mut self, username: TextString, password: TextString) -> bool {
+    async fn auth_password(&mut self, username: TextString<'_>, password: TextString<'_>) -> bool {
         false
     }
 
@@ -249,7 +249,7 @@ pub trait ServBehaviour {
     /// Implementations may need to take care to avoid leaking user existence
     /// based on timing.
     #[allow(unused)]
-    fn auth_pubkey(&mut self, username: TextString, pubkey: &PubKey) -> bool {
+    async fn auth_pubkey(&mut self, username: TextString<'_>, pubkey: &PubKey<'_>) -> bool {
         false
     }
 
diff --git a/src/kex.rs b/src/kex.rs
index f251569..0918b84 100644
--- a/src/kex.rs
+++ b/src/kex.rs
@@ -791,8 +791,8 @@ mod tests {
     }
 
     impl<'a> ServBehaviour for TestServBehaviour<'a> {
-        fn hostkeys(&mut self) -> BhResult<&[&'a SignKey]> {
-            Ok(self.keys.as_slice())
+        fn hostkeys(&mut self) -> BhResult<heapless::Vec<&SignKey, 2>> {
+            Ok(heapless::Vec::from_slice(self.keys.as_slice()).unwrap())
         }
 
         fn have_auth_pubkey(&self, _username: TextString) -> bool {
diff --git a/src/servauth.rs b/src/servauth.rs
index 5b701ee..81326f6 100644
--- a/src/servauth.rs
+++ b/src/servauth.rs
@@ -36,6 +36,9 @@ impl ServAuth {
             FailNoReply,
         }
 
+        // TODO: what to do they've already authed? we have to be careful in case
+        // behaviours don't handle it well.
+
         let username = p.username.clone();
 
         let inner = async {
@@ -45,18 +48,28 @@ impl ServAuth {
             }
 
             let success = match p.method {
-                AuthMethod::Password(m) => b.auth_password(p.username, m.password),
+                AuthMethod::Password(m) => {
+                    if b.have_auth_password(p.username) {
+                        b.auth_password(p.username, m.password).await
+                    } else {
+                        false
+                    }
+                }
                 AuthMethod::PubKey(ref m) => {
-                    let allowed_key = b.auth_pubkey(p.username, &m.pubkey.0);
-                    if allowed_key {
-                        if m.sig.is_some() {
-                            self.verify_sig(&mut p, sess_id)
+                    if b.have_auth_pubkey(p.username) {
+                        let allowed_key = b.auth_pubkey(p.username, &m.pubkey.0).await;
+                        if allowed_key {
+                            if m.sig.is_some() {
+                                self.verify_sig(&mut p, sess_id)
+                            } else {
+                                s.send(Userauth60::PkOk(UserauthPkOk {
+                                    algo: m.sig_algo,
+                                    key: m.pubkey.clone(),
+                                }))?;
+                                return Ok(AuthResp::FailNoReply);
+                            }
                         } else {
-                            s.send(Userauth60::PkOk(UserauthPkOk {
-                                algo: m.sig_algo,
-                                key: m.pubkey.clone(),
-                            }))?;
-                            return Ok(AuthResp::FailNoReply);
+                            false
                         }
                     } else {
                         false
diff --git a/src/sign.rs b/src/sign.rs
index 5357ef3..f2c1ba3 100644
--- a/src/sign.rs
+++ b/src/sign.rs
@@ -168,7 +168,7 @@ pub enum KeyType {
 ///
 /// This may hold the private key part locally
 /// or potentially send the signing requests to an SSH agent or other entity.
-#[derive(ZeroizeOnDrop, Clone)]
+#[derive(ZeroizeOnDrop, Clone, PartialEq)]
 pub enum SignKey {
     // 32 byte seed value is the private key
     Ed25519([u8; 32]),
diff --git a/src/sshwire.rs b/src/sshwire.rs
index 1d4b60e..8e9193c 100644
--- a/src/sshwire.rs
+++ b/src/sshwire.rs
@@ -11,7 +11,7 @@ use {
     log::{debug, error, info, log, trace, warn},
 };
 
-use core::str;
+use core::str::FromStr;
 use core::convert::AsRef;
 use core::fmt::{self,Debug,Display};
 use digest::Output;
@@ -302,7 +302,12 @@ impl<'de> SSHDecode<'de> for BinString<'de> {
         let len = u32::dec(s)? as usize;
         Ok(BinString(s.take(len)?))
     }
+}
 
+impl<const N: usize> SSHEncode for heapless::String<N> {
+    fn enc(&self, s: &mut dyn SSHSink) -> WireResult<()> {
+        self.as_str().enc(s)
+    }
 }
 
 /// A text string that may be presented to a user or used
@@ -578,6 +583,14 @@ impl<'de, const N: usize> SSHDecode<'de> for [u8; N] {
     }
 }
 
+impl<'de, const N: usize> SSHDecode<'de> for heapless::String<N> {
+    fn dec<S>(s: &mut S) -> WireResult<Self>
+    where S: SSHSource<'de> {
+        heapless::String::from_str(SSHDecode::dec(s)?)
+        .map_err(|_| WireError::NoRoom)
+    }
+}
+
 /// Like `digest::DynDigest` but simpler.
 ///
 /// Doesn't have any optional methods that depend on `alloc`.
diff --git a/testing/ci.sh b/testing/ci.sh
index 192f4a2..28a8a01 100755
--- a/testing/ci.sh
+++ b/testing/ci.sh
@@ -52,6 +52,11 @@ cd embassy/demos/std
 cargo build
 )
 
+(
+cd embassy/demos/common
+cargo test
+)
+
 (
 cd embassy/demos/picow
 cargo build --release
-- 
GitLab