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

nordisk/gunnar

crates/gunnar-cli/src/fido2.rs

//! **A FIDO2 security key's `hmac-secret`, from the CLI** (CRYPT-YUBI
//! 2026-10-26): the same secret a browser's WebAuthn `prf` gets from the same
//! key and credential, so one YubiKey opens a person's encryption key in the
//! console and in a terminal.
//!
//! CTAP 2.1 (fidoalliance.org, Client to Authenticator Protocol):
//!
//! * `authenticatorClientPIN` / `getKeyAgreement` gives the key's P-256 key;
//!   PIN/UV auth protocol ONE turns an ECDH share into the shared secret
//!   `SHA-256(x)` (§6.5.6).
//! * `authenticatorGetAssertion` with the `hmac-secret` extension: the salt
//!   AES-256-CBC-encrypted under it (IV zero), authenticated by the first 16
//!   bytes of HMAC-SHA256; the answer is `HMAC-SHA256(CredRandom, salt)`,
//!   encrypted the same way (§12.5).
//! * User presence only, never a PIN or user verification: an authenticator
//!   keeps one secret for "with UV" and another for "without", and the
//!   console asks without (`userVerification: "discouraged"`).
//!
//! The primitives are RustCrypto's (`p256` ECDH, `aes` + `cbc`, `hmac`,
//! `sha2`); this file is message glue. The wire is CTAPHID over Linux
//! `/dev/hidraw*` (plain reads and writes of 64-byte reports), behind the
//! [`Ctap`] trait, so the tests drive a software authenticator through the
//! same code (`tests/fido2_soft.rs`).

use anyhow::{anyhow, bail, Context, Result};
use sha2::{Digest as _, Sha256};

/// The CTAP2 command bytes this client sends.
pub mod cmd {
    /// authenticatorMakeCredential.
    pub const MAKE_CREDENTIAL: u8 = 0x01;
    /// authenticatorGetAssertion.
    pub const GET_ASSERTION: u8 = 0x02;
    /// authenticatorClientPIN.
    pub const CLIENT_PIN: u8 = 0x06;
}

/// One CTAP2 authenticator: a command byte and its CBOR in, the CBOR of a
/// successful answer out (a non-zero status is an error naming it).
pub trait Ctap {
    /// Send one command.
    fn cbor(&mut self, command: u8, payload: &[u8]) -> Result<Vec<u8>>;
}

/// A credential made by [`make_credential`].
#[derive(Debug, Clone)]
pub struct Made {
    /// The credential id the key hands back (and later recognises).
    pub credential_id: Vec<u8>,
}

/// The `hmac-secret` output for one salt.
pub struct Prf {
    /// HMAC-SHA256(CredRandom, salt).
    pub output: [u8; 32],
}

impl Drop for Prf {
    fn drop(&mut self) {
        use gunnar_crypt::zeroize::Zeroize as _;
        self.output.zeroize();
    }
}

/// Make a credential on the first security key found over USB.
pub fn make_credential(rp_id: &str) -> Result<Made> {
    let mut dev = hid::first()?;
    eprintln!("touch your security key to make its credential for {rp_id}");
    make_credential_with(&mut dev, rp_id)
}

/// The `hmac-secret` output of one of `allow` on the first security key found.
pub fn hmac_secret(rp_id: &str, allow: &[Vec<u8>], salt: &[u8; 32]) -> Result<Prf> {
    let mut dev = hid::first()?;
    eprintln!("touch your security key");
    hmac_secret_with(&mut dev, rp_id, allow, salt)
}

/// Why a security key refused, by its CTAP2 status.
fn status_word(code: u8) -> String {
    match code {
        0x2E => "this security key holds no credential for this account here (CTAP2_ERR_NO_CREDENTIALS): it is not one of your unlock methods".into(),
        0x27 => "the touch was refused or timed out (CTAP2_ERR_OPERATION_DENIED)".into(),
        0x2F => "the touch timed out (CTAP2_ERR_USER_ACTION_TIMEOUT)".into(),
        0x2B => "this security key does not support the hmac-secret extension (CTAP2_ERR_UNSUPPORTED_EXTENSION)".into(),
        0x36 => "this security key wants its PIN for this; gunnar asks without one (CTAP2_ERR_PUV_AUTH_REQUIRED)".into(),
        other => format!("the security key answered CTAP2 status 0x{other:02x}"),
    }
}

