From 4301011e91d25c49f326054b2e74cb4b40328ac1 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Mon, 1 Sep 2025 17:51:59 -0600 Subject: [PATCH 1/5] Add SpHandler::unlock method --- gateway-messages/src/sp_impl.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/gateway-messages/src/sp_impl.rs b/gateway-messages/src/sp_impl.rs index 9bd5e76b..ddd225cd 100644 --- a/gateway-messages/src/sp_impl.rs +++ b/gateway-messages/src/sp_impl.rs @@ -29,6 +29,7 @@ use crate::MessageKind; use crate::MgsError; use crate::MgsRequest; use crate::MgsResponse; +use crate::MonorailError; use crate::PowerState; use crate::PowerStateTransition; use crate::RotBootInfo; @@ -46,6 +47,8 @@ use crate::SpUpdatePrepare; use crate::StartupOptions; use crate::SwitchDuration; use crate::TlvPage; +use crate::UnlockChallenge; +use crate::UnlockResponse; use crate::UpdateChunk; use crate::UpdateId; use crate::UpdateStatus; @@ -417,6 +420,15 @@ pub trait SpHandler { fn start_host_flash_hash(&mut self, slot: u16) -> Result<(), SpError>; fn get_host_flash_hash(&mut self, slot: u16) -> Result<[u8; 32], SpError>; + + /// Unlocks the tech port if the challenge and response are compatible + fn unlock( + &mut self, + vid: Self::VLanId, + challenge: UnlockChallenge, + response: UnlockResponse, + time_sec: u32, + ) -> Result<(), MonorailError>; } /// Handle a single incoming message. @@ -1457,6 +1469,16 @@ mod tests { ) -> Result<[u8; 32], SpError> { unimplemented!() } + + fn unlock( + &mut self, + _vid: Self::VLanId, + _challenge: UnlockChallenge, + _response: UnlockResponse, + _time_sec: u32, + ) -> Result<(), MonorailError> { + unimplemented!() + } } #[cfg(feature = "std")] From c691112201bcdabb86a943151fec00be5f3c1487 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Thu, 4 Sep 2025 11:36:05 -0600 Subject: [PATCH 2/5] faux-mgs: tech port unlock with permslip --- faux-mgs/src/main.rs | 222 ++++++++++++++++++++++++++++--------------- 1 file changed, 143 insertions(+), 79 deletions(-) diff --git a/faux-mgs/src/main.rs b/faux-mgs/src/main.rs index 48950ec8..ad2498a2 100644 --- a/faux-mgs/src/main.rs +++ b/faux-mgs/src/main.rs @@ -18,6 +18,7 @@ use futures::StreamExt; use gateway_messages::ignition::TransceiverSelect; use gateway_messages::ComponentAction; use gateway_messages::ComponentActionResponse; +use gateway_messages::EcdsaSha2Nistp256Challenge; use gateway_messages::IgnitionCommand; use gateway_messages::LedComponentAction; use gateway_messages::MonorailComponentAction; @@ -555,17 +556,28 @@ enum MonorailCommand { #[clap(flatten)] cmd: UnlockGroup, - /// Public key for SSH signing challenge + /// Name of the signing key for producing unlock challenge responses /// - /// This is either a path to a public key (ending in `.pub`), or a - /// substring to match against known keys (which can be printed with - /// `faux-mgs monorail unlock --list`). + /// This is either a path to an SSH public key file (ending in `.pub`), + /// or a substring to match against known SSH keys (which can be printed + /// with `faux-mgs monorail unlock --list`), or a permslip key name (see + /// `permslip list-keys -t`). #[clap(short, long, conflicts_with = "list")] key: Option, /// Path to the SSH agent socket #[clap(long, env)] ssh_auth_sock: Option, + + /// Use the Online Signing Service with `permslip` + #[clap( + short, + long, + alias = "online", + conflicts_with = "list", + requires = "key" + )] + permslip: bool, }, /// Lock the technician port @@ -1605,6 +1617,7 @@ async fn run_command( cmd: UnlockGroup { time, list }, key, ssh_auth_sock, + permslip, } => { if list { let Some(ssh_auth_sock) = ssh_auth_sock else { @@ -1624,6 +1637,7 @@ async fn run_command( time_sec, ssh_auth_sock, key, + permslip, ) .await?; } @@ -1900,8 +1914,9 @@ async fn monorail_unlock( log: &Logger, sp: &SingleSp, time_sec: u32, - socket: Option, + ssh_sock: Option, pub_key: Option, + permslip: bool, ) -> Result<()> { let r = sp .component_action_with_response( @@ -1924,82 +1939,14 @@ async fn monorail_unlock( UnlockChallenge::Trivial { timestamp } => { UnlockResponse::Trivial { timestamp } } - UnlockChallenge::EcdsaSha2Nistp256(data) => { - let Some(socket) = socket else { - bail!("must provide --ssh-auth-sock"); - }; - let keys = ssh_list_keys(&socket)?; - let pub_key = if keys.len() == 1 && pub_key.is_none() { - keys[0].clone() + UnlockChallenge::EcdsaSha2Nistp256(ecdsa_challenge) => { + if pub_key.is_some() && permslip { + unlock_permslip(log, pub_key.unwrap(), challenge)? + } else if let Some(socket) = ssh_sock { + unlock_ssh(log, socket, pub_key, ecdsa_challenge)? } else { - let Some(pub_key) = pub_key else { - bail!( - "need --key for ECDSA challenge; \ - multiple keys are available" - ); - }; - if pub_key.ends_with(".pub") { - ssh_key::PublicKey::read_openssh_file(Path::new(&pub_key)) - .with_context(|| { - format!("could not read key from {pub_key:?}") - })? - } else { - let mut found = None; - for k in keys.iter() { - if k.to_openssh()?.contains(&pub_key) { - if found.is_some() { - bail!("multiple keys contain '{pub_key}'"); - } - found = Some(k); - } - } - let Some(found) = found else { - bail!( - "could not match '{pub_key}'; \ - use `faux-mgs monorail unlock --list` \ - to print keys" - ); - }; - found.clone() - } - }; - - let mut data = data.as_bytes().to_vec(); - let signer_nonce: [u8; 8] = rand::random(); - data.extend(signer_nonce); - - let signed = ssh_keygen_sign(socket, pub_key, &data)?; - debug!(log, "got signature {signed:?}"); - - let key_bytes = - signed.public_key().ecdsa().unwrap().as_sec1_bytes(); - assert_eq!(key_bytes.len(), 65, "invalid key length"); - let mut key = [0u8; 65]; - key.copy_from_slice(key_bytes); - - // Signature bytes are encoded per - // https://datatracker.ietf.org/doc/html/rfc5656#section-3.1.2 - // - // They are a pair of `mpint` values, per - // https://datatracker.ietf.org/doc/html/rfc4251 - // - // Each one is either 32 bytes or 33 bytes with a leading zero, so - // we'll awkwardly allow for both cases. - let mut r = std::io::Cursor::new(signed.signature_bytes()); - use std::io::Read; - let mut signature = [0u8; 64]; - for i in 0..2 { - let mut size = [0u8; 4]; - r.read_exact(&mut size)?; - match u32::from_be_bytes(size) { - 32 => (), - 33 => r.read_exact(&mut [0u8])?, // eat the leading byte - _ => bail!("invalid length {i}"), - } - r.read_exact(&mut signature[i * 32..][..32])?; + bail!("don't know how to unlock tech port without ssh or permslip") } - - UnlockResponse::EcdsaSha2Nistp256 { key, signer_nonce, signature } } }; sp.component_action( @@ -2015,6 +1962,123 @@ async fn monorail_unlock( Ok(()) } +fn unlock_permslip( + log: &Logger, + key_name: String, + challenge: UnlockChallenge, +) -> Result { + use std::process::{Command, Stdio}; + + let mut permslip = Command::new("permslip") + .arg("sign") + .arg(key_name) + .arg("--sshauth") + .arg("--kind=tech-port-unlock-challenge") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .context( + "unable to execute `permslip`, is it in your PATH and executable?", + )?; + + let mut input = + permslip.stdin.take().context("can't get permslip input")?; + input.write_all(serde_json::to_string(&challenge)?.as_bytes())?; + input.flush()?; + drop(input); + + let output = + permslip.wait_with_output().context("can't read permslip output")?; + if output.status.success() { + let response = + serde_json::from_slice::(&output.stdout)?; + debug!(log, "got response from permslip"; "response" => ?response); + Ok(response) + } else { + bail!("online signing with permslip failed"); + } +} + +fn unlock_ssh( + log: &Logger, + socket: PathBuf, + pub_key: Option, + challenge: EcdsaSha2Nistp256Challenge, +) -> Result { + let keys = ssh_list_keys(&socket)?; + let pub_key = if keys.len() == 1 && pub_key.is_none() { + keys[0].clone() + } else { + let Some(pub_key) = pub_key else { + bail!( + "need --key for ECDSA challenge; \ + multiple keys are available" + ); + }; + if pub_key.ends_with(".pub") { + ssh_key::PublicKey::read_openssh_file(Path::new(&pub_key)) + .with_context(|| { + format!("could not read key from {pub_key:?}") + })? + } else { + let mut found = None; + for k in keys.iter() { + if k.to_openssh()?.contains(&pub_key) { + if found.is_some() { + bail!("multiple keys contain '{pub_key}'"); + } + found = Some(k); + } + } + let Some(found) = found else { + bail!( + "could not match '{pub_key}'; \ + use `faux-mgs monorail unlock --list` \ + to print keys" + ); + }; + found.clone() + } + }; + + let mut data = challenge.as_bytes().to_vec(); + let signer_nonce: [u8; 8] = rand::random(); + data.extend(signer_nonce); + + let signed = ssh_keygen_sign(socket, pub_key, &data)?; + debug!(log, "got signature {signed:?}"); + + let key_bytes = signed.public_key().ecdsa().unwrap().as_sec1_bytes(); + assert_eq!(key_bytes.len(), 65, "invalid key length"); + let mut key = [0u8; 65]; + key.copy_from_slice(key_bytes); + + // Signature bytes are encoded per + // https://datatracker.ietf.org/doc/html/rfc5656#section-3.1.2 + // + // They are a pair of `mpint` values, per + // https://datatracker.ietf.org/doc/html/rfc4251 + // + // Each one is either 32 bytes or 33 bytes with a leading zero, so + // we'll awkwardly allow for both cases. + let mut r = std::io::Cursor::new(signed.signature_bytes()); + use std::io::Read; + let mut signature = [0u8; 64]; + for i in 0..2 { + let mut size = [0u8; 4]; + r.read_exact(&mut size)?; + match u32::from_be_bytes(size) { + 32 => (), + 33 => r.read_exact(&mut [0u8])?, // eat the leading byte + _ => bail!("invalid length {i}"), + } + r.read_exact(&mut signature[i * 32..][..32])?; + } + + Ok(UnlockResponse::EcdsaSha2Nistp256 { key, signer_nonce, signature }) +} + fn ssh_keygen_sign( socket: PathBuf, pub_key: ssh_key::PublicKey, From 68df5ece3307643ed512759cccd104c1798723ce Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Mon, 29 Sep 2025 16:12:03 -0600 Subject: [PATCH 3/5] faux-mgs: support PERMSLIP environment variable for online unlock --- faux-mgs/src/main.rs | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/faux-mgs/src/main.rs b/faux-mgs/src/main.rs index ad2498a2..4d04cd08 100644 --- a/faux-mgs/src/main.rs +++ b/faux-mgs/src/main.rs @@ -1967,20 +1967,26 @@ fn unlock_permslip( key_name: String, challenge: UnlockChallenge, ) -> Result { + use std::env; use std::process::{Command, Stdio}; - let mut permslip = Command::new("permslip") - .arg("sign") - .arg(key_name) - .arg("--sshauth") - .arg("--kind=tech-port-unlock-challenge") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn() - .context( - "unable to execute `permslip`, is it in your PATH and executable?", - )?; + let mut permslip = Command::new( + env::var("PERMSLIP").unwrap_or_else(|_| String::from("permslip")), + ) + .arg("sign") + .arg(key_name) + .arg("--sshauth") + .arg("--kind=tech-port-unlock-challenge") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|_| { + anyhow!( + "Unable to execute `permslip`, is it in your PATH and executable? \ + You may also override it with the PERMSLIP environment variable." + ) + })?; let mut input = permslip.stdin.take().context("can't get permslip input")?; From 9ddcf1e1cc01927bb1fc73d5e44b9c69e96284f6 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Tue, 30 Sep 2025 12:48:27 -0600 Subject: [PATCH 4/5] Remove unlock method from SpHandler trait --- gateway-messages/src/sp_impl.rs | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/gateway-messages/src/sp_impl.rs b/gateway-messages/src/sp_impl.rs index ddd225cd..9bd5e76b 100644 --- a/gateway-messages/src/sp_impl.rs +++ b/gateway-messages/src/sp_impl.rs @@ -29,7 +29,6 @@ use crate::MessageKind; use crate::MgsError; use crate::MgsRequest; use crate::MgsResponse; -use crate::MonorailError; use crate::PowerState; use crate::PowerStateTransition; use crate::RotBootInfo; @@ -47,8 +46,6 @@ use crate::SpUpdatePrepare; use crate::StartupOptions; use crate::SwitchDuration; use crate::TlvPage; -use crate::UnlockChallenge; -use crate::UnlockResponse; use crate::UpdateChunk; use crate::UpdateId; use crate::UpdateStatus; @@ -420,15 +417,6 @@ pub trait SpHandler { fn start_host_flash_hash(&mut self, slot: u16) -> Result<(), SpError>; fn get_host_flash_hash(&mut self, slot: u16) -> Result<[u8; 32], SpError>; - - /// Unlocks the tech port if the challenge and response are compatible - fn unlock( - &mut self, - vid: Self::VLanId, - challenge: UnlockChallenge, - response: UnlockResponse, - time_sec: u32, - ) -> Result<(), MonorailError>; } /// Handle a single incoming message. @@ -1469,16 +1457,6 @@ mod tests { ) -> Result<[u8; 32], SpError> { unimplemented!() } - - fn unlock( - &mut self, - _vid: Self::VLanId, - _challenge: UnlockChallenge, - _response: UnlockResponse, - _time_sec: u32, - ) -> Result<(), MonorailError> { - unimplemented!() - } } #[cfg(feature = "std")] From b79fe361a49a8157548c60730c32c4de7c21a60f Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Wed, 1 Oct 2025 09:00:35 -0600 Subject: [PATCH 5/5] Make --permslip conflict with --ssh-auth-sock --- faux-mgs/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/faux-mgs/src/main.rs b/faux-mgs/src/main.rs index ca8f843f..f5548f8c 100644 --- a/faux-mgs/src/main.rs +++ b/faux-mgs/src/main.rs @@ -575,6 +575,7 @@ enum MonorailCommand { long, alias = "online", conflicts_with = "list", + conflicts_with = "ssh_auth_sock", requires = "key" )] permslip: bool,