From fc040a2577b0e50baf041ee905562391b11432e0 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Sat, 7 Mar 2026 14:02:42 +0000 Subject: [PATCH 1/2] Handle SSH_AGENTC_ADD_ID_CONSTRAINED (message 25) for SK keys ssh-add sends message 25 instead of 17 for security key types (ed25519-sk, ecdsa-sk) because it needs to pass along additional constraint metadata (sk-provider extension). The message type was already mapped in the enum but returned Request::Unknown, causing "agent refused operation" when trying to ssh-add an SK key. Parse the key data the same way as AddIdentity (message 17). Trailing constraint bytes are safely ignored by from_bytes(). --- rustica-agent/src/sshagent/protocol.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rustica-agent/src/sshagent/protocol.rs b/rustica-agent/src/sshagent/protocol.rs index 50c7c46a..ac4a9381 100644 --- a/rustica-agent/src/sshagent/protocol.rs +++ b/rustica-agent/src/sshagent/protocol.rs @@ -99,7 +99,10 @@ impl Request { }, MessageRequest::RemoveIdentity => Ok(Request::Unknown), MessageRequest::RemoveAllIdentities => Ok(Request::Unknown), - MessageRequest::AddIdConstrained => Ok(Request::Unknown), + MessageRequest::AddIdConstrained => match sshcerts::PrivateKey::from_bytes(buf) { + Ok(private_key) => Ok(Request::AddIdentity { private_key }), + Err(_) => Ok(Request::Unknown), + }, MessageRequest::AddSmartcardKey => Ok(Request::Unknown), MessageRequest::RemoveSmartcardKey => Ok(Request::Unknown), MessageRequest::Lock => Ok(Request::Unknown), From b98ea9530cee92728dce6b1a28e58613a5705a26 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 9 Mar 2026 19:53:14 +0000 Subject: [PATCH 2/2] Parse and log AddIdConstrained key constraints Parse SSH agent key constraints (Lifetime, Confirm, Extension) from AddIdConstrained messages and log them at debug level, positioning us to implement constraint enforcement later. Tests use real wire data captured from ssh-add -t 3600 -c. --- rustica-agent/src/sshagent/protocol.rs | 145 ++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 4 deletions(-) diff --git a/rustica-agent/src/sshagent/protocol.rs b/rustica-agent/src/sshagent/protocol.rs index ac4a9381..488d1190 100644 --- a/rustica-agent/src/sshagent/protocol.rs +++ b/rustica-agent/src/sshagent/protocol.rs @@ -4,8 +4,64 @@ use tokio::{ net::UnixStream, }; +use sshcerts::ssh::Reader; + use super::error::{ParsingError, WrittingError}; +/// Parsed SSH agent key constraint. +#[derive(Debug, PartialEq)] +#[allow(dead_code)] +enum KeyConstraint { + Lifetime(u32), + Confirm, + Extension { name: String, data: Vec }, + Unknown(u8), +} + +/// Parse constraints from an AddIdConstrained message body. +/// +/// Skips past the private key fields and comment, then reads the trailing constraint bytes. +fn parse_constraints(buf: &[u8]) -> Option> { + enum F { Str, Byte } + use F::*; + + let mut r = Reader::new(buf); + let key_type = r.read_string().ok()?; + + // Field layout between key-type string and comment + let fields: &[F] = match key_type.as_str() { + "ssh-rsa" => &[Str, Str, Str, Str, Str, Str], + "ssh-ed25519" => &[Str, Str], + t if t.starts_with("ecdsa-sha2-") => &[Str, Str, Str], + "sk-ssh-ed25519@openssh.com" => &[Str, Str, Byte, Str, Str], + "sk-ecdsa-sha2-nistp256@openssh.com" => &[Str, Str, Str, Byte, Str, Str], + _ => return None, + }; + + for f in fields { + match f { + Str => { r.read_bytes().ok()?; } + Byte => { r.read_raw_bytes(1).ok()?; } + } + } + r.read_string().ok()?; // comment + + let mut r = Reader::new(&buf[r.get_offset()..]); + let mut constraints = Vec::new(); + while let Ok(ty) = r.read_raw_bytes(1).map(|b| b[0]) { + match ty { + 1 => constraints.push(KeyConstraint::Lifetime(r.read_u32().ok()?)), + 2 => constraints.push(KeyConstraint::Confirm), + 255 => constraints.push(KeyConstraint::Extension { + name: r.read_string().ok()?, + data: r.read_bytes().ok()?, + }), + _ => { constraints.push(KeyConstraint::Unknown(ty)); break; } + } + } + Some(constraints) +} + #[derive(Debug, Copy, Clone)] enum MessageRequest { Identities, @@ -99,10 +155,15 @@ impl Request { }, MessageRequest::RemoveIdentity => Ok(Request::Unknown), MessageRequest::RemoveAllIdentities => Ok(Request::Unknown), - MessageRequest::AddIdConstrained => match sshcerts::PrivateKey::from_bytes(buf) { - Ok(private_key) => Ok(Request::AddIdentity { private_key }), - Err(_) => Ok(Request::Unknown), - }, + MessageRequest::AddIdConstrained => { + if let Some(constraints) = parse_constraints(buf) { + debug!("AddIdConstrained constraints: {:?}", constraints); + } + match sshcerts::PrivateKey::from_bytes(buf) { + Ok(private_key) => Ok(Request::AddIdentity { private_key }), + Err(_) => Ok(Request::Unknown), + } + } MessageRequest::AddSmartcardKey => Ok(Request::Unknown), MessageRequest::RemoveSmartcardKey => Ok(Request::Unknown), MessageRequest::Lock => Ok(Request::Unknown), @@ -169,3 +230,79 @@ impl Response { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use byteorder::{BigEndian, WriteBytesExt}; + + // Captured from: ssh-add -t 3600 -c /tmp/test_ed25519 (comment "test-key") + // Message type 25 (SSH_AGENTC_ADD_ID_CONSTRAINED), body only (no msg type byte). + const REAL_ADD_ID_CONSTRAINED: &[u8] = &[ + // key_type: "ssh-ed25519" (len=11) + 0x00, 0x00, 0x00, 0x0b, 0x73, 0x73, 0x68, 0x2d, 0x65, 0x64, 0x32, 0x35, 0x35, 0x31, 0x39, + // pubkey (len=32) + 0x00, 0x00, 0x00, 0x20, + 0x39, 0x2c, 0xfb, 0x01, 0xe0, 0xa3, 0x41, 0x66, 0x08, 0x9b, 0x88, 0x81, 0xa9, 0x65, 0x7a, 0xa9, + 0x49, 0x88, 0x14, 0xc2, 0x2d, 0x7a, 0xe0, 0xd6, 0x8f, 0xa0, 0x8c, 0xfa, 0xac, 0xd5, 0x44, 0x1c, + // private key (len=64) + 0x00, 0x00, 0x00, 0x40, + 0x62, 0x6c, 0xe7, 0x5a, 0xe2, 0x8b, 0x18, 0xf6, 0xaa, 0x2b, 0xf1, 0x70, 0x79, 0x69, 0x0f, 0x79, + 0x93, 0x70, 0x0f, 0x1b, 0xd3, 0x85, 0xec, 0xb9, 0x8f, 0x0c, 0x1e, 0x4e, 0xb7, 0xdb, 0x48, 0xee, + 0x39, 0x2c, 0xfb, 0x01, 0xe0, 0xa3, 0x41, 0x66, 0x08, 0x9b, 0x88, 0x81, 0xa9, 0x65, 0x7a, 0xa9, + 0x49, 0x88, 0x14, 0xc2, 0x2d, 0x7a, 0xe0, 0xd6, 0x8f, 0xa0, 0x8c, 0xfa, 0xac, 0xd5, 0x44, 0x1c, + // comment: "test-key" (len=8) + 0x00, 0x00, 0x00, 0x08, 0x74, 0x65, 0x73, 0x74, 0x2d, 0x6b, 0x65, 0x79, + // constraints: Lifetime(3600) + Confirm + 0x01, 0x00, 0x00, 0x0e, 0x10, + 0x02, + ]; + + #[test] + fn parse_real_ssh_add_message() { + let constraints = parse_constraints(REAL_ADD_ID_CONSTRAINED).unwrap(); + assert_eq!(constraints, vec![ + KeyConstraint::Lifetime(3600), + KeyConstraint::Confirm, + ]); + } + + #[test] + fn real_message_key_parses_with_sshcerts() { + // Verify sshcerts can also parse the key portion of the captured message + let key = sshcerts::PrivateKey::from_bytes(REAL_ADD_ID_CONSTRAINED).unwrap(); + assert_eq!(key.comment, "test-key"); + } + + #[test] + fn parse_no_constraints() { + // Same real key data but without the trailing constraint bytes + let buf = &REAL_ADD_ID_CONSTRAINED[..REAL_ADD_ID_CONSTRAINED.len() - 6]; + let constraints = parse_constraints(buf).unwrap(); + assert!(constraints.is_empty()); + } + + #[test] + fn parse_extension_constraint() { + let mut buf = REAL_ADD_ID_CONSTRAINED[..REAL_ADD_ID_CONSTRAINED.len() - 6].to_vec(); + buf.push(255); // Extension type + WriteBytesExt::write_u32::(&mut buf, 15).unwrap(); + buf.extend_from_slice(b"foo@example.com"); + WriteBytesExt::write_u32::(&mut buf, 3).unwrap(); + buf.extend_from_slice(b"\x01\x02\x03"); + + let constraints = parse_constraints(&buf).unwrap(); + assert_eq!(constraints, vec![KeyConstraint::Extension { + name: "foo@example.com".into(), + data: vec![0x01, 0x02, 0x03], + }]); + } + + #[test] + fn parse_unknown_key_type_returns_none() { + let mut buf = Vec::new(); + WriteBytesExt::write_u32::(&mut buf, 7).unwrap(); + buf.extend_from_slice(b"unknown"); + assert!(parse_constraints(&buf).is_none()); + } +}