#![cfg(feature = "algo-p256")]
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD as B64_STD, URL_SAFE_NO_PAD as B64_URL_NOPAD};
use sha2::{Digest, Sha256};
use crate::Error;
use crate::signer_p256::verify_p256;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebAuthnWrapping {
pub authenticator_data: Vec<u8>,
pub client_data_json: Vec<u8>,
}
impl WebAuthnWrapping {
pub fn from_b64url_fields(
authenticator_data_b64: &str,
client_data_json_b64: &str,
) -> Result<Self, Error> {
let authenticator_data = B64_URL_NOPAD
.decode(authenticator_data_b64.as_bytes())
.map_err(|_| Error::WebAuthnBadAuthenticatorData)?;
let client_data_json = B64_URL_NOPAD
.decode(client_data_json_b64.as_bytes())
.map_err(|_| Error::WebAuthnBadClientDataJson)?;
Ok(Self {
authenticator_data,
client_data_json,
})
}
#[must_use]
pub fn to_b64url_fields(&self) -> (String, String) {
(
B64_URL_NOPAD.encode(&self.authenticator_data),
B64_URL_NOPAD.encode(&self.client_data_json),
)
}
}
const AUTH_FLAG_UP: u8 = 0x01;
const AUTH_FLAG_UV: u8 = 0x04;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebAuthnPolicy {
pub expected_rp_id: Option<String>,
pub allowed_origins: Option<Vec<String>>,
pub require_user_presence: bool,
pub require_user_verification: bool,
pub allow_cross_origin: bool,
pub previous_sign_count: Option<u32>,
}
impl Default for WebAuthnPolicy {
fn default() -> Self {
Self::permissive()
}
}
impl WebAuthnPolicy {
#[must_use]
pub fn permissive() -> Self {
Self {
expected_rp_id: None,
allowed_origins: None,
require_user_presence: false,
require_user_verification: false,
allow_cross_origin: true,
previous_sign_count: None,
}
}
}
pub fn verify_webauthn_wrapping_with_policy(
pae: &[u8],
wrapping: &WebAuthnWrapping,
pubkey_sec1: &[u8],
sig_compact: &[u8],
policy: &WebAuthnPolicy,
) -> Result<(), Error> {
if wrapping.authenticator_data.len() < 37 {
return Err(Error::WebAuthnBadAuthenticatorData);
}
let auth = &wrapping.authenticator_data;
let rp_id_hash = &auth[0..32];
let flags = auth[32];
let sign_count = u32::from_be_bytes([auth[33], auth[34], auth[35], auth[36]]);
if let Some(rp_id) = policy.expected_rp_id.as_deref() {
let expected = Sha256::digest(rp_id.as_bytes());
if rp_id_hash != expected.as_slice() {
return Err(Error::WebAuthnRpIdMismatch);
}
}
if policy.require_user_presence && (flags & AUTH_FLAG_UP) == 0 {
return Err(Error::WebAuthnUserPresenceRequired);
}
if policy.require_user_verification && (flags & AUTH_FLAG_UV) == 0 {
return Err(Error::WebAuthnUserVerificationRequired);
}
if let Some(prev) = policy.previous_sign_count
&& sign_count < prev
{
return Err(Error::WebAuthnCounterRollback);
}
let client_data: serde_json::Value = serde_json::from_slice(&wrapping.client_data_json)
.map_err(|_| Error::WebAuthnBadClientDataJson)?;
let obj = client_data
.as_object()
.ok_or(Error::WebAuthnBadClientDataJson)?;
let ty = obj
.get("type")
.and_then(serde_json::Value::as_str)
.ok_or(Error::WebAuthnBadClientDataJson)?;
if ty != "webauthn.get" {
return Err(Error::WebAuthnBadClientDataJson);
}
if let Some(allowed) = policy.allowed_origins.as_deref() {
let origin = obj
.get("origin")
.and_then(serde_json::Value::as_str)
.ok_or(Error::WebAuthnOriginNotAllowed)?;
if !allowed.iter().any(|o| o == origin) {
return Err(Error::WebAuthnOriginNotAllowed);
}
}
if !policy.allow_cross_origin {
let cross = obj
.get("crossOrigin")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if cross {
return Err(Error::WebAuthnCrossOriginNotAllowed);
}
}
let challenge_str = obj
.get("challenge")
.and_then(serde_json::Value::as_str)
.ok_or(Error::WebAuthnBadClientDataJson)?;
let challenge_bytes = B64_URL_NOPAD
.decode(challenge_str.as_bytes())
.map_err(|_| Error::WebAuthnChallengeMismatch)?;
if challenge_bytes != pae {
return Err(Error::WebAuthnChallengeMismatch);
}
let mut to_verify = Vec::with_capacity(wrapping.authenticator_data.len() + 32);
to_verify.extend_from_slice(&wrapping.authenticator_data);
let cd_hash = Sha256::digest(&wrapping.client_data_json);
to_verify.extend_from_slice(&cd_hash);
verify_p256(pubkey_sec1, &to_verify, sig_compact).map_err(|_| Error::WebAuthnSignatureFailed)
}
pub fn verify_webauthn_wrapping(
pae: &[u8],
wrapping: &WebAuthnWrapping,
pubkey_sec1: &[u8],
sig_compact: &[u8],
) -> Result<(), Error> {
verify_webauthn_wrapping_with_policy(
pae,
wrapping,
pubkey_sec1,
sig_compact,
&WebAuthnPolicy::permissive(),
)
}
#[must_use]
pub fn build_client_data_json(pae: &[u8], rp_origin: &str, cross_origin: bool) -> Vec<u8> {
let challenge = B64_URL_NOPAD.encode(pae);
let body = format!(
"{{\"type\":\"webauthn.get\",\"challenge\":\"{}\",\"origin\":\"{}\",\"crossOrigin\":{}}}",
challenge,
json_escape(rp_origin),
cross_origin,
);
body.into_bytes()
}
pub fn decode_pae_b64_standard(pae_b64: &str) -> Result<Vec<u8>, Error> {
B64_STD
.decode(pae_b64.as_bytes())
.map_err(|_| Error::WebAuthnBadClientDataJson)
}
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{08}' => out.push_str("\\b"),
'\u{0C}' => out.push_str("\\f"),
c if (c as u32) < 0x20 => {
use std::fmt::Write as _;
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::signer_p256::P256Signer;
use p256::ecdsa::{Signature as P256Sig, SigningKey, signature::Signer as _};
const TEST_SECRET: [u8; 32] = [7u8; 32];
fn mock_auth_data(rp_id: &str) -> Vec<u8> {
let rp_id_hash = Sha256::digest(rp_id.as_bytes());
let mut out = Vec::with_capacity(37);
out.extend_from_slice(&rp_id_hash);
out.push(0x05);
out.extend_from_slice(&[0u8; 4]);
out
}
fn sign_webauthn(
pae: &[u8],
rp_id: &str,
origin: &str,
secret: [u8; 32],
) -> (WebAuthnWrapping, Vec<u8>) {
let auth_data = mock_auth_data(rp_id);
let cdj = build_client_data_json(pae, origin, false);
let mut to_sign = Vec::with_capacity(auth_data.len() + 32);
to_sign.extend_from_slice(&auth_data);
to_sign.extend_from_slice(&Sha256::digest(&cdj));
let sk = SigningKey::from_bytes(&secret.into()).unwrap();
let sig: P256Sig = sk.sign(&to_sign);
let sig = sig.normalize_s();
(
WebAuthnWrapping {
authenticator_data: auth_data,
client_data_json: cdj,
},
sig.to_bytes().to_vec(),
)
}
#[test]
fn valid_wrapping_verifies() {
let pae = b"DSSEv1 28 application/vnd.in-toto+json 2 {}";
let (wrap, sig) = sign_webauthn(pae, "mkit.local", "https://mkit.local", TEST_SECRET);
let signer = P256Signer::new(TEST_SECRET).unwrap();
verify_webauthn_wrapping(pae, &wrap, &signer.public_key_sec1(), &sig)
.expect("happy path verifies");
}
#[test]
fn challenge_mismatch_is_rejected() {
let pae = b"the real pae";
let (mut wrap, sig) = sign_webauthn(pae, "mkit.local", "https://mkit.local", TEST_SECRET);
let signer = P256Signer::new(TEST_SECRET).unwrap();
wrap.client_data_json =
build_client_data_json(b"different pae", "https://mkit.local", false);
let err = verify_webauthn_wrapping(pae, &wrap, &signer.public_key_sec1(), &sig)
.expect_err("must reject challenge mismatch");
assert!(
matches!(err, Error::WebAuthnChallengeMismatch),
"got {err:?}"
);
}
#[test]
fn bad_client_data_json_is_rejected() {
let pae = b"pae";
let (mut wrap, sig) = sign_webauthn(pae, "mkit.local", "https://mkit.local", TEST_SECRET);
let signer = P256Signer::new(TEST_SECRET).unwrap();
wrap.client_data_json = b"not json at all".to_vec();
let err = verify_webauthn_wrapping(pae, &wrap, &signer.public_key_sec1(), &sig)
.expect_err("must reject non-JSON clientDataJSON");
assert!(
matches!(err, Error::WebAuthnBadClientDataJson),
"got {err:?}"
);
}
#[test]
fn wrong_type_field_is_rejected() {
let pae = b"pae";
let signer = P256Signer::new(TEST_SECRET).unwrap();
let challenge = B64_URL_NOPAD.encode(pae);
let cdj = format!(
"{{\"type\":\"webauthn.create\",\"challenge\":\"{challenge}\",\"origin\":\"https://mkit.local\",\"crossOrigin\":false}}"
);
let auth_data = mock_auth_data("mkit.local");
let mut to_sign = Vec::new();
to_sign.extend_from_slice(&auth_data);
to_sign.extend_from_slice(&Sha256::digest(cdj.as_bytes()));
let sk = SigningKey::from_bytes(&TEST_SECRET.into()).unwrap();
let sig: P256Sig = sk.sign(&to_sign);
let sig = sig.normalize_s().to_bytes().to_vec();
let wrap = WebAuthnWrapping {
authenticator_data: auth_data,
client_data_json: cdj.into_bytes(),
};
let err = verify_webauthn_wrapping(pae, &wrap, &signer.public_key_sec1(), &sig)
.expect_err("must reject webauthn.create type");
assert!(
matches!(err, Error::WebAuthnBadClientDataJson),
"got {err:?}"
);
}
#[test]
fn wrong_pubkey_yields_signature_failed() {
let pae = b"pae";
let (wrap, sig) = sign_webauthn(pae, "mkit.local", "https://mkit.local", TEST_SECRET);
let other = P256Signer::new([0x42; 32]).unwrap();
let err = verify_webauthn_wrapping(pae, &wrap, &other.public_key_sec1(), &sig)
.expect_err("wrong key must fail");
assert!(matches!(err, Error::WebAuthnSignatureFailed), "got {err:?}");
}
#[test]
fn truncated_authenticator_data_is_rejected() {
let pae = b"pae";
let (mut wrap, sig) = sign_webauthn(pae, "mkit.local", "https://mkit.local", TEST_SECRET);
let signer = P256Signer::new(TEST_SECRET).unwrap();
wrap.authenticator_data.truncate(20);
let err = verify_webauthn_wrapping(pae, &wrap, &signer.public_key_sec1(), &sig)
.expect_err("short auth_data must be rejected");
assert!(
matches!(err, Error::WebAuthnBadAuthenticatorData),
"got {err:?}"
);
}
#[test]
fn b64url_field_roundtrip() {
let w = WebAuthnWrapping {
authenticator_data: vec![1, 2, 3, 4, 5],
client_data_json: vec![b'{', b'}'],
};
let (a_b64, c_b64) = w.to_b64url_fields();
let decoded = WebAuthnWrapping::from_b64url_fields(&a_b64, &c_b64).unwrap();
assert_eq!(decoded, w);
}
#[test]
fn b64url_field_rejects_bad_base64() {
let err = WebAuthnWrapping::from_b64url_fields("!not base64!", "abcd").unwrap_err();
assert!(
matches!(err, Error::WebAuthnBadAuthenticatorData),
"got {err:?}"
);
let err = WebAuthnWrapping::from_b64url_fields("abcd", "!not base64!").unwrap_err();
assert!(
matches!(err, Error::WebAuthnBadClientDataJson),
"got {err:?}"
);
}
fn mock_auth_data_full(rp_id: &str, flags: u8, sign_count: u32) -> Vec<u8> {
let rp_id_hash = Sha256::digest(rp_id.as_bytes());
let mut out = Vec::with_capacity(37);
out.extend_from_slice(&rp_id_hash);
out.push(flags);
out.extend_from_slice(&sign_count.to_be_bytes());
out
}
fn sign_webauthn_full(
pae: &[u8],
rp_id: &str,
origin: &str,
flags: u8,
sign_count: u32,
cross_origin: bool,
secret: [u8; 32],
) -> (WebAuthnWrapping, Vec<u8>) {
let auth_data = mock_auth_data_full(rp_id, flags, sign_count);
let cdj = build_client_data_json(pae, origin, cross_origin);
let mut to_sign = Vec::with_capacity(auth_data.len() + 32);
to_sign.extend_from_slice(&auth_data);
to_sign.extend_from_slice(&Sha256::digest(&cdj));
let sk = SigningKey::from_bytes(&secret.into()).unwrap();
let sig: P256Sig = sk.sign(&to_sign);
let sig = sig.normalize_s();
(
WebAuthnWrapping {
authenticator_data: auth_data,
client_data_json: cdj,
},
sig.to_bytes().to_vec(),
)
}
#[test]
fn permissive_policy_allows_up_only_and_zero_counter() {
let pae = b"pae";
let (wrap, sig) = sign_webauthn_full(
pae,
"mkit.local",
"https://mkit.local",
AUTH_FLAG_UP,
0,
false,
TEST_SECRET,
);
let signer = P256Signer::new(TEST_SECRET).unwrap();
verify_webauthn_wrapping_with_policy(
pae,
&wrap,
&signer.public_key_sec1(),
&sig,
&WebAuthnPolicy::permissive(),
)
.expect("permissive policy accepts UP-only, signCount=0");
}
#[test]
fn policy_rejects_wrong_rp_id() {
let pae = b"pae";
let (wrap, sig) = sign_webauthn(pae, "mkit.local", "https://mkit.local", TEST_SECRET);
let signer = P256Signer::new(TEST_SECRET).unwrap();
let policy = WebAuthnPolicy {
expected_rp_id: Some("evil.example".to_owned()),
..WebAuthnPolicy::permissive()
};
let err = verify_webauthn_wrapping_with_policy(
pae,
&wrap,
&signer.public_key_sec1(),
&sig,
&policy,
)
.expect_err("wrong RP ID must be rejected");
assert!(matches!(err, Error::WebAuthnRpIdMismatch), "got {err:?}");
}
#[test]
fn policy_accepts_matching_rp_id() {
let pae = b"pae";
let (wrap, sig) = sign_webauthn(pae, "mkit.local", "https://mkit.local", TEST_SECRET);
let signer = P256Signer::new(TEST_SECRET).unwrap();
let policy = WebAuthnPolicy {
expected_rp_id: Some("mkit.local".to_owned()),
..WebAuthnPolicy::permissive()
};
verify_webauthn_wrapping_with_policy(pae, &wrap, &signer.public_key_sec1(), &sig, &policy)
.expect("matching RP ID verifies");
}
#[test]
fn policy_rejects_origin_not_in_allowlist() {
let pae = b"pae";
let (wrap, sig) = sign_webauthn(pae, "mkit.local", "https://mkit.local", TEST_SECRET);
let signer = P256Signer::new(TEST_SECRET).unwrap();
let policy = WebAuthnPolicy {
allowed_origins: Some(vec!["https://other.example".to_owned()]),
..WebAuthnPolicy::permissive()
};
let err = verify_webauthn_wrapping_with_policy(
pae,
&wrap,
&signer.public_key_sec1(),
&sig,
&policy,
)
.expect_err("origin not in allow-list must be rejected");
assert!(
matches!(err, Error::WebAuthnOriginNotAllowed),
"got {err:?}"
);
}
#[test]
fn policy_accepts_allowed_origin() {
let pae = b"pae";
let (wrap, sig) = sign_webauthn(pae, "mkit.local", "https://mkit.local", TEST_SECRET);
let signer = P256Signer::new(TEST_SECRET).unwrap();
let policy = WebAuthnPolicy {
allowed_origins: Some(vec![
"https://mkit.local".to_owned(),
"https://other.example".to_owned(),
]),
..WebAuthnPolicy::permissive()
};
verify_webauthn_wrapping_with_policy(pae, &wrap, &signer.public_key_sec1(), &sig, &policy)
.expect("allowed origin verifies");
}
#[test]
fn policy_rejects_missing_user_presence() {
let pae = b"pae";
let (wrap, sig) = sign_webauthn_full(
pae,
"mkit.local",
"https://mkit.local",
AUTH_FLAG_UV,
0,
false,
TEST_SECRET,
);
let signer = P256Signer::new(TEST_SECRET).unwrap();
let policy = WebAuthnPolicy {
require_user_presence: true,
..WebAuthnPolicy::permissive()
};
let err = verify_webauthn_wrapping_with_policy(
pae,
&wrap,
&signer.public_key_sec1(),
&sig,
&policy,
)
.expect_err("missing UP must be rejected");
assert!(
matches!(err, Error::WebAuthnUserPresenceRequired),
"got {err:?}"
);
}
#[test]
fn policy_rejects_missing_user_verification() {
let pae = b"pae";
let (wrap, sig) = sign_webauthn_full(
pae,
"mkit.local",
"https://mkit.local",
AUTH_FLAG_UP,
0,
false,
TEST_SECRET,
);
let signer = P256Signer::new(TEST_SECRET).unwrap();
let policy = WebAuthnPolicy {
require_user_verification: true,
..WebAuthnPolicy::permissive()
};
let err = verify_webauthn_wrapping_with_policy(
pae,
&wrap,
&signer.public_key_sec1(),
&sig,
&policy,
)
.expect_err("missing UV must be rejected");
assert!(
matches!(err, Error::WebAuthnUserVerificationRequired),
"got {err:?}"
);
}
#[test]
fn policy_rejects_cross_origin_when_disallowed() {
let pae = b"pae";
let (wrap, sig) = sign_webauthn_full(
pae,
"mkit.local",
"https://mkit.local",
AUTH_FLAG_UP,
0,
true, TEST_SECRET,
);
let signer = P256Signer::new(TEST_SECRET).unwrap();
let policy = WebAuthnPolicy {
allow_cross_origin: false,
..WebAuthnPolicy::permissive()
};
let err = verify_webauthn_wrapping_with_policy(
pae,
&wrap,
&signer.public_key_sec1(),
&sig,
&policy,
)
.expect_err("crossOrigin=true must be rejected when disallowed");
assert!(
matches!(err, Error::WebAuthnCrossOriginNotAllowed),
"got {err:?}"
);
}
#[test]
fn policy_rejects_counter_rollback() {
let pae = b"pae";
let (wrap, sig) = sign_webauthn_full(
pae,
"mkit.local",
"https://mkit.local",
AUTH_FLAG_UP,
3,
false,
TEST_SECRET,
);
let signer = P256Signer::new(TEST_SECRET).unwrap();
let policy = WebAuthnPolicy {
previous_sign_count: Some(5),
..WebAuthnPolicy::permissive()
};
let err = verify_webauthn_wrapping_with_policy(
pae,
&wrap,
&signer.public_key_sec1(),
&sig,
&policy,
)
.expect_err("counter rollback must be rejected");
assert!(matches!(err, Error::WebAuthnCounterRollback), "got {err:?}");
}
#[test]
fn policy_allows_counter_advance_and_zero_equal() {
let signer = P256Signer::new(TEST_SECRET).unwrap();
let pae = b"pae";
let (wrap, sig) = sign_webauthn_full(
pae,
"mkit.local",
"https://mkit.local",
AUTH_FLAG_UP,
7,
false,
TEST_SECRET,
);
let policy = WebAuthnPolicy {
previous_sign_count: Some(5),
..WebAuthnPolicy::permissive()
};
verify_webauthn_wrapping_with_policy(pae, &wrap, &signer.public_key_sec1(), &sig, &policy)
.expect("counter advance verifies");
let (wrap0, sig0) = sign_webauthn_full(
pae,
"mkit.local",
"https://mkit.local",
AUTH_FLAG_UP,
0,
false,
TEST_SECRET,
);
let policy0 = WebAuthnPolicy {
previous_sign_count: Some(0),
..WebAuthnPolicy::permissive()
};
verify_webauthn_wrapping_with_policy(
pae,
&wrap0,
&signer.public_key_sec1(),
&sig0,
&policy0,
)
.expect("signCount 0 with prev 0 verifies");
}
#[test]
fn bare_helper_delegates_permissively() {
let pae = b"pae";
let (wrap, sig) = sign_webauthn_full(
pae,
"mkit.local",
"https://anything.example",
AUTH_FLAG_UP,
0,
true,
TEST_SECRET,
);
let signer = P256Signer::new(TEST_SECRET).unwrap();
verify_webauthn_wrapping(pae, &wrap, &signer.public_key_sec1(), &sig)
.expect("bare helper is permissive");
}
#[test]
fn build_client_data_json_contains_challenge() {
let pae = b"hello world";
let body = build_client_data_json(pae, "https://mkit.local", false);
let s = std::str::from_utf8(&body).unwrap();
let want_challenge = B64_URL_NOPAD.encode(pae);
assert!(s.contains(&format!("\"challenge\":\"{want_challenge}\"")));
assert!(s.contains("\"type\":\"webauthn.get\""));
assert!(s.contains("\"origin\":\"https://mkit.local\""));
}
}