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/tests/fido2_soft.rs

//! **The CLI's FIDO2 client against a software authenticator** (CRYPT-YUBI
//! 2026-10-26). The authenticator below is the device side of CTAP 2.1's
//! `hmac-secret` (PIN/UV auth protocol one, user presence only), held as a
//! YubiKey 5 holds it: a random CredRandom per credential, the answer
//! HMAC-SHA256(CredRandom, salt). The client is `gunnar_cli::fido2`, the same
//! code `gunnar crypt unlock --security-key` runs over USB.

#![cfg(feature = "client")]

use anyhow::Result;
use gunnar_cli::crypt_key::{open_with, Openers, SecurityKeys};
use gunnar_cli::fido2::{self, cbor::Value as V, Ctap};
use gunnar_crypt::door::{Answer, MethodLine};
use gunnar_crypt::keyring::Keyring;
use gunnar_crypt::members::{Level, Members};
use gunnar_crypt::userkey::{security_key, UserKey};

const RP: &str = "console.mock.invalid";

/// A software security key.
struct Soft {
    /// Its key agreement key (a fixed TEST scalar).
    agreement: p256::SecretKey,
    /// (credential id, rp id, CredRandom).
    credentials: Vec<(Vec<u8>, String, [u8; 32])>,
    hmac_secret: bool,
    touches: usize,
}

impl Soft {
    fn new(hmac_secret: bool) -> Soft {
        Soft {
            agreement: p256::SecretKey::from_slice(&[0x42; 32]).unwrap(),
            credentials: Vec::new(),
            hmac_secret,
            touches: 0,
        }
    }

    fn auth_data(rp: &str, flags: u8, attested: Option<&[u8]>, ext: Option<V>) -> Vec<u8> {
        use sha2::Digest as _;
        let mut d = sha2::Sha256::digest(rp.as_bytes()).to_vec();
        d.push(
            flags
                | 0x01
                | if attested.is_some() { 0x40 } else { 0 }
                | if ext.is_some() { 0x80 } else { 0 },
        );
        d.extend([0, 0, 0, 1]);
        if let Some(id) = attested {
            d.extend([0u8; 16]);
            d.extend((id.len() as u16).to_be_bytes());
            d.extend(id);
            d.extend(fido2::cbor::encode(&fido2::cose_of(
                &p256::SecretKey::from_slice(&[7; 32]).unwrap().public_key(),
            )));
        }
        if let Some(e) = ext {
            d.extend(fido2::cbor::encode(&e));
        }
        d
    }
}

impl Ctap for Soft {
    fn cbor(&mut self, command: u8, payload: &[u8]) -> Result<Vec<u8>> {
        let req = fido2::cbor::decode(payload)?;
        let answer = match command {
            fido2::cmd::CLIENT_PIN => V::Map(vec![(
                V::Int(1),
                fido2::cose_of(&self.agreement.public_key()),
            )]),
            fido2::cmd::MAKE_CREDENTIAL => {
                self.touches += 1;
                let rp = match req.get_int(2).and_then(|r| r.get_text("id")) {
                    Some(V::Text(t)) => t.clone(),
                    _ => anyhow::bail!("no rp id"),
                };
                let id = gunnar_crypt::random_bytes::<16>().to_vec();
                self.credentials
                    .push((id.clone(), rp.clone(), gunnar_crypt::random_bytes::<32>()));
                let wants =
                    req.get_int(6).and_then(|e| e.get_text("hmac-secret")) == Some(&V::Bool(true));
                let ext = (wants && self.hmac_secret)
                    .then(|| V::Map(vec![(V::text("hmac-secret"), V::Bool(true))]));
                V::Map(vec![
                    (V::Int(1), V::text("none")),
                    (V::Int(2), V::Bytes(Soft::auth_data(&rp, 0, Some(&id), ext))),
                    (V::Int(3), V::Map(vec![])),
                ])
            }
            fido2::cmd::GET_ASSERTION => {
                self.touches += 1;
                let rp = match req.get_int(1) {
                    Some(V::Text(t)) => t.clone(),
                    _ => anyhow::bail!("no rp id"),
                };
                let allowed: Vec<Vec<u8>> = match req.get_int(3) {
                    Some(V::Array(a)) => a
                        .iter()
                        .filter_map(|d| d.get_text("id").and_then(V::as_bytes).map(<[u8]>::to_vec))
                        .collect(),
                    _ => vec![],
                };
                let Some((id, _, cred_random)) = self
                    .credentials
                    .iter()
                    .find(|(id, r, _)| *r == rp && allowed.contains(id))
                    .cloned()
                else {
                    return fido2::check_status(&[0x2e]);
                };
                let mut ext = None;
                if let Some(hs) = req.get_int(4).and_then(|e| e.get_text("hmac-secret")) {
                    if !self.hmac_secret {
                        return fido2::check_status(&[0x2b]);
                    }
                    let platform = fido2::cose_p256(hs.get_int(1).unwrap())?;
                    let shared = fido2::shared_secret(
                        self.agreement.diffie_hellman(&platform).raw_secret_bytes(),
                    );
                    let salt_enc = hs.get_int(2).and_then(V::as_bytes).unwrap().to_vec();
                    let salt_auth = hs.get_int(3).and_then(V::as_bytes).unwrap();
                    if fido2::hmac(&shared, &salt_enc)[..16] != *salt_auth {
                        return fido2::check_status(&[0x33]); // CTAP2_ERR_PIN_AUTH_INVALID
                    }
                    let salt = fido2::aes_cbc(&shared, &salt_enc, false);
                    let out = fido2::hmac(&cred_random, &salt);
                    ext = Some(V::Map(vec![(
                        V::text("hmac-secret"),
                        V::Bytes(fido2::aes_cbc(&shared, &out, true)),
                    )]));
                }
                V::Map(vec![
                    (
                        V::Int(1),
                        V::Map(vec![
                            (V::text("id"), V::Bytes(id)),
                            (V::text("type"), V::text("public-key")),
                        ]),
                    ),
                    (V::Int(2), V::Bytes(Soft::auth_data(&rp, 0, None, ext))),
                    (V::Int(3), V::Bytes(vec![0; 64])),
                ])
            }
            _ => return fido2::check_status(&[0x01]),
        };
        let mut out = vec![0u8];
        out.extend(fido2::cbor::encode(&answer));
        fido2::check_status(&out)
    }
}

