Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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)
    )));
}