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