#![forbid(unsafe_code)]
use ciborium::{ser::into_writer, value::Value};
use serde_json::json;
use sha2::{Digest, Sha256};
use uselesskey_core::Factory;
pub const DOMAIN_WEBAUTHN_FIXTURE: &str = "uselesskey:webauthn:fixture:v1";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AttestationMode {
Packed,
SelfAttestation,
}
impl AttestationMode {
fn as_tag(self) -> &'static str {
match self {
Self::Packed => "packed",
Self::SelfAttestation => "self",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WebAuthnSpec {
pub rp_id: String,
pub challenge: Vec<u8>,
pub credential_id: Vec<u8>,
pub authenticator_model: String,
pub attestation_mode: AttestationMode,
}
impl WebAuthnSpec {
pub fn packed(rp_id: impl Into<String>, challenge: impl AsRef<[u8]>) -> Self {
Self {
rp_id: rp_id.into(),
challenge: challenge.as_ref().to_vec(),
credential_id: b"uk-credential-id".to_vec(),
authenticator_model: "UK-PASSKEY-MOCK".to_string(),
attestation_mode: AttestationMode::Packed,
}
}
pub fn stable_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
write_field(&mut out, "rp_id", self.rp_id.as_bytes());
write_field(&mut out, "challenge", &self.challenge);
write_field(&mut out, "credential_id", &self.credential_id);
write_field(
&mut out,
"authenticator_model",
self.authenticator_model.as_bytes(),
);
write_field(
&mut out,
"attestation_mode",
self.attestation_mode.as_tag().as_bytes(),
);
out
}
}
#[derive(Clone, Debug)]
pub struct RegistrationFixture {
pub spec: WebAuthnSpec,
pub client_data_json: Vec<u8>,
pub authenticator_data: Vec<u8>,
pub attestation_object: Vec<u8>,
pub rp_id_hash: [u8; 32],
pub sign_count: u32,
pub aaguid: [u8; 16],
}
#[derive(Clone, Debug)]
pub struct AssertionFixture {
pub spec: WebAuthnSpec,
pub client_data_json: Vec<u8>,
pub authenticator_data: Vec<u8>,
pub signature: Vec<u8>,
pub rp_id_hash: [u8; 32],
pub sign_count: u32,
}
pub trait WebAuthnFactoryExt {
fn webauthn_registration(
&self,
label: impl AsRef<str>,
spec: WebAuthnSpec,
) -> RegistrationFixture;
fn webauthn_assertion(&self, label: impl AsRef<str>, spec: WebAuthnSpec) -> AssertionFixture;
}
impl WebAuthnFactoryExt for Factory {
fn webauthn_registration(
&self,
label: impl AsRef<str>,
spec: WebAuthnSpec,
) -> RegistrationFixture {
let spec_bytes = spec.stable_bytes();
self.get_or_init(
DOMAIN_WEBAUTHN_FIXTURE,
label.as_ref(),
&spec_bytes,
"registration",
move |seed| build_registration(spec, *seed.bytes()),
)
.as_ref()
.clone()
}
fn webauthn_assertion(&self, label: impl AsRef<str>, spec: WebAuthnSpec) -> AssertionFixture {
let spec_bytes = spec.stable_bytes();
self.get_or_init(
DOMAIN_WEBAUTHN_FIXTURE,
label.as_ref(),
&spec_bytes,
"assertion",
move |seed| build_assertion(spec, *seed.bytes()),
)
.as_ref()
.clone()
}
}
fn build_registration(spec: WebAuthnSpec, seed: [u8; 32]) -> RegistrationFixture {
let rp_id_hash = sha256_arr(spec.rp_id.as_bytes());
let sign_count = deterministic_sign_count(&spec);
let aaguid = deterministic_aaguid(&seed, &spec.authenticator_model);
let client_data_json = build_client_data_json("webauthn.create", &spec.challenge, &spec.rp_id);
let credential_public_key = cbor_public_key(&seed);
let auth_data = build_authenticator_data(
rp_id_hash,
sign_count,
Some((
&aaguid,
&spec.credential_id,
credential_public_key.as_slice(),
)),
);
let att_stmt = Value::Map(vec![
(Value::Text("alg".to_string()), Value::Integer((-7).into())),
(
Value::Text("sig".to_string()),
Value::Bytes(mock_signature(
&seed,
&[auth_data.as_slice(), client_data_json.as_slice()].concat(),
b"attestation",
)),
),
]);
let root = Value::Map(vec![
(
Value::Text("fmt".to_string()),
Value::Text(
match spec.attestation_mode {
AttestationMode::Packed => "packed",
AttestationMode::SelfAttestation => "self",
}
.to_string(),
),
),
(Value::Text("attStmt".to_string()), att_stmt),
(
Value::Text("authData".to_string()),
Value::Bytes(auth_data.clone()),
),
]);
let mut attestation_object = Vec::new();
into_writer(&root, &mut attestation_object).expect("serialize attestation object");
RegistrationFixture {
spec,
client_data_json,
authenticator_data: auth_data,
attestation_object,
rp_id_hash,
sign_count,
aaguid,
}
}
fn build_assertion(spec: WebAuthnSpec, seed: [u8; 32]) -> AssertionFixture {
let rp_id_hash = sha256_arr(spec.rp_id.as_bytes());
let sign_count = deterministic_sign_count(&spec).saturating_add(1);
let client_data_json = build_client_data_json("webauthn.get", &spec.challenge, &spec.rp_id);
let auth_data = build_authenticator_data(rp_id_hash, sign_count, None);
let signature = mock_signature(
&seed,
&[auth_data.as_slice(), client_data_json.as_slice()].concat(),
b"assertion",
);
AssertionFixture {
spec,
client_data_json,
authenticator_data: auth_data,
signature,
rp_id_hash,
sign_count,
}
}
fn build_client_data_json(kind: &str, challenge: &[u8], rp_id: &str) -> Vec<u8> {
let val = json!({
"type": kind,
"challenge": base64url(challenge),
"origin": format!("https://{rp_id}"),
"crossOrigin": false
});
serde_json::to_vec(&val).expect("serialize clientDataJSON")
}
fn build_authenticator_data(
rp_id_hash: [u8; 32],
sign_count: u32,
attested: Option<(&[u8; 16], &[u8], &[u8])>,
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&rp_id_hash);
let mut flags: u8 = 0x01; if attested.is_some() {
flags |= 0x40; }
out.push(flags);
out.extend_from_slice(&sign_count.to_be_bytes());
if let Some((aaguid, credential_id, credential_public_key)) = attested {
out.extend_from_slice(aaguid);
out.extend_from_slice(&(credential_id.len() as u16).to_be_bytes());
out.extend_from_slice(credential_id);
out.extend_from_slice(credential_public_key);
}
out
}
fn cbor_public_key(seed: &[u8; 32]) -> Vec<u8> {
let x = sha256_arr(&[seed.as_slice(), b"x"].concat());
let y = sha256_arr(&[seed.as_slice(), b"y"].concat());
let map = Value::Map(
vec![
(Value::Integer(1.into()), Value::Integer(2.into())), (Value::Integer(3.into()), Value::Integer((-7).into())), (Value::Integer((-1).into()), Value::Integer(1.into())), (Value::Integer((-2).into()), Value::Bytes(x.to_vec())),
(Value::Integer((-3).into()), Value::Bytes(y.to_vec())),
]
.into_iter()
.collect(),
);
let mut out = Vec::new();
into_writer(&map, &mut out).expect("serialize credential public key");
out
}
fn deterministic_sign_count(spec: &WebAuthnSpec) -> u32 {
let digest = sha256_arr(&spec.stable_bytes());
u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]])
}
fn deterministic_aaguid(seed: &[u8; 32], model: &str) -> [u8; 16] {
let digest = sha256_arr(&[seed.as_slice(), model.as_bytes()].concat());
let mut aaguid = [0u8; 16];
aaguid.copy_from_slice(&digest[..16]);
aaguid
}
fn mock_signature(seed: &[u8; 32], body: &[u8], context: &[u8]) -> Vec<u8> {
let mut h = Sha256::new();
h.update(seed);
h.update(context);
h.update(body);
h.finalize().to_vec()
}
fn base64url(input: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
let mut i = 0;
while i + 3 <= input.len() {
let chunk = &input[i..i + 3];
let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | chunk[2] as u32;
out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
out.push(TABLE[(n & 0x3f) as usize] as char);
i += 3;
}
let rem = input.len() - i;
if rem == 1 {
let n = (input[i] as u32) << 16;
out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
} else if rem == 2 {
let n = ((input[i] as u32) << 16) | ((input[i + 1] as u32) << 8);
out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
}
out
}
fn sha256_arr(bytes: &[u8]) -> [u8; 32] {
let mut out = [0u8; 32];
out.copy_from_slice(&Sha256::digest(bytes));
out
}
fn write_field(out: &mut Vec<u8>, name: &str, value: &[u8]) {
out.extend_from_slice(name.as_bytes());
out.push(0x1f);
out.extend_from_slice(&(value.len() as u16).to_be_bytes());
out.extend_from_slice(value);
}
#[cfg(test)]
mod tests {
use ciborium::{de::from_reader, value::Value};
use uselesskey_core::Seed;
use super::*;
#[test]
fn registration_is_deterministic() {
let fx = Factory::deterministic(Seed::from_env_value("webauthn-det").unwrap());
let spec = WebAuthnSpec::packed("example.com", b"challenge-a");
let a = fx.webauthn_registration("alice", spec.clone());
let b = fx.webauthn_registration("alice", spec);
assert_eq!(a.attestation_object, b.attestation_object);
assert_eq!(a.sign_count, b.sign_count);
}
#[test]
fn attestation_object_is_cbor_map() {
let fx = Factory::random();
let reg = fx.webauthn_registration(
"alice",
WebAuthnSpec::packed("example.com", b"challenge-cbor"),
);
let v: Value = from_reader(reg.attestation_object.as_slice()).expect("parse cbor");
let m = match v {
Value::Map(entries) => entries,
_ => panic!("attestation object must be cbor map"),
};
assert!(m.iter().any(|(k, _)| *k == Value::Text("fmt".to_string())));
assert!(
m.iter()
.any(|(k, _)| *k == Value::Text("authData".to_string()))
);
}
#[test]
fn assertion_sign_count_monotonic_per_fixture() {
let fx = Factory::deterministic(Seed::from_env_value("webauthn-sign-count").unwrap());
let spec = WebAuthnSpec::packed("example.com", b"challenge-sign");
let reg = fx.webauthn_registration("alice", spec.clone());
let assertion = fx.webauthn_assertion("alice", spec);
assert_eq!(assertion.sign_count, reg.sign_count.saturating_add(1));
}
#[test]
fn client_data_contains_challenge() {
let fx = Factory::random();
let challenge = b"abc-123";
let reg = fx.webauthn_registration("alice", WebAuthnSpec::packed("example.com", challenge));
let json: serde_json::Value =
serde_json::from_slice(®.client_data_json).expect("parse clientDataJSON");
assert_eq!(json["challenge"], base64url(challenge));
assert_eq!(json["origin"], "https://example.com");
}
}