use base64ct::{Base64UrlUnpadded, Encoding as _};
pub const PUBLIC_KEY_LEN: usize = 65;
pub const MAX_CREDENTIAL_ID_LEN: usize = 1023;
pub fn b64url(bytes: &[u8]) -> String {
Base64UrlUnpadded::encode_string(bytes)
}
pub fn from_b64url(text: &str) -> Option<Vec<u8>> {
Base64UrlUnpadded::decode_vec(text).ok()
}
pub fn check_credential_id(id: &[u8]) -> Result<(), String> {
if id.is_empty() {
return Err("`credential_id` is required".to_owned());
}
if id.len() > MAX_CREDENTIAL_ID_LEN {
return Err(format!(
"a credential id is at most {MAX_CREDENTIAL_ID_LEN} bytes and this one is {}",
id.len()
));
}
Ok(())
}
pub fn check_public_key(sec1: &[u8]) -> Result<(), String> {
if sec1.len() != PUBLIC_KEY_LEN || sec1[0] != 0x04 {
return Err(format!(
"`public_key` is P-256, SEC1 uncompressed: 0x04 || X || Y, {PUBLIC_KEY_LEN} bytes \
(this is {} bytes{})",
sec1.len(),
if sec1.first() == Some(&0x04) { "" } else { ", not starting 0x04" }
));
}
p256::ecdsa::VerifyingKey::from_sec1_bytes(sec1)
.map(|_| ())
.map_err(|_| "`public_key` is not a point on P-256".to_owned())
}
pub fn random_bytes(n: usize) -> Vec<u8> {
use rand::Rng as _;
let mut bytes = vec![0u8; n];
rand::rand_core::UnwrapErr(rand::rngs::SysRng).fill_bytes(&mut bytes);
bytes
}
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn sha256_hex(bytes: &[u8]) -> String {
use sha2::Digest as _;
hex(&sha2::Sha256::digest(bytes))
}
pub const CHALLENGE_TTL_MS: i64 = 5 * 60_000;
pub const MAX_OUTSTANDING_CHALLENGES: usize = 10_000;
pub const CHALLENGE_LEN: usize = 32;
#[derive(Default)]
pub struct Challenges {
issued: std::sync::Mutex<std::collections::HashMap<Vec<u8>, i64>>,
}
impl std::fmt::Debug for Challenges {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let outstanding = self.issued.lock().unwrap_or_else(|p| p.into_inner()).len();
f.debug_struct("Challenges").field("outstanding", &outstanding).finish()
}
}
impl Challenges {
pub fn issue(&self, now: i64) -> Result<(Vec<u8>, i64), String> {
let mut issued = self.issued.lock().unwrap_or_else(|p| p.into_inner());
issued.retain(|_, expires| *expires > now);
if issued.len() >= MAX_OUTSTANDING_CHALLENGES {
return Err(format!(
"{MAX_OUTSTANDING_CHALLENGES} web challenges are outstanding; answer or let some \
expire first"
));
}
let challenge = random_bytes(CHALLENGE_LEN);
let expires = now + CHALLENGE_TTL_MS;
issued.insert(challenge.clone(), expires);
Ok((challenge, expires))
}
pub fn take(&self, challenge: &[u8], now: i64) -> bool {
let mut issued = self.issued.lock().unwrap_or_else(|p| p.into_inner());
matches!(issued.remove(challenge), Some(expires) if expires > now)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ClientData {
pub kind: String,
pub challenge: Vec<u8>,
pub origin: String,
}
impl std::fmt::Debug for ClientData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientData")
.field("kind", &self.kind)
.field("challenge", &format_args!("<redacted {} bytes>", self.challenge.len()))
.field("origin", &self.origin)
.finish()
}
}
pub fn client_data(json: &[u8]) -> Result<ClientData, String> {
let value: serde_json::Value =
serde_json::from_slice(json).map_err(|_| "clientDataJSON is not JSON".to_owned())?;
let field = |name: &str| {
value
.get(name)
.and_then(|v| v.as_str())
.map(str::to_owned)
.ok_or_else(|| format!("clientDataJSON has no `{name}`"))
};
let challenge = field("challenge")?;
Ok(ClientData {
kind: field("type")?,
challenge: from_b64url(&challenge)
.ok_or_else(|| "clientDataJSON's challenge is not base64url".to_owned())?,
origin: field("origin")?,
})
}
pub fn origin_fits_rp_id(origin: &str, rp_id: &str) -> bool {
let rp_id = rp_id.trim().to_ascii_lowercase();
if rp_id.is_empty() || rp_id.contains('/') || rp_id.contains(':') {
return false;
}
let (scheme, rest) = match origin.split_once("://") {
Some(parts) => parts,
None => return false,
};
if rest.contains('/') || rest.is_empty() {
return false;
}
let host = match rest.rsplit_once(':') {
Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
Some(_) => return false,
None => rest,
}
.to_ascii_lowercase();
let host_fits = host == rp_id || host.ends_with(&format!(".{rp_id}"));
match scheme {
"https" => host_fits,
"http" => host_fits && (host == "localhost" || host.ends_with(".localhost")),
_ => false,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelyingParty {
pub rp_id: String,
pub origins: Vec<String>,
}
impl std::str::FromStr for RelyingParty {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
let (rp_id, origins) = s.split_once('=').ok_or_else(|| format!("{s:?} is not <rp_id>=<origin>[,<origin>…]"))?;
let rp_id = rp_id.trim().to_ascii_lowercase();
let origins: Vec<String> = origins.split(',').map(|o| o.trim().to_owned()).filter(|o| !o.is_empty()).collect();
if rp_id.is_empty() || origins.is_empty() {
return Err(format!("{s:?} names no relying party or no origin"));
}
if let Some(bad) = origins.iter().find(|o| !origin_fits_rp_id(o, &rp_id)) {
return Err(format!("the origin {bad:?} does not belong to the relying party {rp_id:?}"));
}
Ok(RelyingParty { rp_id, origins })
}
}
pub fn check_relying_party(parties: &[RelyingParty], rp_id: &str, origin: &str) -> Result<(), String> {
if parties.is_empty() {
return Ok(());
}
let rp_id = rp_id.trim();
let Some(party) = parties.iter().find(|p| p.rp_id.eq_ignore_ascii_case(rp_id)) else {
return Err(format!("this server is not the relying party {rp_id:?} (--web-relying-party)"));
};
if !party.origins.iter().any(|o| o == origin.trim()) {
return Err(format!("the origin {origin:?} is not one this server answers for as {:?}", party.rp_id));
}
Ok(())
}
pub fn check_ceremony(
data: &ClientData,
want_kind: &str,
origin: &str,
rp_id: &str,
) -> Result<(), String> {
if data.kind != want_kind {
return Err(format!(
"this is a {:?} ceremony, and {want_kind:?} was asked for",
data.kind
));
}
if data.origin != origin {
return Err(format!(
"the browser was at {:?}, not at {origin:?}",
data.origin
));
}
if !origin_fits_rp_id(origin, rp_id) {
return Err(format!(
"the origin {origin:?} does not belong to the relying party {rp_id:?}"
));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Verified {
pub sign_count: u32,
pub user_verified: bool,
}
const FLAG_UP: u8 = 0x01;
const FLAG_UV: u8 = 0x04;
pub fn verify_assertion(
public_key: &[u8],
stored_count: u32,
client_data_json: &[u8],
authenticator_data: &[u8],
signature_der: &[u8],
rp_id: &str,
require_user_verification: bool,
) -> Result<Verified, String> {
use p256::ecdsa::signature::Verifier as _;
use sha2::Digest as _;
if authenticator_data.len() < 37 {
return Err("authenticatorData is shorter than 37 bytes".to_owned());
}
if authenticator_data[..32] != sha2::Sha256::digest(rp_id.as_bytes())[..] {
return Err(format!(
"authenticatorData was made for another relying party than {rp_id:?}"
));
}
let flags = authenticator_data[32];
if flags & FLAG_UP == 0 {
return Err("the authenticator reports no user presence".to_owned());
}
let user_verified = flags & FLAG_UV != 0;
if require_user_verification && !user_verified {
return Err("user verification was required and the authenticator did not verify the user".to_owned());
}
let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(public_key)
.map_err(|_| "the stored key is not a P-256 point".to_owned())?;
let signature = p256::ecdsa::Signature::from_der(signature_der)
.map_err(|_| "the signature is not ASN.1 DER ECDSA".to_owned())?;
let mut message = authenticator_data.to_vec();
message.extend_from_slice(&sha2::Sha256::digest(client_data_json));
key.verify(&message, &signature)
.map_err(|_| "the signature does not verify with this passkey".to_owned())?;
let sign_count = u32::from_be_bytes([
authenticator_data[33],
authenticator_data[34],
authenticator_data[35],
authenticator_data[36],
]);
if stored_count != 0 && sign_count != 0 && sign_count <= stored_count {
return Err(format!(
"the signature counter went from {stored_count} to {sign_count}: a cloned \
authenticator, or a replay"
));
}
Ok(Verified {
sign_count,
user_verified,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attested {
pub sign_count: u32,
pub user_verified: bool,
pub format: String,
pub self_signed: bool,
}
pub fn verify_attestation(
attestation_object: &[u8],
client_data_json: &[u8],
rp_id: &str,
credential_id: &[u8],
public_key: &[u8],
) -> Result<Attested, String> {
use sha2::Digest as _;
if attestation_object.is_empty() {
return Err("no attestation object: relay the browser's `attestationObject`".to_owned());
}
if attestation_object.len() > MAX_ATTESTATION_LEN {
return Err(format!("the attestation object is over {MAX_ATTESTATION_LEN} bytes"));
}
let mut at = 0;
let top = cbor::read(attestation_object, &mut at, 0)?;
if at != attestation_object.len() {
return Err("bytes after the attestation object".to_owned());
}
let top = top.map().ok_or("the attestation object is not a CBOR map")?;
let format = cbor::field(top, "fmt")
.and_then(cbor::Item::text)
.ok_or("the attestation object has no `fmt`")?
.to_owned();
let statement = cbor::field(top, "attStmt")
.and_then(cbor::Item::map)
.ok_or("the attestation object has no `attStmt` map")?;
let auth_data = cbor::field(top, "authData")
.and_then(cbor::Item::bytes)
.ok_or("the attestation object has no `authData`")?;
if auth_data.len() < 37 {
return Err("authenticatorData is shorter than 37 bytes".to_owned());
}
if auth_data[..32] != sha2::Sha256::digest(rp_id.as_bytes())[..] {
return Err(format!("the authenticator made this credential for another relying party than {rp_id:?}"));
}
let flags = auth_data[32];
if flags & FLAG_UP == 0 {
return Err("the authenticator reports no user presence".to_owned());
}
if flags & FLAG_AT == 0 || auth_data.len() < 55 {
return Err("authenticatorData carries no attested credential data".to_owned());
}
let sign_count = u32::from_be_bytes([auth_data[33], auth_data[34], auth_data[35], auth_data[36]]);
let id_len = usize::from(u16::from_be_bytes([auth_data[53], auth_data[54]]));
let attested_id = auth_data
.get(55..55 + id_len)
.ok_or("authenticatorData is cut short inside its credential id")?;
if attested_id != credential_id {
return Err("the attested credential id is not the relayed `credential_id`".to_owned());
}
let mut at = 55 + id_len;
let cose = cbor::read(auth_data, &mut at, 0)?;
if flags & FLAG_ED == 0 && at != auth_data.len() {
return Err("bytes after the credential key, and no extensions flagged".to_owned());
}
let attested_key = cose_es256_point(&cose)?;
if attested_key != public_key {
return Err("the attested key is not the relayed `public_key`".to_owned());
}
let self_signed = match format.as_str() {
"none" => {
if !statement.is_empty() {
return Err("a `none` attestation with a statement".to_owned());
}
false
}
"packed" if cbor::field(statement, "x5c").is_none() => {
use p256::ecdsa::signature::Verifier as _;
if cbor::field(statement, "alg").and_then(cbor::Item::int) != Some(-7) {
return Err("a packed self-attestation that is not ES256".to_owned());
}
let sig = cbor::field(statement, "sig")
.and_then(cbor::Item::bytes)
.ok_or("a packed attestation with no `sig`")?;
let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(&attested_key)
.map_err(|_| "the attested key is not a P-256 point".to_owned())?;
let signature = p256::ecdsa::Signature::from_der(sig)
.map_err(|_| "the attestation signature is not ASN.1 DER ECDSA".to_owned())?;
let mut message = auth_data.to_vec();
message.extend_from_slice(&sha2::Sha256::digest(client_data_json));
key.verify(&message, &signature)
.map_err(|_| "the self-attestation does not verify with the attested key, over this ceremony".to_owned())?;
true
}
_ => false,
};
Ok(Attested { sign_count, user_verified: flags & FLAG_UV != 0, format, self_signed })
}
pub const MAX_ATTESTATION_LEN: usize = 16 * 1024;
fn cose_es256_point(cose: &cbor::Item<'_>) -> Result<Vec<u8>, String> {
let map = cose.map().ok_or("the credential key is not a COSE map")?;
let get = |label: i64| map.iter().find(|(k, _)| k.int() == Some(label)).map(|(_, v)| v);
if get(1).and_then(cbor::Item::int) != Some(2) {
return Err("the credential key is not an EC2 key".to_owned());
}
if get(3).and_then(cbor::Item::int) != Some(-7) {
return Err("the credential key is not ES256".to_owned());
}
if get(-1).and_then(cbor::Item::int) != Some(1) {
return Err("the credential key is not on P-256".to_owned());
}
let (Some(x), Some(y)) = (get(-2).and_then(cbor::Item::bytes), get(-3).and_then(cbor::Item::bytes)) else {
return Err("the credential key has no x or y".to_owned());
};
if x.len() != 32 || y.len() != 32 {
return Err("the credential key's x and y are not 32 bytes each".to_owned());
}
let mut point = Vec::with_capacity(PUBLIC_KEY_LEN);
point.push(0x04);
point.extend_from_slice(x);
point.extend_from_slice(y);
Ok(point)
}
pub fn attestation_none(rp_id: &str, credential_id: &[u8], public_key: &[u8], sign_count: u32) -> Vec<u8> {
let auth_data = attested_auth_data(rp_id, FLAG_UP | FLAG_AT, sign_count, credential_id, public_key);
attestation_object("none", &[], &auth_data)
}
#[doc(hidden)]
pub fn attested_auth_data(rp_id: &str, flags: u8, sign_count: u32, credential_id: &[u8], public_key: &[u8]) -> Vec<u8> {
use sha2::Digest as _;
let mut out = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
out.push(flags);
out.extend_from_slice(&sign_count.to_be_bytes());
out.extend_from_slice(&[0u8; 16]);
out.extend_from_slice(&u16::try_from(credential_id.len()).unwrap_or(u16::MAX).to_be_bytes());
out.extend_from_slice(credential_id);
let (x, y) = if public_key.len() == PUBLIC_KEY_LEN { (&public_key[1..33], &public_key[33..]) } else { (&[][..], &[][..]) };
cbor::map_head(&mut out, 5);
cbor::int(&mut out, 1);
cbor::int(&mut out, 2);
cbor::int(&mut out, 3);
cbor::int(&mut out, -7);
cbor::int(&mut out, -1);
cbor::int(&mut out, 1);
cbor::int(&mut out, -2);
cbor::bytes(&mut out, x);
cbor::int(&mut out, -3);
cbor::bytes(&mut out, y);
out
}
#[doc(hidden)]
pub fn attestation_object(fmt: &str, att_stmt: &[(&str, AttStmtValue<'_>)], auth_data: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
cbor::map_head(&mut out, 3);
cbor::text(&mut out, "fmt");
cbor::text(&mut out, fmt);
cbor::text(&mut out, "attStmt");
cbor::map_head(&mut out, att_stmt.len() as u64);
for (k, v) in att_stmt {
cbor::text(&mut out, k);
match v {
AttStmtValue::Int(i) => cbor::int(&mut out, *i),
AttStmtValue::Bytes(b) => cbor::bytes(&mut out, b),
AttStmtValue::Certs(certs) => {
cbor::array_head(&mut out, certs.len() as u64);
for c in *certs {
cbor::bytes(&mut out, c);
}
}
}
}
cbor::text(&mut out, "authData");
cbor::bytes(&mut out, auth_data);
out
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
pub enum AttStmtValue<'a> {
Int(i64),
Bytes(&'a [u8]),
Certs(&'a [&'a [u8]]),
}
const FLAG_AT: u8 = 0x40;
const FLAG_ED: u8 = 0x80;
mod cbor {
const MAX_DEPTH: usize = 8;
#[derive(Debug, Clone, PartialEq)]
pub(super) enum Item<'a> {
Int(i128),
Bytes(&'a [u8]),
Text(&'a str),
Array(Vec<Item<'a>>),
Map(Vec<(Item<'a>, Item<'a>)>),
Simple(u8),
}
impl<'a> Item<'a> {
pub(super) fn int(&self) -> Option<i64> {
match self {
Item::Int(i) => i64::try_from(*i).ok(),
_ => None,
}
}
pub(super) fn bytes(&self) -> Option<&'a [u8]> {
match self {
Item::Bytes(b) => Some(b),
_ => None,
}
}
pub(super) fn text(&self) -> Option<&'a str> {
match self {
Item::Text(t) => Some(t),
_ => None,
}
}
pub(super) fn map(&self) -> Option<&[(Item<'a>, Item<'a>)]> {
match self {
Item::Map(m) => Some(m),
_ => None,
}
}
}
pub(super) fn field<'m, 'a>(map: &'m [(Item<'a>, Item<'a>)], key: &str) -> Option<&'m Item<'a>> {
map.iter().find(|(k, _)| k.text() == Some(key)).map(|(_, v)| v)
}
fn take<'a>(buf: &'a [u8], at: &mut usize, n: usize) -> Result<&'a [u8], String> {
let end = at.checked_add(n).filter(|end| *end <= buf.len()).ok_or("CBOR cut short")?;
let out = &buf[*at..end];
*at = end;
Ok(out)
}
pub(super) fn read<'a>(buf: &'a [u8], at: &mut usize, depth: usize) -> Result<Item<'a>, String> {
if depth > MAX_DEPTH {
return Err("CBOR nested too deep".to_owned());
}
let first = take(buf, at, 1)?[0];
let (major, info) = (first >> 5, first & 0x1f);
let n: u64 = match info {
0..=23 => u64::from(info),
24 => u64::from(take(buf, at, 1)?[0]),
25 => u64::from(u16::from_be_bytes(take(buf, at, 2)?.try_into().expect("2"))),
26 => u64::from(u32::from_be_bytes(take(buf, at, 4)?.try_into().expect("4"))),
27 => u64::from_be_bytes(take(buf, at, 8)?.try_into().expect("8")),
_ => return Err("indefinite-length or reserved CBOR".to_owned()),
};
let left = buf.len() - *at;
let count = |n: u64| usize::try_from(n).ok().filter(|n| *n <= left).ok_or_else(|| "CBOR cut short".to_owned());
Ok(match major {
0 => Item::Int(i128::from(n)),
1 => Item::Int(-1 - i128::from(n)),
2 => Item::Bytes(take(buf, at, count(n)?)?),
3 => Item::Text(std::str::from_utf8(take(buf, at, count(n)?)?).map_err(|_| "CBOR text is not UTF-8".to_owned())?),
4 => {
let n = count(n)?;
let mut items = Vec::with_capacity(n);
for _ in 0..n {
items.push(read(buf, at, depth + 1)?);
}
Item::Array(items)
}
5 => {
let n = count(n)?;
let mut pairs = Vec::with_capacity(n);
for _ in 0..n {
let k = read(buf, at, depth + 1)?;
let v = read(buf, at, depth + 1)?;
if pairs.iter().any(|(seen, _)| *seen == k) {
return Err("a CBOR map names one key twice".to_owned());
}
pairs.push((k, v));
}
Item::Map(pairs)
}
6 => return Err("tagged CBOR".to_owned()),
_ if info < 24 => Item::Simple(info),
_ => return Err("CBOR floats have no place in an attestation".to_owned()),
})
}
pub(super) fn head(out: &mut Vec<u8>, major: u8, n: u64) {
let m = major << 5;
match n {
0..=23 => out.push(m | n as u8),
24..=0xff => out.extend_from_slice(&[m | 24, n as u8]),
0x100..=0xffff => {
out.push(m | 25);
out.extend_from_slice(&(n as u16).to_be_bytes());
}
0x1_0000..=0xffff_ffff => {
out.push(m | 26);
out.extend_from_slice(&(n as u32).to_be_bytes());
}
_ => {
out.push(m | 27);
out.extend_from_slice(&n.to_be_bytes());
}
}
}
pub(super) fn int(out: &mut Vec<u8>, v: i64) {
if v >= 0 {
head(out, 0, v as u64);
} else {
head(out, 1, (-1 - v) as u64);
}
}
pub(super) fn bytes(out: &mut Vec<u8>, b: &[u8]) {
head(out, 2, b.len() as u64);
out.extend_from_slice(b);
}
pub(super) fn text(out: &mut Vec<u8>, t: &str) {
head(out, 3, t.len() as u64);
out.extend_from_slice(t.as_bytes());
}
pub(super) fn array_head(out: &mut Vec<u8>, n: u64) {
head(out, 4, n);
}
pub(super) fn map_head(out: &mut Vec<u8>, n: u64) {
head(out, 5, n);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(seed: u8) -> Vec<u8> {
p256::ecdsa::SigningKey::from_slice(&[seed; 32])
.expect("a scalar")
.verifying_key()
.to_sec1_point(false)
.as_bytes()
.to_vec()
}
#[test]
fn a_point_on_the_curve_is_a_key_and_nothing_else_is() {
assert!(check_public_key(&key(1)).is_ok());
let mut off = key(1);
off[64] ^= 1;
assert!(check_public_key(&off).is_err(), "off the curve");
let compressed = p256::ecdsa::SigningKey::from_slice(&[1; 32])
.expect("a scalar")
.verifying_key()
.to_sec1_point(true)
.as_bytes()
.to_vec();
assert!(check_public_key(&compressed).is_err(), "compressed is not the stored form");
assert!(check_public_key(&[0u8; 65]).is_err());
}
#[test]
fn an_origin_belongs_to_its_relying_party_and_no_other() {
for (origin, rp, fits) in [
("https://gunnar.rs", "gunnar.rs", true),
("https://console.gunnar.rs", "gunnar.rs", true),
("https://gunnar.rs:8443", "gunnar.rs", true),
("https://evilgunnar.rs", "gunnar.rs", false),
("http://gunnar.rs", "gunnar.rs", false),
("http://localhost:3210", "localhost", true),
("https://gunnar.rs/path", "gunnar.rs", false),
("gunnar.rs", "gunnar.rs", false),
] {
assert_eq!(origin_fits_rp_id(origin, rp), fits, "{origin} / {rp}");
}
}
#[test]
fn a_challenge_is_spent_once_and_not_after_it_expires() {
let c = Challenges::default();
let (one, expires) = c.issue(1_000).expect("issued");
assert_eq!(one.len(), CHALLENGE_LEN);
assert_eq!(expires, 1_000 + CHALLENGE_TTL_MS);
assert!(c.take(&one, 2_000));
assert!(!c.take(&one, 2_000), "spent");
let (two, expires) = c.issue(1_000).expect("issued");
assert!(!c.take(&two, expires), "expired");
assert!(!c.take(&[0; 32], 1_000), "never issued");
}
#[test]
fn a_credential_id_round_trips_through_its_key() {
let raw = b"\x00\xffcredential";
assert_eq!(from_b64url(&b64url(raw)).as_deref(), Some(&raw[..]));
assert!(check_credential_id(&[]).is_err());
assert!(check_credential_id(&[1; 1024]).is_err());
assert!(check_credential_id(&[1; 1023]).is_ok());
}
#[test]
fn a_debug_print_never_carries_a_live_challenge() {
let c = Challenges::default();
let (one, _) = c.issue(1_000).expect("issued");
let shown = format!("{c:?}");
for spelled in [format!("{one:?}"), format!("{:?}", &one[..4]), b64url(&one)] {
assert!(!shown.contains(spelled.trim_end_matches(']')), "a live challenge is in {shown}");
}
assert!(shown.contains('1'), "how many are outstanding is still said: {shown}");
let json = format!(r#"{{"type":"webauthn.get","challenge":"{}","origin":"https://gunnar.rs"}}"#, b64url(&one));
let data = client_data(json.as_bytes()).expect("parsed");
let shown = format!("{data:?}");
for spelled in [format!("{:?}", data.challenge), format!("{:?}", &data.challenge[..4]), b64url(&one)] {
assert!(!shown.contains(spelled.trim_end_matches(']')), "the challenge is in {shown}");
}
assert!(shown.contains("webauthn.get") && shown.contains("https://gunnar.rs"), "the rest is still said: {shown}");
}
#[test]
fn a_server_told_its_relying_parties_refuses_any_other() {
let parties: Vec<RelyingParty> = vec!["gunnar.rs=https://gunnar.rs,https://app.gunnar.rs".parse().unwrap()];
assert!(check_relying_party(&parties, "gunnar.rs", "https://gunnar.rs").is_ok());
assert!(check_relying_party(&parties, "GUNNAR.rs", "https://app.gunnar.rs").is_ok());
assert!(check_relying_party(&parties, "evil.example", "https://evil.example").is_err(), "another relying party");
assert!(check_relying_party(&parties, "gunnar.rs", "https://evil.gunnar.rs").is_err(), "an origin not listed, though it fits the rp_id");
assert!(check_relying_party(&[], "evil.example", "https://evil.example").is_ok(), "none told: the relay's word, as before");
assert!("gunnar.rs=https://evil.example".parse::<RelyingParty>().is_err());
assert!("gunnar.rs".parse::<RelyingParty>().is_err());
}
fn signer(seed: u8) -> p256::ecdsa::SigningKey {
p256::ecdsa::SigningKey::from_slice(&[seed; 32]).expect("a scalar")
}
fn packed_self(seed: u8, rp_id: &str, id: &[u8], client_data_json: &[u8], sign_with: u8) -> Vec<u8> {
use p256::ecdsa::signature::Signer as _;
use sha2::Digest as _;
let auth_data = attested_auth_data(rp_id, FLAG_UP | FLAG_UV | FLAG_AT, 3, id, &key(seed));
let mut message = auth_data.clone();
message.extend_from_slice(&sha2::Sha256::digest(client_data_json));
let sig: p256::ecdsa::Signature = signer(sign_with).sign(&message);
let der = sig.to_der();
attestation_object(
"packed",
&[("alg", AttStmtValue::Int(-7)), ("sig", AttStmtValue::Bytes(der.as_bytes()))],
&auth_data,
)
}
#[test]
fn a_registration_is_bound_to_what_its_authenticator_attested() {
const CD: &[u8] = br#"{"type":"webauthn.create","challenge":"AAAA","origin":"https://gunnar.rs"}"#;
let none = attestation_none("gunnar.rs", b"phone", &key(5), 0);
let ok = verify_attestation(&none, CD, "gunnar.rs", b"phone", &key(5)).expect("a none attestation that agrees");
assert_eq!((ok.format.as_str(), ok.self_signed, ok.sign_count), ("none", false, 0));
let refused = |obj: &[u8], rp: &str, id: &[u8], pk: &[u8], what: &str| {
let r = verify_attestation(obj, CD, rp, id, pk);
assert!(r.is_err(), "{what}: {r:?}");
};
refused(&none, "gunnar.rs", b"phone", &key(6), "another key than the authenticator's");
refused(&none, "gunnar.rs", b"laptop", &key(5), "another credential than the authenticator's");
refused(&none, "evil.example", b"phone", &key(5), "made for another relying party");
refused(&[], "gunnar.rs", b"phone", &key(5), "no attestation at all");
refused(&none[..none.len() - 3], "gunnar.rs", b"phone", &key(5), "cut short");
refused(&[0xa0], "gunnar.rs", b"phone", &key(5), "an empty map");
refused(&[0xbb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff], "gunnar.rs", b"phone", &key(5), "a map that claims 2^64 entries");
refused(&[[0x81u8; 64].as_slice(), &[0]].concat(), "gunnar.rs", b"phone", &key(5), "nested past the limit");
refused(&[none.as_slice(), &[0]].concat(), "gunnar.rs", b"phone", &key(5), "bytes after the object");
let absent = attestation_object("none", &[], &attested_auth_data("gunnar.rs", FLAG_AT, 0, b"phone", &key(5)));
refused(&absent, "gunnar.rs", b"phone", &key(5), "no user presence");
let bare = attestation_object("none", &[], &attested_auth_data("gunnar.rs", FLAG_UP, 0, b"phone", &key(5))[..37]);
refused(&bare, "gunnar.rs", b"phone", &key(5), "no attested credential data");
let packed = packed_self(5, "gunnar.rs", b"phone", CD, 5);
let ok = verify_attestation(&packed, CD, "gunnar.rs", b"phone", &key(5)).expect("a packed self-attestation");
assert_eq!((ok.format.as_str(), ok.self_signed, ok.sign_count, ok.user_verified), ("packed", true, 3, true));
refused(&packed_self(5, "gunnar.rs", b"phone", CD, 6), "gunnar.rs", b"phone", &key(5), "signed by another key");
let other_cd = br#"{"type":"webauthn.create","challenge":"BBBB","origin":"https://gunnar.rs"}"#;
assert!(
verify_attestation(&packed_self(5, "gunnar.rs", b"phone", other_cd, 5), CD, "gunnar.rs", b"phone", &key(5)).is_err(),
"signed over another ceremony's client data"
);
}
}