pub fn check_status(answer: &[u8]) -> Result<Vec<u8>> {
    match answer.split_first() {
        Some((0, rest)) => Ok(rest.to_vec()),
        Some((code, _)) => bail!("{}", status_word(*code)),
        None => bail!("the security key answered nothing"),
    }
}

/// [`make_credential`] over any [`Ctap`].
pub fn make_credential_with(dev: &mut dyn Ctap, rp_id: &str) -> Result<Made> {
    use cbor::Value as V;
    let client_data_hash =
        Sha256::digest(format!("gunnar crypt key add-security-key {rp_id}").as_bytes());
    let request = V::Map(vec![
        (V::Int(1), V::Bytes(client_data_hash.to_vec())),
        (
            V::Int(2),
            V::Map(vec![
                (V::text("id"), V::text(rp_id)),
                (V::text("name"), V::text(rp_id)),
            ]),
        ),
        (
            V::Int(3),
            V::Map(vec![
                (
                    V::text("id"),
                    V::Bytes(gunnar_crypt::random_bytes::<16>().to_vec()),
                ),
                (V::text("name"), V::text("gunnar encryption key")),
            ]),
        ),
        (
            V::Int(4),
            V::Array(vec![V::Map(vec![
                (V::text("alg"), V::Int(-7)),
                (V::text("type"), V::text("public-key")),
            ])]),
        ),
        (
            V::Int(6),
            V::Map(vec![(V::text("hmac-secret"), V::Bool(true))]),
        ),
        (V::Int(7), V::Map(vec![(V::text("rk"), V::Bool(false))])),
    ]);
    let answer = cbor::decode(&dev.cbor(cmd::MAKE_CREDENTIAL, &cbor::encode(&request))?)?;
    let auth_data = answer
        .get_int(2)
        .and_then(V::as_bytes)
        .ok_or_else(|| anyhow!("the security key answered no authenticator data"))?;
    let parsed = AuthData::parse(auth_data)?;
    if !parsed.extension_says_hmac_secret {
        bail!("{}", status_word(0x2B));
    }
    let credential_id = parsed
        .credential_id
        .ok_or_else(|| anyhow!("the security key made no credential"))?;
    Ok(Made { credential_id })
}

/// [`hmac_secret`] over any [`Ctap`].
pub fn hmac_secret_with(
    dev: &mut dyn Ctap,
    rp_id: &str,
    allow: &[Vec<u8>],
    salt: &[u8; 32],
) -> Result<Prf> {
    use cbor::Value as V;
    // The authenticator's key agreement key (PIN/UV auth protocol one).
    let request = V::Map(vec![(V::Int(1), V::Int(1)), (V::Int(2), V::Int(2))]);
    let answer = cbor::decode(&dev.cbor(cmd::CLIENT_PIN, &cbor::encode(&request))?)?;
    let theirs = answer
        .get_int(1)
        .ok_or_else(|| anyhow!("the security key gave no key agreement key"))?;
    let theirs = cose_p256(theirs)?;
    let (ours_cose, shared) = agree(&theirs)?;

    let salt_enc = aes_cbc(&shared, salt, true);
    let salt_auth = &hmac(&shared[..], &salt_enc)[..16];
    let client_data_hash = Sha256::digest(format!("gunnar crypt unlock {rp_id}").as_bytes());
    let request = V::Map(vec![
        (V::Int(1), V::text(rp_id)),
        (V::Int(2), V::Bytes(client_data_hash.to_vec())),
        (
            V::Int(3),
            V::Array(
                allow
                    .iter()
                    .map(|id| {
                        V::Map(vec![
                            (V::text("id"), V::Bytes(id.clone())),
                            (V::text("type"), V::text("public-key")),
                        ])
                    })
                    .collect(),
            ),
        ),
        (
            V::Int(4),
            V::Map(vec![(
                V::text("hmac-secret"),
                V::Map(vec![
                    (V::Int(1), ours_cose),
                    (V::Int(2), V::Bytes(salt_enc)),
                    (V::Int(3), V::Bytes(salt_auth.to_vec())),
                ]),
            )]),
        ),
        (V::Int(5), V::Map(vec![(V::text("up"), V::Bool(true))])),
    ]);
    let answer = cbor::decode(&dev.cbor(cmd::GET_ASSERTION, &cbor::encode(&request))?)?;
    let auth_data = answer
        .get_int(2)
        .and_then(V::as_bytes)
        .ok_or_else(|| anyhow!("the security key answered no authenticator data"))?;
    let parsed = AuthData::parse(auth_data)?;
    let enc = parsed
        .hmac_secret
        .ok_or_else(|| anyhow!("{}", status_word(0x2B)))?;
    if enc.len() != 32 {
        bail!(
            "the security key's hmac-secret answer is {} bytes, not 32",
            enc.len()
        );
    }
    let plain = gunnar_crypt::zeroize::Zeroizing::new(aes_cbc(&shared, &enc, false));
    let mut output = [0u8; 32];
    output.copy_from_slice(&plain);
    Ok(Prf { output })
}

