use base64::{
Engine as _,
engine::general_purpose::STANDARD_NO_PAD,
};
use ciborium::Value;
use rand::Rng as _;
use crate::{
cbor,
cmd::MakeCredentialOptions,
cose::{
self,
CredentialPublicKey,
},
error::{
Error,
Result,
},
hid::Transport,
pin::{
self,
PinSession,
},
};
pub const COMMAND: u8 = 0x01;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum CredProtect {
UvOptional = 1,
UvOptionalWithList = 2,
UvRequired = 3,
}
impl CredProtect {
#[must_use]
pub const fn as_u8(self) -> u8 {
self as u8
}
}
impl From<CredProtect> for u8 {
fn from(level: CredProtect) -> Self {
level.as_u8()
}
}
#[derive(Clone, Debug)]
pub struct AttestationObject {
pub fmt: String,
pub auth_data: Vec<u8>,
pub att_stmt: Vec<u8>,
}
#[derive(Clone, Debug, Default)]
pub struct CredentialExtensions {
pub cred_blob_set: bool,
pub cred_protect: Option<u8>,
pub min_pin_length: Option<u32>,
}
#[derive(Clone, Debug)]
pub struct Credential {
pub id: Vec<u8>,
pub public_key: CredentialPublicKey,
pub attestation: AttestationObject,
pub large_blob_key: Option<Vec<u8>>,
pub extensions: CredentialExtensions,
}
pub fn call(
transport: &mut Transport,
rp_id: &str,
client_data_hash: &[u8; 32],
opts: &MakeCredentialOptions<'_>,
) -> Result<Credential> {
let mut user_id = [0_u8; 32];
rand::rng().fill_bytes(&mut user_id);
let mut user_name_bytes = [0_u8; 6];
rand::rng().fill_bytes(&mut user_name_bytes);
let user_name = STANDARD_NO_PAD.encode(user_name_bytes);
let pin_auth = match opts.pin {
Some(pin_str) => {
let session = PinSession::establish(transport)?;
let token = session.get_pin_token(transport, pin_str)?;
Some(token.auth_param(client_data_hash))
},
None => None,
};
let mut extensions = vec![(Value::Text("hmac-secret".into()), Value::Bool(true))];
if let Some(level) = opts.cred_protect {
extensions.push((
Value::Text("credProtect".into()),
Value::Integer(level.as_u8().into()),
));
}
if let Some(blob) = opts.cred_blob {
if blob.len() > 32 {
return Err(Error::Pin("credBlob exceeds 32 bytes"));
}
extensions.push((Value::Text("credBlob".into()), Value::Bytes(blob.to_vec())));
}
if opts.large_blob_key {
extensions.push((Value::Text("largeBlobKey".into()), Value::Bool(true)));
}
if opts.min_pin_length {
extensions.push((Value::Text("minPinLength".into()), Value::Bool(true)));
}
let mut request = vec![
(
Value::Integer(1.into()),
Value::Bytes(client_data_hash.to_vec()),
),
(
Value::Integer(2.into()),
Value::Map(vec![(Value::Text("id".into()), Value::Text(rp_id.into()))]),
),
(
Value::Integer(3.into()),
Value::Map(vec![
(Value::Text("id".into()), Value::Bytes(user_id.to_vec())),
(Value::Text("name".into()), Value::Text(user_name)),
]),
),
(
Value::Integer(4.into()),
Value::Array(vec![Value::Map(vec![
(
Value::Text("alg".into()),
Value::Integer(opts.algorithm.cose_id().into()),
),
(Value::Text("type".into()), Value::Text("public-key".into())),
])]),
),
(Value::Integer(6.into()), Value::Map(extensions)),
(
Value::Integer(7.into()),
Value::Map({
let mut options = vec![(Value::Text("uv".into()), Value::Bool(false))];
if opts.resident_key {
options.push((Value::Text("rk".into()), Value::Bool(true)));
}
options
}),
),
];
if let Some(auth) = pin_auth {
request.push((Value::Integer(8.into()), Value::Bytes(auth.to_vec())));
request.push((
Value::Integer(9.into()),
Value::Integer(pin::PROTOCOL_V1.into()),
));
}
let payload = {
let mut bytes = vec![COMMAND];
bytes.extend(cbor::encode(&Value::Map(request))?);
bytes
};
let response = transport.transact(&payload, None)?;
parse_response(&response)
}
fn parse_response(response: &[u8]) -> Result<Credential> {
let Value::Map(mut entries) = cbor::decode(response)? else {
return Err(Error::Parse("makeCredential response not a CBOR map"));
};
let Some(Value::Text(fmt)) = cbor::take_int_field(&mut entries, 0x01) else {
return Err(Error::Parse("makeCredential response missing fmt"));
};
let Some(Value::Bytes(auth_data)) = cbor::take_int_field(&mut entries, 0x02) else {
return Err(Error::Parse("makeCredential response missing authData"));
};
let att_stmt_value = cbor::take_int_field(&mut entries, 0x03)
.ok_or(Error::Parse("makeCredential response missing attStmt"))?;
let att_stmt = cbor::encode(&att_stmt_value)?;
let large_blob_key = match cbor::take_int_field(&mut entries, 0x04) {
Some(Value::Bytes(bytes)) => Some(bytes),
_ => None,
};
let (id, public_key, extensions) = parse_credential(&auth_data)?;
Ok(Credential {
id,
public_key,
attestation: AttestationObject {
fmt,
auth_data,
att_stmt,
},
large_blob_key,
extensions,
})
}
fn parse_credential(
auth_data: &[u8],
) -> Result<(Vec<u8>, CredentialPublicKey, CredentialExtensions)> {
if auth_data.len() < 32 + 1 + 4 + 16 + 2 {
return Err(Error::Parse("authData too short for attested credential"));
}
let flags = auth_data[32];
if flags & 0x40 == 0 {
return Err(Error::Parse(
"authData missing AT flag, no attested credential data",
));
}
let cred_id_len = u16::from_be_bytes([auth_data[53], auth_data[54]]) as usize;
let id_start = 55_usize;
let id_end = id_start
.checked_add(cred_id_len)
.ok_or(Error::Parse("credential id length overflow"))?;
if id_end > auth_data.len() {
return Err(Error::Parse("credential id length exceeds authData"));
}
let id = auth_data[id_start..id_end].to_vec();
let (public_key, ext_offset) = cose::parse_authdata_pubkey(auth_data, id_end)?;
let extensions = if flags & 0x80 != 0 && ext_offset < auth_data.len() {
parse_extensions_echo(&auth_data[ext_offset..])?
} else {
CredentialExtensions::default()
};
Ok((id, public_key, extensions))
}
fn parse_extensions_echo(bytes: &[u8]) -> Result<CredentialExtensions> {
let Value::Map(entries) = cbor::decode(bytes)? else {
return Ok(CredentialExtensions::default());
};
let mut out = CredentialExtensions::default();
for &(ref key, ref value) in &entries {
let Some(name) = key.as_text() else { continue };
match name {
"credBlob" => out.cred_blob_set = value.as_bool().unwrap_or(false),
"credProtect" => {
out.cred_protect = value
.as_integer()
.map(Into::<i128>::into)
.and_then(|i| u8::try_from(i).ok());
},
"minPinLength" => {
out.min_pin_length = value
.as_integer()
.map(Into::<i128>::into)
.and_then(|i| u32::try_from(i).ok());
},
_ => {},
}
}
Ok(out)
}