use core::fmt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::crypto::zeroize::Zeroizing;
use crate::crypto::{RandomError, fill_random};
use crate::encoding::base64url_encode;
use crate::webauthn::error::WebAuthnError;
pub const PRF_SALT_LEN: usize = 32;
#[derive(Clone)]
pub struct PrfSalt {
bytes: Zeroizing<Vec<u8>>,
}
impl PrfSalt {
pub fn generate() -> Result<Self, RandomError> {
let mut buf = vec![0u8; PRF_SALT_LEN];
fill_random(&mut buf)?;
Ok(Self {
bytes: Zeroizing::new(buf),
})
}
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, WebAuthnError> {
if bytes.len() != PRF_SALT_LEN {
return Err(WebAuthnError::invalid_configuration(format!(
"prf salt must be {PRF_SALT_LEN} bytes, got {}",
bytes.len()
)));
}
Ok(Self {
bytes: Zeroizing::new(bytes),
})
}
#[must_use]
#[inline]
pub fn as_bytes(&self) -> &[u8] {
&self.bytes
}
#[must_use]
#[inline]
pub fn to_base64url(&self) -> String {
base64url_encode(&self.bytes)
}
}
impl fmt::Debug for PrfSalt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PrfSalt").finish_non_exhaustive()
}
}
impl Serialize for PrfSalt {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.as_bytes().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for PrfSalt {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let bytes = Vec::<u8>::deserialize(deserializer)?;
Self::from_bytes(bytes).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PrfRegistrationRequest {
pub eval_first_b64url: String,
}
impl PrfRegistrationRequest {
#[must_use]
pub fn from_salt(salt: &PrfSalt) -> Self {
Self {
eval_first_b64url: salt.to_base64url(),
}
}
#[must_use]
pub fn to_json_value(&self) -> Value {
serde_json::json!({
"eval": {
"first": self.eval_first_b64url,
},
})
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PrfAuthenticationRequest {
pub eval_first_b64url: String,
}
impl PrfAuthenticationRequest {
#[must_use]
pub fn from_salt(salt: &PrfSalt) -> Self {
Self {
eval_first_b64url: salt.to_base64url(),
}
}
#[must_use]
pub fn to_json_value(&self) -> Value {
serde_json::json!({
"eval": {
"first": self.eval_first_b64url,
},
})
}
}
#[must_use]
pub(crate) fn eval_by_credential_value(entries: &[(&[u8], &PrfSalt)]) -> Option<Value> {
if entries.is_empty() {
return None;
}
let mut by_cred = serde_json::Map::with_capacity(entries.len());
for (cred_id, salt) in entries {
by_cred.insert(
base64url_encode(cred_id),
serde_json::json!({ "first": salt.to_base64url() }),
);
}
Some(serde_json::json!({ "evalByCredential": by_cred }))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PrfClientResult {
Honored,
Unsupported,
Absent,
}
impl PrfClientResult {
#[must_use]
#[inline]
pub fn was_honored(self) -> bool {
matches!(self, Self::Honored)
}
}
#[must_use]
pub fn inspect_registration_response(client_extension_results: &Value) -> PrfClientResult {
let Some(prf) = client_extension_results.get("prf") else {
return PrfClientResult::Absent;
};
match prf.get("enabled").and_then(Value::as_bool) {
Some(true) => return PrfClientResult::Honored,
Some(false) => return PrfClientResult::Unsupported,
None => {}
}
if prf.get("results").and_then(Value::as_object).is_some() {
return PrfClientResult::Honored;
}
PrfClientResult::Unsupported
}
#[must_use]
pub fn inspect_authentication_response(client_extension_results: &Value) -> PrfClientResult {
let Some(prf) = client_extension_results.get("prf") else {
return PrfClientResult::Absent;
};
if let Some(results) = prf.get("results") {
if results.get("first").and_then(Value::as_str).is_some() {
return PrfClientResult::Honored;
}
}
PrfClientResult::Unsupported
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn salt_generate_is_correct_length() {
let salt = PrfSalt::generate().unwrap();
assert_eq!(salt.as_bytes().len(), PRF_SALT_LEN);
}
#[test]
fn salt_from_bytes_correct_length() {
let salt = PrfSalt::from_bytes(vec![0u8; PRF_SALT_LEN]).unwrap();
assert_eq!(salt.as_bytes(), &[0u8; PRF_SALT_LEN]);
}
#[test]
fn salt_from_bytes_rejects_short_input() {
let err = PrfSalt::from_bytes(vec![0u8; 16]).unwrap_err();
assert!(err.is_invalid_configuration());
}
#[test]
fn salt_from_bytes_rejects_long_input() {
let err = PrfSalt::from_bytes(vec![0u8; 64]).unwrap_err();
assert!(err.is_invalid_configuration());
}
#[test]
fn salt_debug_redacts() {
let salt = PrfSalt::from_bytes(vec![0xAB; PRF_SALT_LEN]).unwrap();
let dbg = format!("{salt:?}");
assert!(dbg.contains("PrfSalt"));
assert!(!dbg.contains("AB"));
assert!(!dbg.contains("ab"));
}
#[test]
fn salt_base64url_round_trip_stable() {
let bytes: Vec<u8> = (0u8..32).collect();
let salt = PrfSalt::from_bytes(bytes.clone()).unwrap();
let s1 = salt.to_base64url();
let s2 = salt.to_base64url();
assert_eq!(s1, s2);
assert!(!s1.contains('='));
assert!(!s1.contains('+'));
assert!(!s1.contains('/'));
}
#[test]
fn prf_client_result_was_honored() {
assert!(PrfClientResult::Honored.was_honored());
assert!(!PrfClientResult::Unsupported.was_honored());
assert!(!PrfClientResult::Absent.was_honored());
}
#[test]
fn registration_request_to_json_value_shape() {
let salt = PrfSalt::from_bytes(vec![1u8; PRF_SALT_LEN]).unwrap();
let req = PrfRegistrationRequest::from_salt(&salt);
let v = req.to_json_value();
assert_eq!(v["eval"]["first"], salt.to_base64url());
}
#[test]
fn authentication_request_to_json_value_shape() {
let salt = PrfSalt::from_bytes(vec![2u8; PRF_SALT_LEN]).unwrap();
let req = PrfAuthenticationRequest::from_salt(&salt);
let v = req.to_json_value();
assert_eq!(v["eval"]["first"], salt.to_base64url());
}
#[test]
fn inspect_registration_response_enabled_true() {
let v = serde_json::json!({"prf": {"enabled": true}});
assert_eq!(inspect_registration_response(&v), PrfClientResult::Honored);
}
#[test]
fn inspect_registration_response_results_present() {
let v = serde_json::json!({"prf": {"results": {"first": "AAAA"}}});
assert_eq!(inspect_registration_response(&v), PrfClientResult::Honored);
}
#[test]
fn inspect_registration_response_enabled_false_overrides_results() {
let v = serde_json::json!({"prf": {"enabled": false, "results": {"first": "AAAA"}}});
assert_eq!(
inspect_registration_response(&v),
PrfClientResult::Unsupported
);
}
#[test]
fn inspect_registration_response_enabled_false() {
let v = serde_json::json!({"prf": {"enabled": false}});
assert_eq!(
inspect_registration_response(&v),
PrfClientResult::Unsupported
);
}
#[test]
fn inspect_registration_response_absent() {
let v = serde_json::json!({});
assert_eq!(inspect_registration_response(&v), PrfClientResult::Absent);
}
#[test]
fn inspect_authentication_response_results_first_present() {
let v = serde_json::json!({"prf": {"results": {"first": "AAAA"}}});
assert_eq!(
inspect_authentication_response(&v),
PrfClientResult::Honored
);
}
#[test]
fn inspect_authentication_response_no_results() {
let v = serde_json::json!({"prf": {}});
assert_eq!(
inspect_authentication_response(&v),
PrfClientResult::Unsupported
);
}
#[test]
fn inspect_authentication_response_absent() {
let v = serde_json::json!({});
assert_eq!(inspect_authentication_response(&v), PrfClientResult::Absent);
}
#[test]
fn inspect_authentication_response_first_null_is_not_honored() {
let v = serde_json::json!({"prf": {"results": {"first": null}}});
assert_eq!(
inspect_authentication_response(&v),
PrfClientResult::Unsupported
);
}
#[test]
fn inspect_authentication_response_first_non_string_is_not_honored() {
let v = serde_json::json!({"prf": {"results": {"first": 123}}});
assert_eq!(
inspect_authentication_response(&v),
PrfClientResult::Unsupported
);
}
#[test]
fn eval_by_credential_maps_each_credential_to_its_own_salt() {
let salt_a = PrfSalt::from_bytes(vec![0xAAu8; PRF_SALT_LEN]).unwrap();
let salt_b = PrfSalt::from_bytes(vec![0xBBu8; PRF_SALT_LEN]).unwrap();
let cred_a: &[u8] = b"cred-a";
let cred_b: &[u8] = b"cred-b";
let v = eval_by_credential_value(&[(cred_a, &salt_a), (cred_b, &salt_b)]).unwrap();
let by_cred = v
.get("evalByCredential")
.and_then(Value::as_object)
.unwrap();
assert_eq!(by_cred.len(), 2);
let key_a = crate::encoding::base64url_encode(cred_a);
let key_b = crate::encoding::base64url_encode(cred_b);
assert_eq!(
by_cred[&key_a]["first"].as_str().unwrap(),
salt_a.to_base64url()
);
assert_eq!(
by_cred[&key_b]["first"].as_str().unwrap(),
salt_b.to_base64url()
);
assert_ne!(salt_a.to_base64url(), salt_b.to_base64url());
}
#[test]
fn eval_by_credential_empty_is_none() {
assert!(eval_by_credential_value(&[]).is_none());
}
#[test]
fn inspect_registration_response_results_null_is_not_honored() {
let v = serde_json::json!({"prf": {"results": null}});
assert_eq!(
inspect_registration_response(&v),
PrfClientResult::Unsupported
);
}
#[test]
fn salt_determinism_same_input_same_output() {
let bytes: Vec<u8> = (0u8..32).map(|i| i.wrapping_mul(7)).collect();
let a = PrfSalt::from_bytes(bytes.clone()).unwrap();
let b = PrfSalt::from_bytes(bytes).unwrap();
assert_eq!(a.to_base64url(), b.to_base64url());
assert_eq!(a.as_bytes(), b.as_bytes());
}
}