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};
const SIGN_REQUEST: u8 = 13;
const SIGN_RESPONSE: u8 = 14;
pub const FLAG_USER_PRESENT: u8 = 0x01;
const AGENT_TIMEOUT: Duration = Duration::from_secs(60);
const MAX_ANSWER: usize = 64 * 1024;
pub fn is_security_key(key: &ssh_key::PublicKey) -> bool {
matches!(key.algorithm(), ssh_key::Algorithm::SkEd25519 | ssh_key::Algorithm::SkEcdsaSha2NistP256)
}
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();
data.len() > 5 && data[data.len() - 5] & FLAG_USER_PRESENT != 0
}
_ => true,
}
}
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(),
)
}
pub fn sign_credential_via_agent(socket: &Path, public: &ssh_key::PublicKey, issued_at_unix_ms: i64, nonce: &str, method: &str) -> Result<Credential, String> {
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)
}
#[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())
}
#[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())
}