use crate::vault::{MASTER_KEY_LEN, MasterKey, NONCE_LEN as AEAD_NONCE_LEN, master_key_to_bytes};
use crate::{Error, ExposeSecret, Result, SecretBytes, SecretString};
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::aead::{AeadInOut, KeyInit};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use secrecy::zeroize::Zeroize;
const MAGIC: &[u8; 8] = b"Q2FAREC\0";
pub const FORMAT_VERSION: u8 = 1;
const ALGORITHM_ARGON2ID_V13: u8 = 1;
pub const SALT_LEN: usize = 16;
pub const DEFAULT_MEMORY_KIB: u32 = 64 * 1024;
pub const DEFAULT_ITERATIONS: u32 = 3;
pub const PARALLELISM: u32 = 1;
pub(crate) const DERIVED_KEY_LEN: usize = 32;
pub const MAX_MEMORY_KIB: u32 = 1024 * 1024;
pub const MAX_ITERATIONS: u32 = 1000;
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::AEAD_NONCE_LEN;
pub const AAD: usize = super::offset::VERSION;
pub const AAD_END: usize = super::offset::CIPHERTEXT;
}
pub const FILE_LEN: usize = offset::CIPHERTEXT + MASTER_KEY_LEN + crate::vault::TAG_LEN;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KdfParams {
memory_kib: u32,
iterations: u32,
parallelism: u32,
}
impl KdfParams {
#[must_use]
pub const fn recommended() -> Self {
Self {
memory_kib: DEFAULT_MEMORY_KIB,
iterations: DEFAULT_ITERATIONS,
parallelism: PARALLELISM,
}
}
pub const fn new(memory_kib: u32, iterations: u32, parallelism: u32) -> Result<Self> {
if memory_kib < Params::MIN_M_COST {
return Err(Error::RecoveryParametersInvalid {
reason: "the memory cost is below the Argon2 minimum",
});
}
if memory_kib > MAX_MEMORY_KIB {
return Err(Error::RecoveryParametersInvalid {
reason: "the memory cost is above the maximum this implementation allows",
});
}
if iterations < Params::MIN_T_COST {
return Err(Error::RecoveryParametersInvalid {
reason: "the iteration count must not be zero",
});
}
if iterations > MAX_ITERATIONS {
return Err(Error::RecoveryParametersInvalid {
reason: "the iteration count is above the maximum this implementation allows",
});
}
if parallelism != PARALLELISM {
return Err(Error::RecoveryParametersInvalid {
reason: "this implementation only supports a parallelism of 1",
});
}
Ok(Self {
memory_kib,
iterations,
parallelism,
})
}
#[must_use]
pub const fn memory_kib(&self) -> u32 {
self.memory_kib
}
#[must_use]
pub const fn iterations(&self) -> u32 {
self.iterations
}
#[must_use]
pub const fn parallelism(&self) -> u32 {
self.parallelism
}
}
impl Default for KdfParams {
fn default() -> Self {
Self::recommended()
}
}
pub fn export(
master: &MasterKey,
passphrase: &SecretString,
params: &KdfParams,
) -> Result<Vec<u8>> {
if passphrase.expose_secret().is_empty() {
return Err(Error::RecoveryPassphraseEmpty);
}
let mut salt = [0_u8; SALT_LEN];
crate::getrandom::fill(&mut salt).map_err(|_| Error::RandomnessUnavailable)?;
let mut nonce = [0_u8; AEAD_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();
result.map_err(|_| Error::RecoveryEncryptionFailed)?;
file.extend_from_slice(&ciphertext);
debug_assert_eq!(file.len(), FILE_LEN);
Ok(file)
}
pub fn import(file: &[u8], passphrase: &SecretString) -> Result<MasterKey> {
if file.len() != FILE_LEN {
return Err(Error::RecoveryFileMalformed {
reason: "the length is not the fixed recovery file length",
});
}
if &file[offset::MAGIC..offset::VERSION] != MAGIC {
return Err(Error::RecoveryFileMalformed {
reason: "magic mismatch: this is not a recovery file of this application",
});
}
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::RecoveryFileMalformed {
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::RecoveryFileMalformed {
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::RecoveryFileMalformed {
reason: "failed to read the parallelism field",
})?,
);
let params = KdfParams::new(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; AEAD_NONCE_LEN];
nonce.copy_from_slice(&file[offset::NONCE..offset::NONCE + AEAD_NONCE_LEN]);
let mut key = derive_key(passphrase, &salt, ¶ms)?;
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(nonce),
&aad,
&mut plaintext,
);
key.zeroize();
result.map_err(|_| Error::RecoveryAuthenticationFailed)?;
let secret: SecretBytes = core::mem::take(&mut plaintext).into();
MasterKey::from_bytes(secret)
}
fn derive_key(
passphrase: &SecretString,
salt: &[u8; SALT_LEN],
params: &KdfParams,
) -> Result<[u8; DERIVED_KEY_LEN]> {
derive_key_with_context(&[], passphrase, salt, params)
}
pub(crate) fn derive_key_with_context(
context: &[u8],
passphrase: &SecretString,
salt: &[u8; SALT_LEN],
params: &KdfParams,
) -> Result<[u8; DERIVED_KEY_LEN]> {
let params = Params::new(
params.memory_kib,
params.iterations,
params.parallelism,
Some(DERIVED_KEY_LEN),
)
.map_err(|_| Error::RecoveryParametersInvalid {
reason: "Argon2 rejected these parameters",
})?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let secret = passphrase.expose_secret().as_bytes();
let mut material = Vec::with_capacity(context.len().saturating_add(secret.len()));
material.extend_from_slice(context);
material.extend_from_slice(secret);
let mut out = [0_u8; DERIVED_KEY_LEN];
let result = argon2.hash_password_into(&material, salt, &mut out);
material.zeroize();
result.map_err(|_| Error::RecoveryKdfFailed)?;
Ok(out)
}
#[must_use]
pub fn master_key_for_storage(master: &MasterKey) -> SecretBytes {
master_key_to_bytes(master)
}
#[cfg(test)]
mod tests {
use super::*;
use argon2::{AssociatedData, ParamsBuilder};
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)
}
#[test]
fn export_import_roundtrip() {
let master = MasterKey::generate().expect("randomness should be available");
let file = export(
&master,
&passphrase("correct horse battery staple"),
&fast_params(),
)
.expect("export should succeed");
assert_eq!(file.len(), FILE_LEN, "the file length must be constant");
let restored = import(&file, &passphrase("correct horse battery staple"))
.expect("import should succeed");
assert_eq!(restored.expose(), master.expose());
}
#[test]
fn wrong_passphrase_fails() {
let master = MasterKey::generate().expect("randomness should be available");
let file = export(&master, &passphrase("right"), &fast_params()).expect("export");
let result = import(&file, &passphrase("wrong"));
assert!(
matches!(result, Err(Error::RecoveryAuthenticationFailed)),
"a wrong passphrase must be rejected"
);
}
#[test]
fn empty_passphrase_is_rejected_on_export() {
let master = MasterKey::generate().expect("randomness should be available");
let result = export(&master, &passphrase(""), &fast_params());
assert!(matches!(result, Err(Error::RecoveryPassphraseEmpty)));
}
#[test]
fn any_single_byte_flip_is_detected() {
let master = MasterKey::generate().expect("randomness should be available");
let phrase = passphrase("tamper test");
let file = export(&master, &phrase, &fast_params()).expect("export");
let param_range = offset::MEMORY..offset::PARALLELISM + 4;
for index in 0..FILE_LEN {
if param_range.contains(&index) {
continue;
}
let mut tampered = file.clone();
tampered[index] ^= 0x01;
match import(&tampered, &phrase) {
Err(_) => {}
Ok(restored) => panic!(
"byte {index} was flipped but the import succeeded (the restored key equals the original = {})",
restored.expose() == master.expose()
),
}
}
}
#[test]
fn flipping_parameter_bytes_is_rejected_by_the_limits() {
let master = MasterKey::generate().expect("randomness should be available");
let phrase = passphrase("param tamper test");
let file = export(&master, &phrase, &fast_params()).expect("export");
for index in offset::MEMORY..offset::PARALLELISM + 4 {
let mut tampered = file.clone();
tampered[index] ^= 0x01;
match import(&tampered, &phrase) {
Err(Error::RecoveryParametersInvalid { .. }) => {}
Err(Error::RecoveryAuthenticationFailed) => {}
Err(other) => panic!("byte {index}: unexpected error type {other}"),
Ok(restored) => panic!(
"byte {index} was flipped but the import succeeded (same key = {})",
restored.expose() == master.expose()
),
}
}
}
#[test]
fn wrong_length_is_rejected() {
let master = MasterKey::generate().expect("randomness should be available");
let phrase = passphrase("length test");
let file = export(&master, &phrase, &fast_params()).expect("export");
for cut in [0_usize, 1, FILE_LEN - 1] {
assert!(
matches!(
import(&file[..cut], &phrase),
Err(Error::RecoveryFileMalformed { .. })
),
"length {cut} must be rejected"
);
}
let mut longer = file.clone();
longer.push(0);
assert!(matches!(
import(&longer, &phrase),
Err(Error::RecoveryFileMalformed { .. })
));
}
#[test]
fn wrong_magic_is_reported_as_malformed_not_wrong_passphrase() {
let master = MasterKey::generate().expect("randomness should be available");
let phrase = passphrase("magic test");
let mut file = export(&master, &phrase, &fast_params()).expect("export");
file[0] = b'X';
assert!(matches!(
import(&file, &phrase),
Err(Error::RecoveryFileMalformed { .. })
));
}
#[test]
fn unknown_version_is_rejected() {
let master = MasterKey::generate().expect("randomness should be available");
let phrase = passphrase("version test");
let mut file = export(&master, &phrase, &fast_params()).expect("export");
file[offset::VERSION] = 0x7F;
match import(&file, &phrase) {
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 master = MasterKey::generate().expect("randomness should be available");
let phrase = passphrase("algorithm test");
let mut file = export(&master, &phrase, &fast_params()).expect("export");
file[offset::ALGORITHM] = 0x7F;
assert!(import(&file, &phrase).is_err());
}
#[test]
fn absurd_memory_cost_is_rejected_without_allocating() {
let master = MasterKey::generate().expect("randomness should be available");
let phrase = passphrase("memory bomb");
let file = export(&master, &phrase, &fast_params()).expect("export");
for memory in [0_u32, 1, Params::MIN_M_COST - 1] {
assert!(
matches!(
import_with_memory(&file, &phrase, memory),
Err(Error::RecoveryParametersInvalid { .. })
),
"memory parameter {memory} must report invalid parameters"
);
}
for memory in [MAX_MEMORY_KIB + 1, u32::MAX, u32::MAX / 2] {
assert!(
matches!(
import_with_memory(&file, &phrase, memory),
Err(Error::RecoveryParametersInvalid { .. })
),
"memory parameter {memory} must report invalid parameters instead of actually allocating"
);
}
assert!(
KdfParams::new(MAX_MEMORY_KIB, 1, 1).is_ok(),
"the limit itself should be valid"
);
assert!(
KdfParams::new(MAX_MEMORY_KIB + 1, 1, 1).is_err(),
"above the limit must be rejected"
);
}
fn import_with_memory(file: &[u8], phrase: &SecretString, memory: u32) -> Result<MasterKey> {
let mut tampered = file.to_vec();
tampered[offset::MEMORY..offset::MEMORY + 4].copy_from_slice(&memory.to_be_bytes());
import(&tampered, phrase)
}
#[test]
fn absurd_iteration_count_is_rejected_without_running_forever() {
let master = MasterKey::generate().expect("randomness should be available");
let phrase = passphrase("iteration bomb");
let file = export(&master, &phrase, &fast_params()).expect("export");
for iterations in [MAX_ITERATIONS + 1, u32::MAX, u32::MAX / 2] {
let mut tampered = file.clone();
tampered[offset::ITERATIONS..offset::ITERATIONS + 4]
.copy_from_slice(&iterations.to_be_bytes());
assert!(
matches!(
import(&tampered, &phrase),
Err(Error::RecoveryParametersInvalid { .. })
),
"iteration count {iterations} must report invalid parameters"
);
}
assert!(
KdfParams::new(1024, MAX_ITERATIONS, 1).is_ok(),
"the limit itself should be valid"
);
assert!(KdfParams::new(1024, MAX_ITERATIONS + 1, 1).is_err());
}
#[test]
fn zero_iterations_is_rejected() {
let master = MasterKey::generate().expect("randomness should be available");
let phrase = passphrase("iterations test");
let mut file = export(&master, &phrase, &fast_params()).expect("export");
file[offset::ITERATIONS..offset::ITERATIONS + 4].copy_from_slice(&0_u32.to_be_bytes());
assert!(matches!(
import(&file, &phrase),
Err(Error::RecoveryParametersInvalid { .. })
));
}
#[test]
fn parallelism_other_than_one_is_rejected() {
assert!(
KdfParams::new(1024, 1, 2).is_err(),
"parallelism 2 must be rejected"
);
assert!(
KdfParams::new(1024, 1, 0).is_err(),
"parallelism 0 must be rejected"
);
assert!(KdfParams::new(1024, 1, 1).is_ok());
}
#[test]
fn rfc9106_argon2id_test_vector() {
let params = ParamsBuilder::new()
.m_cost(32)
.t_cost(3)
.p_cost(4)
.output_len(32)
.data(AssociatedData::new(&[0x04_u8; 12]).expect("12 ≤ MAX_DATA_LEN"))
.build()
.expect("the RFC vector parameters should be valid");
let secret = [0x03_u8; 8];
let context = Argon2::new_with_secret(&secret, Algorithm::Argon2id, Version::V0x13, params)
.expect("construction should succeed");
let mut out = [0_u8; 32];
context
.hash_password_into(&[0x01_u8; 32], &[0x02_u8; 16], &mut out)
.expect("the computation should succeed");
let expected: [u8; 32] = [
0x0d, 0x64, 0x0d, 0xf5, 0x8d, 0x78, 0x76, 0x6c, 0x08, 0xc0, 0x37, 0xa3, 0x4a, 0x8b,
0x53, 0xc9, 0xd0, 0x1e, 0xf0, 0x45, 0x2d, 0x75, 0xb6, 0x5e, 0xb5, 0x25, 0x20, 0xe9,
0x6b, 0x01, 0xe6, 0x59,
];
assert_eq!(
out, expected,
"the Argon2id output does not match the RFC 9106 §5.3 vector"
);
}
#[test]
fn derived_key_is_deterministic_and_pinned() {
let phrase = passphrase("deterministic");
let salt = [0x11_u8; SALT_LEN];
let params = fast_params();
let a = derive_key(&phrase, &salt, ¶ms).expect("derivation should succeed");
let b = derive_key(&phrase, &salt, ¶ms).expect("derivation should succeed");
assert_eq!(
a, b,
"the same passphrase and salt must derive the same key"
);
let mut other_salt = salt;
other_salt[0] ^= 0x01;
assert_ne!(
derive_key(&phrase, &other_salt, ¶ms).expect("derivation should succeed"),
a,
"changing the salt derived the same key"
);
let other_params = KdfParams::new(Params::MIN_M_COST * 2, 1, 1).expect("valid");
assert_ne!(
derive_key(&phrase, &salt, &other_params).expect("derivation should succeed"),
a,
"changing the memory parameter derived the same key"
);
assert_eq!(a.len(), DERIVED_KEY_LEN);
}
#[test]
fn output_length_is_explicitly_requested() {
let phrase = passphrase("output length");
let salt = [0x22_u8; SALT_LEN];
let mut key =
derive_key(&phrase, &salt, &fast_params()).expect("derivation should succeed");
let params = Params::new(Params::MIN_M_COST, 1, 1, Some(DERIVED_KEY_LEN)).expect("valid");
let mut expected = [0_u8; DERIVED_KEY_LEN];
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
.hash_password_into(phrase.expose_secret().as_bytes(), &salt, &mut expected)
.expect("the computation should succeed");
assert_eq!(key, expected);
key.zeroize();
}
#[test]
fn same_passphrase_yields_different_files() {
let master = MasterKey::generate().expect("randomness should be available");
let phrase = passphrase("same passphrase");
let a = export(&master, &phrase, &fast_params()).expect("export");
let b = export(&master, &phrase, &fast_params()).expect("export");
assert_ne!(a, b, "the salt or nonce was reused");
assert_eq!(
import(&a, &phrase).expect("a should import").expose(),
import(&b, &phrase).expect("b should import").expose()
);
}
#[test]
fn file_never_contains_the_master_key_in_clear() {
let master = MasterKey::generate().expect("randomness should be available");
let file = export(&master, &passphrase("cleartext test"), &fast_params()).expect("export");
assert!(
!file.windows(MASTER_KEY_LEN).any(|w| w == master.expose()),
"the recovery file contains the master key in clear"
);
}
#[test]
fn default_params_are_expensive() {
let params = KdfParams::recommended();
assert_eq!(
params.memory_kib(),
64 * 1024,
"the default memory is not 64 MiB"
);
assert!(
params.iterations() >= 3,
"the default iteration count is too low"
);
assert_eq!(params.parallelism(), 1);
assert!(
params.memory_kib() >= Params::DEFAULT_M_COST,
"the default memory is below Argon2's own default"
);
}
#[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 = export(&master, &phrase, ¶ms).expect("export");
assert_eq!(
u32::from_be_bytes(file[offset::MEMORY..offset::MEMORY + 4].try_into().unwrap()),
params.memory_kib()
);
assert_eq!(
u32::from_be_bytes(
file[offset::ITERATIONS..offset::ITERATIONS + 4]
.try_into()
.unwrap()
),
params.iterations()
);
assert_eq!(
import(&file, &phrase).expect("import").expose(),
master.expose()
);
}
#[test]
fn master_key_storage_roundtrip() {
let master = MasterKey::generate().expect("randomness should be available");
let bytes = master_key_for_storage(&master);
let rebuilt =
crate::pairing::master_key_from_storage(bytes).expect("the length is correct");
assert_eq!(master.expose(), rebuilt.expose());
}
#[test]
fn file_layout_is_stable() {
assert_eq!(
FILE_LEN, 110,
"the recovery file length changed, which invalidates already-exported 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);
}
}