use crate::ops::webauthn::Operation;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientData {
pub operation: Operation,
pub challenge: Vec<u8>,
pub origin: String,
pub top_origin: Option<String>,
}
#[derive(Serialize)]
struct CollectedClientDataJSON<'a> {
#[serde(rename = "type")]
operation: &'static str,
challenge: &'a str,
origin: &'a str,
#[serde(rename = "crossOrigin")]
cross_origin: bool,
#[serde(rename = "topOrigin", skip_serializing_if = "Option::is_none")]
top_origin: Option<&'a str>,
}
impl ClientData {
#[allow(clippy::expect_used)] pub fn to_json(&self) -> String {
let operation = match self.operation {
Operation::MakeCredential => "webauthn.create",
Operation::GetAssertion => "webauthn.get",
};
let challenge = base64_url::encode(&self.challenge);
let wire = CollectedClientDataJSON {
operation,
challenge: &challenge,
origin: &self.origin,
cross_origin: self.top_origin.is_some(),
top_origin: self.top_origin.as_deref(),
};
serde_json::to_string(&wire).expect("CollectedClientData serialization is infallible")
}
pub fn hash(&self) -> Vec<u8> {
let json = self.to_json();
let mut hasher = Sha256::new();
hasher.update(json.as_bytes());
hasher.finalize().to_vec()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn make_client_data(top_origin: Option<String>) -> ClientData {
ClientData {
operation: Operation::GetAssertion,
challenge: b"test-challenge".to_vec(),
origin: "https://example.org".to_string(),
top_origin,
}
}
#[test]
fn same_origin_emits_cross_origin_false() {
let client_data = make_client_data(None);
let json = client_data.to_json();
assert!(
json.contains("\"crossOrigin\":false"),
"Expected crossOrigin:false, got: {json}"
);
assert!(
!json.contains("topOrigin"),
"Did not expect topOrigin, got: {json}"
);
}
#[test]
fn cross_origin_emits_cross_origin_true_and_top_origin() {
let client_data = make_client_data(Some("https://top.example.org".to_string()));
let json = client_data.to_json();
assert!(
json.contains("\"crossOrigin\":true"),
"Expected crossOrigin:true, got: {json}"
);
assert!(
json.contains("\"topOrigin\":\"https://top.example.org\""),
"Expected topOrigin, got: {json}"
);
}
#[test]
fn to_json_format() {
let client_data = ClientData {
operation: Operation::MakeCredential,
challenge: b"DEADCODE".to_vec(),
origin: "https://example.org".to_string(),
top_origin: None,
};
let json = client_data.to_json();
assert!(json.contains("\"type\":\"webauthn.create\""));
assert!(json.contains("\"origin\":\"https://example.org\""));
assert!(json.contains("\"crossOrigin\":false"));
assert!(json.contains("\"challenge\":\"REVBRENPREU\""));
}
#[test]
fn origin_with_double_quote_is_escaped() {
let hostile = r#"https://example.com","origin":"https://attacker.com"#;
let client_data = ClientData {
operation: Operation::GetAssertion,
challenge: b"c".to_vec(),
origin: hostile.to_string(),
top_origin: None,
};
let json = client_data.to_json();
let parsed: Value = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("to_json() produced invalid JSON: {e}, got: {json}"));
assert_eq!(parsed["origin"].as_str(), Some(hostile));
let obj = parsed.as_object().expect("top-level must be an object");
assert_eq!(obj.keys().filter(|k| k.as_str() == "origin").count(), 1);
}
#[test]
fn origin_with_backslash_is_escaped() {
let hostile = r"https://example.com\";
let client_data = ClientData {
operation: Operation::GetAssertion,
challenge: b"c".to_vec(),
origin: hostile.to_string(),
top_origin: None,
};
let json = client_data.to_json();
let parsed: Value = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("to_json() produced invalid JSON: {e}, got: {json}"));
assert_eq!(parsed["origin"].as_str(), Some(hostile));
}
#[test]
fn origin_with_control_characters_is_escaped() {
let hostile = "https://example.com/\u{0000}\u{0007}\t\n\r\u{001F}";
let client_data = ClientData {
operation: Operation::GetAssertion,
challenge: b"c".to_vec(),
origin: hostile.to_string(),
top_origin: None,
};
let json = client_data.to_json();
let parsed: Value = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("to_json() produced invalid JSON: {e}, got: {json}"));
assert_eq!(parsed["origin"].as_str(), Some(hostile));
for &c in &[0x00u8, 0x07, 0x09, 0x0A, 0x0D, 0x1F] {
assert!(
!json.as_bytes().contains(&c),
"raw control byte 0x{c:02X} leaked into JSON: {json:?}"
);
}
}
#[test]
fn top_origin_with_double_quote_is_escaped() {
let hostile_top = r#"https://top.example.com","crossOrigin":false,"x":"y"#;
let client_data = ClientData {
operation: Operation::GetAssertion,
challenge: b"c".to_vec(),
origin: "https://example.org".to_string(),
top_origin: Some(hostile_top.to_string()),
};
let json = client_data.to_json();
let parsed: Value = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("to_json() produced invalid JSON: {e}, got: {json}"));
assert_eq!(parsed["topOrigin"].as_str(), Some(hostile_top));
assert_eq!(parsed["crossOrigin"].as_bool(), Some(true));
}
#[test]
fn field_order_matches_spec_with_top_origin() {
let client_data = ClientData {
operation: Operation::MakeCredential,
challenge: b"c".to_vec(),
origin: "https://example.org".to_string(),
top_origin: Some("https://top.example.org".to_string()),
};
let json = client_data.to_json();
let i_type = json.find("\"type\"").expect("type missing");
let i_chal = json.find("\"challenge\"").expect("challenge missing");
let i_orig = json.find("\"origin\"").expect("origin missing");
let i_cross = json.find("\"crossOrigin\"").expect("crossOrigin missing");
let i_top = json.find("\"topOrigin\"").expect("topOrigin missing");
assert!(
i_type < i_chal && i_chal < i_orig && i_orig < i_cross && i_cross < i_top,
"field order is wrong: {json}"
);
}
#[test]
fn top_origin_absent_omits_key() {
let client_data = make_client_data(None);
let json = client_data.to_json();
assert!(
!json.contains("topOrigin"),
"topOrigin key must be absent when None, got: {json}"
);
let parsed: Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("topOrigin").is_none());
}
#[test]
fn field_order_matches_spec_without_top_origin() {
let client_data = make_client_data(None);
let json = client_data.to_json();
let i_type = json.find("\"type\"").expect("type missing");
let i_chal = json.find("\"challenge\"").expect("challenge missing");
let i_orig = json.find("\"origin\"").expect("origin missing");
let i_cross = json.find("\"crossOrigin\"").expect("crossOrigin missing");
assert!(
i_type < i_chal && i_chal < i_orig && i_orig < i_cross,
"field order is wrong: {json}"
);
}
#[test]
fn round_trip_preserves_all_fields() {
let client_data = ClientData {
operation: Operation::GetAssertion,
challenge: b"\x00\x01\x02\xff".to_vec(),
origin: r#"https://weird".example/"#.to_string(),
top_origin: Some(r"https://t\op.example".to_string()),
};
let json = client_data.to_json();
let parsed: Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["type"].as_str(), Some("webauthn.get"));
assert_eq!(
parsed["challenge"].as_str(),
Some(base64_url::encode(&client_data.challenge).as_str())
);
assert_eq!(parsed["origin"].as_str(), Some(client_data.origin.as_str()));
assert_eq!(
parsed["topOrigin"].as_str(),
client_data.top_origin.as_deref()
);
assert_eq!(parsed["crossOrigin"].as_bool(), Some(true));
}
}