use super::error::PasskeyError;
use super::verify;
#[must_use]
pub fn generate_challenge() -> Vec<u8> {
use rand::RngCore as _;
let mut buf = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut buf);
buf.to_vec()
}
#[derive(Debug, Clone)]
pub struct RegistrationOutcome {
pub credential_id: String,
pub cose_public_key: Vec<u8>,
pub sign_count: i64,
}
pub fn verify_registration(
challenge: &[u8],
rp_id: &str,
allowed_origins: &[String],
client_data_json: &[u8],
attestation_object: &[u8],
) -> Result<RegistrationOutcome, PasskeyError> {
verify::parse_and_verify_client_data(
client_data_json,
"webauthn.create",
challenge,
allowed_origins,
)?;
let value: ciborium::value::Value = ciborium::de::from_reader(attestation_object)
.map_err(|e| PasskeyError::Cbor(e.to_string()))?;
let auth_data_bytes = extract_auth_data(&value)?;
let ad = verify::parse_authenticator_data(&auth_data_bytes)?;
verify::verify_rp_id(&ad, rp_id)?;
if !ad.user_present() {
return Err(PasskeyError::UserNotPresent);
}
let attested = ad.attested.ok_or_else(|| {
PasskeyError::AuthData("registration authData has no attested credential data".into())
})?;
verify::cose_es256_key(&attested.cose_public_key)?;
Ok(RegistrationOutcome {
credential_id: verify::b64url_encode(&attested.credential_id),
cose_public_key: attested.cose_public_key,
sign_count: i64::from(ad.sign_count),
})
}
#[allow(clippy::too_many_arguments)]
pub fn verify_authentication(
challenge: &[u8],
rp_id: &str,
allowed_origins: &[String],
stored_cose_public_key: &[u8],
stored_sign_count: i64,
client_data_json: &[u8],
authenticator_data: &[u8],
signature_der: &[u8],
) -> Result<i64, PasskeyError> {
verify::parse_and_verify_client_data(
client_data_json,
"webauthn.get",
challenge,
allowed_origins,
)?;
let ad = verify::parse_authenticator_data(authenticator_data)?;
verify::verify_rp_id(&ad, rp_id)?;
if !ad.user_present() {
return Err(PasskeyError::UserNotPresent);
}
verify::verify_es256_assertion(
stored_cose_public_key,
authenticator_data,
client_data_json,
signature_der,
)?;
let new = i64::from(ad.sign_count);
if new != 0 && stored_sign_count != 0 && new <= stored_sign_count {
return Err(PasskeyError::CounterRegression);
}
Ok(new)
}
#[must_use]
pub fn registration_options_json(
rp_id: &str,
rp_name: &str,
user_id: &[u8],
user_name: &str,
challenge: &[u8],
exclude_credential_ids: &[String],
) -> serde_json::Value {
let exclude: Vec<serde_json::Value> = exclude_credential_ids
.iter()
.map(|id| serde_json::json!({ "type": "public-key", "id": id }))
.collect();
serde_json::json!({
"challenge": verify::b64url_encode(challenge),
"rp": { "id": rp_id, "name": rp_name },
"user": {
"id": verify::b64url_encode(user_id),
"name": user_name,
"displayName": user_name,
},
"pubKeyCredParams": [ { "type": "public-key", "alg": -7 } ],
"timeout": 60000,
"attestation": "none",
"excludeCredentials": exclude,
"authenticatorSelection": { "userVerification": "preferred", "residentKey": "preferred" },
})
}
#[must_use]
pub fn authentication_options_json(
rp_id: &str,
challenge: &[u8],
allow_credential_ids: &[String],
) -> serde_json::Value {
let allow: Vec<serde_json::Value> = allow_credential_ids
.iter()
.map(|id| serde_json::json!({ "type": "public-key", "id": id }))
.collect();
serde_json::json!({
"challenge": verify::b64url_encode(challenge),
"rpId": rp_id,
"timeout": 60000,
"userVerification": "preferred",
"allowCredentials": allow,
})
}
fn extract_auth_data(value: &ciborium::value::Value) -> Result<Vec<u8>, PasskeyError> {
use ciborium::value::Value;
let Value::Map(entries) = value else {
return Err(PasskeyError::AuthData(
"attestationObject is not a CBOR map".into(),
));
};
for (k, v) in entries {
if let Value::Text(key) = k {
if key == "authData" {
if let Value::Bytes(b) = v {
return Ok(b.clone());
}
return Err(PasskeyError::AuthData(
"authData is not a byte string".into(),
));
}
}
}
Err(PasskeyError::AuthData(
"attestationObject has no authData".into(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use ciborium::value::{Integer, Value};
use p256::ecdsa::{signature::Signer as _, Signature, SigningKey};
use sha2::{Digest, Sha256};
fn os_rng() -> p256::elliptic_curve::rand_core::OsRng {
p256::elliptic_curve::rand_core::OsRng
}
fn cose_key_of(vk: &p256::ecdsa::VerifyingKey) -> Vec<u8> {
let pt = vk.to_encoded_point(false);
let map = Value::Map(vec![
(
Value::Integer(Integer::from(1)),
Value::Integer(Integer::from(2)),
),
(
Value::Integer(Integer::from(3)),
Value::Integer(Integer::from(-7)),
),
(
Value::Integer(Integer::from(-1)),
Value::Integer(Integer::from(1)),
),
(
Value::Integer(Integer::from(-2)),
Value::Bytes(pt.x().unwrap().to_vec()),
),
(
Value::Integer(Integer::from(-3)),
Value::Bytes(pt.y().unwrap().to_vec()),
),
]);
let mut out = Vec::new();
ciborium::ser::into_writer(&map, &mut out).unwrap();
out
}
fn rp_hash(rp_id: &str) -> [u8; 32] {
let mut h = Sha256::new();
h.update(rp_id.as_bytes());
h.finalize().into()
}
fn reg_auth_data(rp_id: &str, cred_id: &[u8], cose: &[u8], count: u32) -> Vec<u8> {
let mut d = Vec::new();
d.extend_from_slice(&rp_hash(rp_id));
d.push(0b0100_0001); d.extend_from_slice(&count.to_be_bytes());
d.extend_from_slice(&[0u8; 16]); #[allow(clippy::cast_possible_truncation)]
d.extend_from_slice(&(cred_id.len() as u16).to_be_bytes());
d.extend_from_slice(cred_id);
d.extend_from_slice(cose);
d
}
fn attestation_object(auth_data: &[u8]) -> Vec<u8> {
let map = Value::Map(vec![
(Value::Text("fmt".into()), Value::Text("none".into())),
(Value::Text("attStmt".into()), Value::Map(vec![])),
(
Value::Text("authData".into()),
Value::Bytes(auth_data.to_vec()),
),
]);
let mut out = Vec::new();
ciborium::ser::into_writer(&map, &mut out).unwrap();
out
}
fn client_data(ty: &str, challenge: &[u8], origin: &str) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"type": ty,
"challenge": verify::b64url_encode(challenge),
"origin": origin,
}))
.unwrap()
}
#[test]
fn full_register_then_authenticate_round_trip() {
let rp_id = "example.com";
let origins = vec!["https://example.com".to_owned()];
let sk = SigningKey::random(&mut os_rng());
let cose = cose_key_of(sk.verifying_key());
let cred_id = b"credential-handle-1";
let reg_challenge = generate_challenge();
let reg_ad = reg_auth_data(rp_id, cred_id, &cose, 1);
let att_obj = attestation_object(®_ad);
let reg_client = client_data("webauthn.create", ®_challenge, "https://example.com");
let outcome =
verify_registration(®_challenge, rp_id, &origins, ®_client, &att_obj).unwrap();
assert_eq!(outcome.credential_id, verify::b64url_encode(cred_id));
assert_eq!(outcome.sign_count, 1);
verify::cose_es256_key(&outcome.cose_public_key).unwrap();
let auth_challenge = generate_challenge();
let auth_ad = {
let mut d = Vec::new();
d.extend_from_slice(&rp_hash(rp_id));
d.push(0b0000_0001); d.extend_from_slice(&2u32.to_be_bytes()); d
};
let auth_client = client_data("webauthn.get", &auth_challenge, "https://example.com");
let mut signed = auth_ad.clone();
let mut ch = Sha256::new();
ch.update(&auth_client);
signed.extend_from_slice(&ch.finalize());
let sig: Signature = sk.sign(&signed);
let new_count = verify_authentication(
&auth_challenge,
rp_id,
&origins,
&outcome.cose_public_key,
outcome.sign_count,
&auth_client,
&auth_ad,
sig.to_der().as_bytes(),
)
.unwrap();
assert_eq!(new_count, 2);
}
#[test]
fn counter_regression_is_rejected() {
let rp_id = "example.com";
let origins = vec!["https://example.com".to_owned()];
let sk = SigningKey::random(&mut os_rng());
let cose = cose_key_of(sk.verifying_key());
let challenge = generate_challenge();
let mut ad = Vec::new();
ad.extend_from_slice(&rp_hash(rp_id));
ad.push(0b0000_0001);
ad.extend_from_slice(&3u32.to_be_bytes()); let client = client_data("webauthn.get", &challenge, "https://example.com");
let mut signed = ad.clone();
let mut h = Sha256::new();
h.update(&client);
signed.extend_from_slice(&h.finalize());
let sig: Signature = sk.sign(&signed);
let r = verify_authentication(
&challenge,
rp_id,
&origins,
&cose,
5,
&client,
&ad,
sig.to_der().as_bytes(),
);
assert!(matches!(r, Err(PasskeyError::CounterRegression)));
}
#[test]
fn options_json_has_required_shape() {
let reg =
registration_options_json("example.com", "Example", b"user1", "alice", b"chal", &[]);
assert_eq!(reg["rp"]["id"], "example.com");
assert_eq!(reg["pubKeyCredParams"][0]["alg"], -7);
let auth = authentication_options_json("example.com", b"chal", &["abc".to_owned()]);
assert_eq!(auth["rpId"], "example.com");
assert_eq!(auth["allowCredentials"][0]["id"], "abc");
}
}