use crate::crypto::EventSignatureBytes;
use crate::error::{Result, ShoreError};
const SSH_AGENT_FAILURE: u8 = 5;
const SSH_AGENTC_REQUEST_IDENTITIES: u8 = 11;
const SSH_AGENT_IDENTITIES_ANSWER: u8 = 12;
const SSH_AGENTC_SIGN_REQUEST: u8 = 13;
const SSH_AGENT_SIGN_RESPONSE: u8 = 14;
const SSH_ED25519_TYPE: &[u8] = b"ssh-ed25519";
const ED25519_SIGNATURE_LEN: usize = 64;
pub(crate) fn put_ssh_string(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value);
}
pub(crate) fn read_ssh_string<'a>(cursor: &mut &'a [u8]) -> Option<&'a [u8]> {
if cursor.len() < 4 {
return None;
}
let (len_bytes, rest) = cursor.split_at(4);
let len = u32::from_be_bytes(len_bytes.try_into().expect("4 bytes")) as usize;
if rest.len() < len {
return None;
}
let (value, rest) = rest.split_at(len);
*cursor = rest;
Some(value)
}
fn read_agent_string<'a>(cursor: &mut &'a [u8]) -> Result<&'a [u8]> {
read_ssh_string(cursor).ok_or_else(|| agent_protocol("truncated SSH string"))
}
fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
if cursor.len() < 4 {
return Err(agent_protocol("truncated u32"));
}
let (head, rest) = cursor.split_at(4);
*cursor = rest;
Ok(u32::from_be_bytes(head.try_into().expect("4 bytes")))
}
fn frame_message(body: Vec<u8>) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + body.len());
out.extend_from_slice(&(body.len() as u32).to_be_bytes());
out.extend_from_slice(&body);
out
}
fn unframe_message(frame: &[u8]) -> Result<&[u8]> {
let mut cursor = frame;
let body = read_ssh_string(&mut cursor)
.ok_or_else(|| agent_protocol("truncated agent message frame"))?;
if !cursor.is_empty() {
return Err(agent_protocol("trailing bytes after agent message"));
}
Ok(body)
}
pub(crate) fn ed25519_key_blob(public_key: &[u8; 32]) -> Vec<u8> {
let mut blob = Vec::new();
put_ssh_string(&mut blob, SSH_ED25519_TYPE);
put_ssh_string(&mut blob, public_key);
blob
}
pub(crate) fn request_identities_bytes() -> Vec<u8> {
frame_message(vec![SSH_AGENTC_REQUEST_IDENTITIES])
}
pub(crate) fn sign_request_bytes(public_key: &[u8; 32], data: &[u8]) -> Vec<u8> {
let mut body = vec![SSH_AGENTC_SIGN_REQUEST];
put_ssh_string(&mut body, &ed25519_key_blob(public_key));
put_ssh_string(&mut body, data);
body.extend_from_slice(&0_u32.to_be_bytes()); frame_message(body)
}
pub(crate) fn parse_identities_answer(frame: &[u8]) -> Result<Vec<Vec<u8>>> {
let body = unframe_message(frame)?;
let (&msg_type, mut cursor) = body
.split_first()
.ok_or_else(|| agent_protocol("empty agent message body"))?;
if msg_type == SSH_AGENT_FAILURE {
return Err(agent_failure("agent refused to list identities"));
}
if msg_type != SSH_AGENT_IDENTITIES_ANSWER {
return Err(agent_protocol("unexpected reply to identities request"));
}
let nkeys = read_u32(&mut cursor)?;
let mut blobs = Vec::new();
for _ in 0..nkeys {
let blob = read_agent_string(&mut cursor)?.to_vec();
let _comment = read_agent_string(&mut cursor)?;
blobs.push(blob);
}
Ok(blobs)
}
pub(crate) fn parse_sign_response(frame: &[u8]) -> Result<EventSignatureBytes> {
let body = unframe_message(frame)?;
let (&msg_type, mut cursor) = body
.split_first()
.ok_or_else(|| agent_protocol("empty agent message body"))?;
if msg_type == SSH_AGENT_FAILURE {
return Err(agent_failure(
"agent refused to sign (locked or no matching key)",
));
}
if msg_type != SSH_AGENT_SIGN_RESPONSE {
return Err(agent_protocol("unexpected reply to sign request"));
}
let signature_blob = read_agent_string(&mut cursor)?;
let mut inner = signature_blob;
let inner_type = read_agent_string(&mut inner)?;
if inner_type != SSH_ED25519_TYPE {
return Err(agent_protocol("signature is not an ssh-ed25519 signature"));
}
let raw = read_agent_string(&mut inner)?;
if raw.len() != ED25519_SIGNATURE_LEN {
return Err(agent_protocol("ssh-ed25519 signature is not 64 bytes"));
}
Ok(EventSignatureBytes::from_bytes(raw))
}
fn agent_protocol(message: impl Into<String>) -> ShoreError {
ShoreError::Message(format!("ssh-agent protocol error: {}", message.into()))
}
fn agent_failure(message: impl Into<String>) -> ShoreError {
ShoreError::Message(format!("ssh-agent failure: {}", message.into()))
}
#[cfg(test)]
pub(crate) mod fake {
use std::io::{self, Read, Write};
use ed25519_dalek::{Signer as _, SigningKey};
use super::*;
pub(crate) struct FakeSshAgent {
key: Option<SigningKey>,
locked: bool,
refuse_sign: bool,
}
impl FakeSshAgent {
pub(crate) fn with_key(seed: [u8; 32]) -> Self {
Self {
key: Some(SigningKey::from_bytes(&seed)),
locked: false,
refuse_sign: false,
}
}
pub(crate) fn empty() -> Self {
Self {
key: None,
locked: false,
refuse_sign: false,
}
}
pub(crate) fn locked(mut self) -> Self {
self.locked = true;
self
}
pub(crate) fn refuses_sign(mut self) -> Self {
self.refuse_sign = true;
self
}
pub(crate) fn into_duplex(self) -> FakeAgentDuplex {
FakeAgentDuplex::new(self)
}
pub(crate) fn respond(&self, request: &[u8]) -> Vec<u8> {
let body = unframe_message(request).expect("test request is framed");
match body.first().copied() {
Some(SSH_AGENTC_REQUEST_IDENTITIES) => self.identities_answer(),
Some(SSH_AGENTC_SIGN_REQUEST) => self.sign_answer(body),
_ => frame_message(vec![SSH_AGENT_FAILURE]),
}
}
fn identities_answer(&self) -> Vec<u8> {
let mut out = vec![SSH_AGENT_IDENTITIES_ANSWER];
match (self.locked, &self.key) {
(false, Some(key)) => {
out.extend_from_slice(&1_u32.to_be_bytes());
put_ssh_string(&mut out, &ed25519_key_blob(&key.verifying_key().to_bytes()));
put_ssh_string(&mut out, b"fake-agent-key");
}
_ => out.extend_from_slice(&0_u32.to_be_bytes()),
}
frame_message(out)
}
fn sign_answer(&self, body: &[u8]) -> Vec<u8> {
if self.locked || self.refuse_sign {
return frame_message(vec![SSH_AGENT_FAILURE]);
}
let Some(key) = &self.key else {
return frame_message(vec![SSH_AGENT_FAILURE]);
};
let mut cursor = &body[1..];
let _blob = read_ssh_string(&mut cursor).expect("key blob");
let data = read_ssh_string(&mut cursor).expect("data");
let signature = key.sign(data);
let mut inner = Vec::new();
put_ssh_string(&mut inner, SSH_ED25519_TYPE);
put_ssh_string(&mut inner, &signature.to_bytes());
let mut out = vec![SSH_AGENT_SIGN_RESPONSE];
put_ssh_string(&mut out, &inner);
frame_message(out)
}
}
pub(crate) struct FakeAgentDuplex {
agent: FakeSshAgent,
inbound: Vec<u8>,
outbound: Vec<u8>,
out_pos: usize,
}
impl FakeAgentDuplex {
pub(crate) fn new(agent: FakeSshAgent) -> Self {
Self {
agent,
inbound: Vec::new(),
outbound: Vec::new(),
out_pos: 0,
}
}
fn drain_complete_frames(&mut self) {
while self.inbound.len() >= 4 {
let len =
u32::from_be_bytes(self.inbound[..4].try_into().expect("4 bytes")) as usize;
if self.inbound.len() < 4 + len {
break;
}
let frame: Vec<u8> = self.inbound.drain(..4 + len).collect();
let reply = self.agent.respond(&frame);
self.outbound.extend_from_slice(&reply);
}
}
}
impl Write for FakeAgentDuplex {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.inbound.extend_from_slice(buf);
self.drain_complete_frames();
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Read for FakeAgentDuplex {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let available = &self.outbound[self.out_pos..];
let n = available.len().min(buf.len());
buf[..n].copy_from_slice(&available[..n]);
self.out_pos += n;
Ok(n)
}
}
}
#[cfg(test)]
mod tests {
use super::fake::FakeSshAgent;
use super::*;
use crate::crypto::{EventVerificationStatus, SignerId, verify_ed25519_strict};
const SEED: [u8; 32] = [42_u8; 32];
fn fake_pubkey() -> [u8; 32] {
ed25519_dalek::SigningKey::from_bytes(&SEED)
.verifying_key()
.to_bytes()
}
fn expected_signer() -> SignerId {
SignerId::from_ed25519_public_key(fake_pubkey())
}
fn contains_subsequence(haystack: &[u8], needle: &[u8]) -> bool {
needle.is_empty()
|| haystack
.windows(needle.len())
.any(|window| window == needle)
}
fn sign_response_with_inner_type(inner_type: &[u8], sig: &[u8]) -> Vec<u8> {
let mut inner = Vec::new();
put_ssh_string(&mut inner, inner_type);
put_ssh_string(&mut inner, sig);
let mut out = vec![SSH_AGENT_SIGN_RESPONSE];
put_ssh_string(&mut out, &inner);
frame_message(out)
}
#[test]
fn sign_request_round_trips_and_the_unwrapped_signature_verifies_strict() {
let agent = FakeSshAgent::with_key(SEED);
let message = crate::session::event::pre_authentication_encoding(
crate::session::event::EVENT_TO_BE_SIGNED_V1_PAYLOAD_TYPE,
br#"{"schema":"shore.event","version":1}"#,
);
let request = sign_request_bytes(&fake_pubkey(), &message);
let response = agent.respond(&request); let signature = parse_sign_response(&response).unwrap();
assert_eq!(
verify_ed25519_strict(&expected_signer(), &message, signature.as_str()).unwrap(),
EventVerificationStatus::Valid
);
}
#[test]
fn sign_request_carries_the_data_verbatim_with_no_sshsig_wrapper() {
let message = b"DSSEv1 4 test 5 hello".to_vec();
let request = sign_request_bytes(&fake_pubkey(), &message);
assert!(
!contains_subsequence(&request, b"SSHSIG"),
"no SSHSIG envelope may appear in the sign request"
);
assert!(contains_subsequence(&request, &message));
assert_eq!(&request[request.len() - 4..], &[0, 0, 0, 0]);
}
#[test]
fn identities_answer_lists_the_loaded_key_blob() {
let agent = FakeSshAgent::with_key(SEED);
let request = request_identities_bytes();
let response = agent.respond(&request);
let blobs = parse_identities_answer(&response).unwrap();
let expected_blob = ed25519_key_blob(&fake_pubkey());
assert!(
blobs.contains(&expected_blob),
"the loaded key must be listed"
);
}
#[test]
fn an_absent_key_yields_an_empty_identities_list() {
let agent = FakeSshAgent::empty(); let blobs = parse_identities_answer(&agent.respond(&request_identities_bytes())).unwrap();
assert!(blobs.is_empty());
}
#[test]
fn globally_locked_agent_lists_zero_identities() {
let agent = FakeSshAgent::with_key(SEED).locked();
let blobs = parse_identities_answer(&agent.respond(&request_identities_bytes())).unwrap();
assert!(
blobs.is_empty(),
"a locked agent lists no usable identities"
);
}
#[test]
fn an_agent_that_refuses_the_sign_surfaces_a_typed_failure() {
let agent = FakeSshAgent::with_key(SEED).refuses_sign();
let listed = parse_identities_answer(&agent.respond(&request_identities_bytes())).unwrap();
assert!(
listed.contains(&ed25519_key_blob(&fake_pubkey())),
"a refuses-sign agent still lists the key"
);
let response = agent.respond(&sign_request_bytes(&fake_pubkey(), b"DSSEv1 4 test 5 hi"));
let err = parse_sign_response(&response).unwrap_err();
assert!(err.to_string().to_lowercase().contains("agent"));
}
#[test]
fn sign_response_with_wrong_inner_type_or_short_sig_is_rejected() {
assert!(
parse_sign_response(&sign_response_with_inner_type(b"ssh-rsa", &[0u8; 64])).is_err()
);
assert!(
parse_sign_response(&sign_response_with_inner_type(b"ssh-ed25519", &[0u8; 10]))
.is_err()
);
}
}