#![allow(clippy::expect_used)]
#![allow(clippy::missing_panics_doc)]
use std::convert::TryInto;
use hkdf::Hkdf;
use sha2::Sha256;
use zeroize::Zeroizing;
use crate::error::Error;
pub const V3_MAGIC: [u8; 4] = *b"ESV3";
pub const HMAC_SALT_LEN: usize = 32;
pub const ARGON2_SALT_LEN: usize = 16;
pub const NONCE_LEN: usize = 12;
pub const MAX_CREDENTIAL_ID_LEN: usize = 1023;
pub const MIN_V3_ENVELOPE_LEN: usize = 4 + 2 + 1 + HMAC_SALT_LEN + ARGON2_SALT_LEN + NONCE_LEN + 16;
#[derive(Debug, Clone)]
pub struct V3Envelope {
pub credential_id: Vec<u8>,
pub hmac_salt: [u8; HMAC_SALT_LEN],
pub argon2_salt: [u8; ARGON2_SALT_LEN],
pub nonce: [u8; NONCE_LEN],
pub ciphertext: Vec<u8>,
}
#[must_use]
pub fn is_v3(raw: &[u8]) -> bool {
raw.len() >= V3_MAGIC.len() && raw[..V3_MAGIC.len()] == V3_MAGIC
}
pub fn pack(env: &V3Envelope) -> Result<Vec<u8>, Error> {
if env.credential_id.is_empty() {
return Err(Error::CryptoFailure(
"FIDO2 v3 envelope refused: credential_id is empty".to_string(),
));
}
if env.credential_id.len() > MAX_CREDENTIAL_ID_LEN {
return Err(Error::CryptoFailure(format!(
"FIDO2 v3 envelope refused: credential_id is {} bytes \
(max {MAX_CREDENTIAL_ID_LEN})",
env.credential_id.len()
)));
}
let mut out = Vec::with_capacity(
V3_MAGIC.len()
+ 2
+ env.credential_id.len()
+ HMAC_SALT_LEN
+ ARGON2_SALT_LEN
+ NONCE_LEN
+ env.ciphertext.len(),
);
out.extend_from_slice(&V3_MAGIC);
let id_len_u16: u16 = env
.credential_id
.len()
.try_into()
.expect("credential_id length already bounded by MAX_CREDENTIAL_ID_LEN above");
out.extend_from_slice(&id_len_u16.to_be_bytes());
out.extend_from_slice(&env.credential_id);
out.extend_from_slice(&env.hmac_salt);
out.extend_from_slice(&env.argon2_salt);
out.extend_from_slice(&env.nonce);
out.extend_from_slice(&env.ciphertext);
Ok(out)
}
#[allow(clippy::too_many_lines)]
pub fn parse(raw: &[u8]) -> Result<V3Envelope, Error> {
if !is_v3(raw) {
return Err(Error::CryptoFailure(
"v3 envelope: magic bytes 'ESV3' missing — not a v3 master.key inner blob".to_string(),
));
}
if raw.len() < MIN_V3_ENVELOPE_LEN {
return Err(Error::CryptoFailure(format!(
"v3 envelope: {} bytes is shorter than the minimum {MIN_V3_ENVELOPE_LEN}",
raw.len()
)));
}
let mut cursor = V3_MAGIC.len();
let id_len = u16::from_be_bytes(
raw[cursor..cursor + 2]
.try_into()
.expect("two-byte slice always fits a u16 — bounded by MIN_V3_ENVELOPE_LEN"),
) as usize;
cursor += 2;
if id_len == 0 {
return Err(Error::CryptoFailure(
"v3 envelope: credential_id length is zero — refusing".to_string(),
));
}
if id_len > MAX_CREDENTIAL_ID_LEN {
return Err(Error::CryptoFailure(format!(
"v3 envelope: credential_id length {id_len} exceeds maximum {MAX_CREDENTIAL_ID_LEN}"
)));
}
if raw.len() < cursor + id_len + HMAC_SALT_LEN + ARGON2_SALT_LEN + NONCE_LEN {
return Err(Error::CryptoFailure(format!(
"v3 envelope: declared credential_id length {id_len} \
would overrun the {} byte buffer",
raw.len()
)));
}
let credential_id = raw[cursor..cursor + id_len].to_vec();
cursor += id_len;
let hmac_salt: [u8; HMAC_SALT_LEN] = raw[cursor..cursor + HMAC_SALT_LEN]
.try_into()
.expect("slice length matches HMAC_SALT_LEN — bounded above");
cursor += HMAC_SALT_LEN;
let argon2_salt: [u8; ARGON2_SALT_LEN] = raw[cursor..cursor + ARGON2_SALT_LEN]
.try_into()
.expect("slice length matches ARGON2_SALT_LEN — bounded above");
cursor += ARGON2_SALT_LEN;
let nonce: [u8; NONCE_LEN] = raw[cursor..cursor + NONCE_LEN]
.try_into()
.expect("slice length matches NONCE_LEN — bounded above");
cursor += NONCE_LEN;
let ciphertext = raw[cursor..].to_vec();
Ok(V3Envelope {
credential_id,
hmac_salt,
argon2_salt,
nonce,
ciphertext,
})
}
pub trait Fido2Authenticator {
fn make_credential(
&mut self,
relying_party_id: &str,
relying_party_name: &str,
) -> Result<Vec<u8>, Error>;
fn assert_with_hmac(
&self,
credential_id: &[u8],
salt: &[u8; HMAC_SALT_LEN],
) -> Result<[u8; HMAC_SALT_LEN], Error>;
}
pub const HKDF_INFO_V3: &[u8] = b"envseal-fido2-wrap-v3";
pub fn combine_passphrase_and_fido2(
argon2_output: &[u8; 32],
fido2_secret: &[u8; HMAC_SALT_LEN],
hmac_salt: &[u8; HMAC_SALT_LEN],
) -> Zeroizing<[u8; 32]> {
let mut ikm = Zeroizing::new([0u8; 64]);
ikm[..32].copy_from_slice(argon2_output);
ikm[32..].copy_from_slice(fido2_secret);
let hk = Hkdf::<Sha256>::new(Some(hmac_salt), ikm.as_ref());
let mut out = Zeroizing::new([0u8; 32]);
hk.expand(HKDF_INFO_V3, out.as_mut())
.expect("HKDF-Expand with 32-byte output never fails for SHA-256");
out
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
fn synthetic_envelope() -> V3Envelope {
V3Envelope {
credential_id: vec![0xAA; 64],
hmac_salt: [0x11; HMAC_SALT_LEN],
argon2_salt: [0x22; ARGON2_SALT_LEN],
nonce: [0x33; NONCE_LEN],
ciphertext: vec![0x44; 48],
}
}
#[test]
fn pack_then_parse_round_trips() {
let env = synthetic_envelope();
let packed = pack(&env).unwrap();
let parsed = parse(&packed).unwrap();
assert_eq!(parsed.credential_id, env.credential_id);
assert_eq!(parsed.hmac_salt, env.hmac_salt);
assert_eq!(parsed.argon2_salt, env.argon2_salt);
assert_eq!(parsed.nonce, env.nonce);
assert_eq!(parsed.ciphertext, env.ciphertext);
}
#[test]
fn pack_rejects_empty_credential_id() {
let mut env = synthetic_envelope();
env.credential_id.clear();
let err = pack(&env).unwrap_err();
assert!(err.to_string().contains("empty"), "got {err}");
}
#[test]
fn pack_rejects_oversized_credential_id() {
let mut env = synthetic_envelope();
env.credential_id = vec![0; MAX_CREDENTIAL_ID_LEN + 1];
let err = pack(&env).unwrap_err();
assert!(err.to_string().contains("max"), "got {err}");
}
#[test]
fn parse_rejects_missing_magic() {
let mut bad = pack(&synthetic_envelope()).unwrap();
bad[0] = b'X';
let err = parse(&bad).unwrap_err();
assert!(err.to_string().contains("magic"), "got {err}");
}
#[test]
fn parse_rejects_truncated() {
let packed = pack(&synthetic_envelope()).unwrap();
let truncated = &packed[..MIN_V3_ENVELOPE_LEN - 1];
let err = parse(truncated).unwrap_err();
assert!(err.to_string().contains("shorter"), "got {err}");
}
#[test]
fn parse_rejects_overlong_id_len() {
let mut bogus = Vec::new();
bogus.extend_from_slice(&V3_MAGIC);
bogus.extend_from_slice(&60_000u16.to_be_bytes());
bogus.resize(MIN_V3_ENVELOPE_LEN + 16, 0);
let err = parse(&bogus).unwrap_err();
assert!(err.to_string().contains("exceeds"), "got {err}");
}
#[test]
fn parse_rejects_zero_id_len() {
let mut bogus = Vec::new();
bogus.extend_from_slice(&V3_MAGIC);
bogus.extend_from_slice(&0u16.to_be_bytes());
bogus.resize(MIN_V3_ENVELOPE_LEN + 16, 0);
let err = parse(&bogus).unwrap_err();
assert!(err.to_string().contains("zero"), "got {err}");
}
#[test]
fn parse_rejects_id_overrun() {
let mut bogus = Vec::new();
bogus.extend_from_slice(&V3_MAGIC);
bogus.extend_from_slice(&1000u16.to_be_bytes());
bogus.resize(MIN_V3_ENVELOPE_LEN + 16, 0);
let err = parse(&bogus).unwrap_err();
assert!(err.to_string().contains("overrun"), "got {err}");
}
#[test]
fn is_v3_distinguishes_v3_from_v1_blob() {
let collide = b"ESV3SALTRESTRESTRESTREST";
assert!(is_v3(collide));
let v1_like = b"\x00\x00\x00\x00salt for argonnonce";
assert!(!is_v3(v1_like));
}
#[test]
fn combine_is_deterministic_and_changes_with_inputs() {
let argon = [0x11u8; 32];
let fido = [0x22u8; HMAC_SALT_LEN];
let salt = [0x33u8; HMAC_SALT_LEN];
let a = combine_passphrase_and_fido2(&argon, &fido, &salt);
let b = combine_passphrase_and_fido2(&argon, &fido, &salt);
assert_eq!(a.as_ref(), b.as_ref(), "deterministic for identical inputs");
let mut argon2 = argon;
argon2[0] ^= 0x01;
let c = combine_passphrase_and_fido2(&argon2, &fido, &salt);
assert_ne!(c.as_ref(), a.as_ref());
let mut fido2 = fido;
fido2[0] ^= 0x01;
let d = combine_passphrase_and_fido2(&argon, &fido2, &salt);
assert_ne!(d.as_ref(), a.as_ref());
let mut salt2 = salt;
salt2[0] ^= 0x01;
let e = combine_passphrase_and_fido2(&argon, &fido, &salt2);
assert_ne!(e.as_ref(), a.as_ref());
}
pub(crate) struct MockAuthenticator {
device_key: [u8; 32],
registered: std::collections::HashMap<Vec<u8>, [u8; 32]>,
}
impl MockAuthenticator {
pub fn new(device_key: [u8; 32]) -> Self {
Self {
device_key,
registered: std::collections::HashMap::new(),
}
}
}
impl Fido2Authenticator for MockAuthenticator {
fn make_credential(
&mut self,
_rp_id: &str,
_rp_name: &str,
) -> Result<Vec<u8>, Error> {
use sha2::Digest;
let mut hasher = Sha256::new();
hasher.update(self.device_key);
hasher.update((self.registered.len() as u64).to_be_bytes());
let id = hasher.finalize().to_vec();
let hk = Hkdf::<Sha256>::new(None, &self.device_key);
let mut per_cred = [0u8; 32];
hk.expand(&id, &mut per_cred).expect("32-byte HKDF expand");
self.registered.insert(id.clone(), per_cred);
Ok(id)
}
fn assert_with_hmac(
&self,
credential_id: &[u8],
salt: &[u8; HMAC_SALT_LEN],
) -> Result<[u8; HMAC_SALT_LEN], Error> {
let per_cred = self.registered.get(credential_id).ok_or_else(|| {
Error::Fido2AssertionFailed(
"mock authenticator: unknown credential id".to_string(),
)
})?;
use hmac::{Hmac, Mac};
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(per_cred).expect("HMAC-SHA256 takes any key");
mac.update(salt);
let tag = mac.finalize().into_bytes();
let mut out = [0u8; HMAC_SALT_LEN];
out.copy_from_slice(&tag);
Ok(out)
}
}
#[test]
fn mock_authenticator_round_trip() {
let mut auth = MockAuthenticator::new([0x77; 32]);
let cred = auth.make_credential("envseal.local", "envseal").unwrap();
let salt = [0x88u8; HMAC_SALT_LEN];
let a = auth.assert_with_hmac(&cred, &salt).unwrap();
let b = auth.assert_with_hmac(&cred, &salt).unwrap();
assert_eq!(a, b, "mock must be deterministic for identical inputs");
let mut salt2 = salt;
salt2[0] ^= 0x01;
let c = auth.assert_with_hmac(&cred, &salt2).unwrap();
assert_ne!(c, a, "different salt yields different output");
}
#[test]
fn mock_authenticator_rejects_unknown_credential() {
let auth = MockAuthenticator::new([0x99; 32]);
let bogus_cred = vec![0u8; 32];
let err = auth
.assert_with_hmac(&bogus_cred, &[0u8; HMAC_SALT_LEN])
.unwrap_err();
assert!(matches!(err, Error::Fido2AssertionFailed(_)), "got {err:?}");
}
#[test]
fn mock_authenticator_separates_per_credential_keys() {
let mut auth = MockAuthenticator::new([0xAAu8; 32]);
let cred1 = auth.make_credential("envseal.local", "v1").unwrap();
let cred2 = auth.make_credential("envseal.local", "v2").unwrap();
assert_ne!(cred1, cred2, "two credentials from same mock must differ");
let salt = [0x55u8; HMAC_SALT_LEN];
let a = auth.assert_with_hmac(&cred1, &salt).unwrap();
let b = auth.assert_with_hmac(&cred2, &salt).unwrap();
assert_ne!(
a, b,
"different credentials on same authenticator must produce different secrets"
);
}
}