/// The authenticator data of an answer (WebAuthn §6.1), what this client reads of it.
pub struct AuthData {
    pub credential_id: Option<Vec<u8>>,
    pub extension_says_hmac_secret: bool,
    pub hmac_secret: Option<Vec<u8>>,
}

impl AuthData {
    pub fn parse(d: &[u8]) -> Result<AuthData> {
        if d.len() < 37 {
            bail!(
                "the authenticator data is {} bytes, shorter than 37",
                d.len()
            );
        }
        let flags = d[32];
        let mut rest = &d[37..];
        let mut credential_id = None;
        if flags & 0x40 != 0 {
            if rest.len() < 18 {
                bail!("the attested credential data is cut short");
            }
            let n = usize::from(u16::from_be_bytes([rest[16], rest[17]]));
            let id = rest
                .get(18..18 + n)
                .ok_or_else(|| anyhow!("the credential id is cut short"))?;
            credential_id = Some(id.to_vec());
            rest = &rest[18 + n..];
            let (_, used) = cbor::decode_prefix(rest)?; // the credential public key
            rest = &rest[used..];
        }
        let (mut says, mut secret) = (false, None);
        if flags & 0x80 != 0 {
            let (ext, _) = cbor::decode_prefix(rest)?;
            match ext.get_text("hmac-secret") {
                Some(cbor::Value::Bool(b)) => says = *b,
                Some(cbor::Value::Bytes(b)) => secret = Some(b.clone()),
                _ => {}
            }
        }
        Ok(AuthData {
            credential_id,
            extension_says_hmac_secret: says,
            hmac_secret: secret,
        })
    }
}

/// A COSE_Key (EC2, P-256) as a public key.
pub fn cose_p256(v: &cbor::Value) -> Result<p256::PublicKey> {
    let x = v
        .get_int(-2)
        .and_then(cbor::Value::as_bytes)
        .ok_or_else(|| anyhow!("a COSE key without x"))?;
    let y = v
        .get_int(-3)
        .and_then(cbor::Value::as_bytes)
        .ok_or_else(|| anyhow!("a COSE key without y"))?;
    if x.len() != 32 || y.len() != 32 {
        bail!("a COSE key whose coordinates are not 32 bytes");
    }
    let mut sec1 = vec![4u8];
    sec1.extend_from_slice(x);
    sec1.extend_from_slice(y);
    p256::PublicKey::from_sec1_bytes(&sec1)
        .map_err(|_| anyhow!("the security key's key agreement key is not a P-256 point"))
}

/// A public key as the COSE_Key a platform sends (kty EC2, alg ECDH-ES+HKDF-256).
pub fn cose_of(public: &p256::PublicKey) -> cbor::Value {
    use cbor::Value as V;
    let sec1 = public.to_sec1_bytes();
    V::Map(vec![
        (V::Int(1), V::Int(2)),
        (V::Int(3), V::Int(-25)),
        (V::Int(-1), V::Int(1)),
        (V::Int(-2), V::Bytes(sec1[1..33].to_vec())),
        (V::Int(-3), V::Bytes(sec1[33..65].to_vec())),
    ])
}

