#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
use sha2::Digest;
use webauthn_kit::crypto::{
parse_cose_key, verify_cose_signature, CosePublicKey, COSE_ALG_ES256, COSE_ALG_RS256,
};
use webauthn_kit::policy::CredentialPolicy;
use webauthn_kit::{
base64_decode_urlsafe, base64_encode_urlsafe, check_sign_count, verify_authentication,
verify_registration, AttestationPolicy, AuthenticationParams, ChallengeStore, WebauthnConfig,
WebauthnError,
};
const RP_ID: &str = "localhost";
const ORIGIN: &str = "http://localhost:8080";
fn origins() -> Vec<String> {
vec![ORIGIN.to_string()]
}
fn build_attestation_object(auth_data: &[u8]) -> Vec<u8> {
let map = vec![
(
ciborium::Value::Text("fmt".to_string()),
ciborium::Value::Text("none".to_string()),
),
(
ciborium::Value::Text("attStmt".to_string()),
ciborium::Value::Map(vec![]),
),
(
ciborium::Value::Text("authData".to_string()),
ciborium::Value::Bytes(auth_data.to_vec()),
),
];
let mut buf = Vec::new();
ciborium::ser::into_writer(&ciborium::Value::Map(map), &mut buf).unwrap();
buf
}
fn build_cose_ec2_key(x: &[u8], y: &[u8]) -> Vec<u8> {
let map = vec![
(
ciborium::Value::Integer(1.into()),
ciborium::Value::Integer(2.into()),
),
(
ciborium::Value::Integer(3.into()),
ciborium::Value::Integer((-7).into()),
),
(
ciborium::Value::Integer((-1).into()),
ciborium::Value::Integer(1.into()),
),
(
ciborium::Value::Integer((-2).into()),
ciborium::Value::Bytes(x.to_vec()),
),
(
ciborium::Value::Integer((-3).into()),
ciborium::Value::Bytes(y.to_vec()),
),
];
let mut buf = Vec::new();
ciborium::ser::into_writer(&ciborium::Value::Map(map), &mut buf).unwrap();
buf
}
fn build_auth_data(
rp_id: &str,
flags: u8,
sign_count: u32,
credential_id: &[u8],
cose_key: &[u8],
) -> Vec<u8> {
let mut auth_data = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
auth_data.push(flags);
auth_data.extend_from_slice(&sign_count.to_be_bytes());
if flags & 0x40 != 0 {
auth_data.extend_from_slice(&[0u8; 16]); auth_data.extend_from_slice(&(credential_id.len() as u16).to_be_bytes());
auth_data.extend_from_slice(credential_id);
auth_data.extend_from_slice(cose_key);
}
auth_data
}
#[test]
fn registration_authentication_roundtrip_es256() {
use ring::signature::{EcdsaKeyPair, KeyPair as _};
let rng = ring::rand::SystemRandom::new();
let pkcs8 =
EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
.unwrap();
let key_pair = EcdsaKeyPair::from_pkcs8(
&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let pub_bytes = key_pair.public_key().as_ref();
let cose_key = build_cose_ec2_key(&pub_bytes[1..33], &pub_bytes[33..65]);
let config = WebauthnConfig {
rp_id: RP_ID.to_string(),
rp_name: "Kit".to_string(),
rp_origins: origins(),
allowed_algorithms: vec![-7, -257],
attestation: webauthn_kit::attestation::AttestationPolicy::default(),
credential_policy: webauthn_kit::policy::CredentialPolicy::default(),
resident_key: webauthn_kit::policy::ResidentKeyPolicy::Preferred,
attestation_conveyance: webauthn_kit::policy::AttestationConveyance::None,
challenge_timeout_secs: 300,
};
let mut store = ChallengeStore::new();
let (challenge_id, options) =
store.generate_registration_challenge(&config, "alice", "Alice", &[]);
let raw_challenge = base64_decode_urlsafe(&challenge_id).unwrap();
store.store_registration_challenge(&challenge_id, "alice", raw_challenge.clone());
let (_, challenge_bytes) = store
.consume_registration_challenge(&challenge_id, 300)
.unwrap();
assert_eq!(base64_encode_urlsafe(&challenge_bytes), options.challenge);
let credential_id = vec![0xDE, 0xAD, 0xBE, 0xEF];
let auth_data = build_auth_data(RP_ID, 0x45, 0, &credential_id, &cose_key); let att_obj = build_attestation_object(&auth_data);
let client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": options.challenge,
"origin": ORIGIN,
});
let registration = verify_registration(
&challenge_bytes,
&base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
&base64_encode_urlsafe(&att_obj),
"", RP_ID,
&origins(),
&AttestationPolicy::default(),
&webauthn_kit::policy::CredentialPolicy::default(),
)
.expect("registration must verify");
assert_eq!(
registration.credential_id,
base64_encode_urlsafe(&credential_id)
);
assert_eq!(registration.attestation_format, "none");
assert!(registration.user_verified);
assert!(registration.device_name.contains("ES256"));
let (challenge_id2, options2) =
store.generate_registration_challenge(&config, "alice", "Alice", &[]);
let raw_challenge2 = base64_decode_urlsafe(&challenge_id2).unwrap();
store.store_registration_challenge(&challenge_id2, "alice", raw_challenge2);
let (_, challenge_bytes2) = store
.consume_registration_challenge(&challenge_id2, 300)
.unwrap();
let client_data2 = serde_json::json!({
"type": "webauthn.create",
"challenge": options2.challenge,
"origin": ORIGIN,
});
let duplicate = verify_registration(
&challenge_bytes2,
&base64_encode_urlsafe(&serde_json::to_vec(&client_data2).unwrap()),
&base64_encode_urlsafe(&build_attestation_object(&build_auth_data(
RP_ID,
0x45,
0,
&credential_id,
&cose_key,
))),
®istration.credential_id,
RP_ID,
&origins(),
&AttestationPolicy::default(),
&webauthn_kit::policy::CredentialPolicy::default(),
);
assert!(matches!(
duplicate,
Err(WebauthnError::DuplicateCredential(_))
));
let credential_id_b64 = registration.credential_id.clone();
let (auth_challenge_id, auth_options) =
store.generate_authentication_challenge(&config, vec![credential_id_b64.clone()]);
let raw_auth_challenge = base64_decode_urlsafe(&auth_challenge_id).unwrap();
store.store_authentication_challenge(
&auth_challenge_id,
"alice",
raw_auth_challenge,
vec![credential_id_b64.clone()],
);
let (_, auth_challenge, allowed) = store
.consume_authentication_challenge(&auth_challenge_id, 300)
.unwrap();
assert_eq!(allowed, vec![credential_id_b64.clone()]);
let assertion_auth_data = build_auth_data(RP_ID, 0x01, 1, &[], &[]); let assertion_client_data = serde_json::json!({
"type": "webauthn.get",
"challenge": auth_options.challenge,
"origin": ORIGIN,
});
let client_data_bytes = serde_json::to_vec(&assertion_client_data).unwrap();
let mut signed_data = assertion_auth_data.clone();
signed_data.extend_from_slice(&sha2::Sha256::digest(&client_data_bytes));
let signature = key_pair.sign(&rng, &signed_data).unwrap();
let result = verify_authentication(&AuthenticationParams {
challenge_bytes: auth_challenge,
client_data_json_b64: base64_encode_urlsafe(&client_data_bytes),
authenticator_data_b64: base64_encode_urlsafe(&assertion_auth_data),
signature_b64: base64_encode_urlsafe(signature.as_ref()),
credential_id_b64: registration.credential_id.clone(),
public_key_cose: cose_key.clone(),
current_sign_count: 0, allowed_credential_ids: vec![registration.credential_id.clone()],
rp_id: RP_ID.to_string(),
rp_origins: origins(),
policy: webauthn_kit::policy::CredentialPolicy::default(),
})
.expect("authentication must verify");
assert_eq!(result.credential_id, registration.credential_id);
assert_eq!(result.new_sign_count, 1);
assert!(!result.user_verified);
check_sign_count(result.new_sign_count, 2).expect("counter must advance");
check_sign_count(result.new_sign_count + 1, result.new_sign_count)
.expect_err("decreasing counter must be rejected");
}
#[test]
fn rs256_key_parse_and_dispatch() {
let n = vec![0xABu8; 256];
let e = vec![0x01, 0x00, 0x01];
let map = vec![
(
ciborium::Value::Integer(1.into()),
ciborium::Value::Integer(3.into()),
),
(
ciborium::Value::Integer(3.into()),
ciborium::Value::Integer((-257).into()),
),
(
ciborium::Value::Integer((-1).into()),
ciborium::Value::Bytes(n.clone()),
),
(
ciborium::Value::Integer((-2).into()),
ciborium::Value::Bytes(e.clone()),
),
];
let mut buf = Vec::new();
ciborium::ser::into_writer(&ciborium::Value::Map(map), &mut buf).unwrap();
let (alg, key) = parse_cose_key(&buf).unwrap();
assert_eq!(alg, COSE_ALG_RS256);
let result = verify_cose_signature(COSE_ALG_RS256, &key, b"message", &[0x30u8; 256]);
assert!(matches!(
result,
Err(WebauthnError::SignatureVerificationFailed)
));
let result = verify_cose_signature(-7, &key, b"message", &[0x30u8; 256]);
assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
}
#[test]
fn fixed_vector_challenge_echo() {
let challenge = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D,
0x1E, 0x1F,
];
let client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": base64_encode_urlsafe(&challenge),
"origin": ORIGIN,
});
let result = verify_registration(
&challenge,
&base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
&base64_encode_urlsafe(&[0xFF; 8]),
"",
RP_ID,
&origins(),
&AttestationPolicy::default(),
&webauthn_kit::policy::CredentialPolicy::default(),
);
assert!(matches!(result, Err(WebauthnError::AttestationError(_))));
let mut wrong = challenge;
wrong[0] ^= 0x01;
let client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": base64_encode_urlsafe(&wrong),
"origin": ORIGIN,
});
let result = verify_registration(
&challenge,
&base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
&base64_encode_urlsafe(&[0xFF; 8]),
"",
RP_ID,
&origins(),
&AttestationPolicy::default(),
&webauthn_kit::policy::CredentialPolicy::default(),
);
assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
}
#[test]
fn options_serde_roundtrip() {
let config = WebauthnConfig {
rp_id: "example.com".to_string(),
rp_name: "Example".to_string(),
rp_origins: vec!["https://example.com".to_string()],
allowed_algorithms: vec![-7, -257],
attestation: webauthn_kit::attestation::AttestationPolicy::default(),
credential_policy: webauthn_kit::policy::CredentialPolicy::default(),
resident_key: webauthn_kit::policy::ResidentKeyPolicy::Preferred,
attestation_conveyance: webauthn_kit::policy::AttestationConveyance::None,
challenge_timeout_secs: 300,
};
let store = ChallengeStore::new();
let (_, reg) =
store.generate_registration_challenge(&config, "alice", "Alice", &["cred-9".to_string()]);
let json = serde_json::to_string(®).unwrap();
let back: webauthn_kit::RegistrationOptions = serde_json::from_str(&json).unwrap();
assert_eq!(back.rp.id, "example.com");
assert_eq!(back.exclude_credentials.len(), 1);
let (_, auth) = store.generate_authentication_challenge(&config, vec!["cred-9".to_string()]);
let json = serde_json::to_string(&auth).unwrap();
let back: webauthn_kit::AuthenticationOptions = serde_json::from_str(&json).unwrap();
assert_eq!(back.rp_id, "example.com");
let credential = webauthn_kit::WebauthnCredential {
backup_eligible: true,
backup_state: true,
credential_id: "abc".to_string(),
public_key_cose: vec![1, 2, 3],
sign_count: 7,
device_name: "YubiKey 5".to_string(),
registered_at: 1700000000,
last_used_at: 1700000001,
attestation_format: "none".to_string(),
user_verified: true,
};
let json = serde_json::to_string(&credential).unwrap();
let back: webauthn_kit::WebauthnCredential = serde_json::from_str(&json).unwrap();
assert_eq!(back.sign_count, 7);
}
#[test]
fn registration_authentication_roundtrip_es384() {
use ring::signature::{EcdsaKeyPair, KeyPair as _, ECDSA_P384_SHA384_FIXED_SIGNING};
let rng = ring::rand::SystemRandom::new();
let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, &rng).unwrap();
let key_pair =
EcdsaKeyPair::from_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, pkcs8.as_ref(), &rng).unwrap();
let pub_bytes = key_pair.public_key().as_ref();
let cose_key = build_cose_ec2_key_384(&pub_bytes[1..49], &pub_bytes[49..97]);
let config = WebauthnConfig {
allowed_algorithms: vec![-35],
..test_config()
};
let mut store = ChallengeStore::new();
let (challenge_id, options) = store.generate_registration_challenge(&config, "bob", "Bob", &[]);
let raw = base64_decode_urlsafe(&challenge_id).unwrap();
store.store_registration_challenge(&challenge_id, "bob", raw);
let (_, challenge_bytes) = store
.consume_registration_challenge(&challenge_id, 300)
.unwrap();
let credential_id = vec![0x38, 0x34, 0xAD, 0x01];
let auth_data = build_auth_data(RP_ID, 0x45, 0, &credential_id, &cose_key); let client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": options.challenge,
"origin": ORIGIN,
});
let registration = verify_registration(
&challenge_bytes,
&base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
&base64_encode_urlsafe(&build_attestation_object(&auth_data)),
"",
RP_ID,
&origins(),
&AttestationPolicy::default(),
&CredentialPolicy::default(),
)
.expect("ES384 registration must verify");
assert!(registration.device_name.contains("ES384"));
let (auth_challenge_id, auth_options) =
store.generate_authentication_challenge(&config, vec![registration.credential_id.clone()]);
let raw = base64_decode_urlsafe(&auth_challenge_id).unwrap();
store.store_authentication_challenge(
&auth_challenge_id,
"bob",
raw,
vec![registration.credential_id.clone()],
);
let (_, auth_challenge, _) = store
.consume_authentication_challenge(&auth_challenge_id, 300)
.unwrap();
let assertion_auth_data = build_auth_data(RP_ID, 0x01, 1, &[], &[]);
let assertion_client_data = serde_json::json!({
"type": "webauthn.get",
"challenge": auth_options.challenge,
"origin": ORIGIN,
});
let client_data_bytes = serde_json::to_vec(&assertion_client_data).unwrap();
let mut signed_data = assertion_auth_data.clone();
signed_data.extend_from_slice(&sha2::Sha256::digest(&client_data_bytes));
let signature = key_pair.sign(&rng, &signed_data).unwrap();
let result = verify_authentication(&AuthenticationParams {
challenge_bytes: auth_challenge,
client_data_json_b64: base64_encode_urlsafe(&client_data_bytes),
authenticator_data_b64: base64_encode_urlsafe(&assertion_auth_data),
signature_b64: base64_encode_urlsafe(signature.as_ref()),
credential_id_b64: registration.credential_id.clone(),
public_key_cose: cose_key,
current_sign_count: 0,
allowed_credential_ids: vec![registration.credential_id.clone()],
rp_id: RP_ID.to_string(),
rp_origins: origins(),
policy: CredentialPolicy::default(),
})
.expect("ES384 authentication must verify");
assert_eq!(result.new_sign_count, 1);
}
fn build_cose_ec2_key_384(x: &[u8], y: &[u8]) -> Vec<u8> {
let map = vec![
(
ciborium::Value::Integer(1.into()),
ciborium::Value::Integer(2.into()),
),
(
ciborium::Value::Integer(3.into()),
ciborium::Value::Integer((-35).into()),
),
(
ciborium::Value::Integer((-1).into()),
ciborium::Value::Integer(2.into()),
),
(
ciborium::Value::Integer((-2).into()),
ciborium::Value::Bytes(x.to_vec()),
),
(
ciborium::Value::Integer((-3).into()),
ciborium::Value::Bytes(y.to_vec()),
),
];
let mut buf = Vec::new();
ciborium::ser::into_writer(&ciborium::Value::Map(map), &mut buf).unwrap();
buf
}
fn test_config() -> WebauthnConfig {
WebauthnConfig {
rp_id: RP_ID.to_string(),
rp_name: "Kit".to_string(),
rp_origins: origins(),
allowed_algorithms: vec![-7, -257],
attestation: webauthn_kit::attestation::AttestationPolicy::default(),
credential_policy: webauthn_kit::policy::CredentialPolicy::default(),
resident_key: webauthn_kit::policy::ResidentKeyPolicy::Preferred,
attestation_conveyance: webauthn_kit::policy::AttestationConveyance::None,
challenge_timeout_secs: 300,
}
}
#[test]
fn eddsa_cose_key_parse_and_dispatch() {
use ring::signature::{Ed25519KeyPair, KeyPair as _};
let rng = ring::rand::SystemRandom::new();
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let map = vec![
(
ciborium::Value::Integer(1.into()),
ciborium::Value::Integer(1.into()),
),
(
ciborium::Value::Integer(3.into()),
ciborium::Value::Integer((-8).into()),
),
(
ciborium::Value::Integer((-1).into()),
ciborium::Value::Integer(6.into()),
),
(
ciborium::Value::Integer((-2).into()),
ciborium::Value::Bytes(key_pair.public_key().as_ref().to_vec()),
),
];
let mut buf = Vec::new();
ciborium::ser::into_writer(&ciborium::Value::Map(map), &mut buf).unwrap();
let (alg, key) = parse_cose_key(&buf).unwrap();
assert_eq!(alg, -8);
let message = b"ed25519 dispatch vector";
let signature = key_pair.sign(message);
verify_cose_signature(-8, &key, message, signature.as_ref())
.expect("valid Ed25519 signature must verify");
let result = verify_cose_signature(-7, &key, message, signature.as_ref());
assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
}
#[test]
fn base64url_no_pad_alphabet() {
for len in 0..40usize {
let data: Vec<u8> = (0..len as u8).collect();
let enc = base64_encode_urlsafe(&data);
assert!(!enc.contains('='), "no padding expected");
assert!(!enc.contains('+'), "URL-safe alphabet expected");
assert!(!enc.contains('/'), "URL-safe alphabet expected");
let dec = base64_decode_urlsafe(&enc).unwrap();
assert_eq!(dec, data);
}
}
#[test]
fn parses_the_specification_credential_public_key_bytes() {
const SPEC_X_HEX: &str = "65eda5a12577c2bae829437fe338701a10aaa375e1bb5b5de108de439c08551d";
const SPEC_Y_HEX: &str = "1e52ed75701163f7f9e40ddf9f341b3dc9ba860af7e0ca7ca7e9eecd0084d19c";
let spec_bytes: Vec<u8> = vec![
0xA5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20, 0x65, 0xed, 0xa5, 0xa1, 0x25,
0x77, 0xc2, 0xba, 0xe8, 0x29, 0x43, 0x7f, 0xe3, 0x38, 0x70, 0x1a, 0x10, 0xaa, 0xa3, 0x75,
0xe1, 0xbb, 0x5b, 0x5d, 0xe1, 0x08, 0xde, 0x43, 0x9c, 0x08, 0x55, 0x1d, 0x22, 0x58, 0x20,
0x1e, 0x52, 0xed, 0x75, 0x70, 0x11, 0x63, 0xf7, 0xf9, 0xe4, 0x0d, 0xdf, 0x9f, 0x34, 0x1b,
0x3d, 0xc9, 0xba, 0x86, 0x0a, 0xf7, 0xe0, 0xca, 0x7c, 0xa7, 0xe9, 0xee, 0xcd, 0x00, 0x84,
0xd1, 0x9c,
];
let (alg, key) = parse_cose_key(&spec_bytes).expect("the specification's key must parse");
assert_eq!(alg, COSE_ALG_ES256);
match key {
CosePublicKey::Ec2 { x, y } => {
let expected_x: Vec<u8> = (0..32)
.map(|i| u8::from_str_radix(&SPEC_X_HEX[i * 2..i * 2 + 2], 16).expect("hex"))
.collect();
let expected_y: Vec<u8> = (0..32)
.map(|i| u8::from_str_radix(&SPEC_Y_HEX[i * 2..i * 2 + 2], 16).expect("hex"))
.collect();
assert_eq!(x, expected_x, "x coordinate matches the published key");
assert_eq!(y, expected_y, "y coordinate matches the published key");
}
other => panic!("expected an EC2 key, got {other:?}"),
}
assert!(
verify_cose_signature(
COSE_ALG_ES256,
&CosePublicKey::Ec2 {
x: vec![0; 32],
y: vec![0; 32]
},
b"message",
&[0u8; 64]
)
.is_err(),
"a bogus signature is refused rather than accepted"
);
}