use std::fmt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use webauthn_rs::prelude::{AuthenticationResult, Url, Webauthn, WebauthnBuilder};
use crate::crypto::constant_time::constant_time_eq;
use crate::util::timestamp::Timestamp;
pub mod error;
pub mod prf;
pub use error::WebAuthnError;
pub use prf::{
PRF_SALT_LEN, PrfAuthenticationRequest, PrfClientResult, PrfRegistrationRequest, PrfSalt,
};
pub use webauthn_rs::prelude::{
CredentialID, Passkey, PasskeyAuthentication, PasskeyRegistration, PublicKeyCredential,
RegisterPublicKeyCredential, Uuid,
};
pub struct RelyingParty {
rp_id: String,
rp_name: String,
origins: Vec<String>,
inner: Webauthn,
}
impl RelyingParty {
#[must_use]
pub fn builder(rp_id: &str, rp_name: &str) -> RelyingPartyBuilder {
RelyingPartyBuilder {
rp_id: rp_id.to_owned(),
rp_name: rp_name.to_owned(),
origins: Vec::new(),
}
}
#[must_use]
#[inline]
pub fn rp_id(&self) -> &str {
&self.rp_id
}
#[must_use]
#[inline]
pub fn rp_name(&self) -> &str {
&self.rp_name
}
#[must_use]
#[inline]
pub fn origins(&self) -> &[String] {
&self.origins
}
}
impl fmt::Debug for RelyingParty {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RelyingParty")
.field("rp_id", &self.rp_id)
.field("rp_name", &self.rp_name)
.field("origins", &self.origins)
.finish_non_exhaustive()
}
}
fn is_loopback_host(host: &str) -> bool {
host == "localhost"
}
fn validate_origin(url: &Url, rp_id: &str) -> Result<(), WebAuthnError> {
let host = url
.host_str()
.ok_or_else(|| WebAuthnError::invalid_configuration(format!("origin {url} has no host")))?;
if host.parse::<std::net::IpAddr>().is_ok() || (host.starts_with('[') && host.ends_with(']')) {
return Err(WebAuthnError::invalid_configuration(format!(
"origin {url}: rp_id must be a registrable domain, not an IP literal — \
use http://localhost for local development"
)));
}
if url.scheme() != "https" && !is_loopback_host(host) {
return Err(WebAuthnError::invalid_configuration(format!(
"origin {url} must use https (only loopback may use http)"
)));
}
let matches_rp = host == rp_id
|| host
.strip_suffix(rp_id)
.is_some_and(|prefix| prefix.ends_with('.'));
if !matches_rp {
return Err(WebAuthnError::invalid_configuration(format!(
"origin {url} is not rp_id '{rp_id}' or a sub-domain of it"
)));
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct RelyingPartyBuilder {
rp_id: String,
rp_name: String,
origins: Vec<String>,
}
impl RelyingPartyBuilder {
#[must_use]
pub fn origin(mut self, origin: &str) -> Self {
self.origins.push(origin.to_owned());
self
}
pub fn build(self) -> Result<RelyingParty, WebAuthnError> {
if self.rp_id.is_empty() {
return Err(WebAuthnError::invalid_configuration("rp_id is empty"));
}
if self.origins.is_empty() {
return Err(WebAuthnError::invalid_configuration(
"at least one origin is required",
));
}
let mut parsed_origins: Vec<Url> = Vec::with_capacity(self.origins.len());
for raw in &self.origins {
let url = Url::parse(raw).map_err(|e| {
WebAuthnError::invalid_configuration(format!("invalid origin {raw}: {e}"))
})?;
validate_origin(&url, &self.rp_id)?;
parsed_origins.push(url);
}
let primary = parsed_origins
.first()
.ok_or_else(|| WebAuthnError::invalid_configuration("no primary origin"))?
.clone();
let mut builder = WebauthnBuilder::new(&self.rp_id, &primary)
.map_err(|e| WebAuthnError::invalid_configuration(e.to_string()))?
.rp_name(&self.rp_name);
for extra in parsed_origins.iter().skip(1) {
builder = builder.append_allowed_origin(extra);
}
let inner = builder
.build()
.map_err(|e| WebAuthnError::invalid_configuration(e.to_string()))?;
Ok(RelyingParty {
rp_id: self.rp_id,
rp_name: self.rp_name,
origins: self.origins,
inner,
})
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct PasskeyCredential {
credential_id: Vec<u8>,
passkey: Passkey,
sign_count: u32,
transports: Vec<String>,
aaguid: Vec<u8>,
label: String,
prf_supported: bool,
prf_salt: Option<PrfSalt>,
created_at: Timestamp,
last_used_at: Option<Timestamp>,
}
impl PasskeyCredential {
#[must_use]
#[inline]
pub fn credential_id(&self) -> &[u8] {
&self.credential_id
}
#[must_use]
#[inline]
pub fn passkey(&self) -> &Passkey {
&self.passkey
}
#[must_use]
#[inline]
pub fn sign_count(&self) -> u32 {
self.sign_count
}
pub fn apply_authentication_result(&mut self, result: &AuthenticationResult) -> bool {
let changed = self.passkey.update_credential(result).unwrap_or(false);
if self.sign_count < result.counter() {
self.sign_count = result.counter();
return true;
}
changed
}
#[must_use]
#[inline]
pub fn transports(&self) -> &[String] {
&self.transports
}
#[must_use]
#[inline]
pub fn aaguid(&self) -> &[u8] {
&self.aaguid
}
#[must_use]
#[inline]
pub fn label(&self) -> &str {
&self.label
}
#[must_use]
#[inline]
pub fn prf_supported(&self) -> bool {
self.prf_supported
}
#[must_use]
#[inline]
pub fn prf_salt(&self) -> Option<&PrfSalt> {
self.prf_salt.as_ref()
}
#[must_use]
#[inline]
pub fn created_at(&self) -> &Timestamp {
&self.created_at
}
#[must_use]
#[inline]
pub fn last_used_at(&self) -> Option<&Timestamp> {
self.last_used_at.as_ref()
}
#[must_use]
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
#[must_use]
pub fn with_sign_count(mut self, sign_count: u32) -> Self {
self.sign_count = sign_count;
self
}
}
impl fmt::Debug for PasskeyCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PasskeyCredential")
.field("credential_id_len", &self.credential_id.len())
.field("sign_count", &self.sign_count)
.field("transports", &self.transports)
.field("aaguid_len", &self.aaguid.len())
.field("label", &self.label)
.field("prf_supported", &self.prf_supported)
.field("prf_salt", &self.prf_salt)
.field("created_at", &self.created_at)
.field("last_used_at", &self.last_used_at)
.finish_non_exhaustive()
}
}
pub trait PasskeyCredentialStore {
type Error: std::error::Error;
fn lookup_by_user(&self, user_id: &[u8]) -> Result<Vec<PasskeyCredential>, Self::Error>;
fn lookup_by_credential_id(
&self,
credential_id: &[u8],
) -> Result<Option<PasskeyCredential>, Self::Error>;
fn store(&mut self, user_id: &[u8], credential: &PasskeyCredential) -> Result<(), Self::Error>;
fn update_sign_count(
&mut self,
credential_id: &[u8],
sign_count: u32,
) -> Result<(), Self::Error>;
fn update_last_used(
&mut self,
credential_id: &[u8],
last_used_at: Timestamp,
) -> Result<(), Self::Error>;
fn remove(&mut self, credential_id: &[u8]) -> Result<bool, Self::Error>;
}
pub struct InMemoryPasskeyStore {
entries: Vec<(Vec<u8>, PasskeyCredential)>,
}
impl Default for InMemoryPasskeyStore {
fn default() -> Self {
Self::new()
}
}
impl InMemoryPasskeyStore {
#[must_use]
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
}
impl fmt::Debug for InMemoryPasskeyStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("InMemoryPasskeyStore")
.field("len", &self.entries.len())
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InMemoryPasskeyStoreError {
kind: InMemoryPasskeyStoreErrorKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InMemoryPasskeyStoreErrorKind {
CredentialIdInUse,
}
impl InMemoryPasskeyStoreError {
#[must_use]
pub fn is_credential_id_in_use(&self) -> bool {
self.kind == InMemoryPasskeyStoreErrorKind::CredentialIdInUse
}
}
impl fmt::Display for InMemoryPasskeyStoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
InMemoryPasskeyStoreErrorKind::CredentialIdInUse => {
f.write_str("in-memory passkey store: credential ID already registered")
}
}
}
}
impl std::error::Error for InMemoryPasskeyStoreError {}
impl PasskeyCredentialStore for InMemoryPasskeyStore {
type Error = InMemoryPasskeyStoreError;
fn lookup_by_user(&self, user_id: &[u8]) -> Result<Vec<PasskeyCredential>, Self::Error> {
Ok(self
.entries
.iter()
.filter(|(uid, _)| constant_time_eq(uid, user_id))
.map(|(_, cred)| cred.clone())
.collect())
}
fn lookup_by_credential_id(
&self,
credential_id: &[u8],
) -> Result<Option<PasskeyCredential>, Self::Error> {
let mut found: Option<PasskeyCredential> = None;
for (_, cred) in &self.entries {
if constant_time_eq(&cred.credential_id, credential_id) {
found = Some(cred.clone());
}
}
Ok(found)
}
fn store(&mut self, user_id: &[u8], credential: &PasskeyCredential) -> Result<(), Self::Error> {
let mut replaced = false;
for entry in &mut self.entries {
if constant_time_eq(&entry.1.credential_id, &credential.credential_id) {
if !constant_time_eq(&entry.0, user_id) {
return Err(InMemoryPasskeyStoreError {
kind: InMemoryPasskeyStoreErrorKind::CredentialIdInUse,
});
}
entry.1 = credential.clone();
replaced = true;
}
}
if !replaced {
self.entries.push((user_id.to_vec(), credential.clone()));
}
Ok(())
}
fn update_sign_count(
&mut self,
credential_id: &[u8],
sign_count: u32,
) -> Result<(), Self::Error> {
for entry in &mut self.entries {
if constant_time_eq(&entry.1.credential_id, credential_id) {
entry.1.sign_count = sign_count;
}
}
Ok(())
}
fn update_last_used(
&mut self,
credential_id: &[u8],
last_used_at: Timestamp,
) -> Result<(), Self::Error> {
for entry in &mut self.entries {
if constant_time_eq(&entry.1.credential_id, credential_id) {
entry.1.last_used_at = Some(last_used_at);
}
}
Ok(())
}
fn remove(&mut self, credential_id: &[u8]) -> Result<bool, Self::Error> {
let before = self.entries.len();
self.entries
.retain(|(_, cred)| !constant_time_eq(&cred.credential_id, credential_id));
Ok(self.entries.len() != before)
}
}
#[derive(Clone, Debug)]
pub struct PasskeyUser {
pub id: Uuid,
pub name: String,
pub display_name: String,
}
pub struct RegistrationChallenge {
challenge_json: Value,
state: PasskeyRegistration,
prf_salt: Option<PrfSalt>,
prf_extension_json: Option<Value>,
}
impl RegistrationChallenge {
#[must_use]
pub fn challenge_json(&self) -> &Value {
&self.challenge_json
}
#[must_use]
pub fn merged_challenge_json(&self) -> Value {
merge_prf_extension(&self.challenge_json, self.prf_extension_json.as_ref())
}
#[must_use]
pub fn state(&self) -> &PasskeyRegistration {
&self.state
}
#[must_use]
pub fn into_state(self) -> PasskeyRegistration {
self.state
}
#[must_use]
pub fn into_state_with_salt(self) -> (PasskeyRegistration, Option<PrfSalt>) {
(self.state, self.prf_salt)
}
#[must_use]
pub fn prf_salt(&self) -> Option<&PrfSalt> {
self.prf_salt.as_ref()
}
#[must_use]
pub fn prf_extension_json(&self) -> Option<&Value> {
self.prf_extension_json.as_ref()
}
}
pub struct RegistrationOutcome {
pub credential: PasskeyCredential,
pub prf_supported: bool,
}
pub struct RegistrationCeremony;
impl RegistrationCeremony {
#[allow(clippy::needless_pass_by_value)]
pub fn begin(
rp: &RelyingParty,
user: PasskeyUser,
exclude_credentials: Option<Vec<CredentialID>>,
request_prf: bool,
) -> Result<RegistrationChallenge, WebAuthnError> {
let (ccr, state) = rp
.inner
.start_passkey_registration(
user.id,
&user.name,
&user.display_name,
exclude_credentials,
)
.map_err(|e| WebAuthnError::internal(format!("start_passkey_registration: {e}")))?;
let challenge_json = serde_json::to_value(&ccr)
.map_err(|e| WebAuthnError::internal(format!("serialise challenge: {e}")))?;
let (prf_salt, prf_extension_json) = if request_prf {
let salt = PrfSalt::generate()
.map_err(|e| WebAuthnError::internal(format!("prf salt random: {e}")))?;
let ext = PrfRegistrationRequest::from_salt(&salt).to_json_value();
(Some(salt), Some(ext))
} else {
(None, None)
};
Ok(RegistrationChallenge {
challenge_json,
state,
prf_salt,
prf_extension_json,
})
}
pub fn finish(
rp: &RelyingParty,
state: &PasskeyRegistration,
client_response: &RegisterPublicKeyCredential,
client_extension_results: Option<&Value>,
prf_salt: Option<PrfSalt>,
label: impl Into<String>,
now: Timestamp,
) -> Result<RegistrationOutcome, WebAuthnError> {
let passkey = rp
.inner
.finish_passkey_registration(client_response, state)
.map_err(map_webauthn_register_error)?;
let credential_id = passkey.cred_id().as_ref().to_vec();
let (aaguid, transports, sign_count) = passkey_metadata(&passkey);
let prf_result = client_extension_results
.map_or(PrfClientResult::Absent, prf::inspect_registration_response);
let prf_supported = prf_salt.is_some() && prf_result.was_honored();
let stored_salt = if prf_supported { prf_salt } else { None };
let credential = PasskeyCredential {
credential_id,
passkey,
sign_count,
transports,
aaguid,
label: label.into(),
prf_supported,
prf_salt: stored_salt,
created_at: now,
last_used_at: None,
};
Ok(RegistrationOutcome {
credential,
prf_supported,
})
}
}
pub struct AuthenticationChallenge {
challenge_json: Value,
state: PasskeyAuthentication,
prf_extension_json: Option<Value>,
}
impl AuthenticationChallenge {
#[must_use]
pub fn challenge_json(&self) -> &Value {
&self.challenge_json
}
#[must_use]
pub fn merged_challenge_json(&self) -> Value {
merge_prf_extension(&self.challenge_json, self.prf_extension_json.as_ref())
}
#[must_use]
pub fn state(&self) -> &PasskeyAuthentication {
&self.state
}
#[must_use]
pub fn into_state(self) -> PasskeyAuthentication {
self.state
}
#[must_use]
pub fn prf_extension_json(&self) -> Option<&Value> {
self.prf_extension_json.as_ref()
}
}
pub struct AuthenticationOutcome {
pub credential_id: Vec<u8>,
pub new_sign_count: u32,
pub user_verified: bool,
pub prf_supported: bool,
pub needs_update: bool,
}
pub struct AuthenticationCeremony;
impl AuthenticationCeremony {
pub fn begin(
rp: &RelyingParty,
allow_credentials: &[PasskeyCredential],
) -> Result<AuthenticationChallenge, WebAuthnError> {
let passkeys: Vec<Passkey> = allow_credentials
.iter()
.map(|c| c.passkey.clone())
.collect();
let (rcr, state) = rp
.inner
.start_passkey_authentication(&passkeys)
.map_err(|e| WebAuthnError::internal(format!("start_passkey_authentication: {e}")))?;
let challenge_json = serde_json::to_value(&rcr)
.map_err(|e| WebAuthnError::internal(format!("serialise challenge: {e}")))?;
let prf_entries: Vec<(&[u8], &PrfSalt)> = allow_credentials
.iter()
.filter_map(|c| {
c.prf_salt
.as_ref()
.map(|salt| (c.credential_id.as_slice(), salt))
})
.collect();
let prf_extension_json = prf::eval_by_credential_value(&prf_entries);
Ok(AuthenticationChallenge {
challenge_json,
state,
prf_extension_json,
})
}
pub fn finish<S>(
rp: &RelyingParty,
state: &PasskeyAuthentication,
client_response: &PublicKeyCredential,
client_extension_results: Option<&Value>,
store: &S,
require_user_verification: bool,
) -> Result<AuthenticationOutcome, WebAuthnError>
where
S: PasskeyCredentialStore,
{
let result: AuthenticationResult = rp
.inner
.finish_passkey_authentication(client_response, state)
.map_err(map_webauthn_auth_error)?;
let credential_id: Vec<u8> = result.cred_id().as_ref().to_vec();
let stored = store
.lookup_by_credential_id(&credential_id)
.map_err(|e| WebAuthnError::internal(format!("store lookup: {e}")))?
.ok_or_else(WebAuthnError::credential_not_found)?;
let new_sign_count: u32 = result.counter();
check_sign_count(new_sign_count, stored.sign_count)?;
if require_user_verification && !result.user_verified() {
return Err(WebAuthnError::user_verification_failed());
}
let prf_result = client_extension_results.map_or(
PrfClientResult::Absent,
prf::inspect_authentication_response,
);
let prf_supported = stored.prf_supported && prf_result.was_honored();
Ok(AuthenticationOutcome {
credential_id,
new_sign_count,
user_verified: result.user_verified(),
prf_supported,
needs_update: result.needs_update(),
})
}
}
fn map_webauthn_register_error(err: webauthn_rs::prelude::WebauthnError) -> WebAuthnError {
use webauthn_rs::prelude::WebauthnError as W;
match err {
W::InvalidRPOrigin => WebAuthnError::origin_mismatch(),
W::InvalidRPIDHash => WebAuthnError::rp_id_mismatch(),
W::MismatchedChallenge => WebAuthnError::challenge_mismatch(),
W::UserNotVerified | W::UserNotPresent => WebAuthnError::user_verification_failed(),
other => WebAuthnError::attestation_failed(other.to_string()),
}
}
fn map_webauthn_auth_error(err: webauthn_rs::prelude::WebauthnError) -> WebAuthnError {
use webauthn_rs::prelude::WebauthnError as W;
match err {
W::InvalidRPOrigin => WebAuthnError::origin_mismatch(),
W::InvalidRPIDHash => WebAuthnError::rp_id_mismatch(),
W::MismatchedChallenge => WebAuthnError::challenge_mismatch(),
W::UserNotVerified | W::UserNotPresent => WebAuthnError::user_verification_failed(),
W::CredentialNotFound => WebAuthnError::credential_not_found(),
W::CredentialPossibleCompromise => WebAuthnError::sign_count_rollback(),
other => WebAuthnError::attestation_failed(other.to_string()),
}
}
fn merge_prf_extension(challenge_json: &Value, prf_extension_json: Option<&Value>) -> Value {
let mut merged = challenge_json.clone();
let Some(prf) = prf_extension_json else {
return merged;
};
let Some(root) = merged.as_object_mut() else {
return merged;
};
let public_key = root
.entry("publicKey")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
let Some(public_key) = public_key.as_object_mut() else {
return merged;
};
let extensions = public_key
.entry("extensions")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
let Some(extensions) = extensions.as_object_mut() else {
return merged;
};
extensions.insert("prf".to_owned(), prf.clone());
merged
}
fn check_sign_count(new: u32, stored: u32) -> Result<(), WebAuthnError> {
let rollback = (new != 0 && new <= stored) || (new == 0 && stored != 0);
if rollback {
return Err(WebAuthnError::sign_count_rollback());
}
Ok(())
}
fn passkey_metadata(passkey: &Passkey) -> (Vec<u8>, Vec<String>, u32) {
let Ok(value) = serde_json::to_value(passkey) else {
return (Vec::new(), Vec::new(), 0);
};
metadata_from_value(&value)
}
fn metadata_from_value(value: &Value) -> (Vec<u8>, Vec<String>, u32) {
let Some(cred) = value.get("cred") else {
return (Vec::new(), Vec::new(), 0);
};
let counter =
u32::try_from(cred.get("counter").and_then(Value::as_u64).unwrap_or(0)).unwrap_or(0);
let transports: Vec<String> = cred
.get("transports")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
let aaguid = cred
.get("attestation")
.and_then(|a| a.get("metadata"))
.and_then(Value::as_object)
.and_then(|m| m.values().next())
.and_then(|inner| inner.get("aaguid"))
.and_then(parse_uuid_from_value)
.unwrap_or_default();
(aaguid, transports, counter)
}
fn parse_uuid_from_value(v: &Value) -> Option<Vec<u8>> {
if let Some(s) = v.as_str() {
return Uuid::parse_str(s).ok().map(|u| u.as_bytes().to_vec());
}
if let Some(arr) = v.as_array() {
if arr.len() == 16 {
let mut bytes = Vec::with_capacity(16);
for el in arr {
let b = el.as_u64()?;
bytes.push(u8::try_from(b).ok()?);
}
return Some(bytes);
}
}
None
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StoredPasskey {
pub credential_id: Vec<u8>,
pub passkey: Passkey,
pub sign_count: u32,
pub transports: Vec<String>,
pub aaguid: Vec<u8>,
pub label: String,
pub prf_supported: bool,
#[serde(default)]
pub prf_salt: Option<Vec<u8>>,
pub created_at_unix_secs: u64,
#[serde(default)]
pub last_used_at_unix_secs: Option<u64>,
}
impl PasskeyCredential {
#[must_use]
pub fn to_stored(&self) -> StoredPasskey {
StoredPasskey {
credential_id: self.credential_id.clone(),
passkey: self.passkey.clone(),
sign_count: self.sign_count,
transports: self.transports.clone(),
aaguid: self.aaguid.clone(),
label: self.label.clone(),
prf_supported: self.prf_supported,
prf_salt: self.prf_salt.as_ref().map(|s| s.as_bytes().to_vec()),
created_at_unix_secs: self.created_at.unix_epoch_secs(),
last_used_at_unix_secs: self.last_used_at.as_ref().map(Timestamp::unix_epoch_secs),
}
}
pub fn from_stored(stored: StoredPasskey) -> Result<Self, WebAuthnError> {
let prf_salt = match stored.prf_salt {
Some(bytes) => Some(PrfSalt::from_bytes(bytes)?),
None => None,
};
Ok(Self {
credential_id: stored.credential_id,
passkey: stored.passkey,
sign_count: stored.sign_count,
transports: stored.transports,
aaguid: stored.aaguid,
label: stored.label,
prf_supported: stored.prf_supported,
prf_salt,
created_at: Timestamp::from_unix_secs(stored.created_at_unix_secs),
last_used_at: stored.last_used_at_unix_secs.map(Timestamp::from_unix_secs),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_builder() -> RelyingPartyBuilder {
RelyingParty::builder("example.com", "Example Corp").origin("https://example.com")
}
#[test]
fn rp_builder_happy_path() {
let rp = valid_builder().build().unwrap();
assert_eq!(rp.rp_id(), "example.com");
assert_eq!(rp.rp_name(), "Example Corp");
assert_eq!(rp.origins(), &["https://example.com".to_owned()]);
}
#[test]
fn rp_builder_rejects_empty_rp_id() {
let err = RelyingParty::builder("", "x")
.origin("https://example.com")
.build()
.unwrap_err();
assert!(err.is_invalid_configuration());
assert!(err.to_string().contains("rp_id is empty"));
}
#[test]
fn rp_builder_rejects_no_origins() {
let err = RelyingParty::builder("example.com", "x")
.build()
.unwrap_err();
assert!(err.is_invalid_configuration());
assert!(err.to_string().contains("origin"));
}
#[test]
fn rp_builder_rejects_malformed_origin() {
let err = RelyingParty::builder("example.com", "x")
.origin("not a url")
.build()
.unwrap_err();
assert!(err.is_invalid_configuration());
}
#[test]
fn rp_builder_accepts_multiple_origins() {
let rp = RelyingParty::builder("example.com", "x")
.origin("https://example.com")
.origin("https://app.example.com")
.build()
.unwrap();
assert_eq!(rp.origins().len(), 2);
}
#[test]
fn rp_debug_includes_rp_id() {
let rp = valid_builder().build().unwrap();
let dbg = format!("{rp:?}");
assert!(dbg.contains("example.com"));
}
#[test]
fn empty_store_lookups_return_none_and_empty() {
let store = InMemoryPasskeyStore::new();
assert!(store.lookup_by_credential_id(b"x").unwrap().is_none());
assert!(store.lookup_by_user(b"u").unwrap().is_empty());
}
#[test]
fn empty_store_remove_returns_false() {
let mut store = InMemoryPasskeyStore::new();
assert!(!store.remove(b"never").unwrap());
}
#[test]
fn empty_store_update_is_silent_noop() {
let mut store = InMemoryPasskeyStore::new();
store.update_sign_count(b"never", 7).unwrap();
store
.update_last_used(b"never", Timestamp::from_unix_secs(0))
.unwrap();
assert!(store.lookup_by_credential_id(b"never").unwrap().is_none());
}
#[test]
fn in_memory_store_debug_reports_size() {
let store = InMemoryPasskeyStore::new();
let dbg = format!("{store:?}");
assert!(dbg.contains("InMemoryPasskeyStore"));
assert!(dbg.contains("len"));
}
#[test]
fn stub_to_satisfy_pattern_completeness() {
let cred_id: Vec<u8> = b"sentinel".to_vec();
let _: fn(&PasskeyCredential) -> &[u8] = PasskeyCredential::credential_id;
let _: fn(&PasskeyCredential) -> u32 = PasskeyCredential::sign_count;
let _: fn(&PasskeyCredential) -> bool = PasskeyCredential::prf_supported;
let _: fn(&PasskeyCredential) -> &str = PasskeyCredential::label;
let _: fn(&PasskeyCredential) -> Option<&PrfSalt> = PasskeyCredential::prf_salt;
drop(cred_id);
}
#[test]
fn merge_prf_extension_returns_unchanged_when_prf_is_none() {
let challenge = serde_json::json!({
"publicKey": {
"challenge": "abc",
"rp": { "id": "example.com", "name": "Example" },
}
});
let merged = merge_prf_extension(&challenge, None);
assert_eq!(merged, challenge);
assert!(merged["publicKey"].get("extensions").is_none());
}
#[test]
fn merge_prf_extension_splices_under_public_key_extensions_prf() {
let challenge = serde_json::json!({
"publicKey": {
"challenge": "abc",
"rp": { "id": "example.com", "name": "Example" },
}
});
let prf = serde_json::json!({"eval": {"first": "salt-b64"}});
let merged = merge_prf_extension(&challenge, Some(&prf));
assert_eq!(
merged["publicKey"]["extensions"]["prf"]["eval"]["first"],
"salt-b64"
);
assert_eq!(merged["publicKey"]["challenge"], "abc");
assert_eq!(merged["publicKey"]["rp"]["id"], "example.com");
}
#[test]
fn merge_prf_extension_preserves_pre_existing_extensions_keys() {
let challenge = serde_json::json!({
"publicKey": {
"challenge": "abc",
"extensions": {
"credProps": true,
"appid": "https://example.com/appid",
},
}
});
let prf = serde_json::json!({"eval": {"first": "salt-b64"}});
let merged = merge_prf_extension(&challenge, Some(&prf));
assert_eq!(merged["publicKey"]["extensions"]["credProps"], true);
assert_eq!(
merged["publicKey"]["extensions"]["appid"],
"https://example.com/appid"
);
assert_eq!(
merged["publicKey"]["extensions"]["prf"]["eval"]["first"],
"salt-b64"
);
}
#[test]
fn merge_prf_extension_is_idempotent() {
let challenge = serde_json::json!({
"publicKey": { "challenge": "abc" }
});
let prf = serde_json::json!({"eval": {"first": "salt-b64"}});
let once = merge_prf_extension(&challenge, Some(&prf));
let twice = merge_prf_extension(&once, Some(&prf));
assert_eq!(once, twice, "merge must be idempotent");
}
#[test]
fn merge_prf_extension_creates_extensions_object_when_absent() {
let challenge = serde_json::json!({"publicKey": {}});
let prf = serde_json::json!({"eval": {"first": "x"}});
let merged = merge_prf_extension(&challenge, Some(&prf));
assert_eq!(
merged["publicKey"]["extensions"]["prf"]["eval"]["first"],
"x"
);
}
#[test]
fn merge_prf_extension_does_not_mutate_input() {
let challenge = serde_json::json!({"publicKey": {}});
let before = challenge.clone();
let prf = serde_json::json!({"eval": {"first": "x"}});
let _ = merge_prf_extension(&challenge, Some(&prf));
assert_eq!(challenge, before, "merge must not mutate its input");
}
fn synthetic_passkey_json(aaguid_uuid: &str, transports: &[&str], counter: u64) -> Value {
serde_json::json!({
"cred": {
"cred_id": "Y3JlZGlk",
"counter": counter,
"transports": transports,
"attestation": {
"metadata": { "Packed": { "aaguid": aaguid_uuid } }
}
}
})
}
use super::metadata_from_value;
#[test]
fn passkey_metadata_recovers_aaguid_transports_and_counter() {
let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
let json = synthetic_passkey_json(uuid_str, &["usb", "nfc"], 7);
let (aaguid, transports, counter) = metadata_from_value(&json);
let expected = Uuid::parse_str(uuid_str).unwrap().as_bytes().to_vec();
assert_eq!(aaguid, expected, "AAGUID must round-trip through 16 bytes");
assert_eq!(transports, vec!["usb".to_owned(), "nfc".to_owned()]);
assert_eq!(counter, 7);
}
#[test]
fn passkey_metadata_handles_missing_attestation_metadata() {
let json = serde_json::json!({
"cred": {
"cred_id": "Y3JlZGlk",
"counter": 0,
"transports": null,
"attestation": { "metadata": "None" }
}
});
let (aaguid, transports, counter) = metadata_from_value(&json);
assert!(aaguid.is_empty());
assert!(transports.is_empty());
assert_eq!(counter, 0);
}
#[test]
fn passkey_metadata_handles_completely_missing_fields() {
let json = serde_json::json!({});
let (aaguid, transports, counter) = metadata_from_value(&json);
assert!(aaguid.is_empty());
assert!(transports.is_empty());
assert_eq!(counter, 0);
}
#[test]
fn passkey_metadata_tolerates_oversized_counter() {
let json = serde_json::json!({
"cred": { "counter": u64::MAX }
});
let (_, _, counter) = metadata_from_value(&json);
assert_eq!(counter, 0);
}
#[test]
fn sign_count_advancing_is_accepted() {
assert!(check_sign_count(5, 4).is_ok());
}
#[test]
fn sign_count_first_assertion_from_zero_stored_is_accepted() {
assert!(check_sign_count(5, 0).is_ok());
}
#[test]
fn sign_count_both_zero_is_accepted() {
assert!(check_sign_count(0, 0).is_ok());
}
#[test]
fn sign_count_equal_nonzero_is_rollback() {
let err = check_sign_count(4, 4).unwrap_err();
assert!(err.is_sign_count_rollback());
}
#[test]
fn sign_count_decreasing_is_rollback() {
let err = check_sign_count(3, 4).unwrap_err();
assert!(err.is_sign_count_rollback());
}
#[test]
fn sign_count_zero_after_nonzero_is_rollback() {
let err = check_sign_count(0, 4).unwrap_err();
assert!(err.is_sign_count_rollback());
}
#[test]
fn parse_uuid_accepts_canonical_string() {
let v = serde_json::json!("550e8400-e29b-41d4-a716-446655440000");
let bytes = parse_uuid_from_value(&v).unwrap();
assert_eq!(bytes.len(), 16);
}
#[test]
fn parse_uuid_accepts_byte_array() {
let bytes: Vec<Value> = (0u8..16).map(|b| Value::from(u64::from(b))).collect();
let v = Value::Array(bytes);
let recovered = parse_uuid_from_value(&v).unwrap();
assert_eq!(recovered, (0u8..16).collect::<Vec<u8>>());
}
#[test]
fn parse_uuid_rejects_unrelated_shape() {
assert!(parse_uuid_from_value(&serde_json::json!(42)).is_none());
assert!(parse_uuid_from_value(&serde_json::json!({})).is_none());
assert!(parse_uuid_from_value(&serde_json::json!([1, 2, 3])).is_none());
}
#[test]
fn empty_store_lookup_returns_none_for_short_and_long_inputs() {
let store = InMemoryPasskeyStore::new();
assert!(store.lookup_by_credential_id(b"").unwrap().is_none());
assert!(store.lookup_by_credential_id(&[0u8; 64]).unwrap().is_none());
assert!(
store
.lookup_by_credential_id(&[0u8; 1024])
.unwrap()
.is_none()
);
}
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
struct StoredPasskeyShapeProbe {
credential_id: Vec<u8>,
sign_count: u32,
transports: Vec<String>,
aaguid: Vec<u8>,
label: String,
prf_supported: bool,
#[serde(default)]
prf_salt: Option<Vec<u8>>,
created_at_unix_secs: u64,
#[serde(default)]
last_used_at_unix_secs: Option<u64>,
}
#[test]
fn stored_passkey_shape_round_trips_through_json() {
let probe = StoredPasskeyShapeProbe {
credential_id: vec![1, 2, 3, 4],
sign_count: 9,
transports: vec!["usb".to_owned(), "hybrid".to_owned()],
aaguid: vec![0xAA; 16],
label: "YubiKey 5".to_owned(),
prf_supported: true,
prf_salt: Some(vec![0xCC; PRF_SALT_LEN]),
created_at_unix_secs: 1_700_000_000,
last_used_at_unix_secs: Some(1_710_000_000),
};
let json = serde_json::to_string(&probe).expect("serialise");
let back: StoredPasskeyShapeProbe = serde_json::from_str(&json).expect("deserialise");
assert_eq!(probe, back);
}
#[test]
fn stored_passkey_shape_round_trips_with_optional_fields_absent() {
let probe = StoredPasskeyShapeProbe {
credential_id: vec![],
sign_count: 0,
transports: vec![],
aaguid: vec![],
label: String::new(),
prf_supported: false,
prf_salt: None,
created_at_unix_secs: 0,
last_used_at_unix_secs: None,
};
let json = serde_json::to_string(&probe).unwrap();
let back: StoredPasskeyShapeProbe = serde_json::from_str(&json).unwrap();
assert_eq!(probe, back);
}
}