use secrecy::SecretString;
use crate::CryptoError;
use crate::ProgressEvent;
use crate::crypto::aead::{WRAP_NONCE_SIZE, WRAPPED_FILE_KEY_SIZE, open_file_key, seal_file_key};
use crate::crypto::kdf::{ARGON2_SALT_SIZE, KDF_PARAMS_SIZE, KdfLimit, KdfParams};
use crate::crypto::keys::{FileKey, derive_passphrase_wrap_key, random_bytes};
pub(crate) const TYPE_NAME: &str = "argon2id";
pub(crate) const BODY_LENGTH: usize =
ARGON2_SALT_SIZE + KDF_PARAMS_SIZE + WRAP_NONCE_SIZE + WRAPPED_FILE_KEY_SIZE;
pub(crate) const HKDF_INFO_WRAP: &[u8] = b"ferrocrypt/v1/recipient/argon2id/wrap";
const SALT_OFFSET: usize = 0;
const KDF_PARAMS_OFFSET: usize = SALT_OFFSET + ARGON2_SALT_SIZE;
const WRAP_NONCE_OFFSET: usize = KDF_PARAMS_OFFSET + KDF_PARAMS_SIZE;
const WRAPPED_FILE_KEY_OFFSET: usize = WRAP_NONCE_OFFSET + WRAP_NONCE_SIZE;
pub(crate) fn wrap(
file_key: &FileKey,
passphrase: &SecretString,
kdf_params: &KdfParams,
on_event: &dyn Fn(&ProgressEvent),
) -> Result<[u8; BODY_LENGTH], CryptoError> {
let argon2_salt = random_bytes::<ARGON2_SALT_SIZE>()?;
on_event(&ProgressEvent::DerivingPassphraseWrapKey);
let wrap_key =
derive_passphrase_wrap_key(passphrase, &argon2_salt, kdf_params, HKDF_INFO_WRAP)?;
let wrap_nonce = random_bytes::<WRAP_NONCE_SIZE>()?;
let wrapped_file_key = seal_file_key(&wrap_key, &wrap_nonce, file_key)?;
let mut body = [0u8; BODY_LENGTH];
body[SALT_OFFSET..SALT_OFFSET + ARGON2_SALT_SIZE].copy_from_slice(&argon2_salt);
body[KDF_PARAMS_OFFSET..KDF_PARAMS_OFFSET + KDF_PARAMS_SIZE]
.copy_from_slice(&kdf_params.to_bytes());
body[WRAP_NONCE_OFFSET..WRAP_NONCE_OFFSET + WRAP_NONCE_SIZE].copy_from_slice(&wrap_nonce);
body[WRAPPED_FILE_KEY_OFFSET..].copy_from_slice(&wrapped_file_key);
Ok(body)
}
pub(crate) fn unwrap(
body: &[u8; BODY_LENGTH],
passphrase: &SecretString,
kdf_limit: Option<&KdfLimit>,
on_event: &dyn Fn(&ProgressEvent),
) -> Result<FileKey, CryptoError> {
let mut argon2_salt = [0u8; ARGON2_SALT_SIZE];
argon2_salt.copy_from_slice(&body[SALT_OFFSET..SALT_OFFSET + ARGON2_SALT_SIZE]);
let mut kdf_bytes = [0u8; KDF_PARAMS_SIZE];
kdf_bytes.copy_from_slice(&body[KDF_PARAMS_OFFSET..KDF_PARAMS_OFFSET + KDF_PARAMS_SIZE]);
let kdf_params = KdfParams::from_bytes(&kdf_bytes, kdf_limit)?;
let mut wrap_nonce = [0u8; WRAP_NONCE_SIZE];
wrap_nonce.copy_from_slice(&body[WRAP_NONCE_OFFSET..WRAP_NONCE_OFFSET + WRAP_NONCE_SIZE]);
let mut wrapped_file_key = [0u8; WRAPPED_FILE_KEY_SIZE];
wrapped_file_key.copy_from_slice(&body[WRAPPED_FILE_KEY_OFFSET..]);
on_event(&ProgressEvent::DerivingPassphraseWrapKey);
let wrap_key =
derive_passphrase_wrap_key(passphrase, &argon2_salt, &kdf_params, HKDF_INFO_WRAP)?;
open_file_key(&wrap_key, &wrap_nonce, &wrapped_file_key, || {
CryptoError::RecipientUnwrapFailed {
type_name: TYPE_NAME.to_string(),
}
})
}
pub(crate) struct PassphraseRecipient<'a> {
pub passphrase: &'a SecretString,
pub kdf_params: KdfParams,
}
impl<'a> crate::protocol::RecipientScheme for PassphraseRecipient<'a> {
const TYPE_NAME: &'static str = TYPE_NAME;
const MIXING_RULE: crate::recipient::policy::NativeMixingRule =
crate::recipient::policy::NativeRecipientType::Argon2id.mixing_rule();
fn wrap_file_key(
&self,
file_key: &FileKey,
on_event: &dyn Fn(&ProgressEvent),
) -> Result<crate::recipient::entry::RecipientBody, CryptoError> {
let bytes = wrap(file_key, self.passphrase, &self.kdf_params, on_event)?;
Ok(crate::recipient::entry::RecipientBody {
type_name: TYPE_NAME,
bytes: bytes.to_vec(),
})
}
}
pub(crate) struct PassphraseCredential<'a> {
pub passphrase: &'a SecretString,
pub kdf_limit: Option<&'a KdfLimit>,
}
impl<'a> crate::protocol::DecryptionCredential for PassphraseCredential<'a> {
const TYPE_NAME: &'static str = TYPE_NAME;
const EXPECTED_MODE: crate::UnauthenticatedRecipientMode =
crate::UnauthenticatedRecipientMode::Passphrase;
fn unwrap_file_key(
&self,
body: &[u8],
on_event: &dyn Fn(&ProgressEvent),
) -> Result<Option<FileKey>, CryptoError> {
let body_array: &[u8; BODY_LENGTH] = body.try_into().map_err(|_| {
CryptoError::InvalidFormat(crate::error::FormatDefect::MalformedRecipientEntry)
})?;
match unwrap(body_array, self.passphrase, self.kdf_limit, on_event) {
Ok(file_key) => Ok(Some(file_key)),
Err(CryptoError::RecipientUnwrapFailed { .. }) => Ok(None),
Err(other) => Err(other),
}
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use super::*;
use crate::crypto::keys::FILE_KEY_SIZE;
fn passphrase(s: &str) -> SecretString {
SecretString::from(s.to_string())
}
fn noop() -> impl Fn(&ProgressEvent) {
|_: &ProgressEvent| {}
}
fn recording() -> (
impl Fn(&ProgressEvent),
std::rc::Rc<RefCell<Vec<ProgressEvent>>>,
) {
let events = std::rc::Rc::new(RefCell::new(Vec::<ProgressEvent>::new()));
let sink = events.clone();
let f = move |e: &ProgressEvent| {
sink.borrow_mut().push(*e);
};
(f, events)
}
#[test]
fn body_length_matches_field_sum() {
assert_eq!(
BODY_LENGTH,
ARGON2_SALT_SIZE + KDF_PARAMS_SIZE + WRAP_NONCE_SIZE + WRAPPED_FILE_KEY_SIZE
);
assert_eq!(BODY_LENGTH, 116);
}
#[test]
fn type_name_is_canonical_lowercase() {
assert_eq!(TYPE_NAME, "argon2id");
}
#[test]
fn hkdf_info_wrap_is_canonical() {
assert_eq!(HKDF_INFO_WRAP, b"ferrocrypt/v1/recipient/argon2id/wrap");
}
#[test]
fn wrap_unwrap_round_trip() {
let file_key = FileKey::from_bytes_for_tests([0x42u8; FILE_KEY_SIZE]);
let pass = passphrase("correct horse battery staple");
let kdf = KdfParams::test_fast_default();
let body = wrap(&file_key, &pass, &kdf, &noop()).unwrap();
let recovered = unwrap(&body, &pass, None, &noop()).unwrap();
assert_eq!(recovered.expose(), file_key.expose());
}
#[test]
fn unwrap_with_wrong_passphrase_fails_with_recipient_unwrap_failed() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let right = passphrase("right");
let wrong = passphrase("wrong");
let kdf = KdfParams::test_fast_default();
let body = wrap(&file_key, &right, &kdf, &noop()).unwrap();
match unwrap(&body, &wrong, None, &noop()) {
Err(CryptoError::RecipientUnwrapFailed { type_name }) => {
assert_eq!(type_name, TYPE_NAME);
}
other => panic!("expected RecipientUnwrapFailed, got {other:?}"),
}
}
#[test]
fn unwrap_with_tampered_wrapped_file_key_fails_with_recipient_unwrap_failed() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let pass = passphrase("p");
let kdf = KdfParams::test_fast_default();
let mut body = wrap(&file_key, &pass, &kdf, &noop()).unwrap();
body[WRAPPED_FILE_KEY_OFFSET] ^= 0x01;
match unwrap(&body, &pass, None, &noop()) {
Err(CryptoError::RecipientUnwrapFailed { type_name }) => {
assert_eq!(type_name, TYPE_NAME);
}
other => panic!("expected RecipientUnwrapFailed, got {other:?}"),
}
}
#[test]
fn unwrap_with_tampered_argon2_salt_fails_with_recipient_unwrap_failed() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let pass = passphrase("p");
let kdf = KdfParams::test_fast_default();
let mut body = wrap(&file_key, &pass, &kdf, &noop()).unwrap();
body[SALT_OFFSET] ^= 0x01;
match unwrap(&body, &pass, None, &noop()) {
Err(CryptoError::RecipientUnwrapFailed { type_name }) => {
assert_eq!(type_name, TYPE_NAME);
}
other => panic!("expected RecipientUnwrapFailed, got {other:?}"),
}
}
#[test]
fn unwrap_with_tampered_kdf_params_within_bounds_fails_with_recipient_unwrap_failed() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let pass = passphrase("p");
let kdf = KdfParams::test_fast_default();
let mut body = wrap(&file_key, &pass, &kdf, &noop()).unwrap();
body[KDF_PARAMS_OFFSET + 7] ^= 0x02;
match unwrap(&body, &pass, None, &noop()) {
Err(CryptoError::RecipientUnwrapFailed { type_name }) => {
assert_eq!(type_name, TYPE_NAME);
}
other => panic!("expected RecipientUnwrapFailed, got {other:?}"),
}
}
#[test]
fn unwrap_with_tampered_wrap_nonce_fails_with_recipient_unwrap_failed() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let pass = passphrase("p");
let kdf = KdfParams::test_fast_default();
let mut body = wrap(&file_key, &pass, &kdf, &noop()).unwrap();
body[WRAP_NONCE_OFFSET] ^= 0x01;
match unwrap(&body, &pass, None, &noop()) {
Err(CryptoError::RecipientUnwrapFailed { type_name }) => {
assert_eq!(type_name, TYPE_NAME);
}
other => panic!("expected RecipientUnwrapFailed, got {other:?}"),
}
}
#[test]
fn unwrap_with_malformed_kdf_params_fails_before_argon2id_runs() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let pass = passphrase("p");
let kdf = KdfParams::test_fast_default();
let mut body = wrap(&file_key, &pass, &kdf, &noop()).unwrap();
body[KDF_PARAMS_OFFSET + 8..KDF_PARAMS_OFFSET + 12].fill(0);
match unwrap(&body, &pass, None, &noop()) {
Err(CryptoError::InvalidKdfParams(_)) => {}
other => panic!("expected InvalidKdfParams, got {other:?}"),
}
}
#[test]
fn unwrap_rejects_kdf_params_above_resource_cap() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let pass = passphrase("p");
let high_mem_kdf = KdfParams {
mem_cost: KdfParams::MAX_MEM_COST,
time_cost: 1,
lanes: 1,
};
let kdf_low = KdfParams::test_fast_default();
let mut body = wrap(&file_key, &pass, &kdf_low, &noop()).unwrap();
body[KDF_PARAMS_OFFSET..KDF_PARAMS_OFFSET + KDF_PARAMS_SIZE]
.copy_from_slice(&high_mem_kdf.to_bytes());
let limit = KdfLimit::new(64);
match unwrap(&body, &pass, Some(&limit), &noop()) {
Err(CryptoError::KdfResourceCapExceeded {
mem_cost_kib,
local_cap_kib,
}) => {
assert_eq!(mem_cost_kib, 2 * 1024 * 1024);
assert_eq!(local_cap_kib, 64);
}
other => panic!("expected KdfResourceCapExceeded, got {other:?}"),
}
}
#[test]
fn unwrap_field_offsets_are_correct() {
let file_key = FileKey::from_bytes_for_tests([0x11u8; FILE_KEY_SIZE]);
let pass = passphrase("p");
let kdf = KdfParams::test_fast_default();
let body = wrap(&file_key, &pass, &kdf, &noop()).unwrap();
assert_eq!(SALT_OFFSET, 0);
assert_eq!(KDF_PARAMS_OFFSET, 32);
assert_eq!(WRAP_NONCE_OFFSET, 44);
assert_eq!(WRAPPED_FILE_KEY_OFFSET, 68);
assert_eq!(body.len(), 116);
}
#[test]
fn wrap_emits_deriving_passphrase_wrap_key_exactly_once() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let pass = passphrase("p");
let kdf = KdfParams::test_fast_default();
let (sink, events) = recording();
wrap(&file_key, &pass, &kdf, &sink).unwrap();
assert_eq!(
events.borrow().clone(),
vec![ProgressEvent::DerivingPassphraseWrapKey]
);
}
#[test]
fn unwrap_emits_deriving_passphrase_wrap_key_exactly_once_on_success() {
let file_key = FileKey::from_bytes_for_tests([0x42u8; FILE_KEY_SIZE]);
let pass = passphrase("p");
let kdf = KdfParams::test_fast_default();
let body = wrap(&file_key, &pass, &kdf, &noop()).unwrap();
let (sink, events) = recording();
unwrap(&body, &pass, None, &sink).unwrap();
assert_eq!(
events.borrow().clone(),
vec![ProgressEvent::DerivingPassphraseWrapKey]
);
}
#[test]
fn unwrap_emits_no_event_when_kdf_params_are_structurally_malformed() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let pass = passphrase("p");
let kdf = KdfParams::test_fast_default();
let mut body = wrap(&file_key, &pass, &kdf, &noop()).unwrap();
body[KDF_PARAMS_OFFSET + 8..KDF_PARAMS_OFFSET + 12].fill(0);
let (sink, events) = recording();
let _ = unwrap(&body, &pass, None, &sink);
assert!(
events.borrow().is_empty(),
"no event should fire before structural validation passes; got {:?}",
events.borrow()
);
}
#[test]
fn unwrap_emits_no_event_when_kdf_params_exceed_resource_cap() {
let file_key = FileKey::from_bytes_for_tests([0u8; FILE_KEY_SIZE]);
let pass = passphrase("p");
let high_mem_kdf = KdfParams {
mem_cost: KdfParams::MAX_MEM_COST,
time_cost: 1,
lanes: 1,
};
let kdf_low = KdfParams::test_fast_default();
let mut body = wrap(&file_key, &pass, &kdf_low, &noop()).unwrap();
body[KDF_PARAMS_OFFSET..KDF_PARAMS_OFFSET + KDF_PARAMS_SIZE]
.copy_from_slice(&high_mem_kdf.to_bytes());
let limit = KdfLimit::new(64);
let (sink, events) = recording();
let _ = unwrap(&body, &pass, Some(&limit), &sink);
assert!(
events.borrow().is_empty(),
"no event should fire before resource-cap check passes; got {:?}",
events.borrow()
);
}
}