/// A fresh P-256 share against `theirs`: our COSE key and protocol one's
/// shared secret, SHA-256 of the ECDH x-coordinate.
pub fn agree(
    theirs: &p256::PublicKey,
) -> Result<(cbor::Value, gunnar_crypt::zeroize::Zeroizing<[u8; 32]>)> {
    let secret = loop {
        let bytes = gunnar_crypt::zeroize::Zeroizing::new(gunnar_crypt::random_bytes::<32>());
        if let Ok(k) = p256::SecretKey::from_slice(&bytes[..]) {
            break k;
        }
    };
    let shared = secret.diffie_hellman(theirs);
    Ok((
        cose_of(&secret.public_key()),
        gunnar_crypt::zeroize::Zeroizing::new(shared_secret(shared.raw_secret_bytes())),
    ))
}

/// Protocol one's shared secret from an ECDH x-coordinate.
pub fn shared_secret(x: &[u8]) -> [u8; 32] {
    Sha256::digest(x).into()
}

/// AES-256-CBC with a zero IV over whole blocks (protocol one's encrypt / decrypt).
pub fn aes_cbc(key: &[u8; 32], data: &[u8], encrypt: bool) -> Vec<u8> {
    use cbc::cipher::{BlockModeDecrypt as _, BlockModeEncrypt as _, KeyIvInit as _};
    let iv = [0u8; 16];
    let mut out = Vec::with_capacity(data.len());
    if encrypt {
        let mut m = cbc::Encryptor::<aes::Aes256>::new(key.into(), (&iv).into());
        for chunk in data.chunks(16) {
            let mut b = cbc::cipher::Block::<cbc::Encryptor<aes::Aes256>>::default();
            b.copy_from_slice(chunk);
            m.encrypt_block(&mut b);
            out.extend_from_slice(&b);
        }
    } else {
        let mut m = cbc::Decryptor::<aes::Aes256>::new(key.into(), (&iv).into());
        for chunk in data.chunks(16) {
            let mut b = cbc::cipher::Block::<cbc::Decryptor<aes::Aes256>>::default();
            b.copy_from_slice(chunk);
            m.decrypt_block(&mut b);
            out.extend_from_slice(&b);
        }
    }
    out
}

/// HMAC-SHA256.
pub fn hmac(key: &[u8], data: &[u8]) -> [u8; 32] {
    use hmac::Mac as _;
    let mut m =
        <hmac::Hmac<Sha256> as hmac::KeyInit>::new_from_slice(key).expect("HMAC takes any key");
    m.update(data);
    m.finalize().into_bytes().into()
}

/// **The CBOR subset CTAP2 speaks** (RFC 8949, CTAP2's canonical form: the
/// shortest heads, definite lengths, map keys as given — callers list them in
/// canonical order).
pub mod cbor {
    use anyhow::{anyhow, bail, Result};

    /// A CBOR value.
    #[derive(Debug, Clone, PartialEq)]
    pub enum Value {
        /// An integer (major types 0 and 1).
        Int(i64),
        /// A byte string.
        Bytes(Vec<u8>),
        /// A text string.
        Text(String),
        /// An array.
        Array(Vec<Value>),
        /// A map, in the order given.
        Map(Vec<(Value, Value)>),
        /// true or false.
        Bool(bool),
    }

    impl Value {
        /// A text value.
        pub fn text(s: &str) -> Value {
            Value::Text(s.to_string())
        }
        /// A map's value under an integer key.
        pub fn get_int(&self, k: i64) -> Option<&Value> {
            match self {
                Value::Map(m) => m
                    .iter()
                    .find(|(key, _)| *key == Value::Int(k))
                    .map(|(_, v)| v),
                _ => None,
            }
        }
        /// A map's value under a text key.
        pub fn get_text(&self, k: &str) -> Option<&Value> {
            match self {
                Value::Map(m) => m
                    .iter()
                    .find(|(key, _)| matches!(key, Value::Text(t) if t == k))
                    .map(|(_, v)| v),
                _ => None,
            }
        }
        /// A byte string's bytes.
        pub fn as_bytes(&self) -> Option<&[u8]> {
            match self {
                Value::Bytes(b) => Some(b),
                _ => None,
            }
        }
    }

