use crate::traits::Backend;
use crate::{Error, Result};
const MAGIC: &[u8; 8] = b"Q2FABIO1";
pub const FORMAT_VERSION: u8 = 1;
pub const MAX_WRAPPED_LEN: usize = 1024;
pub const HEADER_LEN: usize = 14;
pub const MAX_BLOB_LEN: usize = HEADER_LEN + MAX_WRAPPED_LEN;
mod offset {
pub const MAGIC: usize = 0;
pub const VERSION: usize = 8;
pub const BACKEND: usize = 9;
pub const LENGTH: usize = 10;
pub const PAYLOAD: usize = super::HEADER_LEN;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthBlob {
backend: Backend,
wrapped: Vec<u8>,
}
impl AuthBlob {
pub fn new(backend: Backend, wrapped: Vec<u8>) -> Result<Self> {
if wrapped.is_empty() {
return Err(Error::PayloadEmpty);
}
if wrapped.len() > MAX_WRAPPED_LEN {
return Err(Error::PayloadTooLong {
actual: wrapped.len() as u32,
limit: MAX_WRAPPED_LEN as u32,
});
}
Ok(Self { backend, wrapped })
}
pub fn parse(bytes: &[u8]) -> Result<Self> {
if bytes.len() < HEADER_LEN {
return Err(Error::Malformed {
reason: "the blob is shorter than its fixed header",
});
}
if &bytes[offset::MAGIC..offset::VERSION] != MAGIC {
return Err(Error::Malformed {
reason: "magic mismatch: this is not an auth-bound blob",
});
}
if bytes[offset::VERSION] != FORMAT_VERSION {
return Err(Error::UnsupportedVersion {
version: bytes[offset::VERSION],
});
}
let backend = Backend::from_id(bytes[offset::BACKEND])?;
let declared =
u32::from_be_bytes(bytes[offset::LENGTH..offset::PAYLOAD].try_into().map_err(
|_| Error::Malformed {
reason: "failed to read the wrapped payload length",
},
)?);
if declared == 0 {
return Err(Error::PayloadEmpty);
}
if declared as usize > MAX_WRAPPED_LEN {
return Err(Error::PayloadTooLong {
actual: declared,
limit: MAX_WRAPPED_LEN as u32,
});
}
let expected = HEADER_LEN
.checked_add(declared as usize)
.ok_or(Error::PayloadTooLong {
actual: declared,
limit: MAX_WRAPPED_LEN as u32,
})?;
if bytes.len() != expected {
return Err(Error::Malformed {
reason: "the blob length does not match the payload length it declares",
});
}
let wrapped = bytes[offset::PAYLOAD..].to_vec();
Ok(Self { backend, wrapped })
}
#[must_use]
pub const fn backend(&self) -> Backend {
self.backend
}
#[must_use]
pub fn wrapped(&self) -> &[u8] {
&self.wrapped
}
pub fn expect_backend(&self, expected: Backend) -> Result<()> {
if self.backend == expected {
Ok(())
} else {
Err(Error::WrongBackend {
expected,
actual: self.backend,
})
}
}
#[must_use]
pub fn encode(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(HEADER_LEN + self.wrapped.len());
bytes.extend_from_slice(MAGIC);
bytes.push(FORMAT_VERSION);
bytes.push(self.backend.id());
bytes.extend_from_slice(&(self.wrapped.len() as u32).to_be_bytes());
bytes.extend_from_slice(&self.wrapped);
debug_assert_eq!(bytes.len(), HEADER_LEN + self.wrapped.len());
bytes
}
}
#[cfg(test)]
mod tests {
use super::*;
fn payload(length: usize) -> Vec<u8> {
(0..length).map(|index| index as u8).collect()
}
#[test]
fn encode_parse_roundtrip() {
for backend in [
Backend::AndroidKeystore,
Backend::WindowsWebAuthn,
Backend::AppleSecureEnclave,
Backend::Mock,
] {
let blob = AuthBlob::new(backend, payload(72)).expect("valid payload");
let encoded = blob.encode();
assert_eq!(encoded.len(), HEADER_LEN + 72);
let parsed = AuthBlob::parse(&encoded).expect("valid blob");
assert_eq!(parsed.backend(), backend);
assert_eq!(parsed.wrapped(), blob.wrapped());
assert_eq!(parsed, blob);
}
}
#[test]
fn layout_is_stable() {
assert_eq!(HEADER_LEN, 14);
assert_eq!(MAX_WRAPPED_LEN, 1024);
assert_eq!(MAX_BLOB_LEN, 1038);
assert_eq!(offset::MAGIC, 0);
assert_eq!(offset::VERSION, 8);
assert_eq!(offset::BACKEND, 9);
assert_eq!(offset::LENGTH, 10);
assert_eq!(offset::PAYLOAD, 14);
}
#[test]
fn wrong_magic_is_rejected() {
let mut encoded = AuthBlob::new(Backend::Mock, payload(32))
.expect("valid")
.encode();
encoded[0] = b'X';
assert!(matches!(
AuthBlob::parse(&encoded),
Err(Error::Malformed { .. })
));
}
#[test]
fn unknown_version_is_rejected() {
let mut encoded = AuthBlob::new(Backend::Mock, payload(32))
.expect("valid")
.encode();
encoded[offset::VERSION] = 2;
assert!(matches!(
AuthBlob::parse(&encoded),
Err(Error::UnsupportedVersion { version: 2 })
));
}
#[test]
fn unknown_backend_is_rejected() {
let mut encoded = AuthBlob::new(Backend::Mock, payload(32))
.expect("valid")
.encode();
encoded[offset::BACKEND] = 0x7F;
assert!(matches!(
AuthBlob::parse(&encoded),
Err(Error::UnknownBackend { backend: 0x7F })
));
}
#[test]
fn wrong_backend_is_rejected() {
let blob = AuthBlob::new(Backend::WindowsWebAuthn, payload(32)).expect("valid");
assert!(matches!(
blob.expect_backend(Backend::AndroidKeystore),
Err(Error::WrongBackend {
expected: Backend::AndroidKeystore,
actual: Backend::WindowsWebAuthn,
})
));
assert!(blob.expect_backend(Backend::WindowsWebAuthn).is_ok());
}
#[test]
fn payload_limits_are_enforced_on_construction() {
assert!(matches!(
AuthBlob::new(Backend::Mock, Vec::new()),
Err(Error::PayloadEmpty)
));
assert!(
AuthBlob::new(Backend::Mock, payload(MAX_WRAPPED_LEN)).is_ok(),
"the limit itself must be allowed"
);
assert!(matches!(
AuthBlob::new(Backend::Mock, payload(MAX_WRAPPED_LEN + 1)),
Err(Error::PayloadTooLong { .. })
));
}
#[test]
fn zero_length_is_rejected() {
let mut encoded = AuthBlob::new(Backend::Mock, payload(32))
.expect("valid")
.encode();
encoded[offset::LENGTH..offset::PAYLOAD].fill(0);
assert!(matches!(
AuthBlob::parse(&encoded),
Err(Error::PayloadEmpty)
));
}
#[test]
fn oversized_declared_length_is_rejected_without_allocating() {
let mut encoded = AuthBlob::new(Backend::Mock, payload(32))
.expect("valid")
.encode();
let bad = (MAX_WRAPPED_LEN as u32 + 1).to_be_bytes();
encoded[offset::LENGTH..offset::PAYLOAD].copy_from_slice(&bad);
assert!(matches!(
AuthBlob::parse(&encoded),
Err(Error::PayloadTooLong { .. })
));
}
#[test]
fn exact_length_is_required() {
let encoded = AuthBlob::new(Backend::Mock, payload(32))
.expect("valid")
.encode();
for cut in [0_usize, 1, HEADER_LEN - 1, HEADER_LEN, encoded.len() - 1] {
assert!(
AuthBlob::parse(&encoded[..cut]).is_err(),
"truncation to {cut} must be rejected"
);
}
let mut longer = encoded.clone();
longer.push(0);
assert!(matches!(
AuthBlob::parse(&longer),
Err(Error::Malformed { .. })
));
}
#[test]
fn declared_length_beyond_the_file_is_rejected() {
let mut encoded = AuthBlob::new(Backend::Mock, payload(32))
.expect("valid")
.encode();
encoded[offset::LENGTH..offset::PAYLOAD].copy_from_slice(&33_u32.to_be_bytes());
assert!(matches!(
AuthBlob::parse(&encoded),
Err(Error::Malformed { .. })
));
}
}