impl SecurityKeys for Soft {
    fn prf(&mut self, rp_id: &str, credential: &[u8]) -> Result<[u8; 32]> {
        Ok(fido2::hmac_secret_with(
            self,
            rp_id,
            &[credential.to_vec()],
            &security_key::hmac_secret_salt(),
        )?
        .output)
    }
}

/// ★ Enrol once, then open with the security key only: the CLI's secret is
/// exactly the browser's WebAuthn `prf` output for gunnar's input
/// (HMAC(CredRandom, SHA-256("WebAuthn PRF" ‖ 0 ‖ input))), so one key opens
/// the encryption key in both, and with it every ring.
#[test]
fn enrolled_once_the_security_key_opens_the_user_key_and_every_ring() {
    let mut key = Soft::new(true);
    let made = fido2::make_credential_with(&mut key, RP).unwrap();
    let out = key.prf(RP, &made.credential_id).unwrap();
    let cred_random = key.credentials[0].2;
    let browser = {
        use sha2::Digest as _;
        let mut h = sha2::Sha256::new();
        h.update(b"WebAuthn PRF\0");
        h.update(security_key::PRF_INPUT);
        fido2::hmac(&cred_random, &h.finalize())
    };
    assert_eq!(out, browser, "the CLI's hmac-secret is the browser's prf");
    assert_eq!(
        key.prf(RP, &made.credential_id).unwrap(),
        out,
        "fixed per credential"
    );

    // The user key wrapped to the key's recipient (what enrolment uploads)…
    let user = UserKey::generate(Level::Classic).unwrap();
    let method = MethodLine {
        id: "0011223344556677".into(),
        kind: "security-key".into(),
        method_ref: gunnar_cli::crypt_key::b64url_encode(&made.credential_id),
        rp_id: RP.into(),
        wrap: user
            .wrap_to_security_key(&security_key::recipient(&out, &made.credential_id))
            .unwrap(),
        ..MethodLine::default()
    };
    let answer = Answer {
        public_key: Some(user.public()),
        methods: vec![method],
    };
    // …opens with the security key alone, and two repositories' rings with it.
    let how = Openers {
        security_key: true,
        ..Openers::default()
    };
    let opened = open_with(&answer, &how, &mut key).unwrap();
    assert_eq!(opened.public(), user.public());
    for _ in 0..2 {
        let ring = Keyring::generate();
        let mut m = Members::at(gunnar_crypt::members::Level::Classic);
        m.add("alice", &user.public()).unwrap();
        let keys = gunnar_crypt::wrap::wrap(&ring, &m).unwrap();
        assert_eq!(
            gunnar_crypt::wrap::unwrap_checked(keys.as_bytes(), &m.to_text(), &[opened.identity()])
                .unwrap(),
            ring
        );
    }
}

/// ★ Refusals by name: a key without hmac-secret, and a key that holds no
/// credential of this account.
#[test]
fn a_key_without_hmac_secret_and_a_stranger_key_are_named() {
    let mut plain = Soft::new(false);
    let e = fido2::make_credential_with(&mut plain, RP)
        .unwrap_err()
        .to_string();
    assert!(
        e.contains("does not support the hmac-secret extension"),
        "{e}"
    );

    let mut mine = Soft::new(true);
    let made = fido2::make_credential_with(&mut mine, RP).unwrap();
    let mut other = Soft::new(true);
    fido2::make_credential_with(&mut other, RP).unwrap();
    let e = match other.prf(RP, &made.credential_id) {
        Err(e) => e.to_string(),
        Ok(_) => panic!("another key answered for this credential"),
    };
    assert!(e.contains("not one of your unlock methods"), "{e}");
    // The right credential at another console's name is a stranger too.
    assert!(mine
        .prf("elsewhere.mock.invalid", &made.credential_id)
        .is_err());
}