    fn head(out: &mut Vec<u8>, major: u8, n: u64) {
        let m = major << 5;
        match n {
            0..=23 => out.push(m | n as u8),
            24..=0xff => out.extend([m | 24, n as u8]),
            0x100..=0xffff => {
                out.push(m | 25);
                out.extend((n as u16).to_be_bytes());
            }
            0x1_0000..=0xffff_ffff => {
                out.push(m | 26);
                out.extend((n as u32).to_be_bytes());
            }
            _ => {
                out.push(m | 27);
                out.extend(n.to_be_bytes());
            }
        }
    }

    /// Encode.
    pub fn encode(v: &Value) -> Vec<u8> {
        let mut out = Vec::new();
        put(&mut out, v);
        out
    }

    fn put(out: &mut Vec<u8>, v: &Value) {
        match v {
            Value::Int(i) if *i >= 0 => head(out, 0, *i as u64),
            Value::Int(i) => head(out, 1, (-1 - *i) as u64),
            Value::Bytes(b) => {
                head(out, 2, b.len() as u64);
                out.extend(b);
            }
            Value::Text(t) => {
                head(out, 3, t.len() as u64);
                out.extend(t.as_bytes());
            }
            Value::Array(a) => {
                head(out, 4, a.len() as u64);
                a.iter().for_each(|x| put(out, x));
            }
            Value::Map(m) => {
                head(out, 5, m.len() as u64);
                for (k, x) in m {
                    put(out, k);
                    put(out, x);
                }
            }
            Value::Bool(b) => out.push(if *b { 0xf5 } else { 0xf4 }),
        }
    }

    /// Decode one whole value.
    pub fn decode(bytes: &[u8]) -> Result<Value> {
        let (v, used) = decode_prefix(bytes)?;
        if used != bytes.len() {
            bail!("{} bytes after the CBOR value", bytes.len() - used);
        }
        Ok(v)
    }

    /// Decode the value at the start of `bytes`; it and the bytes it took.
    pub fn decode_prefix(bytes: &[u8]) -> Result<(Value, usize)> {
        let mut at = 0;
        let v = take(bytes, &mut at, 0)?;
        Ok((v, at))
    }

    fn take(b: &[u8], at: &mut usize, depth: usize) -> Result<Value> {
        if depth > 16 {
            bail!("CBOR nested deeper than 16");
        }
        let short = || anyhow!("the CBOR is cut short");
        let first = *b.get(*at).ok_or_else(short)?;
        *at += 1;
        let (major, info) = (first >> 5, first & 31);
        let mut arg = |info: u8| -> Result<u64> {
            let n = match info {
                0..=23 => return Ok(u64::from(info)),
                24 => 1,
                25 => 2,
                26 => 4,
                27 => 8,
                _ => bail!("an indefinite or reserved CBOR length"),
            };
            let s = b.get(*at..*at + n).ok_or_else(short)?;
            *at += n;
            Ok(s.iter().fold(0u64, |a, x| (a << 8) | u64::from(*x)))
        };
        Ok(match major {
            0 => Value::Int(i64::try_from(arg(info)?)?),
            1 => Value::Int(-1 - i64::try_from(arg(info)?)?),
            2 | 3 => {
                let n = usize::try_from(arg(info)?)?;
                let s = b
                    .get(*at..at.checked_add(n).ok_or_else(short)?)
                    .ok_or_else(short)?
                    .to_vec();
                *at += n;
                if major == 2 {
                    Value::Bytes(s)
                } else {
                    Value::Text(
                        String::from_utf8(s).map_err(|_| anyhow!("CBOR text that is not UTF-8"))?,
                    )
                }
            }
            4 => {
                let n = usize::try_from(arg(info)?)?.min(4096);
                let mut v = Vec::new();
                for _ in 0..n {
                    v.push(take(b, at, depth + 1)?);
                }
                Value::Array(v)
            }
            5 => {
                let n = usize::try_from(arg(info)?)?.min(4096);
                let mut v = Vec::new();
                for _ in 0..n {
                    let k = take(b, at, depth + 1)?;
                    let x = take(b, at, depth + 1)?;
                    v.push((k, x));
                }
                Value::Map(v)
            }
            7 => match info {
                20 => Value::Bool(false),
                21 => Value::Bool(true),
                _ => bail!("a CBOR simple value this client does not read"),
            },
            _ => bail!("a CBOR tag this client does not read"),
        })
    }
}

