webauthn passkeys

This commit is contained in:
2025-11-08 16:51:55 +02:00
parent 5b9d2cba70
commit 048b56493b
+331
View File
@@ -0,0 +1,331 @@
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<PubKeyCredParam>,
#[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<AllowCredential>,
}
#[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<String>,
#[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<u8>,
pub public_key_x: Vec<u8>,
pub public_key_y: Vec<u8>,
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<u8> = (0..32).map(|_| rng.random::<u8>()).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<StoredCredential, String> {
// 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<u32, String> {
// 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)
}