/// ★ RA4-14: a bogus wrap the server holds (another key under the real
/// credential, or a credential no key has) does not block unlock: it is
/// skipped and the next method opens.
#[test]
fn a_bogus_wrap_first_is_skipped_and_the_real_one_opens() {
    let mut key = Soft::new(true);
    let made = fido2::make_credential_with(&mut key, RP).unwrap();
    let out = key.prf(RP, &made.credential_id).unwrap();
    let user = UserKey::generate(Level::Classic).unwrap();
    let planted = UserKey::generate(Level::Classic).unwrap();
    let recipient = security_key::recipient(&out, &made.credential_id);
    let line = |id: &str, cred: &[u8], wrap: String| MethodLine {
        id: id.into(),
        kind: "security-key".into(),
        method_ref: gunnar_cli::crypt_key::b64url_encode(cred),
        rp_id: RP.into(),
        wrap,
        ..MethodLine::default()
    };
    let answer = Answer {
        public_key: Some(user.public()),
        methods: vec![
            line(
                "aaaaaaaaaaaaaaaa",
                b"no key has this credential",
                planted.wrap_to_security_key(&recipient).unwrap(),
            ),
            line(
                "bbbbbbbbbbbbbbbb",
                &made.credential_id,
                planted.wrap_to_security_key(&recipient).unwrap(),
            ),
            line(
                "cccccccccccccccc",
                &made.credential_id,
                user.wrap_to_security_key(&recipient).unwrap(),
            ),
        ],
    };
    let how = Openers {
        security_key: true,
        ..Openers::default()
    };
    assert_eq!(
        open_with(&answer, &how, &mut key).unwrap().public(),
        user.public()
    );
    // With only the bogus ones, the refusal names the one it skipped.
    let only_bogus = Answer {
        public_key: Some(user.public()),
        methods: answer.methods[..2].to_vec(),
    };
    let e = match open_with(&only_bogus, &how, &mut key) {
        Err(e) => e.to_string(),
        Ok(_) => panic!("a planted key opened"),
    };
    assert!(
        e.contains("bbbbbbbbbbbbbbbb") && e.contains("Skipped"),
        "{e}"
    );
}

/// ★ CRYPT-PQ-SK: a security key's v1 (X25519) wrap still opens, and the CLI
/// hands back the v2 re-wrap of the same method (same credential, relying
/// party and label) to send on the next unlock; a v2 wrap opens and asks for
/// nothing. The v2 wrap opens with the same touch.
#[test]
fn a_v1_security_key_wrap_opens_and_hands_back_its_v2_rewrap() {
    let mut key = Soft::new(true);
    let made = fido2::make_credential_with(&mut key, RP).unwrap();
    let out = key.prf(RP, &made.credential_id).unwrap();
    let user = UserKey::generate(Level::NEW).unwrap();
    let v1 = MethodLine {
        id: "0011223344556677".into(),
        kind: "security-key".into(),
        label: "YubiKey".into(),
        method_ref: gunnar_cli::crypt_key::b64url_encode(&made.credential_id),
        rp_id: RP.into(),
        wrap: user
            .wrap_to_security_key(&security_key::recipient(&out, &made.credential_id))
            .unwrap(),
        ..MethodLine::default()
    };
    let how = Openers {
        security_key: true,
        ..Openers::default()
    };
    let answer = Answer {
        public_key: Some(user.public()),
        methods: vec![v1.clone()],
    };
    let (opened, rewrap) =
        gunnar_cli::crypt_key::open_with_rewrap(&answer, &how, &mut key).unwrap();
    assert_eq!(opened.public(), user.public());
    let v2 = rewrap.expect("a v1 wrap is re-wrapped to v2");
    assert!(gunnar_crypt::skwrap::is_v2(&v2.wrap), "{}", v2.wrap);
    assert_eq!(
        (
            v2.kind.as_str(),
            v2.method_ref.as_str(),
            v2.rp_id.as_str(),
            v2.label.as_str()
        ),
        ("security-key", v1.method_ref.as_str(), RP, "YubiKey")
    );
    let answer = Answer {
        public_key: Some(user.public()),
        methods: vec![v2],
    };
    let (again, rewrap) = gunnar_cli::crypt_key::open_with_rewrap(&answer, &how, &mut key).unwrap();
    assert_eq!((again.public(), rewrap.is_none()), (user.public(), true));
}