diff --git a/Cargo.lock b/Cargo.lock index 8a845d0d338..8cbb5662cc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8575,13 +8575,15 @@ dependencies = [ name = "omicron-workspace-hack" version = "0.1.0" dependencies = [ + "aead", + "aes-gcm", "ahash", "aho-corasick", "anyhow", "aws-lc-rs", "base16ct", "base64 0.22.1", - "base64ct", + "bcrypt-pbkdf", "bitflags 1.3.2", "bitflags 2.9.4", "bstr", @@ -8589,6 +8591,7 @@ dependencies = [ "byteorder", "bytes", "cc", + "chacha20", "chrono", "cipher", "clang-sys", @@ -8657,8 +8660,8 @@ dependencies = [ "num-traits", "once_cell", "openapiv3", + "p256", "peg-runtime", - "pem-rfc7468", "percent-encoding", "petgraph 0.6.5", "phf_shared 0.11.2", @@ -8695,6 +8698,8 @@ dependencies = [ "slog", "smallvec 1.15.1", "spin", + "ssh-cipher", + "ssh-key", "string_cache", "strum 0.26.3", "strum 0.27.2", @@ -12997,6 +13002,7 @@ dependencies = [ "async-trait", "clap", "dropshot", + "ecdsa", "futures", "gateway-ereport-messages", "gateway-messages", @@ -13008,12 +13014,15 @@ dependencies = [ "omicron-common", "omicron-workspace-hack", "oxide-tokio-rt", + "p256", + "rand 0.9.2", "serde", "serde_cbor", "sha2", "sha3", "slog", "slog-dtrace", + "ssh-key", "thiserror 2.0.17", "tokio", "toml 0.8.23", @@ -13131,9 +13140,9 @@ dependencies = [ [[package]] name = "ssh-key" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca9b366a80cf18bb6406f4cf4d10aebfb46140a8c0c33f666a144c5c76ecbafc" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" dependencies = [ "bcrypt-pbkdf", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index 7157e37991a..94d0c1b7b19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -439,6 +439,7 @@ dns-service-client = { path = "clients/dns-service-client" } dpd-client = { git = "https://github.com/oxidecomputer/dendrite", rev = "b3cdd095437f388069aa57be5efac4b4fadc7b4f" } dropshot = { version = "0.16.3", features = [ "usdt-probes" ] } dyn-clone = "1.0.20" +ecdsa = { version = "0.16.9", features = ["pem", "signing", "std", "verifying"] } either = "1.15.0" ereport-types = { path = "ereport/types" } expectorate = "1.2.0" @@ -685,7 +686,7 @@ semver = { version = "1.0.26", features = ["std", "serde"] } serde = { version = "1.0", default-features = false, features = [ "derive", "rc" ] } serde_cbor = "0.11.2" serde_human_bytes = { git = "https://github.com/oxidecomputer/serde_human_bytes", branch = "main" } -serde_json = "1.0.142" +serde_json = "1.0.143" serde_tokenstream = "0.2" serde_urlencoded = "0.7.1" serde_with = { version = "3.14.0", default-features = false, features = ["alloc", "macros"] } @@ -726,6 +727,7 @@ sp-sim = { path = "sp-sim" } sprockets-tls = { git = "https://github.com/oxidecomputer/sprockets.git", rev = "6d31fa63217c6a51061dc4afa1ebe175a0021981" } sqlformat = "0.3.5" sqlparser = { version = "0.45.0", features = [ "visitor" ] } +ssh-key = "0.6.7" static_assertions = "1.1.0" # Please do not change the Steno version to a Git dependency. It makes it # harder than expected to make breaking changes (even if you specify a specific diff --git a/sp-sim/Cargo.toml b/sp-sim/Cargo.toml index 0f453d416c8..53ccd400f04 100644 --- a/sp-sim/Cargo.toml +++ b/sp-sim/Cargo.toml @@ -12,6 +12,7 @@ anyhow.workspace = true async-trait.workspace = true clap.workspace = true dropshot.workspace = true +ecdsa.workspace = true futures.workspace = true gateway-ereport-messages.workspace = true gateway-messages.workspace = true @@ -21,6 +22,9 @@ hubtools.workspace = true nexus-types.workspace = true omicron-common.workspace = true oxide-tokio-rt.workspace = true +p256.workspace = true +rand.workspace = true +ssh-key.workspace = true serde.workspace = true serde_cbor.workspace = true sha2.workspace = true diff --git a/sp-sim/examples/config.toml b/sp-sim/examples/config.toml index 35f6ad46529..e4077b2f01f 100644 --- a/sp-sim/examples/config.toml +++ b/sp-sim/examples/config.toml @@ -6,6 +6,7 @@ serial_number = "SimSidecar0" manufacturing_root_cert_seed = "01de01de01de01de01de01de01de01de01de01de01de01de01de01de01de01de" device_id_cert_seed = "01de000000000000000000000000000000000000000000000000000000000000" +authorized_keys = "../smf/switch_zone_setup/support_authorized_keys" [[simulated_sps.sidecar.network_config]] [simulated_sps.sidecar.network_config.simulated] diff --git a/sp-sim/src/config.rs b/sp-sim/src/config.rs index 418bc5125cd..4b51f0533da 100644 --- a/sp-sim/src/config.rs +++ b/sp-sim/src/config.rs @@ -143,6 +143,7 @@ pub struct SpComponentConfig { pub struct SidecarConfig { #[serde(flatten)] pub common: SpCommonConfig, + pub authorized_keys: Option, } /// Configuration of a simulated gimlet SP diff --git a/sp-sim/src/sidecar.rs b/sp-sim/src/sidecar.rs index 828049b8807..812f517a067 100644 --- a/sp-sim/src/sidecar.rs +++ b/sp-sim/src/sidecar.rs @@ -19,7 +19,7 @@ use crate::server::SimSpHandler; use crate::server::UdpServer; use crate::update::BaseboardKind; use crate::update::SimSpUpdate; -use anyhow::Result; +use anyhow::{Context, Result}; use async_trait::async_trait; use futures::Future; use futures::future; @@ -32,11 +32,15 @@ use gateway_messages::DumpCompression; use gateway_messages::DumpError; use gateway_messages::DumpSegment; use gateway_messages::DumpTask; +use gateway_messages::EcdsaSha2Nistp256Challenge; use gateway_messages::IgnitionCommand; use gateway_messages::IgnitionState; use gateway_messages::MgsError; use gateway_messages::MgsRequest; use gateway_messages::MgsResponse; +use gateway_messages::MonorailComponentAction; +use gateway_messages::MonorailComponentActionResponse; +use gateway_messages::MonorailError; use gateway_messages::PowerState; use gateway_messages::RotBootInfo; use gateway_messages::RotRequest; @@ -46,6 +50,8 @@ use gateway_messages::SpError; use gateway_messages::SpPort; use gateway_messages::SpStateV2; use gateway_messages::StartupOptions; +use gateway_messages::UnlockChallenge; +use gateway_messages::UnlockResponse; use gateway_messages::ignition; use gateway_messages::ignition::IgnitionError; use gateway_messages::ignition::LinkEvents; @@ -54,10 +60,14 @@ use gateway_messages::sp_impl::DeviceDescription; use gateway_messages::sp_impl::Sender; use gateway_messages::sp_impl::SpHandler; use gateway_types::component::SpState; +use rand::TryRngCore; +use rand::rngs::OsRng; use slog::Logger; use slog::debug; use slog::info; use slog::warn; +use ssh_key::AuthorizedKeys; +use ssh_key::PublicKey; use std::collections::HashMap; use std::iter; use std::net::SocketAddrV6; @@ -65,6 +75,8 @@ use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use tokio::select; use tokio::sync::Mutex as TokioMutex; use tokio::sync::mpsc; @@ -72,7 +84,10 @@ use tokio::sync::oneshot; use tokio::sync::watch; use tokio::task; use tokio::task::JoinHandle; +use zerocopy::IntoBytes; +const CHALLENGE_EXPIRATION_TIME_SECS: u64 = 10; +const MAX_UNLOCK_TIME_SECS: u32 = 600; pub const SIM_SIDECAR_BOARD: &str = "SimSidecarSp"; pub struct Sidecar { @@ -206,6 +221,20 @@ impl Sidecar { let (commands, commands_rx) = mpsc::unbounded_channel(); + let trusted_keys = match &sidecar.authorized_keys { + None => Vec::new(), + Some(authorized_keys) => AuthorizedKeys::read_file(authorized_keys) + .with_context(|| { + format!( + "failed to read authorized keys from {}", + authorized_keys.display() + ) + })? + .into_iter() + .map(|entry| entry.public_key().clone()) + .collect(), + }; + if let Some(network_config) = &sidecar.common.network_config { // bind to our two local "KSZ" ports let servers = future::try_join( @@ -276,6 +305,7 @@ impl Sidecar { sidecar.common.old_rot_state, update_state, Arc::clone(&power_state_changes), + trusted_keys, ); let inner_task = task::spawn(async move { inner.run().await.unwrap() }); @@ -348,6 +378,7 @@ impl Inner { old_rot_state: bool, update_state: SimSpUpdate, power_state_changes: Arc, + trusted_keys: Vec, ) -> (Self, Arc>, watch::Receiver) { let [udp0, udp1] = servers; let handler = Arc::new(TokioMutex::new(Handler::new( @@ -358,6 +389,7 @@ impl Inner { old_rot_state, update_state, power_state_changes, + trusted_keys, ))); let responses_sent_count = watch::Sender::new(0); let responses_sent_count_rx = responses_sent_count.subscribe(); @@ -512,9 +544,15 @@ struct Handler { should_fail_to_respond_signal: Option>, old_rot_state: bool, sp_dumps: HashMap<[u8; 16], u32>, + + // To simulate tech port unlock, we must store the set of trusted keys and + // the latest challenge together with the time at which it was issued. + trusted_keys: Vec, + last_challenge: Option<(UnlockChallenge, u64)>, } impl Handler { + #[allow(clippy::too_many_arguments)] fn new( serial_number: String, components: Vec, @@ -523,6 +561,7 @@ impl Handler { old_rot_state: bool, update_state: SimSpUpdate, power_state_changes: Arc, + trusted_keys: Vec, ) -> Self { let mut leaked_component_device_strings = Vec::with_capacity(components.len()); @@ -555,6 +594,8 @@ impl Handler { should_fail_to_respond_signal: None, old_rot_state, sp_dumps, + trusted_keys, + last_challenge: None, } } @@ -574,6 +615,66 @@ impl Handler { rot: Ok(rot_state_v2(self.update_state.rot_state())), } } + + /// Pretends to unlock the tech port if the challenge and response are compatible + fn unlock( + &mut self, + _vid: ::VLanId, + challenge: UnlockChallenge, + response: UnlockResponse, + time_sec: u32, + ) -> Result<(), MonorailError> { + if time_sec > MAX_UNLOCK_TIME_SECS { + warn!(&self.log, "unlock time too long"; "time_sec" => time_sec); + return Err(MonorailError::TimeIsTooLong); + } + + // Callers only get one attempt per challenge; if they fail to authorize + // the unlock, they have to ask for a new challenge. + let Some((last_challenge, challenge_time)) = self.last_challenge.take() + else { + warn!(&self.log, "no challenge for monorail unlock"); + return Err(MonorailError::UnlockAuthFailed); + }; + + if challenge != last_challenge { + warn!(&self.log, "wrong challenge for monorail unlock"); + return Err(MonorailError::UnlockAuthFailed); + } + + if now() >= challenge_time + CHALLENGE_EXPIRATION_TIME_SECS { + warn!(&self.log, "challenge expired"); + return Err(MonorailError::UnlockAuthFailed); + } + + // Check that the response is valid for our current challenge + // and trusted keys. + match (challenge, response) { + ( + UnlockChallenge::Trivial { timestamp: ts1 }, + UnlockResponse::Trivial { timestamp: ts2 }, + ) if ts1 == ts2 => Ok(()), + ( + UnlockChallenge::EcdsaSha2Nistp256(data), + UnlockResponse::EcdsaSha2Nistp256 { + key, + signer_nonce, + signature, + }, + ) => verify_signature( + &self.log, + &self.trusted_keys, + &data, + &key, + &signer_nonce, + &signature, + ), + _ => Err(MonorailError::UnlockAuthFailed), + }?; + debug!(&self.log, "unlock auth succeeded"); + + Ok(()) + } } impl SpHandler for Handler { @@ -1061,13 +1162,42 @@ impl SpHandler for Handler { component: SpComponent, action: ComponentAction, ) -> Result { - warn!( - &self.log, "asked to perform component action (not supported for sim components)"; - "sender" => ?sender, - "component" => ?component, - "action" => ?action, - ); - Err(SpError::RequestUnsupportedForComponent) + match action { + ComponentAction::Monorail( + MonorailComponentAction::RequestChallenge, + ) => { + // Emulate a `LifecycleState::Release` device and issue + // an ECDSA challenge. + let (challenge, time) = get_ecdsa_challenge()?; + let challenge = UnlockChallenge::EcdsaSha2Nistp256(challenge); + self.last_challenge = Some((challenge, time)); + Ok(ComponentActionResponse::Monorail( + MonorailComponentActionResponse::RequestChallenge( + challenge, + ), + )) + } + ComponentAction::Monorail(MonorailComponentAction::Unlock { + challenge, + response, + time_sec, + }) => self + .unlock(sender.vid, challenge, response, time_sec) + .map_err(SpError::Monorail) + .map(|()| ComponentActionResponse::Ack), + ComponentAction::Monorail(MonorailComponentAction::Lock) => { + Ok(ComponentActionResponse::Ack) + } + ComponentAction::Led(_) => { + warn!( + &self.log, "asked to perform LED component action (not supported for sim components)"; + "sender" => ?sender, + "component" => ?component, + "action" => ?action, + ); + Err(SpError::RequestUnsupportedForComponent) + } + } } fn get_startup_options(&mut self) -> Result { @@ -1454,3 +1584,89 @@ impl FakeIgnition { Ok(()) } } + +/// The current Unix time, i.e., seconds since the epoch. +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("should be able to get Unix time") + .as_secs() +} + +/// Generate a fresh ECDSA tech port unlock challenge. +fn get_ecdsa_challenge() -> Result<(EcdsaSha2Nistp256Challenge, u64), SpError> { + // Get a nonce from the OS RNG. + let mut nonce = [0u8; 32]; + OsRng.try_fill_bytes(&mut nonce).expect("OS out of entropy"); + + // Get the current time from the OS clock. + let now = now(); + + Ok(( + EcdsaSha2Nistp256Challenge { + hw_id: [0; _], // fake + sw_id: [0, 0, 0, 1], // placeholder + time: now.to_le_bytes(), + nonce, + }, + now, + )) +} + +fn verify_signature( + log: &Logger, + trusted_keys: &[PublicKey], + data: &EcdsaSha2Nistp256Challenge, + key: &[u8; 65], + signer_nonce: &[u8; 8], + signature: &[u8; 64], +) -> Result<(), MonorailError> { + if !trusted_keys.iter().any(|t| { + t.key_data().ecdsa().expect("must be ECDSA key").as_sec1_bytes() == key + }) { + warn!(log, "wrong key for tech port unlock"); + return Err(MonorailError::UnlockAuthFailed); + } + + let sig = p256::ecdsa::Signature::from_bytes(signature.as_slice().into()) + .map_err(|_| MonorailError::UnlockAuthFailed)?; + + let v = p256::ecdsa::VerifyingKey::from_encoded_point( + &p256::EncodedPoint::from_bytes(key) + .map_err(|_| MonorailError::UnlockAuthFailed)?, + ) + .map_err(|_| MonorailError::UnlockAuthFailed)?; + + // Build an SSH signature blob to be verified + // + // See https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig + // for the signature blob format + #[rustfmt::skip] + let mut buf = vec![ + // MAGIC_PREAMBLE + b'S', b'S', b'H', b'S', b'I', b'G', + + // namespace + 0, 0, 0, 15, // length + b'm', b'o', b'n', b'o', b'r', b'a', b'i', b'l', b'-', + b'u', b'n', b'l', b'o', b'c', b'k', + + // reserved + 0, 0, 0, 0, + + // hash type + 0, 0, 0, 6, // length + b's', b'h', b'a', b'2', b'5', b'6', + ]; + + let mut hasher = sha2::Sha256::new(); + use sha2::Digest; + hasher.update(data.as_bytes()); + hasher.update(signer_nonce); + let hash = hasher.finalize(); + buf.extend_from_slice(&(hash.len() as u32).to_be_bytes()); + buf.extend_from_slice(&hash); + + use p256::ecdsa::signature::Verifier; + v.verify(&buf, &sig).map_err(|_| MonorailError::UnlockAuthFailed) +} diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index 835e8523c25..a0fd64b27b9 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -18,19 +18,22 @@ workspace = true ### BEGIN HAKARI SECTION [dependencies] +aead = { version = "0.5.2", default-features = false, features = ["alloc", "getrandom"] } +aes-gcm = { version = "0.10.3" } ahash = { version = "0.8.12" } aho-corasick = { version = "1.1.3" } anyhow = { version = "1.0.99", features = ["backtrace"] } aws-lc-rs = { version = "1.12.4", features = ["prebuilt-nasm"] } base16ct = { version = "0.2.0", default-features = false, features = ["alloc"] } base64 = { version = "0.22.1" } -base64ct = { version = "1.6.0", default-features = false, features = ["std"] } +bcrypt-pbkdf = { version = "0.10.0" } bitflags-dff4ba8e3ae991db = { package = "bitflags", version = "1.3.2" } bitflags-f595c2ba2a3f28df = { package = "bitflags", version = "2.9.4", default-features = false, features = ["serde"] } bstr = { version = "1.10.0" } buf-list = { version = "1.0.3", default-features = false, features = ["tokio1"] } byteorder = { version = "1.5.0" } bytes = { version = "1.10.1", features = ["serde"] } +chacha20 = { version = "0.9.1", default-features = false, features = ["zeroize"] } chrono = { version = "0.4.42", features = ["serde"] } cipher = { version = "0.4.4", default-features = false, features = ["block-padding", "zeroize"] } clap = { version = "4.5.48", features = ["cargo", "derive", "env", "wrap_help"] } @@ -89,8 +92,8 @@ num-iter = { version = "0.1.45", default-features = false, features = ["i128"] } num-traits = { version = "0.2.19", features = ["i128", "libm"] } once_cell = { version = "1.21.3", features = ["critical-section"] } openapiv3 = { version = "2.2.0", default-features = false, features = ["skip_serializing_defaults"] } +p256 = { version = "0.13.2", features = ["ecdh"] } peg-runtime = { version = "0.8.5", default-features = false, features = ["std"] } -pem-rfc7468 = { version = "0.7.0", default-features = false, features = ["std"] } percent-encoding = { version = "2.3.2" } petgraph = { version = "0.6.5", features = ["serde-1"] } phf_shared = { version = "0.11.2" } @@ -125,6 +128,8 @@ similar = { version = "2.7.0", features = ["bytes", "inline", "unicode"] } slog = { version = "2.7.0", features = ["dynamic-keys", "max_level_trace", "release_max_level_debug", "release_max_level_trace"] } smallvec = { version = "1.15.1", default-features = false, features = ["const_new"] } spin = { version = "0.9.8" } +ssh-cipher = { version = "0.2.0", default-features = false, features = ["aes-cbc", "aes-ctr", "aes-gcm", "chacha20poly1305"] } +ssh-key = { version = "0.6.7", features = ["ed25519", "encryption", "rsa"] } string_cache = { version = "0.8.9" } strum-2f80eeee3b1b6c7e = { package = "strum", version = "0.26.3", features = ["derive"] } strum-754bda37e0fb3874 = { package = "strum", version = "0.27.2", features = ["derive"] } @@ -154,13 +159,15 @@ zip-164d15cefe24d7eb = { package = "zip", version = "4.2.0", default-features = zip-3b31131e45eafb45 = { package = "zip", version = "0.6.6", default-features = false, features = ["bzip2", "deflate"] } [build-dependencies] +aead = { version = "0.5.2", default-features = false, features = ["alloc", "getrandom"] } +aes-gcm = { version = "0.10.3" } ahash = { version = "0.8.12" } aho-corasick = { version = "1.1.3" } anyhow = { version = "1.0.99", features = ["backtrace"] } aws-lc-rs = { version = "1.12.4", features = ["prebuilt-nasm"] } base16ct = { version = "0.2.0", default-features = false, features = ["alloc"] } base64 = { version = "0.22.1" } -base64ct = { version = "1.6.0", default-features = false, features = ["std"] } +bcrypt-pbkdf = { version = "0.10.0" } bitflags-dff4ba8e3ae991db = { package = "bitflags", version = "1.3.2" } bitflags-f595c2ba2a3f28df = { package = "bitflags", version = "2.9.4", default-features = false, features = ["serde"] } bstr = { version = "1.10.0" } @@ -168,6 +175,7 @@ buf-list = { version = "1.0.3", default-features = false, features = ["tokio1"] byteorder = { version = "1.5.0" } bytes = { version = "1.10.1", features = ["serde"] } cc = { version = "1.2.30", default-features = false, features = ["parallel"] } +chacha20 = { version = "0.9.1", default-features = false, features = ["zeroize"] } chrono = { version = "0.4.42", features = ["serde"] } cipher = { version = "0.4.4", default-features = false, features = ["block-padding", "zeroize"] } clap = { version = "4.5.48", features = ["cargo", "derive", "env", "wrap_help"] } @@ -226,8 +234,8 @@ num-iter = { version = "0.1.45", default-features = false, features = ["i128"] } num-traits = { version = "0.2.19", features = ["i128", "libm"] } once_cell = { version = "1.21.3", features = ["critical-section"] } openapiv3 = { version = "2.2.0", default-features = false, features = ["skip_serializing_defaults"] } +p256 = { version = "0.13.2", features = ["ecdh"] } peg-runtime = { version = "0.8.5", default-features = false, features = ["std"] } -pem-rfc7468 = { version = "0.7.0", default-features = false, features = ["std"] } percent-encoding = { version = "2.3.2" } petgraph = { version = "0.6.5", features = ["serde-1"] } phf_shared = { version = "0.11.2" } @@ -262,6 +270,8 @@ similar = { version = "2.7.0", features = ["bytes", "inline", "unicode"] } slog = { version = "2.7.0", features = ["dynamic-keys", "max_level_trace", "release_max_level_debug", "release_max_level_trace"] } smallvec = { version = "1.15.1", default-features = false, features = ["const_new"] } spin = { version = "0.9.8" } +ssh-cipher = { version = "0.2.0", default-features = false, features = ["aes-cbc", "aes-ctr", "aes-gcm", "chacha20poly1305"] } +ssh-key = { version = "0.6.7", features = ["ed25519", "encryption", "rsa"] } string_cache = { version = "0.8.9" } strum-2f80eeee3b1b6c7e = { package = "strum", version = "0.26.3", features = ["derive"] } strum-754bda37e0fb3874 = { package = "strum", version = "0.27.2", features = ["derive"] }