use crate::vault::{MASTER_KEY_LEN, MasterKey, NONCE_LEN, TAG_LEN};
use crate::{Error, ExposeSecret, Result, SecretBytes, SecretString};
use argon2::Params;
use chacha20poly1305::aead::{AeadInOut, KeyInit};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use secrecy::zeroize::Zeroize;
mod marker;
pub use marker::{LockMarker, LockPending, MARKER_LEN, PENDING_LEN, STATE_FORMAT_VERSION};
const MAGIC: &[u8; 8] = b"Q2FAPW01";
pub const FORMAT_VERSION: u8 = 1;
const ALGORITHM_ARGON2ID_V13: u8 = 1;
const KDF_DOMAIN: &[u8] = b"Qualia2FA lock password v1\0";
const SALT_LEN: usize = 16;
const DERIVED_KEY_LEN: usize = crate::recovery::DERIVED_KEY_LEN;
mod offset {
pub const MAGIC: usize = 0;
pub const VERSION: usize = 8;
pub const ALGORITHM: usize = 9;
pub const MEMORY: usize = 10;
pub const ITERATIONS: usize = 14;
pub const PARALLELISM: usize = 18;
pub const SALT: usize = 22;
pub const NONCE: usize = SALT + super::SALT_LEN;
pub const CIPHERTEXT: usize = NONCE + super::NONCE_LEN;
pub const AAD: usize = VERSION;
pub const AAD_END: usize = CIPHERTEXT;
}
pub const FILE_LEN: usize = offset::CIPHERTEXT + MASTER_KEY_LEN + TAG_LEN;
#[derive(Clone, PartialEq, Eq)]
pub struct WrapHeader {
params: crate::recovery::KdfParams,
salt: [u8; SALT_LEN],
nonce: [u8; NONCE_LEN],
}
impl core::fmt::Debug for WrapHeader {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("WrapHeader")
.field("params", &self.params)
.finish_non_exhaustive()
}
}
impl WrapHeader {
pub fn parse(file: &[u8]) -> Result<Self> {
if file.len() != FILE_LEN {
return Err(Error::LockFileMalformed {
reason: "the length is not the fixed password wrap length",
});
}
if &file[offset::MAGIC..offset::VERSION] != MAGIC {
return Err(Error::LockFileMalformed {
reason: "magic mismatch: this is not a password-wrapped master key",
});
}
if file[offset::VERSION] != FORMAT_VERSION {
return Err(Error::UnsupportedFormatVersion {
version: file[offset::VERSION],
});
}
if file[offset::ALGORITHM] != ALGORITHM_ARGON2ID_V13 {
return Err(Error::UnsupportedFormatVersion {
version: file[offset::ALGORITHM],
});
}
let memory_kib = u32::from_be_bytes(
file[offset::MEMORY..offset::MEMORY + 4]
.try_into()
.map_err(|_| Error::LockFileMalformed {
reason: "failed to read the memory cost field",
})?,
);
let iterations = u32::from_be_bytes(
file[offset::ITERATIONS..offset::ITERATIONS + 4]
.try_into()
.map_err(|_| Error::LockFileMalformed {
reason: "failed to read the iteration count field",
})?,
);
let parallelism = u32::from_be_bytes(
file[offset::PARALLELISM..offset::PARALLELISM + 4]
.try_into()
.map_err(|_| Error::LockFileMalformed {
reason: "failed to read the parallelism field",
})?,
);
let params = checked_params(memory_kib, iterations, parallelism)?;
let mut salt = [0_u8; SALT_LEN];
salt.copy_from_slice(&file[offset::SALT..offset::SALT + SALT_LEN]);
let mut nonce = [0_u8; NONCE_LEN];
nonce.copy_from_slice(&file[offset::NONCE..offset::NONCE + NONCE_LEN]);
Ok(Self {
params,
salt,
nonce,
})
}
#[must_use]
pub const fn params(&self) -> &crate::recovery::KdfParams {
&self.params
}
}
fn checked_params(
memory_kib: u32,
iterations: u32,
parallelism: u32,
) -> Result<crate::recovery::KdfParams> {
if memory_kib < Params::MIN_M_COST {
return Err(Error::LockParametersInvalid {
reason: "the memory cost is below the Argon2 minimum",
});
}
if memory_kib > crate::recovery::MAX_MEMORY_KIB {
return Err(Error::LockParametersInvalid {
reason: "the memory cost is above the maximum this implementation allows",
});
}
if iterations < Params::MIN_T_COST {
return Err(Error::LockParametersInvalid {
reason: "the iteration count must not be zero",
});
}
if iterations > crate::recovery::MAX_ITERATIONS {
return Err(Error::LockParametersInvalid {
reason: "the iteration count is above the maximum this implementation allows",
});
}
if parallelism != crate::recovery::PARALLELISM {
return Err(Error::LockParametersInvalid {
reason: "this implementation only supports a parallelism of 1",
});
}
crate::recovery::KdfParams::new(memory_kib, iterations, parallelism).map_err(|_| {
Error::LockParametersInvalid {
reason: "Argon2 rejected these parameters",
}
})
}
pub fn wrap(
master: &MasterKey,
passphrase: &SecretString,
params: &crate::recovery::KdfParams,
) -> Result<Vec<u8>> {
if passphrase.expose_secret().is_empty() {
return Err(Error::LockPassphraseEmpty);
}
let mut salt = [0_u8; SALT_LEN];
crate::getrandom::fill(&mut salt).map_err(|_| Error::RandomnessUnavailable)?;
let mut nonce = [0_u8; NONCE_LEN];
crate::getrandom::fill(&mut nonce).map_err(|_| Error::RandomnessUnavailable)?;
let mut key = derive_key(passphrase, &salt, params)?;
let mut file = Vec::with_capacity(FILE_LEN);
file.extend_from_slice(MAGIC);
file.push(FORMAT_VERSION);
file.push(ALGORITHM_ARGON2ID_V13);
file.extend_from_slice(¶ms.memory_kib().to_be_bytes());
file.extend_from_slice(¶ms.iterations().to_be_bytes());
file.extend_from_slice(¶ms.parallelism().to_be_bytes());
file.extend_from_slice(&salt);
file.extend_from_slice(&nonce);
let mut ciphertext = master.expose().to_vec();
let aad = file[offset::AAD..offset::AAD_END].to_vec();
let result = XChaCha20Poly1305::new(&Key::from(key)).encrypt_in_place(
&XNonce::from(nonce),
&aad,
&mut ciphertext,
);
key.zeroize();
if result.is_err() {
ciphertext.zeroize();
return Err(Error::LockEncryptionFailed);
}
file.extend_from_slice(&ciphertext);
debug_assert_eq!(file.len(), FILE_LEN);
Ok(file)
}
pub fn unwrap(file: &[u8], passphrase: &SecretString) -> Result<MasterKey> {
if passphrase.expose_secret().is_empty() {
return Err(Error::LockPassphraseEmpty);
}
let header = WrapHeader::parse(file)?;
let mut key = derive_key(passphrase, &header.salt, &header.params)?;
let mut plaintext = file[offset::CIPHERTEXT..].to_vec();
let aad = file[offset::AAD..offset::AAD_END].to_vec();
let result = XChaCha20Poly1305::new(&Key::from(key)).decrypt_in_place(
&XNonce::from(header.nonce),
&aad,
&mut plaintext,
);
key.zeroize();
if result.is_err() {
plaintext.zeroize();
return Err(Error::LockPasswordRejected);
}
let secret: SecretBytes = core::mem::take(&mut plaintext).into();
MasterKey::from_bytes(secret)
}
fn derive_key(
passphrase: &SecretString,
salt: &[u8; SALT_LEN],
params: &crate::recovery::KdfParams,
) -> Result<[u8; DERIVED_KEY_LEN]> {
crate::recovery::derive_key_with_context(KDF_DOMAIN, passphrase, salt, params)
.map_err(|_| Error::LockKdfFailed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::recovery::{KdfParams, export as export_recovery};
fn fast_params() -> KdfParams {
KdfParams::new(Params::MIN_M_COST, 1, 1).expect("the minimum parameters should be valid")
}
fn passphrase(text: &str) -> SecretString {
SecretString::from(text)
}
fn sample(text: &str) -> (MasterKey, Vec<u8>) {
let master = MasterKey::generate().expect("randomness should be available");
let file = wrap(&master, &passphrase(text), &fast_params()).expect("wrap should succeed");
(master, file)
}
#[test]
fn wrap_unwrap_roundtrip() {
let (master, file) = sample("correct horse battery staple");
assert_eq!(file.len(), FILE_LEN, "the file length must be constant");
let restored = unwrap(&file, &passphrase("correct horse battery staple"))
.expect("unwrap should succeed");
assert_eq!(restored.expose(), master.expose());
}
#[test]
fn wrong_password_is_rejected() {
let (_, file) = sample("right");
assert!(matches!(
unwrap(&file, &passphrase("wrong")),
Err(Error::LockPasswordRejected)
));
}
#[test]
fn empty_password_is_rejected_on_wrap() {
let master = MasterKey::generate().expect("randomness should be available");
assert!(matches!(
wrap(&master, &passphrase(""), &fast_params()),
Err(Error::LockPassphraseEmpty)
));
}
#[test]
fn empty_password_is_rejected_on_unwrap() {
let (_, file) = sample("real password");
assert!(matches!(
unwrap(&file, &passphrase("")),
Err(Error::LockPassphraseEmpty)
));
}
#[test]
fn any_single_byte_flip_is_detected() {
let (master, file) = sample("tamper test");
let phrase = passphrase("tamper test");
for index in 0..FILE_LEN {
let mut tampered = file.clone();
tampered[index] ^= 0x01;
match unwrap(&tampered, &phrase) {
Err(_) => {}
Ok(restored) => panic!(
"byte {index} was flipped but unwrap succeeded (restored key equals the original = {})",
restored.expose() == master.expose()
),
}
}
}
#[test]
fn out_of_range_parameters_are_rejected_before_the_kdf() {
let (_, file) = sample("parameter bomb");
let phrase = passphrase("parameter bomb");
for memory in [
0_u32,
1,
Params::MIN_M_COST - 1,
crate::recovery::MAX_MEMORY_KIB + 1,
u32::MAX,
] {
let mut tampered = file.clone();
tampered[offset::MEMORY..offset::MEMORY + 4].copy_from_slice(&memory.to_be_bytes());
assert!(
matches!(
unwrap(&tampered, &phrase),
Err(Error::LockParametersInvalid { .. })
),
"memory parameter {memory} must report invalid parameters"
);
}
for iterations in [0_u32, crate::recovery::MAX_ITERATIONS + 1, u32::MAX] {
let mut tampered = file.clone();
tampered[offset::ITERATIONS..offset::ITERATIONS + 4]
.copy_from_slice(&iterations.to_be_bytes());
assert!(
matches!(
unwrap(&tampered, &phrase),
Err(Error::LockParametersInvalid { .. })
),
"iteration count {iterations} must report invalid parameters"
);
}
for parallelism in [0_u32, 2, u32::MAX] {
let mut tampered = file.clone();
tampered[offset::PARALLELISM..offset::PARALLELISM + 4]
.copy_from_slice(¶llelism.to_be_bytes());
assert!(
matches!(
unwrap(&tampered, &phrase),
Err(Error::LockParametersInvalid { .. })
),
"parallelism {parallelism} must report invalid parameters"
);
}
}
#[test]
fn wrong_length_is_rejected() {
let (_, file) = sample("length test");
let phrase = passphrase("length test");
for cut in [0_usize, 1, FILE_LEN - 1] {
assert!(
matches!(
unwrap(&file[..cut], &phrase),
Err(Error::LockFileMalformed { .. })
),
"length {cut} must be rejected"
);
}
let mut longer = file.clone();
longer.push(0);
assert!(matches!(
unwrap(&longer, &phrase),
Err(Error::LockFileMalformed { .. })
));
}
#[test]
fn wrong_magic_is_reported_as_malformed() {
let (_, mut file) = sample("magic test");
file[0] = b'X';
assert!(matches!(
unwrap(&file, &passphrase("magic test")),
Err(Error::LockFileMalformed { .. })
));
}
#[test]
fn unknown_version_is_rejected() {
let (_, mut file) = sample("version test");
file[offset::VERSION] = 0x7F;
match unwrap(&file, &passphrase("version test")) {
Err(Error::UnsupportedFormatVersion { version }) => assert_eq!(version, 0x7F),
other => panic!(
"an unsupported version should be reported, got is_ok={}",
other.is_ok()
),
}
}
#[test]
fn unknown_algorithm_is_rejected() {
let (_, mut file) = sample("algorithm test");
file[offset::ALGORITHM] = 0x7F;
assert!(matches!(
unwrap(&file, &passphrase("algorithm test")),
Err(Error::UnsupportedFormatVersion { .. })
));
}
#[test]
fn same_password_yields_different_files() {
let master = MasterKey::generate().expect("randomness should be available");
let phrase = passphrase("same password");
let a = wrap(&master, &phrase, &fast_params()).expect("wrap");
let b = wrap(&master, &phrase, &fast_params()).expect("wrap");
assert_ne!(a, b, "the salt or nonce was reused");
assert_eq!(
unwrap(&a, &phrase).expect("a").expose(),
unwrap(&b, &phrase).expect("b").expose()
);
}
#[test]
fn file_never_contains_the_master_key_in_clear() {
let (master, file) = sample("cleartext test");
assert!(
!file
.windows(MASTER_KEY_LEN)
.any(|window| window == master.expose()),
"the password wrap contains the master key in clear"
);
}
#[test]
fn parameters_travel_with_the_file() {
let master = MasterKey::generate().expect("randomness should be available");
let phrase = passphrase("params travel");
let params = KdfParams::new(Params::MIN_M_COST * 4, 2, 1).expect("valid parameters");
let file = wrap(&master, &phrase, ¶ms).expect("wrap");
let header = WrapHeader::parse(&file).expect("the header should parse");
assert_eq!(header.params().memory_kib(), params.memory_kib());
assert_eq!(header.params().iterations(), params.iterations());
assert_eq!(header.params().parallelism(), params.parallelism());
assert_eq!(
unwrap(&file, &phrase).expect("unwrap").expose(),
master.expose()
);
}
#[test]
fn recovery_file_cannot_be_opened_as_a_lock_wrap() {
let master = MasterKey::generate().expect("randomness should be available");
let phrase = passphrase("domain separation");
let mut recovery_file =
export_recovery(&master, &phrase, &fast_params()).expect("recovery export");
recovery_file[offset::MAGIC..offset::VERSION].copy_from_slice(MAGIC);
assert!(matches!(
unwrap(&recovery_file, &phrase),
Err(Error::LockPasswordRejected)
));
}
#[test]
fn lock_wrap_cannot_be_imported_as_a_recovery_file() {
let (_, mut file) = sample("domain separation");
file[offset::MAGIC..offset::VERSION].copy_from_slice(b"Q2FAREC\0");
assert!(matches!(
crate::recovery::import(&file, &passphrase("domain separation")),
Err(Error::RecoveryAuthenticationFailed)
));
}
#[test]
fn file_layout_is_stable() {
assert_eq!(
FILE_LEN, 110,
"the password wrap length changed, which invalidates already-written files"
);
assert_eq!(offset::MAGIC, 0);
assert_eq!(offset::VERSION, 8);
assert_eq!(offset::ALGORITHM, 9);
assert_eq!(offset::MEMORY, 10);
assert_eq!(offset::ITERATIONS, 14);
assert_eq!(offset::PARALLELISM, 18);
assert_eq!(offset::SALT, 22);
assert_eq!(offset::NONCE, 38);
assert_eq!(offset::CIPHERTEXT, 62);
assert_eq!(offset::AAD, 8);
assert_eq!(offset::AAD_END, 62);
}
#[test]
fn header_parse_accepts_a_wrap_and_reports_its_parameters() {
let (_, file) = sample("header");
let header = WrapHeader::parse(&file).expect("valid header");
assert_eq!(header.params().memory_kib(), Params::MIN_M_COST);
assert_eq!(header.params().iterations(), 1);
assert_eq!(header.params().parallelism(), 1);
}
#[test]
fn header_debug_does_not_print_the_salt_or_nonce() {
let (_, file) = sample("debug");
let header = WrapHeader::parse(&file).expect("valid header");
let rendered = format!("{header:?}");
assert!(rendered.contains("params"));
assert!(!rendered.contains("salt"));
}
}