/// **CTAPHID over Linux hidraw** (CTAP 2.1 §11.2): 64-byte reports, a channel
/// from INIT, CBOR messages, keep-alives while the key waits for a touch.
pub mod hid {
    use super::*;
    use std::io::{Read as _, Write as _};

    const BROADCAST: u32 = 0xffff_ffff;
    const INIT: u8 = 0x86;
    const CBOR: u8 = 0x90;
    const KEEPALIVE: u8 = 0xbb;
    const ERROR: u8 = 0xbf;
    const REPORT: usize = 64;

    /// One opened security key.
    pub struct Device {
        file: std::fs::File,
        cid: u32,
    }

    /// Frame one message into reports (`[report id 0] + 64 bytes` each).
    pub fn frames(cid: u32, command: u8, data: &[u8]) -> Vec<[u8; REPORT + 1]> {
        let mut out = Vec::new();
        let mut first = [0u8; REPORT + 1];
        first[1..5].copy_from_slice(&cid.to_be_bytes());
        first[5] = command;
        first[6..8].copy_from_slice(&(data.len() as u16).to_be_bytes());
        let n = data.len().min(REPORT - 7);
        first[8..8 + n].copy_from_slice(&data[..n]);
        out.push(first);
        for (seq, chunk) in data[n..].chunks(REPORT - 5).enumerate() {
            let mut r = [0u8; REPORT + 1];
            r[1..5].copy_from_slice(&cid.to_be_bytes());
            r[5] = seq as u8;
            r[6..6 + chunk.len()].copy_from_slice(chunk);
            out.push(r);
        }
        out
    }

    /// Read one message (skipping keep-alives) from reports `next` yields.
    pub fn unframe(
        cid: u32,
        mut next: impl FnMut() -> Result<[u8; REPORT]>,
    ) -> Result<(u8, Vec<u8>)> {
        loop {
            let r = next()?;
            if u32::from_be_bytes([r[0], r[1], r[2], r[3]]) != cid {
                continue;
            }
            let command = r[4];
            if command == KEEPALIVE {
                continue;
            }
            let len = usize::from(u16::from_be_bytes([r[5], r[6]]));
            let mut data = r[7..].iter().take(len).copied().collect::<Vec<u8>>();
            let mut seq = 0u8;
            while data.len() < len {
                let c = next()?;
                if u32::from_be_bytes([c[0], c[1], c[2], c[3]]) != cid {
                    continue;
                }
                if c[4] != seq {
                    bail!("the security key's answer arrived out of order");
                }
                seq = seq.wrapping_add(1);
                let want = (len - data.len()).min(REPORT - 5);
                data.extend_from_slice(&c[5..5 + want]);
            }
            if command == ERROR {
                bail!(
                    "the security key's channel refused the message (CTAPHID error 0x{:02x})",
                    data.first().copied().unwrap_or(0)
                );
            }
            return Ok((command, data));
        }
    }

    impl Device {
        fn send(&mut self, cid: u32, command: u8, data: &[u8]) -> Result<(u8, Vec<u8>)> {
            for f in frames(cid, command, data) {
                self.file
                    .write_all(&f)
                    .context("write to the security key")?;
            }
            let file = &mut self.file;
            unframe(cid, || {
                let mut r = [0u8; REPORT];
                file.read_exact(&mut r)
                    .context("read from the security key")?;
                Ok(r)
            })
        }

        fn open(path: &std::path::Path) -> Result<Device> {
            let file = std::fs::OpenOptions::new()
                .read(true)
                .write(true)
                .open(path)
                .with_context(|| format!("open {}", path.display()))?;
            let mut d = Device {
                file,
                cid: BROADCAST,
            };
            let nonce = gunnar_crypt::random_bytes::<8>();
            let (command, answer) = d.send(BROADCAST, INIT, &nonce)?;
            if command != INIT || answer.len() < 17 || answer[..8] != nonce {
                bail!("{} did not answer CTAPHID_INIT", path.display());
            }
            d.cid = u32::from_be_bytes([answer[8], answer[9], answer[10], answer[11]]);
            Ok(d)
        }
    }

