gunnar-grpc 2.0.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
//! **FIDO2 security keys (`sk-ssh-ed25519@openssh.com`) for the CLI** (5.4
//! §3b, owner 2026-10-10: "CLI uses a FIDO2 sk-ssh key or a `gunnar login`
//! browser device flow").
//!
//! An sk private key file holds a key HANDLE, not a key: the signature is
//! made by the authenticator, and OpenSSH's own agent is what talks to it
//! (`ssh-add ~/.ssh/id_ed25519_sk`). So a credential for an sk key is signed
//! by asking the agent at `$SSH_AUTH_SOCK` (`SSH_AGENTC_SIGN_REQUEST`, the
//! agent protocol, draft-miller-ssh-agent) — pure Rust, a Unix socket and a
//! dozen bytes of framing. The agent asks the authenticator, which waits for
//! the touch.
//!
//! The server half is [`crate::auth::verify_signature`]: an sk signature
//! verifies only with the user-presence flag set ([`user_present`]).

use std::io::{Read as _, Write as _};
use std::path::Path;
use std::time::Duration;

use nornir_auth_sshsig::ssh_key as sshsig_key;

use crate::auth::{Credential, GunnarScheme, SSHSIG_NAMESPACE};

/// `SSH_AGENTC_SIGN_REQUEST`.
const SIGN_REQUEST: u8 = 13;
/// `SSH_AGENT_SIGN_RESPONSE`.
const SIGN_RESPONSE: u8 = 14;
/// The authenticator's user-presence flag.
pub const FLAG_USER_PRESENT: u8 = 0x01;
/// How long the agent (and the person's finger) is given.
const AGENT_TIMEOUT: Duration = Duration::from_secs(60);
/// An agent answer larger than this is not a signature.
const MAX_ANSWER: usize = 64 * 1024;

/// Is this key a FIDO2 security key, whose signature only its authenticator
/// (through ssh-agent) can make?
pub fn is_security_key(key: &ssh_key::PublicKey) -> bool {
    matches!(key.algorithm(), ssh_key::Algorithm::SkEd25519 | ssh_key::Algorithm::SkEcdsaSha2NistP256)
}

/// **Did somebody touch the key?** For an sk signature, the authenticator's
/// flags byte (after the signature, before the counter) with user presence
/// set; every other algorithm has no such flag and answers `true`.
pub fn user_present(signature: &sshsig_key::Signature) -> bool {
    match signature.algorithm() {
        sshsig_key::Algorithm::SkEd25519 | sshsig_key::Algorithm::SkEcdsaSha2NistP256 => {
            let data = signature.as_bytes();
            // flags (1) ‖ counter (4) trail the signature.
            data.len() > 5 && data[data.len() - 5] & FLAG_USER_PRESENT != 0
        }
        _ => true,
    }
}

/// [`sign_credential_via_agent`] at `$SSH_AUTH_SOCK`.
///
/// # Errors
///
/// No agent, an agent that does not hold the key or refuses, an answer
/// without a touch — each said in a sentence that names the fix.
pub fn sign_credential_with_agent(public: &ssh_key::PublicKey, issued_at_unix_ms: i64, nonce: &str, method: &str) -> Result<Credential, String> {
    let socket = std::env::var_os("SSH_AUTH_SOCK").filter(|s| !s.is_empty()).ok_or_else(|| no_agent(public, "SSH_AUTH_SOCK is not set"))?;
    sign_credential_via_agent(Path::new(&socket), public, issued_at_unix_ms, nonce, method)
}

fn no_agent(public: &ssh_key::PublicKey, why: &str) -> String {
    format!(
        "{} is a FIDO2 security key ({}): its private half lives on the key, so gunnar signs through \
         ssh-agent, and {why}. Start an agent (SSH_AUTH_SOCK) and `ssh-add` the key file, then touch the \
         key when it blinks",
        public.fingerprint(ssh_key::HashAlg::Sha256),
        public.algorithm().as_str(),
    )
}

