crates/gunnar-grpc/tests/security_key_agent.rs
//! **The CLI signs with a FIDO2 key through ssh-agent** (5.4 §3b passkey-cli).
//!
//! An `sk-ssh-ed25519` private key file holds a key HANDLE, not a key: the
//! signature is the authenticator's. OpenSSH's own agent (`ssh-add
//! ~/.ssh/id_ed25519_sk`) is what talks to the authenticator, so gunnar asks
//! the agent (`SSH_AGENTC_SIGN_REQUEST`) — pure Rust, over `$SSH_AUTH_SOCK`.
//! The agent here is a fake that answers like the authenticator would.
mod security_key_common;
use std::io::{Read as _, Write as _};
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use security_key_common::{SecurityKey, USER_PRESENT};
const NONCE: &str = "0123456789abcdef0123456789abcdef";
fn socket_path(name: &str) -> PathBuf {
let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("sk-agent");
std::fs::create_dir_all(&dir).expect("a dir");
let path = dir.join(format!("{name}-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&path);
path
}
fn read_string(buf: &mut &[u8]) -> Vec<u8> {
let len = u32::from_be_bytes(buf[..4].try_into().expect("4 bytes")) as usize;
let out = buf[4..4 + len].to_vec();
*buf = &buf[4 + len..];
out
}
fn frame(payload: &[u8]) -> Vec<u8> {
let mut out = (payload.len() as u32).to_be_bytes().to_vec();
out.extend_from_slice(payload);
out
}
/// Every (key blob, data) an agent was asked to sign.
type Asked = Arc<Mutex<Vec<(Vec<u8>, Vec<u8>)>>>;
/// An ssh-agent holding `key`: answers one SIGN_REQUEST per connection, and
/// records (key blob, data) it was asked to sign.
fn fake_agent(path: &PathBuf, key: SecurityKey, flags: u8, refuse: bool) -> Asked {
let listener = UnixListener::bind(path).expect("a socket");
let asked = Arc::new(Mutex::new(Vec::new()));
let seen = asked.clone();
let key = Arc::new(Mutex::new(key));
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { return };
let mut len = [0u8; 4];
if stream.read_exact(&mut len).is_err() {
continue;
}
let mut body = vec![0u8; u32::from_be_bytes(len) as usize];
stream.read_exact(&mut body).expect("a request");
assert_eq!(body[0], 13, "SSH_AGENTC_SIGN_REQUEST");
let mut rest = &body[1..];
let blob = read_string(&mut rest);
let data = read_string(&mut rest);
seen.lock().expect("seen").push((blob, data.clone()));
if refuse {
stream.write_all(&frame(&[5])).expect("answers");
continue;
}
let signature = key.lock().expect("key").sign(&data, flags);
let sig_blob = Vec::<u8>::try_from(signature).expect("encodes");
let mut answer = vec![14u8];
answer.extend_from_slice(&(sig_blob.len() as u32).to_be_bytes());
answer.extend_from_slice(&sig_blob);
stream.write_all(&frame(&answer)).expect("answers");
}
});
asked
}
fn public_07(key: &SecurityKey) -> ssh_key::PublicKey {
ssh_key::PublicKey::from_openssh(&key.openssh())
.expect("gunnar's ssh-key reads an sk public key")
}
#[test]
fn an_sk_key_signs_a_gunnar_credential_through_the_agent() {
let path = socket_path("touch");
let key = SecurityKey::new(0x31);
let openssh = key.openssh();
let public = public_07(&key);
let asked = fake_agent(&path, key, USER_PRESENT, false);
let credential = gunnar_grpc::security_key::sign_credential_via_agent(
&path,
&public,
1_700_000_000_000,
NONCE,
"Repos.List",
)
.expect("the agent signs");
assert_eq!(credential.method.as_deref(), Some("Repos.List"));
assert!(
gunnar_grpc::auth::verify_signature(&openssh, &credential),
"what the agent signed is a credential gunnar accepts"
);
let asked = asked.lock().expect("asked").clone();
assert_eq!(asked.len(), 1, "one touch per call");
let blob = nornir_auth_sshsig::ssh_key::PublicKey::from_openssh(&openssh)
.expect("parses")
.to_bytes()
.expect("encodes");
assert_eq!(asked[0].0, blob, "the agent is asked for THIS key");
assert!(
asked[0].1.starts_with(b"SSHSIG"),
"the agent signs sshsig's signed data, namespace and all"
);
}
#[test]
fn a_touchless_answer_from_the_agent_is_refused_before_it_is_sent() {
let path = socket_path("no-touch");
let key = SecurityKey::new(0x32);
let public = public_07(&key);
fake_agent(&path, key, 0x00, false);
let why = gunnar_grpc::security_key::sign_credential_via_agent(
&path,
&public,
1_700_000_000_000,
NONCE,
"Repos.List",
)
.expect_err("no presence, no credential");
assert!(
why.contains("touch"),
"the error says what was missing: {why}"
);
}
#[test]
fn an_agent_that_refuses_or_is_absent_says_how_to_add_the_key() {
let path = socket_path("refuses");
let key = SecurityKey::new(0x33);
let public = public_07(&key);
fake_agent(&path, key, USER_PRESENT, true);
let why = gunnar_grpc::security_key::sign_credential_via_agent(
&path,
&public,
1_700_000_000_000,
NONCE,
"Repos.List",
)
.expect_err("refused");
assert!(why.contains("ssh-add"), "{why}");
let gone = socket_path("absent");
let why = gunnar_grpc::security_key::sign_credential_via_agent(
&gone,
&public,
1_700_000_000_000,
NONCE,
"Repos.List",
)
.expect_err("no agent");
assert!(
why.contains("ssh-add") && why.contains("SSH_AUTH_SOCK"),
"{why}"
);
}
#[test]
fn only_security_keys_go_to_the_agent() {
let plain = ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[7u8; 32]));
assert!(!gunnar_grpc::security_key::is_security_key(
plain.public_key()
));
assert!(gunnar_grpc::security_key::is_security_key(&public_07(
&SecurityKey::new(0x34)
)));
}