use crate::security::SecretStore;
use anyhow::{Context, Result};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use ring::rand::SecureRandom;
use ring::signature;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
const COSE_ALG_ES256: i64 = -7;
const CHALLENGE_LEN: usize = 32;
const MAX_CREDENTIAL_ID_LEN: usize = 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebAuthnConfig {
pub enabled: bool,
pub rp_id: String,
pub rp_origin: String,
pub rp_name: String,
}
impl Default for WebAuthnConfig {
fn default() -> Self {
Self {
enabled: false,
rp_id: "localhost".into(),
rp_origin: "http://localhost:42617".into(),
rp_name: "ZeroClaw".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebAuthnCredential {
pub credential_id: String,
pub public_key: String,
pub sign_count: u32,
pub label: String,
pub registered_at: String,
pub algorithm: i64,
pub user_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistrationState {
pub challenge: String,
pub user_id: String,
pub user_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthenticationState {
pub challenge: String,
pub user_id: String,
pub allowed_credentials: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreationChallengeResponse {
pub challenge: String,
pub rp: RelyingParty,
pub user: PublicKeyUser,
pub pub_key_cred_params: Vec<PubKeyCredParam>,
pub timeout: u64,
pub attestation: String,
pub exclude_credentials: Vec<CredentialDescriptor>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestChallengeResponse {
pub challenge: String,
pub rp_id: String,
pub allow_credentials: Vec<CredentialDescriptor>,
pub timeout: u64,
pub user_verification: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelyingParty {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicKeyUser {
pub id: String,
pub name: String,
pub display_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PubKeyCredParam {
#[serde(rename = "type")]
pub type_: String,
pub alg: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialDescriptor {
#[serde(rename = "type")]
pub type_: String,
pub id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterCredentialResponse {
pub id: String,
pub attestation_object: String,
pub client_data_json: String,
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthenticateCredentialResponse {
pub id: String,
pub authenticator_data: String,
pub client_data_json: String,
pub signature: String,
}
pub struct WebAuthnManager {
config: WebAuthnConfig,
secret_store: Arc<SecretStore>,
credentials_path: PathBuf,
rng: ring::rand::SystemRandom,
}
impl WebAuthnManager {
pub fn new(config: WebAuthnConfig, secret_store: Arc<SecretStore>, storage_dir: &Path) -> Self {
Self {
config,
secret_store,
credentials_path: storage_dir.join("webauthn_credentials.json"),
rng: ring::rand::SystemRandom::new(),
}
}
pub fn start_registration(
&self,
user_id: &str,
user_name: &str,
) -> Result<(CreationChallengeResponse, RegistrationState)> {
let challenge = self.generate_challenge()?;
let existing = self.load_credentials_for_user(user_id)?;
let exclude: Vec<CredentialDescriptor> = existing
.iter()
.map(|c| CredentialDescriptor {
type_: "public-key".into(),
id: c.credential_id.clone(),
})
.collect();
let user_id_b64 = URL_SAFE_NO_PAD.encode(user_id.as_bytes());
let creation = CreationChallengeResponse {
challenge: challenge.clone(),
rp: RelyingParty {
id: self.config.rp_id.clone(),
name: self.config.rp_name.clone(),
},
user: PublicKeyUser {
id: user_id_b64,
name: user_name.into(),
display_name: user_name.into(),
},
pub_key_cred_params: vec![PubKeyCredParam {
type_: "public-key".into(),
alg: COSE_ALG_ES256,
}],
timeout: 60_000,
attestation: "none".into(),
exclude_credentials: exclude,
};
let state = RegistrationState {
challenge,
user_id: user_id.into(),
user_name: user_name.into(),
};
Ok((creation, state))
}
pub fn finish_registration(
&self,
reg_state: &RegistrationState,
response: &RegisterCredentialResponse,
) -> Result<WebAuthnCredential> {
let client_data_bytes = URL_SAFE_NO_PAD
.decode(&response.client_data_json)
.context("Invalid base64url in client_data_json")?;
let client_data: serde_json::Value =
serde_json::from_slice(&client_data_bytes).context("Invalid client data JSON")?;
let cd_type = client_data["type"].as_str().unwrap_or_default();
anyhow::ensure!(
cd_type == "webauthn.create",
"Expected type 'webauthn.create', got '{cd_type}'"
);
let cd_challenge = client_data["challenge"].as_str().unwrap_or_default();
anyhow::ensure!(
cd_challenge == reg_state.challenge,
"Challenge mismatch in registration response"
);
let cd_origin = client_data["origin"].as_str().unwrap_or_default();
anyhow::ensure!(
cd_origin == self.config.rp_origin,
"Origin mismatch: expected '{}', got '{cd_origin}'",
self.config.rp_origin
);
let attestation_bytes = URL_SAFE_NO_PAD
.decode(&response.attestation_object)
.context("Invalid base64url in attestation_object")?;
let (public_key_bytes, sign_count) =
extract_public_key_from_attestation(&attestation_bytes)?;
let cred_id_bytes = URL_SAFE_NO_PAD
.decode(&response.id)
.context("Invalid base64url in credential ID")?;
anyhow::ensure!(
cred_id_bytes.len() <= MAX_CREDENTIAL_ID_LEN,
"Credential ID too long ({} bytes, max {MAX_CREDENTIAL_ID_LEN})",
cred_id_bytes.len()
);
let now = chrono::Utc::now().to_rfc3339();
let label = response
.label
.clone()
.unwrap_or_else(|| "Hardware Key".into());
let credential = WebAuthnCredential {
credential_id: response.id.clone(),
public_key: URL_SAFE_NO_PAD.encode(&public_key_bytes),
sign_count,
label,
registered_at: now,
algorithm: COSE_ALG_ES256,
user_id: reg_state.user_id.clone(),
};
self.store_credential(&credential)?;
Ok(credential)
}
pub fn start_authentication(
&self,
user_id: &str,
) -> Result<(RequestChallengeResponse, AuthenticationState)> {
let credentials = self.load_credentials_for_user(user_id)?;
anyhow::ensure!(
!credentials.is_empty(),
"No registered credentials for user '{user_id}'"
);
let challenge = self.generate_challenge()?;
let allow: Vec<CredentialDescriptor> = credentials
.iter()
.map(|c| CredentialDescriptor {
type_: "public-key".into(),
id: c.credential_id.clone(),
})
.collect();
let allowed_ids: Vec<String> = credentials
.iter()
.map(|c| c.credential_id.clone())
.collect();
let request = RequestChallengeResponse {
challenge: challenge.clone(),
rp_id: self.config.rp_id.clone(),
allow_credentials: allow,
timeout: 60_000,
user_verification: "preferred".into(),
};
let state = AuthenticationState {
challenge,
user_id: user_id.into(),
allowed_credentials: allowed_ids,
};
Ok((request, state))
}
pub fn finish_authentication(
&self,
auth_state: &AuthenticationState,
response: &AuthenticateCredentialResponse,
) -> Result<()> {
anyhow::ensure!(
auth_state.allowed_credentials.contains(&response.id),
"Credential ID not in allowed list"
);
let mut all_credentials = self.load_all_credentials()?;
let credential = all_credentials
.values()
.flatten()
.find(|c| c.credential_id == response.id)
.cloned()
.ok_or_else(|| anyhow::anyhow!("Credential not found: {}", response.id))?;
let client_data_bytes = URL_SAFE_NO_PAD
.decode(&response.client_data_json)
.context("Invalid base64url in client_data_json")?;
let client_data: serde_json::Value =
serde_json::from_slice(&client_data_bytes).context("Invalid client data JSON")?;
let cd_type = client_data["type"].as_str().unwrap_or_default();
anyhow::ensure!(
cd_type == "webauthn.get",
"Expected type 'webauthn.get', got '{cd_type}'"
);
let cd_challenge = client_data["challenge"].as_str().unwrap_or_default();
anyhow::ensure!(
cd_challenge == auth_state.challenge,
"Challenge mismatch in authentication response"
);
let cd_origin = client_data["origin"].as_str().unwrap_or_default();
anyhow::ensure!(
cd_origin == self.config.rp_origin,
"Origin mismatch: expected '{}', got '{cd_origin}'",
self.config.rp_origin
);
let auth_data_bytes = URL_SAFE_NO_PAD
.decode(&response.authenticator_data)
.context("Invalid base64url in authenticator_data")?;
let client_data_hash = ring::digest::digest(&ring::digest::SHA256, &client_data_bytes);
let mut signed_data = auth_data_bytes.clone();
signed_data.extend_from_slice(client_data_hash.as_ref());
let public_key_bytes = URL_SAFE_NO_PAD
.decode(&credential.public_key)
.context("Invalid base64url in stored public key")?;
let sig_bytes = URL_SAFE_NO_PAD
.decode(&response.signature)
.context("Invalid base64url in signature")?;
verify_es256_signature(&public_key_bytes, &signed_data, &sig_bytes)?;
if auth_data_bytes.len() >= 37 {
let new_count = u32::from_be_bytes([
auth_data_bytes[33],
auth_data_bytes[34],
auth_data_bytes[35],
auth_data_bytes[36],
]);
if new_count > 0 || credential.sign_count > 0 {
anyhow::ensure!(
new_count > credential.sign_count,
"Sign counter did not increase ({new_count} <= {}). Possible cloned authenticator.",
credential.sign_count
);
}
if let Some(user_creds) = all_credentials.get_mut(&credential.user_id) {
if let Some(cred) = user_creds
.iter_mut()
.find(|c| c.credential_id == response.id)
{
cred.sign_count = new_count;
}
}
self.save_all_credentials(&all_credentials)?;
}
Ok(())
}
pub fn list_credentials(&self, user_id: &str) -> Result<Vec<WebAuthnCredential>> {
self.load_credentials_for_user(user_id)
}
pub fn remove_credential(&self, user_id: &str, credential_id: &str) -> Result<()> {
let mut all = self.load_all_credentials()?;
if let Some(user_creds) = all.get_mut(user_id) {
let before = user_creds.len();
user_creds.retain(|c| c.credential_id != credential_id);
anyhow::ensure!(
user_creds.len() < before,
"Credential '{credential_id}' not found for user '{user_id}'"
);
} else {
anyhow::bail!("No credentials found for user '{user_id}'");
}
self.save_all_credentials(&all)
}
fn generate_challenge(&self) -> Result<String> {
let mut buf = [0u8; CHALLENGE_LEN];
self.rng
.fill(&mut buf)
.map_err(|_| anyhow::anyhow!("Failed to generate random challenge"))?;
Ok(URL_SAFE_NO_PAD.encode(buf))
}
fn load_credentials_for_user(&self, user_id: &str) -> Result<Vec<WebAuthnCredential>> {
let all = self.load_all_credentials()?;
Ok(all.get(user_id).cloned().unwrap_or_default())
}
fn store_credential(&self, credential: &WebAuthnCredential) -> Result<()> {
let mut all = self.load_all_credentials()?;
all.entry(credential.user_id.clone())
.or_default()
.push(credential.clone());
self.save_all_credentials(&all)
}
fn load_all_credentials(&self) -> Result<HashMap<String, Vec<WebAuthnCredential>>> {
if !self.credentials_path.exists() {
return Ok(HashMap::new());
}
let encrypted = std::fs::read_to_string(&self.credentials_path)
.context("Failed to read WebAuthn credentials file")?;
if encrypted.is_empty() {
return Ok(HashMap::new());
}
let json = self
.secret_store
.decrypt(&encrypted)
.context("Failed to decrypt WebAuthn credentials")?;
serde_json::from_str(&json).context("Failed to parse WebAuthn credentials JSON")
}
fn save_all_credentials(
&self,
credentials: &HashMap<String, Vec<WebAuthnCredential>>,
) -> Result<()> {
let json = serde_json::to_string(credentials).context("Failed to serialize credentials")?;
let encrypted = self
.secret_store
.encrypt(&json)
.context("Failed to encrypt WebAuthn credentials")?;
if let Some(parent) = self.credentials_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&self.credentials_path, &encrypted)
.context("Failed to write WebAuthn credentials file")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
&self.credentials_path,
std::fs::Permissions::from_mode(0o600),
)
.context("Failed to set credentials file permissions")?;
}
Ok(())
}
}
fn extract_public_key_from_attestation(attestation_bytes: &[u8]) -> Result<(Vec<u8>, u32)> {
if let Ok(att) = serde_json::from_slice::<AttestationObject>(attestation_bytes) {
let pk = URL_SAFE_NO_PAD
.decode(&att.public_key)
.context("Invalid base64url in attestation public key")?;
return Ok((pk, att.sign_count.unwrap_or(0)));
}
if attestation_bytes.len() >= 37 {
let sign_count = u32::from_be_bytes([
attestation_bytes[33],
attestation_bytes[34],
attestation_bytes[35],
attestation_bytes[36],
]);
let flags = attestation_bytes[32];
if flags & 0x40 != 0 && attestation_bytes.len() > 55 {
let cred_id_len =
u16::from_be_bytes([attestation_bytes[53], attestation_bytes[54]]) as usize;
let cose_key_start = 55 + cred_id_len;
if attestation_bytes.len() > cose_key_start {
let cose_key = &attestation_bytes[cose_key_start..];
let pk = extract_p256_from_cose(cose_key)?;
return Ok((pk, sign_count));
}
}
}
anyhow::bail!(
"Unable to extract public key from attestation object ({} bytes)",
attestation_bytes.len()
)
}
#[derive(Deserialize)]
struct AttestationObject {
public_key: String,
sign_count: Option<u32>,
}
fn extract_p256_from_cose(cose: &[u8]) -> Result<Vec<u8>> {
if cose.len() >= 65 && cose[0] == 0x04 {
return Ok(cose[..65].to_vec());
}
anyhow::bail!(
"Unsupported COSE key format (expected uncompressed P-256, got {} bytes starting with 0x{:02x})",
cose.len(),
cose.first().copied().unwrap_or(0)
)
}
fn verify_es256_signature(public_key: &[u8], message: &[u8], sig: &[u8]) -> Result<()> {
let pk = signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_ASN1, public_key);
pk.verify(message, sig)
.map_err(|_| anyhow::anyhow!("WebAuthn signature verification failed"))
}
fn encode_p256_spki(uncompressed_point: &[u8]) -> Vec<u8> {
let mut spki = vec![
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, ];
spki.extend_from_slice(uncompressed_point);
spki
}
#[cfg(test)]
mod tests {
use super::*;
use ring::signature::KeyPair;
use tempfile::TempDir;
fn test_config() -> WebAuthnConfig {
WebAuthnConfig {
enabled: true,
rp_id: "localhost".into(),
rp_origin: "http://localhost:42617".into(),
rp_name: "ZeroClaw Test".into(),
}
}
fn test_manager(tmp: &TempDir) -> WebAuthnManager {
let store = Arc::new(SecretStore::new(tmp.path(), true));
WebAuthnManager::new(test_config(), store, tmp.path())
}
#[test]
fn start_registration_returns_valid_challenge() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let (creation, state) = mgr.start_registration("user1", "Alice").unwrap();
assert_eq!(creation.rp.id, "localhost");
assert_eq!(creation.rp.name, "ZeroClaw Test");
assert_eq!(creation.user.name, "Alice");
assert_eq!(creation.attestation, "none");
assert!(!creation.challenge.is_empty());
assert_eq!(creation.challenge, state.challenge);
assert_eq!(state.user_id, "user1");
let decoded = URL_SAFE_NO_PAD.decode(&creation.challenge).unwrap();
assert_eq!(decoded.len(), CHALLENGE_LEN);
}
#[test]
fn start_registration_produces_unique_challenges() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let (c1, _) = mgr.start_registration("user1", "Alice").unwrap();
let (c2, _) = mgr.start_registration("user1", "Alice").unwrap();
assert_ne!(
c1.challenge, c2.challenge,
"Each registration should produce a unique challenge"
);
}
#[test]
fn finish_registration_validates_challenge() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let (_, state) = mgr.start_registration("user1", "Alice").unwrap();
let client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": "wrong-challenge",
"origin": "http://localhost:42617"
});
let client_data_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&client_data).unwrap());
let attestation = serde_json::json!({
"public_key": URL_SAFE_NO_PAD.encode(vec![0x04; 65]),
"sign_count": 0
});
let att_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&attestation).unwrap());
let response = RegisterCredentialResponse {
id: URL_SAFE_NO_PAD.encode(b"cred-123"),
attestation_object: att_b64,
client_data_json: client_data_b64,
label: None,
};
let result = mgr.finish_registration(&state, &response);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Challenge mismatch"),
"Should fail on challenge mismatch"
);
}
#[test]
fn finish_registration_validates_origin() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let (_, state) = mgr.start_registration("user1", "Alice").unwrap();
let client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": state.challenge,
"origin": "https://evil.com"
});
let client_data_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&client_data).unwrap());
let attestation = serde_json::json!({
"public_key": URL_SAFE_NO_PAD.encode(vec![0x04; 65]),
"sign_count": 0
});
let att_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&attestation).unwrap());
let response = RegisterCredentialResponse {
id: URL_SAFE_NO_PAD.encode(b"cred-123"),
attestation_object: att_b64,
client_data_json: client_data_b64,
label: None,
};
let result = mgr.finish_registration(&state, &response);
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("Origin mismatch"),
"Should fail on origin mismatch"
);
}
#[test]
fn finish_registration_validates_type() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let (_, state) = mgr.start_registration("user1", "Alice").unwrap();
let client_data = serde_json::json!({
"type": "webauthn.get",
"challenge": state.challenge,
"origin": "http://localhost:42617"
});
let client_data_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&client_data).unwrap());
let attestation = serde_json::json!({
"public_key": URL_SAFE_NO_PAD.encode(vec![0x04; 65]),
"sign_count": 0
});
let att_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&attestation).unwrap());
let response = RegisterCredentialResponse {
id: URL_SAFE_NO_PAD.encode(b"cred-123"),
attestation_object: att_b64,
client_data_json: client_data_b64,
label: None,
};
let result = mgr.finish_registration(&state, &response);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Expected type 'webauthn.create'"),
);
}
#[test]
fn registration_stores_credential_and_lists_it() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let (_, state) = mgr.start_registration("user1", "Alice").unwrap();
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
&rng,
)
.unwrap();
let key_pair = ring::signature::EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let public_key = key_pair.public_key().as_ref();
let client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": state.challenge,
"origin": "http://localhost:42617"
});
let client_data_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&client_data).unwrap());
let attestation = serde_json::json!({
"public_key": URL_SAFE_NO_PAD.encode(public_key),
"sign_count": 0
});
let att_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&attestation).unwrap());
let response = RegisterCredentialResponse {
id: URL_SAFE_NO_PAD.encode(b"test-cred-1"),
attestation_object: att_b64,
client_data_json: client_data_b64,
label: Some("Test YubiKey".into()),
};
let credential = mgr.finish_registration(&state, &response).unwrap();
assert_eq!(credential.user_id, "user1");
assert_eq!(credential.label, "Test YubiKey");
assert_eq!(credential.algorithm, COSE_ALG_ES256);
assert_eq!(credential.sign_count, 0);
let creds = mgr.list_credentials("user1").unwrap();
assert_eq!(creds.len(), 1);
assert_eq!(creds[0].credential_id, credential.credential_id);
}
#[test]
fn multiple_credentials_per_user() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
for i in 0..3 {
let (_, state) = mgr.start_registration("user1", "Alice").unwrap();
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
&rng,
)
.unwrap();
let key_pair = ring::signature::EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": state.challenge,
"origin": "http://localhost:42617"
});
let client_data_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&client_data).unwrap());
let attestation = serde_json::json!({
"public_key": URL_SAFE_NO_PAD.encode(key_pair.public_key().as_ref()),
"sign_count": 0
});
let att_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&attestation).unwrap());
let response = RegisterCredentialResponse {
id: URL_SAFE_NO_PAD.encode(format!("cred-{i}").as_bytes()),
attestation_object: att_b64,
client_data_json: client_data_b64,
label: Some(format!("Key {i}")),
};
mgr.finish_registration(&state, &response).unwrap();
}
let creds = mgr.list_credentials("user1").unwrap();
assert_eq!(creds.len(), 3);
}
#[test]
fn remove_credential_works() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let (_, state) = mgr.start_registration("user1", "Alice").unwrap();
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
&rng,
)
.unwrap();
let key_pair = ring::signature::EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": state.challenge,
"origin": "http://localhost:42617"
});
let client_data_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&client_data).unwrap());
let attestation = serde_json::json!({
"public_key": URL_SAFE_NO_PAD.encode(key_pair.public_key().as_ref()),
"sign_count": 0
});
let att_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&attestation).unwrap());
let cred_id = URL_SAFE_NO_PAD.encode(b"cred-to-remove");
let response = RegisterCredentialResponse {
id: cred_id.clone(),
attestation_object: att_b64,
client_data_json: client_data_b64,
label: None,
};
mgr.finish_registration(&state, &response).unwrap();
assert_eq!(mgr.list_credentials("user1").unwrap().len(), 1);
mgr.remove_credential("user1", &cred_id).unwrap();
assert_eq!(mgr.list_credentials("user1").unwrap().len(), 0);
}
#[test]
fn remove_nonexistent_credential_fails() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let result = mgr.remove_credential("user1", "nonexistent");
assert!(result.is_err());
}
#[test]
fn start_authentication_fails_without_credentials() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let result = mgr.start_authentication("user1");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("No registered credentials"),
);
}
#[test]
fn start_authentication_returns_valid_options() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let (_, state) = mgr.start_registration("user1", "Alice").unwrap();
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
&rng,
)
.unwrap();
let key_pair = ring::signature::EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": state.challenge,
"origin": "http://localhost:42617"
});
let cred_id = URL_SAFE_NO_PAD.encode(b"auth-test-cred");
let attestation = serde_json::json!({
"public_key": URL_SAFE_NO_PAD.encode(key_pair.public_key().as_ref()),
"sign_count": 0
});
mgr.finish_registration(
&state,
&RegisterCredentialResponse {
id: cred_id.clone(),
attestation_object: URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&attestation).unwrap()),
client_data_json: URL_SAFE_NO_PAD.encode(serde_json::to_vec(&client_data).unwrap()),
label: None,
},
)
.unwrap();
let (request, auth_state) = mgr.start_authentication("user1").unwrap();
assert_eq!(request.rp_id, "localhost");
assert!(!request.challenge.is_empty());
assert_eq!(request.allow_credentials.len(), 1);
assert_eq!(request.allow_credentials[0].id, cred_id);
assert_eq!(auth_state.user_id, "user1");
}
#[test]
fn full_authentication_flow_with_real_keys() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let (_, reg_state) = mgr.start_registration("user1", "Alice").unwrap();
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
&rng,
)
.unwrap();
let key_pair = ring::signature::EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let reg_client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": reg_state.challenge,
"origin": "http://localhost:42617"
});
let cred_id = URL_SAFE_NO_PAD.encode(b"full-flow-cred");
let attestation = serde_json::json!({
"public_key": URL_SAFE_NO_PAD.encode(key_pair.public_key().as_ref()),
"sign_count": 0
});
mgr.finish_registration(
®_state,
&RegisterCredentialResponse {
id: cred_id.clone(),
attestation_object: URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&attestation).unwrap()),
client_data_json: URL_SAFE_NO_PAD
.encode(serde_json::to_vec(®_client_data).unwrap()),
label: Some("Full Flow Key".into()),
},
)
.unwrap();
let (_, auth_state) = mgr.start_authentication("user1").unwrap();
let auth_client_data = serde_json::json!({
"type": "webauthn.get",
"challenge": auth_state.challenge,
"origin": "http://localhost:42617"
});
let auth_client_data_bytes = serde_json::to_vec(&auth_client_data).unwrap();
let rp_id_hash = ring::digest::digest(&ring::digest::SHA256, b"localhost");
let mut auth_data = Vec::with_capacity(37);
auth_data.extend_from_slice(rp_id_hash.as_ref()); auth_data.push(0x01); auth_data.extend_from_slice(&1u32.to_be_bytes());
let client_data_hash = ring::digest::digest(&ring::digest::SHA256, &auth_client_data_bytes);
let mut signed_data = auth_data.clone();
signed_data.extend_from_slice(client_data_hash.as_ref());
let sig = key_pair.sign(&rng, &signed_data).unwrap();
let auth_response = AuthenticateCredentialResponse {
id: cred_id,
authenticator_data: URL_SAFE_NO_PAD.encode(&auth_data),
client_data_json: URL_SAFE_NO_PAD.encode(&auth_client_data_bytes),
signature: URL_SAFE_NO_PAD.encode(sig.as_ref()),
};
mgr.finish_authentication(&auth_state, &auth_response)
.unwrap();
let creds = mgr.list_credentials("user1").unwrap();
assert_eq!(creds[0].sign_count, 1);
}
#[test]
fn authentication_rejects_wrong_credential_id() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let (_, reg_state) = mgr.start_registration("user1", "Alice").unwrap();
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
&rng,
)
.unwrap();
let key_pair = ring::signature::EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": reg_state.challenge,
"origin": "http://localhost:42617"
});
let attestation = serde_json::json!({
"public_key": URL_SAFE_NO_PAD.encode(key_pair.public_key().as_ref()),
"sign_count": 0
});
mgr.finish_registration(
®_state,
&RegisterCredentialResponse {
id: URL_SAFE_NO_PAD.encode(b"real-cred"),
attestation_object: URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&attestation).unwrap()),
client_data_json: URL_SAFE_NO_PAD.encode(serde_json::to_vec(&client_data).unwrap()),
label: None,
},
)
.unwrap();
let (_, auth_state) = mgr.start_authentication("user1").unwrap();
let response = AuthenticateCredentialResponse {
id: URL_SAFE_NO_PAD.encode(b"wrong-cred"),
authenticator_data: URL_SAFE_NO_PAD.encode(b"dummy"),
client_data_json: URL_SAFE_NO_PAD.encode(b"{}"),
signature: URL_SAFE_NO_PAD.encode(b"dummy"),
};
let result = mgr.finish_authentication(&auth_state, &response);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("not in allowed list"),
);
}
#[test]
fn credentials_are_encrypted_on_disk() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let (_, state) = mgr.start_registration("user1", "Alice").unwrap();
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
&rng,
)
.unwrap();
let key_pair = ring::signature::EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": state.challenge,
"origin": "http://localhost:42617"
});
let attestation = serde_json::json!({
"public_key": URL_SAFE_NO_PAD.encode(key_pair.public_key().as_ref()),
"sign_count": 0
});
mgr.finish_registration(
&state,
&RegisterCredentialResponse {
id: URL_SAFE_NO_PAD.encode(b"enc-test"),
attestation_object: URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&attestation).unwrap()),
client_data_json: URL_SAFE_NO_PAD.encode(serde_json::to_vec(&client_data).unwrap()),
label: None,
},
)
.unwrap();
let raw = std::fs::read_to_string(tmp.path().join("webauthn_credentials.json")).unwrap();
assert!(
raw.starts_with("enc2:"),
"Credentials file should be encrypted"
);
assert!(
!raw.contains("user1"),
"User ID should not appear in encrypted file"
);
}
#[test]
fn exclude_credentials_populated_on_second_registration() {
let tmp = TempDir::new().unwrap();
let mgr = test_manager(&tmp);
let (_, state) = mgr.start_registration("user1", "Alice").unwrap();
let rng = ring::rand::SystemRandom::new();
let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
&rng,
)
.unwrap();
let key_pair = ring::signature::EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
let first_cred_id = URL_SAFE_NO_PAD.encode(b"first-cred");
let client_data = serde_json::json!({
"type": "webauthn.create",
"challenge": state.challenge,
"origin": "http://localhost:42617"
});
let attestation = serde_json::json!({
"public_key": URL_SAFE_NO_PAD.encode(key_pair.public_key().as_ref()),
"sign_count": 0
});
mgr.finish_registration(
&state,
&RegisterCredentialResponse {
id: first_cred_id.clone(),
attestation_object: URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&attestation).unwrap()),
client_data_json: URL_SAFE_NO_PAD.encode(serde_json::to_vec(&client_data).unwrap()),
label: None,
},
)
.unwrap();
let (creation2, _) = mgr.start_registration("user1", "Alice").unwrap();
assert_eq!(creation2.exclude_credentials.len(), 1);
assert_eq!(creation2.exclude_credentials[0].id, first_cred_id);
}
#[test]
fn encode_p256_spki_produces_correct_length() {
let point = [0x04u8; 65];
let spki = encode_p256_spki(&point);
assert_eq!(spki.len(), 91);
assert_eq!(spki[0], 0x30);
}
#[test]
fn default_config_has_sane_values() {
let config = WebAuthnConfig::default();
assert!(!config.enabled);
assert_eq!(config.rp_id, "localhost");
assert_eq!(config.rp_name, "ZeroClaw");
}
}