/// **Sign a method-bound gunnar credential with an sk key, through the agent
/// at `socket`.** The agent is handed sshsig's signed data (namespace
/// [`SSHSIG_NAMESPACE`], SHA-512), exactly what `ssh-keygen -Y sign` hands it,
/// and its signature is wrapped into the sshsig armour the server verifies.
///
/// # Errors
///
/// See [`sign_credential_with_agent`].
pub fn sign_credential_via_agent(socket: &Path, public: &ssh_key::PublicKey, issued_at_unix_ms: i64, nonce: &str, method: &str) -> Result<Credential, String> {
    // The bytes cross the ssh-key version line, the types do not (see
    // `auth::sshsig_key`): the public key is handed over as its OpenSSH line.
    let line = public.to_openssh().map_err(|e| format!("encoding the public key: {e}"))?;
    let public = sshsig_key::PublicKey::from_openssh(&line).map_err(|e| format!("reading the public key: {e}"))?;
    let fingerprint = public.fingerprint(sshsig_key::HashAlg::Sha256).to_string();
    let message = nornir_auth_sshsig::challenge_bound::<GunnarScheme>(&fingerprint, issued_at_unix_ms, nonce, method);
    let signed = sshsig_key::SshSig::signed_data(SSHSIG_NAMESPACE, nornir_auth_sshsig::HASH_ALG, message.as_bytes())
        .map_err(|e| format!("building the signed data: {e}"))?;
    let key_blob = public.to_bytes().map_err(|e| format!("encoding the public key: {e}"))?;
    let answer = ask_agent(socket, &key_blob, &signed).map_err(|why| {
        let shown = ssh_key::PublicKey::from_openssh(&line).ok();
        match shown {
            Some(key) => no_agent(&key, &why),
            None => why,
        }
    })?;
    let signature = sshsig_key::Signature::try_from(answer.as_slice()).map_err(|e| format!("the agent's answer is not an SSH signature: {e}"))?;
    if signature.algorithm() != public.algorithm() {
        return Err(format!(
            "the agent signed with {} for a {} key",
            signature.algorithm().as_str(),
            public.algorithm().as_str()
        ));
    }
    if !user_present(&signature) {
        return Err(
            "the security key signed without a touch (its user-presence flag is not set) — gunnar refuses that; \
             touch the key when it blinks, and do not enrol it with no-touch-required"
                .to_owned(),
        );
    }
    let sshsig = sshsig_key::SshSig::new(public.key_data().clone(), SSHSIG_NAMESPACE, nornir_auth_sshsig::HASH_ALG, signature)
        .map_err(|e| format!("wrapping the signature: {e}"))?;
    let armor = sshsig.to_pem(sshsig_key::LineEnding::LF).map_err(|e| format!("armouring the signature: {e}"))?;
    Ok(Credential::new(fingerprint, issued_at_unix_ms, nonce.to_owned(), Some(method.to_owned()), nornir_auth_sshsig::armor_to_line(&armor)))
}

fn put_string(out: &mut Vec<u8>, bytes: &[u8]) -> Result<(), String> {
    let len = u32::try_from(bytes.len()).map_err(|_| "a field longer than 4 GiB".to_owned())?;
    out.extend_from_slice(&len.to_be_bytes());
    out.extend_from_slice(bytes);
    Ok(())
}

fn take_string<'a>(buf: &mut &'a [u8]) -> Option<&'a [u8]> {
    let len = usize::try_from(u32::from_be_bytes(buf.get(..4)?.try_into().ok()?)).ok()?;
    let out = buf.get(4..4usize.checked_add(len)?)?;
    *buf = &buf[4 + len..];
    Some(out)
}

/// One `SSH_AGENTC_SIGN_REQUEST` → the signature blob.
#[cfg(not(unix))]
fn ask_agent(_socket: &Path, _key_blob: &[u8], _data: &[u8]) -> Result<Vec<u8>, String> {
    Err("ssh-agent is reached over a Unix socket, and this platform has none".to_owned())
}

/// One `SSH_AGENTC_SIGN_REQUEST` → the signature blob.
#[cfg(unix)]
fn ask_agent(socket: &Path, key_blob: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
    let mut stream = std::os::unix::net::UnixStream::connect(socket)
        .map_err(|e| format!("the agent at {} cannot be reached ({e}; is SSH_AUTH_SOCK right?)", socket.display()))?;
    stream.set_read_timeout(Some(AGENT_TIMEOUT)).map_err(|e| e.to_string())?;
    stream.set_write_timeout(Some(AGENT_TIMEOUT)).map_err(|e| e.to_string())?;
    let mut request = vec![SIGN_REQUEST];
    put_string(&mut request, key_blob)?;
    put_string(&mut request, data)?;
    request.extend_from_slice(&0u32.to_be_bytes());
    let mut framed = Vec::with_capacity(request.len() + 4);
    put_string(&mut framed, &request)?;
    stream.write_all(&framed).map_err(|e| format!("writing to the agent: {e}"))?;
    let mut len = [0u8; 4];
    stream.read_exact(&mut len).map_err(|e| format!("the agent did not answer ({e}) — was the key touched in time?"))?;
    let len = usize::try_from(u32::from_be_bytes(len)).unwrap_or(usize::MAX);
    if len == 0 || len > MAX_ANSWER {
        return Err(format!("the agent's answer is {len} bytes"));
    }
    let mut answer = vec![0u8; len];
    stream.read_exact(&mut answer).map_err(|e| format!("reading the agent's answer: {e}"))?;
    if answer[0] != SIGN_RESPONSE {
        return Err("the agent refused to sign: it does not hold this key (`ssh-add` the key file), or the key was not touched".to_owned());
    }
    let mut rest = &answer[1..];
    take_string(&mut rest)
        .map(<[u8]>::to_vec)
        .ok_or_else(|| "the agent's answer is truncated".to_owned())
}