Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
222 changes: 143 additions & 79 deletions faux-mgs/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String>,

/// Path to the SSH agent socket
#[clap(long, env)]
ssh_auth_sock: Option<PathBuf>,

/// Use the Online Signing Service with `permslip`
#[clap(
short,
long,
alias = "online",
conflicts_with = "list",
requires = "key"
)]
permslip: bool,
Comment on lines 572 to 581
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make permslip conflict with ssh_auth_sock at the clap level?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing, added in b79fe36.

},

/// Lock the technician port
Expand Down Expand Up @@ -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 {
Expand All @@ -1624,6 +1637,7 @@ async fn run_command(
time_sec,
ssh_auth_sock,
key,
permslip,
)
.await?;
}
Expand Down Expand Up @@ -1900,8 +1914,9 @@ async fn monorail_unlock(
log: &Logger,
sp: &SingleSp,
time_sec: u32,
socket: Option<PathBuf>,
ssh_sock: Option<PathBuf>,
pub_key: Option<String>,
permslip: bool,
) -> Result<()> {
let r = sp
.component_action_with_response(
Expand All @@ -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(
Expand All @@ -2015,6 +1962,123 @@ async fn monorail_unlock(
Ok(())
}

fn unlock_permslip(
log: &Logger,
key_name: String,
challenge: UnlockChallenge,
) -> Result<UnlockResponse> {
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?",
)?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll defer to others but I have a light preference to also allow overloading the path via environment variable e.g. PERMSLIP if I need to test something

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, added in 68df5ec.


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::<UnlockResponse>(&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<String>,
challenge: EcdsaSha2Nistp256Challenge,
) -> Result<UnlockResponse> {
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,
Expand Down
22 changes: 22 additions & 0 deletions gateway-messages/src/sp_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the advantage of moving this into the trait? Is this to avoid the extra monorail step?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have not worked in this code base before, so it may just be a mistake. But I was guided by the module-level doc-comment //! Behavior implemented by both real and simulated SPs. I figured that with oxidecomputer/omicron#8994, the trait would now be the right place for this operation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing techport unlock is a monorail command e.g. https://github.com/oxidecomputer/rackletteadm/blob/c27d99ad8558f61bcbf2d7b92dfc1599b393c92d/scripts/common/unlock-techport.sh#L17 and is treated as a component action. My preference would be to keep that same behavior unless there's something speciically preventing us from continuing to use the monorail command. This trait would require changes in hubris. Can you implement a faux monorail command in the simulator?

Copy link
Contributor Author

@plotnick plotnick Sep 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, removed from the trait in 9ddcf1e and pushed a corresponding update to oxidecomputer/omicron#8994.

}

/// Handle a single incoming message.
Expand Down Expand Up @@ -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")]
Expand Down
Loading