    impl Ctap for Device {
        fn cbor(&mut self, command: u8, payload: &[u8]) -> Result<Vec<u8>> {
            let mut msg = vec![command];
            msg.extend_from_slice(payload);
            let (c, answer) = self.send(self.cid, CBOR, &msg)?;
            if c != CBOR {
                bail!("the security key answered CTAPHID command 0x{c:02x}");
            }
            check_status(&answer)
        }
    }

    /// Is a hidraw node a FIDO device? Its report descriptor declares usage
    /// page 0xF1D0 (`06 D0 F1`).
    fn is_fido(node: &str) -> bool {
        std::fs::read(format!("/sys/class/hidraw/{node}/device/report_descriptor"))
            .is_ok_and(|d| d.windows(3).any(|w| w == [0x06, 0xd0, 0xf1]))
    }

    /// The first FIDO security key on this machine, or why there is none.
    pub fn first() -> Result<Device> {
        let mut nodes: Vec<String> = std::fs::read_dir("/sys/class/hidraw")
            .map(|d| {
                d.flatten()
                    .map(|e| e.file_name().to_string_lossy().into_owned())
                    .collect()
            })
            .unwrap_or_default();
        nodes.sort();
        for node in nodes.iter().filter(|n| is_fido(n)) {
            match Device::open(std::path::Path::new(&format!("/dev/{node}"))) {
                Ok(d) => return Ok(d),
                Err(e) => eprintln!("{node}: {e:#}"),
            }
        }
        bail!(
            "no FIDO2 security key is plugged in (or this user may not open /dev/hidraw*: on Linux \
             that is a udev rule, which most distributions ship for YubiKeys)"
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cbor_round_trips_and_uses_the_shortest_heads() {
        use cbor::Value as V;
        let v = V::Map(vec![
            (V::Int(1), V::Int(-25)),
            (V::Int(-2), V::Bytes(vec![7; 32])),
            (V::text("up"), V::Bool(true)),
            (
                V::Int(3),
                V::Array(vec![V::Int(500), V::Int(70_000), V::text("x")]),
            ),
        ]);
        let e = cbor::encode(&v);
        assert_eq!(cbor::decode(&e).unwrap(), v);
        assert_eq!(cbor::encode(&V::Int(23)), [0x17]);
        assert_eq!(cbor::encode(&V::Int(24)), [0x18, 24]);
        assert_eq!(cbor::encode(&V::Int(-1)), [0x20]);
        assert_eq!(cbor::encode(&V::Int(-7)), [0x26]);
        assert!(
            cbor::decode(&[0x5f]).is_err(),
            "indefinite lengths are refused"
        );
        assert!(
            cbor::decode(&[0x58, 40, 1]).is_err(),
            "a cut string is refused"
        );
    }

    #[test]
    fn a_message_splits_into_reports_and_comes_back_whole() {
        let data: Vec<u8> = (0..300u32).map(|i| i as u8).collect();
        let frames = hid::frames(0x0102_0304, 0x90, &data);
        assert_eq!(frames.len(), 1 + (300 - 57usize).div_ceil(59));
        let mut it = frames.into_iter().map(|f| {
            let mut r = [0u8; 64];
            r.copy_from_slice(&f[1..]);
            r
        });
        let (c, back) =
            hid::unframe(0x0102_0304, || it.next().ok_or_else(|| anyhow!("no more"))).unwrap();
        assert_eq!((c, back), (0x90, data));
    }

    #[test]
    fn a_ctap_error_status_is_named() {
        assert!(check_status(&[0x2e])
            .unwrap_err()
            .to_string()
            .contains("not one of your unlock methods"));
        assert!(check_status(&[0x2b])
            .unwrap_err()
            .to_string()
            .contains("hmac-secret"));
        assert_eq!(check_status(&[0, 0xa0]).unwrap(), vec![0xa0]);
    }
}