use crate::vault::{MASTER_KEY_LEN, MasterKey, master_key_to_bytes};
use crate::{Error, ExposeSecret, Result, SecretBytes};
pub const PLAIN_MAGIC: &[u8; 8] = b"Q2FAKEY1";
pub const ENCRYPTED_MAGIC: &[u8; 8] = b"Q2FABK01";
pub const KEY_ONLY_MAGIC: &[u8; 8] = b"Q2FAREC\0";
const VAULT_MAGIC: &[u8; 8] = b"Q2FAVLT\0";
pub const VERSION: u8 = 1;
pub const PLAIN_HEADER_LEN: usize = 8 + 1 + 1 + MASTER_KEY_LEN + 4;
pub const ENCRYPTED_HEADER_LEN: usize = 8 + 1 + 4 + 4;
pub const MAX_VAULT_BYTES: usize = 64 * 1024 * 1024;
pub const MAX_RECOVERY_BYTES: usize = 4 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortableKind {
PlainVault,
EncryptedVault,
KeyOnly,
}
impl PortableKind {
#[must_use]
pub const fn needs_password(self) -> bool {
matches!(self, Self::EncryptedVault)
}
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::PlainVault => "plain vault",
Self::EncryptedVault => "encrypted vault",
Self::KeyOnly => "key-only recovery file",
}
}
}
#[derive(Debug)]
pub enum PortableFile {
PlainVault {
master_key: SecretBytes,
vault: Vec<u8>,
},
EncryptedVault {
recovery: Vec<u8>,
vault: Vec<u8>,
},
KeyOnly {
recovery: Vec<u8>,
},
}
impl PortableFile {
#[must_use]
pub const fn kind(&self) -> PortableKind {
match self {
Self::PlainVault { .. } => PortableKind::PlainVault,
Self::EncryptedVault { .. } => PortableKind::EncryptedVault,
Self::KeyOnly { .. } => PortableKind::KeyOnly,
}
}
#[must_use]
pub fn vault(&self) -> Option<&[u8]> {
match self {
Self::PlainVault { vault, .. } | Self::EncryptedVault { vault, .. } => Some(vault),
Self::KeyOnly { .. } => None,
}
}
}
#[must_use]
pub fn detect(file: &[u8]) -> Option<PortableKind> {
let magic = file.get(..8)?;
if magic == PLAIN_MAGIC {
Some(PortableKind::PlainVault)
} else if magic == ENCRYPTED_MAGIC {
Some(PortableKind::EncryptedVault)
} else if magic == KEY_ONLY_MAGIC {
Some(PortableKind::KeyOnly)
} else {
None
}
}
#[must_use]
pub fn is_portable(file: &[u8]) -> bool {
detect(file).is_some()
}
pub fn parse(file: &[u8]) -> Result<PortableFile> {
let magic = file.get(..8).ok_or(Error::PortableTruncated)?;
if magic == KEY_ONLY_MAGIC {
return Ok(PortableFile::KeyOnly {
recovery: file.to_vec(),
});
}
if magic == VAULT_MAGIC {
return Err(Error::PortableVaultFileWithoutKey);
}
if magic == PLAIN_MAGIC {
return parse_plain(file);
}
if magic == ENCRYPTED_MAGIC {
return parse_encrypted(file);
}
Err(Error::PortableBadMagic)
}
fn parse_plain(file: &[u8]) -> Result<PortableFile> {
if file.len() < PLAIN_HEADER_LEN {
return Err(Error::PortableTruncated);
}
check_version(file[8])?;
if file[9] != 0 {
return Err(Error::PortableBadMagic);
}
let master_key = SecretBytes::from(file[10..10 + MASTER_KEY_LEN].to_vec());
let declared = read_u32(file, 10 + MASTER_KEY_LEN)?;
if declared > MAX_VAULT_BYTES {
return Err(Error::PortableTooLarge {
limit: MAX_VAULT_BYTES,
actual: declared,
});
}
if file.len() != PLAIN_HEADER_LEN + declared {
return Err(Error::PortableLengthMismatch);
}
Ok(PortableFile::PlainVault {
master_key,
vault: file[PLAIN_HEADER_LEN..].to_vec(),
})
}
fn parse_encrypted(file: &[u8]) -> Result<PortableFile> {
if file.len() < ENCRYPTED_HEADER_LEN {
return Err(Error::PortableTruncated);
}
check_version(file[8])?;
let recovery_len = read_u32(file, 9)?;
let vault_len = read_u32(file, 13)?;
if recovery_len == 0 || recovery_len > MAX_RECOVERY_BYTES {
return Err(Error::PortableTooLarge {
limit: MAX_RECOVERY_BYTES,
actual: recovery_len,
});
}
if vault_len == 0 || vault_len > MAX_VAULT_BYTES {
return Err(Error::PortableTooLarge {
limit: MAX_VAULT_BYTES,
actual: vault_len,
});
}
if file.len() != ENCRYPTED_HEADER_LEN + recovery_len + vault_len {
return Err(Error::PortableLengthMismatch);
}
let vault_at = ENCRYPTED_HEADER_LEN + recovery_len;
Ok(PortableFile::EncryptedVault {
recovery: file[ENCRYPTED_HEADER_LEN..vault_at].to_vec(),
vault: file[vault_at..].to_vec(),
})
}
pub fn pack_plain(master: &MasterKey, vault: &[u8]) -> Result<Vec<u8>> {
if vault.len() > MAX_VAULT_BYTES {
return Err(Error::ExportTooLarge);
}
let bytes = master_key_to_bytes(master);
#[expect(
clippy::cast_possible_truncation,
reason = "vault.len() was just compared against MAX_VAULT_BYTES, far below u32::MAX"
)]
let len = vault.len() as u32;
let mut out = Vec::with_capacity(PLAIN_HEADER_LEN + vault.len());
out.extend_from_slice(PLAIN_MAGIC);
out.push(VERSION);
out.push(0);
out.extend_from_slice(bytes.expose_secret());
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(vault);
Ok(out)
}
pub fn pack_encrypted(recovery: &[u8], vault: &[u8]) -> Result<Vec<u8>> {
if recovery.is_empty() || recovery.len() > MAX_RECOVERY_BYTES {
return Err(Error::ExportTooLarge);
}
if vault.is_empty() || vault.len() > MAX_VAULT_BYTES {
return Err(Error::ExportTooLarge);
}
#[expect(
clippy::cast_possible_truncation,
reason = "both lengths were just compared against limits far below u32::MAX"
)]
let recovery_len = recovery.len() as u32;
#[expect(
clippy::cast_possible_truncation,
reason = "both lengths were just compared against limits far below u32::MAX"
)]
let vault_len = vault.len() as u32;
let mut out = Vec::with_capacity(ENCRYPTED_HEADER_LEN + recovery.len() + vault.len());
out.extend_from_slice(ENCRYPTED_MAGIC);
out.push(VERSION);
out.extend_from_slice(&recovery_len.to_be_bytes());
out.extend_from_slice(&vault_len.to_be_bytes());
out.extend_from_slice(recovery);
out.extend_from_slice(vault);
Ok(out)
}
fn check_version(version: u8) -> Result<()> {
if version == VERSION {
Ok(())
} else {
Err(Error::PortableUnsupportedVersion { version })
}
}
fn read_u32(file: &[u8], at: usize) -> Result<usize> {
let end = at.checked_add(4).ok_or(Error::PortableTruncated)?;
let bytes: [u8; 4] = file
.get(at..end)
.ok_or(Error::PortableTruncated)?
.try_into()
.map_err(|_| Error::PortableTruncated)?;
Ok(u32::from_be_bytes(bytes) as usize)
}
#[cfg(test)]
mod tests {
use super::*;
fn master() -> MasterKey {
MasterKey::generate().expect("OS randomness available")
}
fn vault_bytes() -> Vec<u8> {
b"Q2FAVLT\0payload".to_vec()
}
#[test]
fn the_magics_are_distinct_and_recognized() {
assert_eq!(PLAIN_MAGIC, b"Q2FAKEY1");
assert_eq!(ENCRYPTED_MAGIC, b"Q2FABK01");
assert_eq!(KEY_ONLY_MAGIC, b"Q2FAREC\0");
assert_ne!(PLAIN_MAGIC, ENCRYPTED_MAGIC);
assert_eq!(detect(PLAIN_MAGIC), Some(PortableKind::PlainVault));
assert_eq!(detect(ENCRYPTED_MAGIC), Some(PortableKind::EncryptedVault));
assert_eq!(detect(KEY_ONLY_MAGIC), Some(PortableKind::KeyOnly));
assert_eq!(detect(VAULT_MAGIC), None);
assert_eq!(detect(b"nothing"), None);
assert!(PortableKind::EncryptedVault.needs_password());
assert!(!PortableKind::PlainVault.needs_password());
assert!(!PortableKind::KeyOnly.needs_password());
}
#[test]
fn a_plain_container_round_trips_its_two_parts() {
let key = master();
let expected = master_key_to_bytes(&key).expose_secret().to_vec();
let packed = pack_plain(&key, &vault_bytes()).expect("it packs");
assert_eq!(&packed[..8], PLAIN_MAGIC);
assert_eq!(packed[8], VERSION);
assert_eq!(packed[9], 0);
let parsed = parse(&packed).expect("it parses");
assert_eq!(parsed.kind(), PortableKind::PlainVault);
match parsed {
PortableFile::PlainVault { master_key, vault } => {
assert_eq!(master_key.expose_secret(), &expected);
assert_eq!(vault, vault_bytes());
}
other => panic!("expected a plain vault, actual {other:?}"),
}
}
#[test]
fn an_encrypted_container_round_trips_its_two_parts() {
let recovery = vec![0x5A; 110];
let packed = pack_encrypted(&recovery, &vault_bytes()).expect("it packs");
assert_eq!(&packed[..8], ENCRYPTED_MAGIC);
assert_eq!(packed[8], VERSION);
match parse(&packed).expect("it parses") {
PortableFile::EncryptedVault {
recovery: got,
vault,
} => {
assert_eq!(got, recovery);
assert_eq!(vault, vault_bytes());
}
other => panic!("expected an encrypted vault, actual {other:?}"),
}
}
#[test]
fn a_truncated_container_is_refused() {
let key = master();
let packed = pack_plain(&key, &vault_bytes()).expect("it packs");
for cut in [0, 1, 8, 9, 10, PLAIN_HEADER_LEN - 1] {
let short = &packed[..cut.min(packed.len())];
assert!(
matches!(
parse(short),
Err(Error::PortableTruncated | Error::PortableBadMagic)
),
"a container cut to {cut} bytes was accepted"
);
}
let encrypted = pack_encrypted(&[0x11; 110], &vault_bytes()).expect("it packs");
assert!(matches!(
parse(&encrypted[..ENCRYPTED_HEADER_LEN - 1]),
Err(Error::PortableTruncated)
));
}
#[test]
fn a_length_beyond_the_limit_is_refused_before_it_is_used() {
let key = master();
let mut packed = pack_plain(&key, &vault_bytes()).expect("it packs");
packed[PLAIN_HEADER_LEN - 4..PLAIN_HEADER_LEN].copy_from_slice(&[0xFF; 4]);
assert!(matches!(
parse(&packed),
Err(Error::PortableTooLarge { .. })
));
}
#[test]
fn trailing_bytes_are_refused_instead_of_ignored() {
let key = master();
let mut packed = pack_plain(&key, &vault_bytes()).expect("it packs");
packed.push(0x00);
assert!(matches!(parse(&packed), Err(Error::PortableLengthMismatch)));
let mut encrypted = pack_encrypted(&[0x11; 110], &vault_bytes()).expect("it packs");
encrypted.push(0x00);
assert!(matches!(
parse(&encrypted),
Err(Error::PortableLengthMismatch)
));
}
#[test]
fn a_version_this_build_does_not_read_is_refused() {
let key = master();
let mut packed = pack_plain(&key, &vault_bytes()).expect("it packs");
packed[8] = 2;
assert!(matches!(
parse(&packed),
Err(Error::PortableUnsupportedVersion { version: 2 })
));
}
#[test]
fn a_bare_vault_file_is_refused_with_its_own_diagnosis() {
let err = parse(b"Q2FAVLT\0anything");
assert!(
matches!(err, Err(Error::PortableVaultFileWithoutKey)),
"actual {err:?}"
);
assert!(!is_portable(b"Q2FAVLT\0anything"));
}
#[test]
fn the_reserved_byte_must_be_zero() {
let key = master();
let mut packed = pack_plain(&key, &vault_bytes()).expect("it packs");
packed[9] = 1;
assert!(matches!(parse(&packed), Err(Error::PortableBadMagic)));
}
}