use base64::Engine;
use p256::ecdsa::{signature::Verifier as _, VerifyingKey};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use super::signature::{Signature, SignatureVerification, WebAuthnSignature};
use super::Verifier;
use crate::error::signature_error;
use crate::{DocumentId, Result};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ClientData {
#[serde(rename = "type")]
type_: String,
challenge: String,
origin: String,
#[serde(default)]
#[allow(dead_code)]
cross_origin: Option<bool>,
}
pub struct WebAuthnVerifier {
expected_origin: String,
verifying_key: VerifyingKey,
expected_credential_id: Option<Vec<u8>>,
}
impl WebAuthnVerifier {
pub fn new(expected_origin: impl Into<String>, public_key: &[u8]) -> Result<Self> {
let verifying_key = VerifyingKey::from_sec1_bytes(public_key)
.map_err(|e| signature_error(format!("Invalid WebAuthn public key: {e}")))?;
Ok(Self {
expected_origin: expected_origin.into(),
verifying_key,
expected_credential_id: None,
})
}
pub fn from_pem(expected_origin: impl Into<String>, pem: &str) -> Result<Self> {
use p256::pkcs8::DecodePublicKey;
let verifying_key = VerifyingKey::from_public_key_pem(pem)
.map_err(|e| signature_error(format!("Invalid WebAuthn public key PEM: {e}")))?;
Ok(Self {
expected_origin: expected_origin.into(),
verifying_key,
expected_credential_id: None,
})
}
#[must_use]
pub fn with_credential_id(mut self, credential_id: Vec<u8>) -> Self {
self.expected_credential_id = Some(credential_id);
self
}
fn verify_webauthn(
&self,
signature_id: &str,
document_id: &DocumentId,
webauthn: &WebAuthnSignature,
) -> Result<SignatureVerification> {
let engine = base64::engine::general_purpose::STANDARD;
let credential_id = engine
.decode(&webauthn.credential_id)
.map_err(|e| signature_error(format!("Invalid credential ID base64: {e}")))?;
if let Some(ref expected) = self.expected_credential_id {
if &credential_id != expected {
return Ok(SignatureVerification::invalid(
signature_id,
"Credential ID mismatch",
));
}
}
let client_data_bytes = engine
.decode(&webauthn.client_data_json)
.map_err(|e| signature_error(format!("Invalid clientDataJSON base64: {e}")))?;
let client_data: ClientData = serde_json::from_slice(&client_data_bytes)
.map_err(|e| signature_error(format!("Invalid clientDataJSON: {e}")))?;
if client_data.type_ != "webauthn.get" {
return Ok(SignatureVerification::invalid(
signature_id,
format!(
"Invalid type: expected 'webauthn.get', got '{}'",
client_data.type_
),
));
}
if client_data.origin != self.expected_origin {
return Ok(SignatureVerification::invalid(
signature_id,
format!(
"Origin mismatch: expected '{}', got '{}'",
self.expected_origin, client_data.origin
),
));
}
let challenge_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&client_data.challenge)
.map_err(|e| signature_error(format!("Invalid challenge base64url: {e}")))?;
if challenge_bytes != document_id.digest() {
return Ok(SignatureVerification::invalid(
signature_id,
"Challenge does not match document ID",
));
}
let authenticator_data = engine
.decode(&webauthn.authenticator_data)
.map_err(|e| signature_error(format!("Invalid authenticatorData base64: {e}")))?;
if authenticator_data.len() < 37 {
return Ok(SignatureVerification::invalid(
signature_id,
"Authenticator data too short",
));
}
let expected_rp_id = self
.expected_origin
.strip_prefix("https://")
.or_else(|| self.expected_origin.strip_prefix("http://"))
.unwrap_or(&self.expected_origin);
let expected_rp_id_hash = Sha256::digest(expected_rp_id.as_bytes());
if authenticator_data[..32] != expected_rp_id_hash[..] {
return Ok(SignatureVerification::invalid(
signature_id,
"RP ID hash mismatch in authenticator data",
));
}
let flags = authenticator_data[32];
if flags & 0x01 == 0 {
return Ok(SignatureVerification::invalid(
signature_id,
"User presence flag not set",
));
}
let signature_bytes = engine
.decode(&webauthn.signature)
.map_err(|e| signature_error(format!("Invalid signature base64: {e}")))?;
let signature = p256::ecdsa::DerSignature::from_bytes(&signature_bytes)
.map_err(|e| signature_error(format!("Invalid ECDSA signature: {e}")))?;
let client_data_hash = Sha256::digest(&client_data_bytes);
let mut signed_data = authenticator_data.clone();
signed_data.extend_from_slice(&client_data_hash);
match self.verifying_key.verify(&signed_data, &signature) {
Ok(()) => Ok(SignatureVerification::valid(signature_id)),
Err(e) => Ok(SignatureVerification::invalid(
signature_id,
format!("Signature verification failed: {e}"),
)),
}
}
}
impl Verifier for WebAuthnVerifier {
fn verify(
&self,
document_id: &DocumentId,
signature: &Signature,
) -> Result<SignatureVerification> {
let Some(webauthn) = &signature.webauthn else {
return Ok(SignatureVerification::invalid(
&signature.id,
"Not a WebAuthn signature",
));
};
self.verify_webauthn(&signature.id, document_id, webauthn)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::SignerInfo;
use crate::{HashAlgorithm, Hasher};
#[test]
fn test_webauthn_signature_new() {
let sig = WebAuthnSignature::new(
"Y3JlZGVudGlhbC1pZA==",
"YXV0aGVudGljYXRvci1kYXRh",
"Y2xpZW50LWRhdGEtanNvbg==",
"c2lnbmF0dXJl",
);
assert_eq!(sig.credential_id, "Y3JlZGVudGlhbC1pZA==");
assert_eq!(sig.authenticator_data, "YXV0aGVudGljYXRvci1kYXRh");
assert_eq!(sig.client_data_json, "Y2xpZW50LWRhdGEtanNvbg==");
assert_eq!(sig.signature, "c2lnbmF0dXJl");
}
#[test]
fn test_signature_new_webauthn() {
let webauthn = WebAuthnSignature::new("Y3JlZA==", "YXV0aA==", "Y2xpZW50", "c2ln");
let signer = SignerInfo::new("Test User");
let sig = Signature::new_webauthn("sig-webauthn-1", signer, webauthn);
assert!(sig.is_webauthn());
assert_eq!(sig.algorithm, super::super::SignatureAlgorithm::ES256);
assert!(sig.value.is_empty());
assert!(sig.webauthn_data().is_some());
}
#[test]
fn test_webauthn_signature_serialization() {
let webauthn =
WebAuthnSignature::new("credential-id", "auth-data", "client-data", "signature");
let json = serde_json::to_string(&webauthn).unwrap();
assert!(json.contains("\"credentialId\":\"credential-id\""));
assert!(json.contains("\"authenticatorData\":\"auth-data\""));
assert!(json.contains("\"clientDataJson\":\"client-data\""));
assert!(json.contains("\"signature\":\"signature\""));
let parsed: WebAuthnSignature = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.credential_id, "credential-id");
}
#[test]
fn test_verifier_rejects_non_webauthn() {
use p256::elliptic_curve::Generate;
let signing_key = p256::ecdsa::SigningKey::generate();
let verifying_key = signing_key.verifying_key();
let public_key = verifying_key.to_sec1_bytes();
let verifier = WebAuthnVerifier::new("https://example.com", &public_key).unwrap();
let doc_id = Hasher::hash(HashAlgorithm::Sha256, b"test");
let signer = SignerInfo::new("Test");
let sig = Signature::new(
"sig-1",
super::super::SignatureAlgorithm::ES256,
signer,
"base64sig",
);
let result = verifier.verify(&doc_id, &sig).unwrap();
assert!(!result.is_valid());
assert!(result.error.as_ref().unwrap().contains("Not a WebAuthn"));
}
}