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