use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use p256::ecdsa::{VerifyingKey, Signature, signature::Verifier}; use sha2::{Sha256, Digest}; use serde::{Serialize, Deserialize}; #[derive(Debug, Serialize)] pub struct RegistrationChallenge { pub challenge: String, pub rp: RpInfo, pub user: UserInfo, pub timeout: u64, #[serde(rename = "pubKeyCredParams")] pub pub_key_cred_params: Vec, #[serde(rename = "authenticatorSelection")] pub authenticator_selection: AuthenticatorSelection, pub attestation: String, } #[derive(Debug, Serialize)] pub struct AuthenticationChallenge { pub challenge: String, pub rp_id: String, pub timeout: u64, #[serde(rename = "allowCredentials")] pub allow_credentials: Vec, } #[derive(Debug, Serialize)] pub struct RpInfo { pub id: String, pub name: String, } #[derive(Debug, Serialize)] pub struct UserInfo { pub id: String, pub name: String, #[serde(rename = "displayName")] pub display_name: String, } #[derive(Debug, Serialize)] pub struct PubKeyCredParam { #[serde(rename = "type")] pub cred_type: String, pub alg: i64, } #[derive(Debug, Serialize)] pub struct AuthenticatorSelection { #[serde(rename = "authenticatorAttachment")] pub authenticator_attachment: Option, #[serde(rename = "residentKey")] pub resident_key: String, #[serde(rename = "requireResidentKey")] pub require_resident_key: bool, #[serde(rename = "userVerification")] pub user_verification: String, } #[derive(Debug, Serialize)] pub struct AllowCredential { #[serde(rename = "type")] pub cred_type: String, pub id: String, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct StoredCredential { pub credential_id: Vec, pub public_key_x: Vec, pub public_key_y: Vec, pub counter: u32, } #[derive(Debug, Deserialize)] pub struct RegistrationResponse { #[allow(dead_code)] pub id: String, #[serde(rename = "clientDataJSON")] pub client_data_json: String, #[serde(rename = "attestationObject")] pub attestation_object: String, } #[derive(Debug, Deserialize)] pub struct AuthenticationResponse { pub id: String, #[serde(rename = "clientDataJSON")] pub client_data_json: String, #[serde(rename = "authenticatorData")] pub authenticator_data: String, pub signature: String, } #[derive(Debug, Deserialize)] struct ClientData { #[serde(rename = "type")] ctype: String, challenge: String, origin: String, } pub fn generate_challenge() -> String { use rand::Rng; let mut rng = rand::rng(); let bytes: Vec = (0..32).map(|_| rng.random::()).collect(); URL_SAFE_NO_PAD.encode(&bytes) } pub fn create_registration_challenge( challenge: &str, rp_id: &str, rp_name: &str, user_id: &str, user_name: &str, ) -> RegistrationChallenge { RegistrationChallenge { challenge: challenge.to_string(), rp: RpInfo { id: rp_id.to_string(), name: rp_name.to_string() }, user: UserInfo { id: URL_SAFE_NO_PAD.encode(user_id.as_bytes()), name: user_name.to_string(), display_name: user_name.to_string(), }, timeout: 60000, pub_key_cred_params: vec![ PubKeyCredParam { cred_type: "public-key".into(), alg: -7 }, // ES256 ], authenticator_selection: AuthenticatorSelection { authenticator_attachment: None, resident_key: "preferred".into(), require_resident_key: false, user_verification: "preferred".into(), }, attestation: "none".into(), } } pub fn create_authentication_challenge( challenge: &str, rp_id: &str, credentials: &[StoredCredential], ) -> AuthenticationChallenge { AuthenticationChallenge { challenge: challenge.to_string(), rp_id: rp_id.to_string(), timeout: 60000, allow_credentials: credentials.iter().map(|c| AllowCredential { cred_type: "public-key".into(), id: URL_SAFE_NO_PAD.encode(&c.credential_id), }).collect(), } } pub fn verify_registration( response: &RegistrationResponse, expected_challenge: &str, expected_origin: &str, expected_rp_id: &str, ) -> Result { // decode and verify clientDataJSON let client_data_bytes = URL_SAFE_NO_PAD.decode(&response.client_data_json) .map_err(|_| "invalid clientDataJSON base64")?; let client_data: ClientData = serde_json::from_slice(&client_data_bytes) .map_err(|_| "invalid clientDataJSON")?; if client_data.ctype != "webauthn.create" { return Err("wrong type in clientDataJSON".into()); } if client_data.challenge != expected_challenge { return Err("challenge mismatch".into()); } if client_data.origin != expected_origin { return Err(format!("origin mismatch: got {}, expected {}", client_data.origin, expected_origin)); } // decode attestationObject (CBOR) let att_bytes = URL_SAFE_NO_PAD.decode(&response.attestation_object) .map_err(|_| "invalid attestationObject base64")?; let att: ciborium::Value = ciborium::from_reader(&att_bytes[..]) .map_err(|e| format!("invalid CBOR: {}", e))?; let att_map = att.as_map().ok_or("attestationObject is not a map")?; let auth_data_val = att_map.iter() .find(|(k, _)| k.as_text() == Some("authData")) .map(|(_, v)| v) .ok_or("missing authData")?; let auth_data = auth_data_val.as_bytes().ok_or("authData is not bytes")?; if auth_data.len() < 37 { return Err("authData too short".into()); } // verify rpIdHash let rp_id_hash = &auth_data[..32]; let expected_hash = Sha256::digest(expected_rp_id.as_bytes()); if rp_id_hash != &expected_hash[..] { return Err("rpIdHash mismatch".into()); } // check flags: bit 0 = UP (user present), bit 6 = AT (attested credential data) let flags = auth_data[32]; if flags & 0x01 == 0 { return Err("user not present".into()); } if flags & 0x40 == 0 { return Err("no attested credential data".into()); } let counter = u32::from_be_bytes([auth_data[33], auth_data[34], auth_data[35], auth_data[36]]); // parse attested credential data (starts at byte 37) // aaguid (16 bytes) + credentialIdLength (2 bytes) + credentialId + COSE key if auth_data.len() < 55 { return Err("authData too short for credential data".into()); } let cred_id_len = u16::from_be_bytes([auth_data[53], auth_data[54]]) as usize; let cred_id_end = 55 + cred_id_len; if auth_data.len() < cred_id_end { return Err("authData too short for credential ID".into()); } let credential_id = auth_data[55..cred_id_end].to_vec(); // parse COSE key (remaining bytes) let cose_key_bytes = &auth_data[cred_id_end..]; let cose_key: ciborium::Value = ciborium::from_reader(cose_key_bytes) .map_err(|e| format!("invalid COSE key CBOR: {}", e))?; let key_map = cose_key.as_map().ok_or("COSE key is not a map")?; let get_int = |label: i64| -> Option<&ciborium::Value> { key_map.iter().find(|(k, _)| { k.as_integer().and_then(|i| i64::try_from(i).ok()) == Some(label) }).map(|(_, v)| v) }; // verify alg is ES256 (-7) let alg = get_int(3) .and_then(|v| v.as_integer()) .and_then(|i| i64::try_from(i).ok()) .ok_or("missing alg")?; if alg != -7 { return Err(format!("unsupported algorithm: {}", alg)); } // extract x (-2) and y (-3) coordinates let x = get_int(-2).and_then(|v| v.as_bytes()).ok_or("missing x coordinate")?; let y = get_int(-3).and_then(|v| v.as_bytes()).ok_or("missing y coordinate")?; if x.len() != 32 || y.len() != 32 { return Err("invalid key coordinates".into()); } Ok(StoredCredential { credential_id, public_key_x: x.to_vec(), public_key_y: y.to_vec(), counter, }) } pub fn verify_authentication( response: &AuthenticationResponse, credential: &StoredCredential, expected_challenge: &str, expected_origin: &str, expected_rp_id: &str, ) -> Result { // decode and verify clientDataJSON let client_data_bytes = URL_SAFE_NO_PAD.decode(&response.client_data_json) .map_err(|_| "invalid clientDataJSON base64")?; let client_data: ClientData = serde_json::from_slice(&client_data_bytes) .map_err(|_| "invalid clientDataJSON")?; if client_data.ctype != "webauthn.get" { return Err("wrong type".into()); } if client_data.challenge != expected_challenge { return Err("challenge mismatch".into()); } if client_data.origin != expected_origin { return Err("origin mismatch".into()); } // decode authenticatorData let auth_data = URL_SAFE_NO_PAD.decode(&response.authenticator_data) .map_err(|_| "invalid authenticatorData base64")?; if auth_data.len() < 37 { return Err("authenticatorData too short".into()); } // verify rpIdHash let rp_id_hash = &auth_data[..32]; let expected_hash = Sha256::digest(expected_rp_id.as_bytes()); if rp_id_hash != &expected_hash[..] { return Err("rpIdHash mismatch".into()); } // check user present flag if auth_data[32] & 0x01 == 0 { return Err("user not present".into()); } let counter = u32::from_be_bytes([auth_data[33], auth_data[34], auth_data[35], auth_data[36]]); // verify signature: sign(authenticatorData || SHA-256(clientDataJSON)) let client_data_hash = Sha256::digest(&client_data_bytes); let mut signed_data = auth_data.to_vec(); signed_data.extend_from_slice(&client_data_hash); // reconstruct P-256 public key let mut uncompressed = vec![0x04u8]; uncompressed.extend_from_slice(&credential.public_key_x); uncompressed.extend_from_slice(&credential.public_key_y); let verifying_key = VerifyingKey::from_sec1_bytes(&uncompressed) .map_err(|e| format!("invalid public key: {}", e))?; let sig_bytes = URL_SAFE_NO_PAD.decode(&response.signature) .map_err(|_| "invalid signature base64")?; // WebAuthn signatures are DER-encoded let signature = Signature::from_der(&sig_bytes) .map_err(|e| format!("invalid signature DER: {}", e))?; verifying_key.verify(&signed_data, &signature) .map_err(|e| format!("signature verification failed: {}", e))?; Ok(counter) }