use crate::{Error, Result};
pub(super) const VERSION: u8 = 1;
pub(super) const MAX_CREDENTIAL_ID_LEN: usize = 512;
pub(super) const AAD_PREFIX: &[u8] = b"qualia-bioauth windows webauthn v1";
pub(super) const SALT_LEN: usize = 32;
pub(super) const NONCE_LEN: usize = 24;
pub(super) const TAG_LEN: usize = 16;
const MASTER_KEY_LEN: usize = 32;
pub(super) const CIPHERTEXT_LEN: usize = MASTER_KEY_LEN + TAG_LEN;
const FIXED_LEN: usize = 1 + 2 + SALT_LEN + NONCE_LEN + CIPHERTEXT_LEN;
mod offset {
pub const VERSION: usize = 0;
pub const ID_LEN: usize = 1;
pub const ID: usize = 3;
}
#[derive(Clone, PartialEq, Eq)]
pub(super) struct Payload {
credential_id: Vec<u8>,
salt: [u8; SALT_LEN],
nonce: [u8; NONCE_LEN],
ciphertext: [u8; CIPHERTEXT_LEN],
}
impl core::fmt::Debug for Payload {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("WindowsPayload")
.field("credential_id_len", &self.credential_id.len())
.finish_non_exhaustive()
}
}
impl Payload {
pub(super) fn new(
credential_id: Vec<u8>,
salt: [u8; SALT_LEN],
nonce: [u8; NONCE_LEN],
ciphertext: [u8; CIPHERTEXT_LEN],
) -> Result<Self> {
if credential_id.is_empty() {
return Err(Error::WrappedPayloadInvalid {
reason: "the Windows credential id is empty",
});
}
if credential_id.len() > MAX_CREDENTIAL_ID_LEN {
return Err(Error::WrappedPayloadInvalid {
reason: "the Windows credential id is longer than the format allows",
});
}
Ok(Self {
credential_id,
salt,
nonce,
ciphertext,
})
}
pub(super) fn parse(bytes: &[u8]) -> Result<Self> {
if bytes.len() < FIXED_LEN + 1 {
return Err(Error::WrappedPayloadInvalid {
reason: "the Windows payload is shorter than its fixed fields",
});
}
if bytes[offset::VERSION] != VERSION {
return Err(Error::WrappedPayloadInvalid {
reason: "the Windows payload version is not supported",
});
}
let id_len =
u16::from_be_bytes([bytes[offset::ID_LEN], bytes[offset::ID_LEN + 1]]) as usize;
if id_len == 0 || id_len > MAX_CREDENTIAL_ID_LEN {
return Err(Error::WrappedPayloadInvalid {
reason: "the Windows credential id length is outside the format limits",
});
}
let expected = FIXED_LEN
.checked_add(id_len)
.ok_or(Error::WrappedPayloadInvalid {
reason: "the Windows payload length overflows",
})?;
if bytes.len() != expected {
return Err(Error::WrappedPayloadInvalid {
reason: "the Windows payload length does not match its credential id length",
});
}
let mut cursor = offset::ID;
let credential_id = bytes[cursor..cursor + id_len].to_vec();
cursor += id_len;
let mut salt = [0_u8; SALT_LEN];
salt.copy_from_slice(&bytes[cursor..cursor + SALT_LEN]);
cursor += SALT_LEN;
let mut nonce = [0_u8; NONCE_LEN];
nonce.copy_from_slice(&bytes[cursor..cursor + NONCE_LEN]);
cursor += NONCE_LEN;
let mut ciphertext = [0_u8; CIPHERTEXT_LEN];
ciphertext.copy_from_slice(&bytes[cursor..cursor + CIPHERTEXT_LEN]);
Ok(Self {
credential_id,
salt,
nonce,
ciphertext,
})
}
pub(super) fn encode(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(FIXED_LEN + self.credential_id.len());
bytes.push(VERSION);
bytes.extend_from_slice(&(self.credential_id.len() as u16).to_be_bytes());
bytes.extend_from_slice(&self.credential_id);
bytes.extend_from_slice(&self.salt);
bytes.extend_from_slice(&self.nonce);
bytes.extend_from_slice(&self.ciphertext);
bytes
}
pub(super) fn credential_id(&self) -> &[u8] {
&self.credential_id
}
pub(super) fn salt(&self) -> &[u8; SALT_LEN] {
&self.salt
}
pub(super) fn nonce(&self) -> &[u8; NONCE_LEN] {
&self.nonce
}
pub(super) fn ciphertext(&self) -> &[u8; CIPHERTEXT_LEN] {
&self.ciphertext
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(credential_id: &[u8]) -> Payload {
Payload::new(
credential_id.to_vec(),
[0x11_u8; SALT_LEN],
[0x22_u8; NONCE_LEN],
[0x33_u8; CIPHERTEXT_LEN],
)
.expect("valid payload")
}
#[test]
fn roundtrip() {
for length in [1_usize, 16, 64, MAX_CREDENTIAL_ID_LEN] {
let payload = sample(&vec![0xAB_u8; length]);
let encoded = payload.encode();
assert_eq!(encoded.len(), FIXED_LEN + length);
let parsed = Payload::parse(&encoded).expect("valid payload");
assert_eq!(parsed, payload);
assert_eq!(parsed.credential_id().len(), length);
assert_eq!(parsed.salt(), &[0x11_u8; SALT_LEN]);
assert_eq!(parsed.nonce(), &[0x22_u8; NONCE_LEN]);
}
}
#[test]
fn layout_is_stable() {
assert_eq!(VERSION, 1);
assert_eq!(MAX_CREDENTIAL_ID_LEN, 512);
assert_eq!(SALT_LEN, 32);
assert_eq!(NONCE_LEN, 24);
assert_eq!(CIPHERTEXT_LEN, 48);
assert_eq!(FIXED_LEN, 107);
}
#[test]
fn empty_credential_id_is_rejected() {
assert!(matches!(
Payload::new(
Vec::new(),
[0; SALT_LEN],
[0; NONCE_LEN],
[0; CIPHERTEXT_LEN]
),
Err(Error::WrappedPayloadInvalid { .. })
));
}
#[test]
fn oversized_credential_id_is_rejected() {
assert!(matches!(
Payload::new(
vec![0xAB; MAX_CREDENTIAL_ID_LEN + 1],
[0; SALT_LEN],
[0; NONCE_LEN],
[0; CIPHERTEXT_LEN]
),
Err(Error::WrappedPayloadInvalid { .. })
));
}
#[test]
fn tampering_does_not_silently_roundtrip() {
let payload = sample(&[0xAB_u8; 32]);
let encoded = payload.encode();
for index in 0..encoded.len() {
let mut tampered = encoded.clone();
tampered[index] ^= 0x01;
match Payload::parse(&tampered) {
Err(_) => {}
Ok(parsed) => assert_ne!(
parsed, payload,
"byte {index} was flipped but the payload parsed to the same value"
),
}
}
}
#[test]
fn exact_length_is_required() {
let encoded = sample(&[0xAB_u8; 32]).encode();
for cut in [0_usize, 1, 3, 16, FIXED_LEN, encoded.len() - 1] {
assert!(
Payload::parse(&encoded[..cut]).is_err(),
"truncation to {cut} must be rejected"
);
}
let mut longer = encoded;
longer.push(0);
assert!(Payload::parse(&longer).is_err());
}
#[test]
fn version_is_checked() {
let mut encoded = sample(&[0xAB_u8; 32]).encode();
encoded[offset::VERSION] = 2;
assert!(matches!(
Payload::parse(&encoded),
Err(Error::WrappedPayloadInvalid { .. })
));
}
#[test]
fn credential_id_length_is_bounded() {
for length in [0_u16, (MAX_CREDENTIAL_ID_LEN + 1) as u16] {
let mut encoded = sample(&[0xAB_u8; 32]).encode();
encoded[offset::ID_LEN..offset::ID].copy_from_slice(&length.to_be_bytes());
assert!(
Payload::parse(&encoded).is_err(),
"credential id length {length} must be rejected"
);
}
}
#[test]
fn debug_is_redacted() {
let payload = sample(&[0xAB_u8; 32]);
let rendered = format!("{payload:?}");
assert!(rendered.contains("credential_id_len"));
assert!(!rendered.contains("ab"));
}
}