#![allow(dead_code)]
pub const SSH_AGENT_REQUEST_IDENTITIES: u8 = 11;
pub const SSH_AGENT_IDENTITIES_ANSWER: u8 = 12;
pub const SSH_AGENT_SIGN_REQUEST: u8 = 13;
pub const SSH_AGENT_SIGN_RESPONSE: u8 = 14;
#[cfg(windows)]
const OPENSSH_AGENT_PIPE: &str = r"\\.\pipe\openssh-agent";
#[cfg(windows)]
const PAGEANT_PIPE: &str = r"\\.\pipe\pageant";
pub fn encode_message(msg_type: u8, body: &[u8]) -> Vec<u8> {
let len = (1 + body.len()) as u32;
let mut buf = Vec::with_capacity(4 + len as usize);
buf.extend_from_slice(&len.to_be_bytes());
buf.push(msg_type);
buf.extend_from_slice(body);
buf
}
pub fn decode_length_prefix(data: &[u8]) -> crate::Result<(u32, &[u8])> {
if data.len() < 4 {
return Err(crate::Error::SshSign(
"response too short for length prefix".into(),
));
}
let len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
let payload = &data[4..];
if payload.len() < len as usize {
return Err(crate::Error::SshSign(format!(
"expected {} payload bytes, got {}",
len,
payload.len()
)));
}
Ok((len, &payload[..len as usize]))
}
pub fn encode_request_identities() -> Vec<u8> {
encode_message(SSH_AGENT_REQUEST_IDENTITIES, &[])
}
pub fn decode_identities_answer(data: &[u8]) -> crate::Result<Vec<(Vec<u8>, String)>> {
let (_len, payload) = decode_length_prefix(data)?;
if payload.is_empty() {
return Err(crate::Error::SshSign(
"identities answer payload is empty".into(),
));
}
let msg_type = payload[0];
if msg_type != SSH_AGENT_IDENTITIES_ANSWER {
return Err(crate::Error::SshSign(format!(
"expected identities answer (12), got {}",
msg_type
)));
}
let mut pos = 1usize;
if pos + 4 > payload.len() {
return Err(crate::Error::SshSign(
"identities answer truncated at key count".into(),
));
}
let key_count = u32::from_be_bytes([
payload[pos],
payload[pos + 1],
payload[pos + 2],
payload[pos + 3],
]) as usize;
pos += 4;
let mut identities = Vec::with_capacity(key_count);
for _ in 0..key_count {
if pos + 4 > payload.len() {
return Err(crate::Error::SshSign(
"identities answer truncated at key blob length".into(),
));
}
let blob_len = u32::from_be_bytes([
payload[pos],
payload[pos + 1],
payload[pos + 2],
payload[pos + 3],
]) as usize;
pos += 4;
if pos + blob_len > payload.len() {
return Err(crate::Error::SshSign(format!(
"identities answer truncated: key blob expects {} bytes, {} available",
blob_len,
payload.len() - pos
)));
}
let blob = payload[pos..pos + blob_len].to_vec();
pos += blob_len;
if pos + 4 > payload.len() {
return Err(crate::Error::SshSign(
"identities answer truncated at comment length".into(),
));
}
let comment_len = u32::from_be_bytes([
payload[pos],
payload[pos + 1],
payload[pos + 2],
payload[pos + 3],
]) as usize;
pos += 4;
if pos + comment_len > payload.len() {
return Err(crate::Error::SshSign(format!(
"identities answer truncated: comment expects {} bytes, {} available",
comment_len,
payload.len() - pos
)));
}
let comment_bytes = &payload[pos..pos + comment_len];
let comment = String::from_utf8_lossy(comment_bytes).into_owned();
pos += comment_len;
identities.push((blob, comment));
}
Ok(identities)
}
pub fn encode_sign_request(key_blob: &[u8], data: &[u8], flags: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(4 + key_blob.len() + 4 + data.len() + 4);
body.extend_from_slice(&(key_blob.len() as u32).to_be_bytes());
body.extend_from_slice(key_blob);
body.extend_from_slice(&(data.len() as u32).to_be_bytes());
body.extend_from_slice(data);
body.extend_from_slice(&flags.to_be_bytes());
encode_message(SSH_AGENT_SIGN_REQUEST, &body)
}
pub fn decode_sign_response(data: &[u8]) -> crate::Result<Vec<u8>> {
let (_len, payload) = decode_length_prefix(data)?;
if payload.is_empty() {
return Err(crate::Error::SshSign(
"sign response payload is empty".into(),
));
}
let msg_type = payload[0];
if msg_type != SSH_AGENT_SIGN_RESPONSE {
return Err(crate::Error::SshSign(format!(
"expected sign response (14), got {}",
msg_type
)));
}
let mut pos = 1usize;
if pos + 4 > payload.len() {
return Err(crate::Error::SshSign(
"sign response truncated at signature length".into(),
));
}
let sig_len = u32::from_be_bytes([
payload[pos],
payload[pos + 1],
payload[pos + 2],
payload[pos + 3],
]) as usize;
pos += 4;
if pos + sig_len > payload.len() {
return Err(crate::Error::SshSign(format!(
"sign response truncated: signature expects {} bytes, {} available",
sig_len,
payload.len() - pos
)));
}
Ok(payload[pos..pos + sig_len].to_vec())
}
#[cfg(windows)]
pub async fn list_identities() -> crate::Result<Vec<(Vec<u8>, String)>> {
match list_identities_openssh_pipe().await {
Ok(identities) => return Ok(identities),
Err(e) => tracing::debug!("OpenSSH pipe failed: {e}, trying Pageant"),
}
list_identities_pageant().await
}
#[cfg(windows)]
pub async fn sign_data(key_blob: &[u8], data: &[u8], flags: u32) -> crate::Result<Vec<u8>> {
match sign_data_openssh_pipe(key_blob, data, flags).await {
Ok(sig) => return Ok(sig),
Err(e) => tracing::debug!("OpenSSH pipe failed: {e}, trying Pageant"),
}
sign_data_pageant(key_blob, data, flags).await
}
#[cfg(windows)]
async fn list_identities_openssh_pipe() -> crate::Result<Vec<(Vec<u8>, String)>> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::windows::named_pipe::ClientOptions;
let pipe = ClientOptions::new()
.open(OPENSSH_AGENT_PIPE)
.map_err(|e| crate::Error::SshSign(format!("failed to open OpenSSH agent pipe: {e}")))?;
let msg = encode_request_identities();
agent_roundtrip(&pipe, &msg).await
}
#[cfg(windows)]
async fn sign_data_openssh_pipe(
key_blob: &[u8],
data: &[u8],
flags: u32,
) -> crate::Result<Vec<u8>> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::windows::named_pipe::ClientOptions;
let pipe = ClientOptions::new()
.open(OPENSSH_AGENT_PIPE)
.map_err(|e| crate::Error::SshSign(format!("failed to open OpenSSH agent pipe: {e}")))?;
let msg = encode_sign_request(key_blob, data, flags);
let response = agent_roundtrip_raw(&pipe, &msg).await?;
decode_sign_response(&response)
}
#[cfg(windows)]
async fn agent_roundtrip_raw(
pipe: &tokio::net::windows::named_pipe::NamedPipeClient,
msg: &[u8],
) -> crate::Result<Vec<u8>> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut pipe = pipe
.try_clone()
.map_err(|e| crate::Error::SshSign(format!("failed to clone pipe handle: {e}")))?;
pipe.write_all(msg)
.await
.map_err(|e| crate::Error::SshSign(format!("failed to write to agent pipe: {e}")))?;
let mut len_buf = [0u8; 4];
pipe.read_exact(&mut len_buf)
.await
.map_err(|e| crate::Error::SshSign(format!("failed to read agent response length: {e}")))?;
let resp_len = u32::from_be_bytes(len_buf) as usize;
const MAX_RESPONSE_SIZE: usize = 256 * 1024;
if resp_len > MAX_RESPONSE_SIZE {
return Err(crate::Error::SshSign(format!(
"agent response too large: {} bytes (max {})",
resp_len, MAX_RESPONSE_SIZE
)));
}
let mut resp_buf = vec![0u8; resp_len];
pipe.read_exact(&mut resp_buf)
.await
.map_err(|e| crate::Error::SshSign(format!("failed to read agent response body: {e}")))?;
let mut full_response = Vec::with_capacity(4 + resp_len);
full_response.extend_from_slice(&len_buf);
full_response.extend_from_slice(&resp_buf);
Ok(full_response)
}
#[cfg(windows)]
async fn agent_roundtrip(
pipe: &tokio::net::windows::named_pipe::NamedPipeClient,
msg: &[u8],
) -> crate::Result<Vec<(Vec<u8>, String)>> {
let response = agent_roundtrip_raw(pipe, msg).await?;
decode_identities_answer(&response)
}
#[cfg(windows)]
async fn list_identities_pageant() -> crate::Result<Vec<(Vec<u8>, String)>> {
Err(crate::Error::SshSign(
"Pageant shared-memory protocol not yet implemented".into(),
))
}
#[cfg(windows)]
async fn sign_data_pageant(_key_blob: &[u8], _data: &[u8], _flags: u32) -> crate::Result<Vec<u8>> {
Err(crate::Error::SshSign(
"Pageant shared-memory protocol not yet implemented".into(),
))
}
#[cfg(not(windows))]
pub async fn list_identities() -> crate::Result<Vec<(Vec<u8>, String)>> {
Err(crate::Error::SshSign(
"SSH agent identity listing is not supported on this platform".into(),
))
}
#[cfg(not(windows))]
pub async fn sign_data(_key_blob: &[u8], _data: &[u8], _flags: u32) -> crate::Result<Vec<u8>> {
Err(crate::Error::SshSign(
"SSH agent signing is not supported on this platform".into(),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_message_type_only() {
let msg = encode_message(11, &[]);
assert_eq!(msg, vec![0, 0, 0, 1, 11]);
}
#[test]
fn test_encode_message_with_body() {
let body = vec![0xDE, 0xAD, 0xBE, 0xEF];
let msg = encode_message(13, &body);
assert_eq!(msg, vec![0, 0, 0, 5, 13, 0xDE, 0xAD, 0xBE, 0xEF]);
}
#[test]
fn test_decode_length_prefix_valid() {
let data = vec![0, 0, 0, 9, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let (len, payload) = decode_length_prefix(&data).expect("decode should succeed");
assert_eq!(len, 9);
assert_eq!(payload, &[1, 2, 3, 4, 5, 6, 7, 8, 9]);
}
#[test]
fn test_decode_length_prefix_truncated_header() {
let data = vec![0, 0, 1];
let result = decode_length_prefix(&data);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("too short"));
}
#[test]
fn test_decode_length_prefix_truncated_payload() {
let data = vec![0, 0, 0, 100, 0xAA, 0xBB];
let result = decode_length_prefix(&data);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("expected 100 payload bytes"));
}
#[test]
fn test_encode_request_identities() {
let msg = encode_request_identities();
assert_eq!(msg, vec![0, 0, 0, 1, SSH_AGENT_REQUEST_IDENTITIES]);
}
#[test]
fn test_decode_identities_answer_empty() {
let data = encode_message(SSH_AGENT_IDENTITIES_ANSWER, &[0, 0, 0, 0]);
let identities = decode_identities_answer(&data).expect("decode should succeed");
assert!(identities.is_empty());
}
#[test]
fn test_decode_identities_answer_one_key() {
let key_blob = vec![0x01, 0x02, 0x03, 0x04];
let comment = b"test-key";
let mut body = Vec::new();
body.extend_from_slice(&1u32.to_be_bytes());
body.extend_from_slice(&(key_blob.len() as u32).to_be_bytes());
body.extend_from_slice(&key_blob);
body.extend_from_slice(&(comment.len() as u32).to_be_bytes());
body.extend_from_slice(comment);
let data = encode_message(SSH_AGENT_IDENTITIES_ANSWER, &body);
let identities = decode_identities_answer(&data).expect("decode should succeed");
assert_eq!(identities.len(), 1);
assert_eq!(identities[0].0, key_blob);
assert_eq!(identities[0].1, "test-key");
}
#[test]
fn test_decode_identities_answer_two_keys() {
let key_blob1 = vec![0x01];
let comment1 = b"first";
let key_blob2 = vec![0x02, 0x03];
let comment2 = b"second";
let mut body = Vec::new();
body.extend_from_slice(&2u32.to_be_bytes());
body.extend_from_slice(&(key_blob1.len() as u32).to_be_bytes());
body.extend_from_slice(&key_blob1);
body.extend_from_slice(&(comment1.len() as u32).to_be_bytes());
body.extend_from_slice(comment1);
body.extend_from_slice(&(key_blob2.len() as u32).to_be_bytes());
body.extend_from_slice(&key_blob2);
body.extend_from_slice(&(comment2.len() as u32).to_be_bytes());
body.extend_from_slice(comment2);
let data = encode_message(SSH_AGENT_IDENTITIES_ANSWER, &body);
let identities = decode_identities_answer(&data).expect("decode should succeed");
assert_eq!(identities.len(), 2);
assert_eq!(identities[0].0, key_blob1);
assert_eq!(identities[0].1, "first");
assert_eq!(identities[1].0, key_blob2);
assert_eq!(identities[1].1, "second");
}
#[test]
fn test_decode_identities_answer_empty_comment() {
let key_blob = vec![0xAA];
let comment: &[u8] = b"";
let mut body = Vec::new();
body.extend_from_slice(&1u32.to_be_bytes());
body.extend_from_slice(&(key_blob.len() as u32).to_be_bytes());
body.extend_from_slice(&key_blob);
body.extend_from_slice(&(comment.len() as u32).to_be_bytes());
body.extend_from_slice(comment);
let data = encode_message(SSH_AGENT_IDENTITIES_ANSWER, &body);
let identities = decode_identities_answer(&data).expect("decode should succeed");
assert_eq!(identities.len(), 1);
assert_eq!(identities[0].1, "");
}
#[test]
fn test_decode_identities_answer_wrong_type() {
let data = encode_message(99, &[0, 0, 0, 0]);
let result = decode_identities_answer(&data);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("expected identities answer (12)")
);
}
#[test]
fn test_decode_identities_answer_truncated_at_count() {
let data = vec![0, 0, 0, 1, SSH_AGENT_IDENTITIES_ANSWER];
let result = decode_identities_answer(&data);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("truncated at key count")
);
}
#[test]
fn test_decode_identities_answer_truncated_blob() {
let mut body = Vec::new();
body.extend_from_slice(&1u32.to_be_bytes()); body.extend_from_slice(&100u32.to_be_bytes());
let data = encode_message(SSH_AGENT_IDENTITIES_ANSWER, &body);
let result = decode_identities_answer(&data);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("truncated: key blob expects")
);
}
#[test]
fn test_decode_identities_answer_truncated_comment() {
let key_blob = vec![0x01];
let mut body = Vec::new();
body.extend_from_slice(&1u32.to_be_bytes());
body.extend_from_slice(&(key_blob.len() as u32).to_be_bytes());
body.extend_from_slice(&key_blob);
body.extend_from_slice(&50u32.to_be_bytes());
let data = encode_message(SSH_AGENT_IDENTITIES_ANSWER, &body);
let result = decode_identities_answer(&data);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("truncated: comment expects")
);
}
#[test]
fn test_decode_identities_answer_empty_payload() {
let result = decode_identities_answer(&[]);
assert!(result.is_err());
}
#[test]
fn test_encode_sign_request() {
let key_blob = vec![0x01, 0x02];
let data = vec![0xAA, 0xBB, 0xCC];
let flags = 0;
let msg = encode_sign_request(&key_blob, &data, flags);
assert_eq!(&msg[0..4], &[0, 0, 0, 1 + 4 + 2 + 4 + 3 + 4]); assert_eq!(msg[4], SSH_AGENT_SIGN_REQUEST);
let body = &msg[5..];
assert_eq!(&body[0..4], &(2u32.to_be_bytes())); assert_eq!(&body[4..6], &[0x01, 0x02]); assert_eq!(&body[6..10], &(3u32.to_be_bytes())); assert_eq!(&body[10..13], &[0xAA, 0xBB, 0xCC]); assert_eq!(&body[13..17], &0u32.to_be_bytes()); }
#[test]
fn test_encode_sign_request_with_flags() {
let key_blob = vec![0x42];
let data = vec![0xFF];
let flags = 2;
let msg = encode_sign_request(&key_blob, &data, flags);
let body = &msg[5..];
assert_eq!(&body[0..4], &(1u32.to_be_bytes()));
assert_eq!(&body[4..5], &[0x42]);
assert_eq!(&body[5..9], &(1u32.to_be_bytes()));
assert_eq!(&body[9..10], &[0xFF]);
assert_eq!(&body[10..14], &2u32.to_be_bytes());
}
#[test]
fn test_decode_sign_response_valid() {
let sig_blob = vec![0x01, 0x02, 0x03, 0x04, 0x05];
let mut body = Vec::new();
body.extend_from_slice(&(sig_blob.len() as u32).to_be_bytes());
body.extend_from_slice(&sig_blob);
let data = encode_message(SSH_AGENT_SIGN_RESPONSE, &body);
let sig = decode_sign_response(&data).expect("decode should succeed");
assert_eq!(sig, sig_blob);
}
#[test]
fn test_decode_sign_response_empty_sig() {
let body = vec![0, 0, 0, 0]; let data = encode_message(SSH_AGENT_SIGN_RESPONSE, &body);
let sig = decode_sign_response(&data).expect("decode should succeed");
assert!(sig.is_empty());
}
#[test]
fn test_decode_sign_response_wrong_type() {
let body = vec![0, 0, 0, 0];
let data = encode_message(42, &body);
let result = decode_sign_response(&data);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("expected sign response (14)")
);
}
#[test]
fn test_decode_sign_response_truncated_length() {
let data = encode_message(SSH_AGENT_SIGN_RESPONSE, &[]);
let result = decode_sign_response(&data);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("truncated at signature length")
);
}
#[test]
fn test_decode_sign_response_truncated_sig() {
let mut body = Vec::new();
body.extend_from_slice(&10u32.to_be_bytes()); body.extend_from_slice(&[0x01, 0x02]);
let data = encode_message(SSH_AGENT_SIGN_RESPONSE, &body);
let result = decode_sign_response(&data);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("truncated: signature expects")
);
}
#[test]
fn test_decode_sign_response_empty_payload() {
let result = decode_sign_response(&[]);
assert!(result.is_err());
}
#[test]
fn test_roundtrip_identities() {
let key1 = vec![
0x00, 0x00, 0x00, 0x0B, b's', b's', b'h', b'-', b'e', b'd', b'2', b'5', b'5', b'1',
b'9',
];
let comment1 = b"alice@work";
let key2 = vec![
0x00, 0x00, 0x00, 0x09, b's', b's', b'h', b'-', b'r', b's', b'a', b' ', b' ',
];
let comment2 = b"bob@home";
let mut body = Vec::new();
body.extend_from_slice(&2u32.to_be_bytes());
body.extend_from_slice(&(key1.len() as u32).to_be_bytes());
body.extend_from_slice(&key1);
body.extend_from_slice(&(comment1.len() as u32).to_be_bytes());
body.extend_from_slice(comment1);
body.extend_from_slice(&(key2.len() as u32).to_be_bytes());
body.extend_from_slice(&key2);
body.extend_from_slice(&(comment2.len() as u32).to_be_bytes());
body.extend_from_slice(comment2);
let data = encode_message(SSH_AGENT_IDENTITIES_ANSWER, &body);
let identities = decode_identities_answer(&data).expect("roundtrip should succeed");
assert_eq!(identities.len(), 2);
assert_eq!(identities[0].0, key1);
assert_eq!(identities[0].1, "alice@work");
assert_eq!(identities[1].0, key2);
assert_eq!(identities[1].1, "bob@home");
}
#[test]
fn test_roundtrip_sign() {
let sig_blob = vec![0x00, 0x00, 0x00, 0x40, 0xDE, 0xAD];
let mut body = Vec::new();
body.extend_from_slice(&(sig_blob.len() as u32).to_be_bytes());
body.extend_from_slice(&sig_blob);
let data = encode_message(SSH_AGENT_SIGN_RESPONSE, &body);
let sig = decode_sign_response(&data).expect("roundtrip should succeed");
assert_eq!(sig, sig_blob);
}
#[tokio::test]
async fn test_list_identities_not_windows() {
let result = list_identities().await;
#[cfg(not(windows))]
assert!(result.is_err());
}
#[tokio::test]
async fn test_sign_data_not_windows() {
let result = sign_data(&[1, 2, 3], &[4, 5, 6], 0).await;
#[cfg(not(windows))]
assert!(result.is_err());
}
}