Skip to content

Commit

Permalink
Add Yubikey-based challenge for tech port unlocking (#274)
Browse files Browse the repository at this point in the history
See https://rfd.shared.oxide.computer/rfd/492#_sketch_of_an_unlock_policy
for the backstory here.

This adds a new kind of `UnlockChallenge`, which requires that the caller
generate an SSH signature
(https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig)
of a particular blob of data, using trusted keys which are baked into the SP's
firmware.
  • Loading branch information
mkeeter authored Aug 30, 2024
1 parent 9194382 commit 1885b52
Show file tree
Hide file tree
Showing 8 changed files with 748 additions and 43 deletions.
384 changes: 347 additions & 37 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,19 @@ async-trait = "0.1"
backoff = { version = "0.4.0", features = ["tokio"] }
bitflags = "2.6.0"
camino = "1.1.9"
clap = { version = "4.5", features = ["derive"] }
clap = { version = "4.5", features = ["derive", "env"] }
futures = "0.3.30"
fxhash = "0.2.1"
glob = "0.3.1"
hex = "0.4.3"
hubpack = "0.1.2"
humantime = "2.1.0"
lru-cache = "0.1.2"
nix = { version = "0.27.1", features = ["net"] }
omicron-zone-package = "0.11.0"
once_cell = "1.19.0"
paste = "1.0.15"
rand = "0.8.5"
serde = { version = "1.0", default-features = false, features = ["derive"] }
serde-big-array = "0.5.1"
serde_json = "1.0.127"
Expand All @@ -44,6 +46,8 @@ slog-async = "2.8"
slog-term = "2.9"
smoltcp = { version = "0.9", default-features = false, features = ["proto-ipv6"] }
socket2 = "0.5.7"
ssh-agent-client-rs = "0.9.1"
ssh-key = { version = "0.6.6", features = ["p256"] }
static_assertions = "1.1.0"
strum_macros = "0.25"
string_cache = "0.8.7"
Expand Down
5 changes: 5 additions & 0 deletions faux-mgs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,23 @@ clap.workspace = true
futures.workspace = true
glob.workspace = true
hex.workspace = true
humantime.workspace = true
nix.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
slog.workspace = true
slog-async.workspace = true
slog-term.workspace = true
ssh-agent-client-rs.workspace = true
ssh-key.workspace = true
termios.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
tokio-util.workspace = true
uuid = { workspace = true, features = ["std", "v4"] }
zerocopy.workspace = true

gateway-messages = { workspace = true, features = ["std"] }
gateway-sp-comms.workspace = true
180 changes: 176 additions & 4 deletions faux-mgs/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ use gateway_sp_comms::SwitchPortConfig;
use gateway_sp_comms::VersionedSpState;
use gateway_sp_comms::MGS_PORT;
use serde_json::json;
use slog::debug;
use slog::info;
use slog::o;
use slog::warn;
Expand All @@ -56,6 +57,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
use zerocopy::AsBytes;

mod picocom_map;
mod usart;
Expand Down Expand Up @@ -419,14 +421,38 @@ enum LedCommand {
enum MonorailCommand {
/// Unlock the technician port, allowing access to other SPs
Unlock {
/// How long to unlock for
time_sec: u32,
#[clap(flatten)]
cmd: UnlockGroup,

/// Public key for SSH signing challenge
///
/// 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`).
#[clap(short, long, conflicts_with = "list")]
key: Option<String>,

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

/// Lock the technician port
Lock,
}

#[derive(Clone, Debug, clap::Args)]
#[group(required = true, multiple = false)]
pub struct UnlockGroup {
/// How long to unlock for
#[clap(short, long)]
time: Option<humantime::Duration>,

/// List available keys
#[clap(short, long)]
list: bool,
}

#[derive(ValueEnum, Debug, Clone)]
enum CfpaSlot {
Active,
Expand Down Expand Up @@ -778,6 +804,21 @@ async fn main() -> Result<()> {
Ok(())
}

fn get_ssh_client<P: AsRef<Path> + std::fmt::Debug>(
socket: P,
) -> Result<ssh_agent_client_rs::Client> {
let client = ssh_agent_client_rs::Client::connect(socket.as_ref())
.with_context(|| {
format!("failed to connect to SSH agent on {socket:?}")
})?;
Ok(client)
}

fn ssh_list_keys(socket: &PathBuf) -> Result<Vec<ssh_key::PublicKey>> {
let mut client = get_ssh_client(socket)?;
client.list_identities().context("failed to list identities")
}

async fn run_command(
sp: SingleSp,
command: Command,
Expand Down Expand Up @@ -1318,8 +1359,32 @@ async fn run_command(
)
.await?
}
MonorailCommand::Unlock { time_sec } => {
monorail_unlock(&log, &sp, time_sec).await?;
MonorailCommand::Unlock {
cmd: UnlockGroup { time, list },
key,
ssh_auth_sock,
} => {
if list {
let Some(ssh_auth_sock) = ssh_auth_sock else {
bail!("must provide --ssh-auth-sock");
};
for k in ssh_list_keys(&ssh_auth_sock)? {
println!("{}", k.to_openssh()?);
}
} else {
let time_sec = time.unwrap().as_secs_f32() as u32;
if time_sec == 0 {
bail!("--time must be >= 1 second");
}
monorail_unlock(
&log,
&sp,
time_sec,
ssh_auth_sock,
key,
)
.await?;
}
}
}
if json {
Expand Down Expand Up @@ -1435,6 +1500,8 @@ async fn monorail_unlock(
log: &Logger,
sp: &SingleSp,
time_sec: u32,
socket: Option<PathBuf>,
pub_key: Option<String>,
) -> Result<()> {
let r = sp
.component_action_with_response(
Expand All @@ -1457,6 +1524,83 @@ 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()
} 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])?;
}

UnlockResponse::EcdsaSha2Nistp256 { key, signer_nonce, signature }
}
};
sp.component_action(
SpComponent::MONORAIL,
Expand All @@ -1471,6 +1615,34 @@ async fn monorail_unlock(
Ok(())
}

fn ssh_keygen_sign(
socket: PathBuf,
pub_key: ssh_key::PublicKey,
data: &[u8],
) -> Result<ssh_key::SshSig> {
use ssh_key::{Algorithm, EcdsaCurve, HashAlg, SshSig};

let mut client = get_ssh_client(socket)?;

const NAMESPACE: &str = "monorail-unlock";
const HASH: HashAlg = HashAlg::Sha256;
let blob = SshSig::signed_data(NAMESPACE, HASH, data)?;

let sig = client.sign(&pub_key, &blob)?;
let sig = SshSig::new(pub_key.into(), NAMESPACE, HASH, sig)?;

// Confirm that the signature is of the expected form
match sig.algorithm() {
Algorithm::Ecdsa { curve: EcdsaCurve::NistP256 } => {}
h => bail!("invalid signature algorithm {h:?}"),
}
match sig.hash_alg() {
HashAlg::Sha256 => {}
h => bail!("invalid hash algorithm {h:?}"),
}
Ok(sig)
}

fn handle_cxpa(
name: &str,
data: [u8; ROT_PAGE_SIZE],
Expand Down
2 changes: 1 addition & 1 deletion gateway-messages/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ pub const ROT_PAGE_SIZE: usize = 512;
/// for more detail and discussion.
pub mod version {
pub const MIN: u32 = 2;
pub const CURRENT: u32 = 14;
pub const CURRENT: u32 = 15;

/// MGS protocol version in which SP watchdog messages were added
pub const WATCHDOG_VERSION: u32 = 12;
Expand Down
46 changes: 46 additions & 0 deletions gateway-messages/src/mgs_to_sp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use crate::UpdateId;
use hubpack::SerializedSize;
use serde::Deserialize;
use serde::Serialize;
use serde_big_array::BigArray;

#[derive(
Debug, Clone, Copy, SerializedSize, Serialize, Deserialize, PartialEq, Eq,
Expand Down Expand Up @@ -278,6 +279,7 @@ pub struct ComponentUpdatePrepare {
#[derive(
Copy, Clone, Serialize, SerializedSize, Deserialize, PartialEq, Eq, Debug,
)]
#[allow(clippy::large_enum_variant)]
pub enum ComponentAction {
Led(LedComponentAction),
Monorail(MonorailComponentAction),
Expand All @@ -297,6 +299,7 @@ pub enum LedComponentAction {
#[derive(
Copy, Clone, Serialize, SerializedSize, Deserialize, PartialEq, Eq, Debug,
)]
#[allow(clippy::large_enum_variant)]
pub enum MonorailComponentAction {
/// Request an `UnlockChallenge`
///
Expand All @@ -322,14 +325,57 @@ pub enum MonorailComponentAction {
pub enum UnlockChallenge {
/// Unlock given an [UnlockResponse::Trivial] with the same timestamp
Trivial { timestamp: u64 },

/// Hash and sign the given data with a key that the SP trusts
///
/// Trusted keys are hardcoded into SP firmware.
EcdsaSha2Nistp256(EcdsaSha2Nistp256Challenge),
}

#[derive(
Copy,
Clone,
Serialize,
SerializedSize,
Deserialize,
PartialEq,
Eq,
Debug,
zerocopy::AsBytes,
)]
#[repr(C)]
pub struct EcdsaSha2Nistp256Challenge {
/// Hardware ID, e.g. serial name
pub hw_id: [u8; 32],
/// Software version ID
pub sw_id: [u8; 4],
/// Time (according to the SP's internal clock)
pub time: [u8; 8],
/// Additional nonce chosen by the SP
pub nonce: [u8; 32],
}

/// Response to an [`UnlockChallenge`]
#[derive(
Copy, Clone, Serialize, SerializedSize, Deserialize, PartialEq, Eq, Debug,
)]
pub enum UnlockResponse {
/// Unlocks [UnlockChallenge::Trivial]
Trivial { timestamp: u64 },

/// Here's your signature!
EcdsaSha2Nistp256 {
/// SEC1 encoding of the corresponding public key
#[serde(with = "BigArray")]
key: [u8; 65],

/// Additional nonce chosen by the signer
signer_nonce: [u8; 8],

/// Signature data, as an (x, y) tuple (32 bytes each)
#[serde(with = "BigArray")]
signature: [u8; 64],
},
}

#[derive(
Expand Down
1 change: 1 addition & 0 deletions gateway-messages/tests/versioning/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod v11;
mod v12;
mod v13;
mod v14;
mod v15;

pub fn assert_serialized(
out: &mut [u8],
Expand Down
Loading

0 comments on commit 1885b52

Please sign in to comment.