use std::fs;
use std::path::{Path, PathBuf};
use crate::archive::{ArchiveLimits, IncompleteOutputPolicy, unarchive};
use crate::container::{
HeaderReadLimits, build_encrypted_header, read_encrypted_header, resolve_encrypted_output_path,
write_encrypted_file,
};
use crate::crypto::keys::{DerivedSubkeys, FileKey, derive_subkeys, random_bytes};
use crate::crypto::stream::{STREAM_NONCE_SIZE, payload_decryptor};
use crate::crypto::tlv::validate_tlv;
use crate::error::CryptoError;
use crate::format;
use crate::fs::paths::{encryption_base_name, reject_occupied};
use crate::recipient::entry::{RecipientBody, RecipientEntry};
#[cfg(test)]
use crate::recipient::policy::MixingPolicy;
use crate::recipient::policy::{NativeMixingRule, NativeRecipientType, classify_recipient_mode};
use crate::{ProgressEvent, UnauthenticatedRecipientMode};
pub(crate) trait RecipientScheme {
const TYPE_NAME: &'static str;
const MIXING_RULE: NativeMixingRule;
fn wrap_file_key(
&self,
file_key: &FileKey,
on_event: &dyn Fn(&ProgressEvent),
) -> Result<RecipientBody, CryptoError>;
}
pub(crate) trait DecryptionCredential {
const TYPE_NAME: &'static str;
const EXPECTED_MODE: UnauthenticatedRecipientMode;
fn unwrap_file_key(
&self,
body: &[u8],
on_event: &dyn Fn(&ProgressEvent),
) -> Result<Option<FileKey>, CryptoError>;
}
pub(crate) fn encrypt<R: RecipientScheme>(
recipients: &[R],
archive_limits: ArchiveLimits,
input_path: &Path,
output_dir: &Path,
output_file: Option<&Path>,
on_event: &dyn Fn(&ProgressEvent),
) -> Result<PathBuf, CryptoError> {
if recipients.is_empty() {
return Err(CryptoError::EmptyRecipientList);
}
if R::MIXING_RULE.requires_single_entry() && recipients.len() > 1 {
return Err(CryptoError::IncompatibleRecipients {
type_name: R::TYPE_NAME.to_string(),
policy: R::MIXING_RULE.diagnostic_policy(),
});
}
let base_name = encryption_base_name(input_path)?;
let output_path = resolve_encrypted_output_path(output_dir, output_file, &base_name);
reject_occupied(&output_path, "Output")?;
let file_key = FileKey::generate()?;
let stream_nonce = random_bytes::<STREAM_NONCE_SIZE>()?;
let DerivedSubkeys {
payload_key,
header_key,
} = derive_subkeys(&file_key, &stream_nonce)?;
let mut entries = Vec::with_capacity(recipients.len());
for recipient in recipients {
let body = recipient.wrap_file_key(&file_key, on_event)?;
entries.push(build_native_entry(R::TYPE_NAME, body)?);
}
drop(file_key);
let built = build_encrypted_header(
&entries,
b"", stream_nonce,
payload_key,
&header_key,
)?;
drop(header_key);
on_event(&ProgressEvent::Encrypting);
write_encrypted_file(
input_path,
output_dir,
output_file,
&base_name,
&built,
archive_limits,
)
}
fn build_native_entry(
type_name: &'static str,
body: RecipientBody,
) -> Result<RecipientEntry, CryptoError> {
debug_assert_eq!(body.type_name, type_name);
let ty = NativeRecipientType::from_type_name(type_name)
.ok_or(CryptoError::InternalInvariant("Unknown native scheme"))?;
RecipientEntry::native(ty, body.bytes)
}
pub(crate) fn decrypt<I: DecryptionCredential>(
credential: &I,
input_path: &Path,
output_dir: &Path,
archive_limits: ArchiveLimits,
header_read_limits: HeaderReadLimits,
incomplete_output_policy: IncompleteOutputPolicy,
on_event: &dyn Fn(&ProgressEvent),
) -> Result<PathBuf, CryptoError> {
let mut encrypted_file = fs::File::open(input_path)?;
let parsed = read_encrypted_header(&mut encrypted_file, header_read_limits)?;
let mode = classify_recipient_mode(&parsed.recipient_entries)?;
check_mode_matches_scheme::<I>(mode)?;
let stream_nonce = parsed.fixed.stream_nonce;
let mut had_successful_unwrap = false;
let mut selected_payload_key: Option<crate::crypto::keys::PayloadKey> = None;
for entry in parsed.recipient_entries.iter() {
if entry.type_name != I::TYPE_NAME {
continue;
}
if entry.recipient_flags != 0 {
return Err(CryptoError::InvalidFormat(
crate::error::FormatDefect::MalformedRecipientEntry,
));
}
let file_key = match credential.unwrap_file_key(&entry.body, on_event)? {
Some(k) => k,
None => continue,
};
had_successful_unwrap = true;
let DerivedSubkeys {
payload_key,
header_key,
} = derive_subkeys(&file_key, &stream_nonce)?;
drop(file_key);
if format::verify_header_mac(
&parsed.prefix_bytes,
&parsed.header_bytes,
&header_key,
&parsed.header_mac,
)
.is_ok()
&& selected_payload_key.is_none()
{
selected_payload_key = Some(payload_key);
}
drop(header_key);
}
let Some(payload_key) = selected_payload_key else {
return Err(failure_for(mode, I::TYPE_NAME, had_successful_unwrap));
};
validate_tlv(&parsed.ext_bytes)?;
on_event(&ProgressEvent::Decrypting);
let decrypt_reader = payload_decryptor(&payload_key, &stream_nonce, encrypted_file);
unarchive(
decrypt_reader,
output_dir,
archive_limits,
incomplete_output_policy,
)
}
fn check_mode_matches_scheme<I: DecryptionCredential>(
mode: UnauthenticatedRecipientMode,
) -> Result<(), CryptoError> {
if mode == I::EXPECTED_MODE {
return Ok(());
}
Err(CryptoError::DecryptorModeMismatch {
expected: I::EXPECTED_MODE,
found: mode,
})
}
fn failure_for(
mode: UnauthenticatedRecipientMode,
type_name: &'static str,
had_unwrap: bool,
) -> CryptoError {
if !had_unwrap {
return CryptoError::RecipientUnwrapFailed {
type_name: type_name.to_string(),
};
}
match mode {
UnauthenticatedRecipientMode::Passphrase => CryptoError::HeaderTampered,
UnauthenticatedRecipientMode::PublicKey => CryptoError::HeaderMacFailedAfterUnwrap {
type_name: type_name.to_string(),
},
}
}
pub(crate) fn generate_key_pair(
passphrase: &secrecy::SecretString,
kdf_params: &crate::crypto::kdf::KdfParams,
output_dir: &Path,
on_event: &dyn Fn(&ProgressEvent),
) -> Result<(PathBuf, PathBuf, String), CryptoError> {
use std::io::Write as _;
use crate::fs::atomic;
use crate::key::files::{PRIVATE_KEY_FILENAME, PUBLIC_KEY_FILENAME};
use crate::key::private::seal_private_key;
use crate::key::public::{encode_recipient_string, fingerprint_hex};
use crate::recipient::native::x25519;
fs::create_dir_all(output_dir)?;
let private_key_path = output_dir.join(PRIVATE_KEY_FILENAME);
let public_key_path = output_dir.join(PUBLIC_KEY_FILENAME);
reject_occupied(&private_key_path, "Key file")?;
reject_occupied(&public_key_path, "Key file")?;
on_event(&ProgressEvent::GeneratingKeyPair);
let (secret_material, public_material) = x25519::generate_keypair()?;
let private_key_bytes = seal_private_key(
secret_material.as_ref(),
x25519::TYPE_NAME,
&public_material,
&[], passphrase,
kdf_params,
)?;
drop(secret_material);
let recipient_string = encode_recipient_string(x25519::TYPE_NAME, &public_material)?;
let mut public_builder = tempfile::Builder::new();
public_builder
.prefix(".ferrocrypt-public_key-")
.suffix(".tmp");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
public_builder.permissions(fs::Permissions::from_mode(0o644));
}
let mut public_tmp = public_builder.tempfile_in(output_dir)?;
public_tmp
.as_file_mut()
.write_all(recipient_string.as_bytes())?;
public_tmp.as_file_mut().write_all(b"\n")?;
public_tmp.as_file().sync_all()?;
atomic::finalize_file(public_tmp, &public_key_path)?;
let private_write: Result<(), CryptoError> = (|| {
let mut private_builder = tempfile::Builder::new();
private_builder
.prefix(".ferrocrypt-private_key-")
.suffix(".tmp");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
private_builder.permissions(fs::Permissions::from_mode(0o600));
}
let mut private_tmp = private_builder.tempfile_in(output_dir)?;
private_tmp.as_file_mut().write_all(&private_key_bytes)?;
private_tmp.as_file().sync_all()?;
atomic::finalize_file(private_tmp, &private_key_path)?;
Ok(())
})();
if let Err(e) = private_write {
let _ = fs::remove_file(&public_key_path);
return Err(e);
}
let fingerprint = fingerprint_hex(x25519::TYPE_NAME, &public_material);
Ok((private_key_path, public_key_path, fingerprint))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::container::build_encrypted_header;
use crate::crypto::stream::payload_encryptor;
use crate::error::FormatDefect;
use crate::format;
use crate::key::public::read_public_key;
use crate::recipient::entry::RECIPIENT_FLAG_CRITICAL;
use crate::recipient::native::{argon2id, x25519};
use crate::recipient::policy::NativeRecipientType;
use secrecy::SecretString;
fn build_multi_recipient_fcr(
entries: &[RecipientEntry],
file_key: &FileKey,
plaintext: &[u8],
path: &Path,
) -> Result<(), CryptoError> {
let stream_nonce = random_bytes::<STREAM_NONCE_SIZE>()?;
let DerivedSubkeys {
payload_key,
header_key,
} = derive_subkeys(file_key, &stream_nonce)?;
let built = build_encrypted_header(entries, b"", stream_nonce, payload_key, &header_key)?;
let mut payload_buf: Vec<u8> = Vec::new();
{
use crate::archive::ArchiveLimits;
use crate::archive::format::{serialize_manifest, write_fca_header};
use crate::archive::model::{ArchiveEntryKind, Manifest, make_entry};
use std::ffi::OsString;
use std::io::Write;
let manifest = Manifest {
entries: vec![make_entry(
"data.txt",
ArchiveEntryKind::File,
plaintext.len() as u64,
0o644,
)],
total_file_bytes: plaintext.len() as u64,
root_name: OsString::from("data.txt"),
root_is_file: true,
root_mode: 0o644,
};
let manifest_bytes = serialize_manifest(&manifest, ArchiveLimits::default())?;
let mut writer =
payload_encryptor(&built.payload_key, &built.stream_nonce, &mut payload_buf);
writer = write_fca_header(
writer,
1,
0,
manifest_bytes.len() as u32,
plaintext.len() as u64,
)?;
writer.write_all(&manifest_bytes).map_err(CryptoError::Io)?;
writer.write_all(plaintext).map_err(CryptoError::Io)?;
let _ = writer.finish()?;
}
let mut buf: Vec<u8> = Vec::new();
buf.extend_from_slice(&built.prefix_bytes);
buf.extend_from_slice(&built.header_bytes);
buf.extend_from_slice(&built.header_mac);
buf.extend_from_slice(&payload_buf);
fs::write(path, &buf)?;
Ok(())
}
fn keypair_fixture(
keys_dir: &Path,
label: &str,
pass: &str,
) -> Result<([u8; 32], PathBuf, SecretString), CryptoError> {
let pass = SecretString::from(pass.to_string());
let dir = keys_dir.join(label);
fs::create_dir_all(&dir)?;
let (private_key_path, public_key_path, _fingerprint) = generate_key_pair(
&pass,
&crate::crypto::kdf::KdfParams::test_fast_default(),
&dir,
&|_| {},
)?;
let pub_bytes = read_public_key(&public_key_path)?.bytes;
Ok((pub_bytes, private_key_path, pass))
}
fn recipient_decrypt(
fcr: &Path,
dec_dir: &Path,
private_key_path: &Path,
pass: &SecretString,
) -> Result<PathBuf, CryptoError> {
let private_key_bytes =
x25519::open_x25519_private_key(private_key_path, pass, None, &|_| {})?;
let credential = x25519::X25519Credential { private_key_bytes };
decrypt(
&credential,
fcr,
dec_dir,
ArchiveLimits::default(),
HeaderReadLimits::default(),
IncompleteOutputPolicy::default(),
&|_| {},
)
}
#[test]
fn multi_x25519_decrypts_via_either_recipient() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let keys_dir = tmp.path().join("keys");
let (pub_a, priv_a, pass_a) = keypair_fixture(&keys_dir, "alice", "alice-pass")?;
let (pub_b, priv_b, pass_b) = keypair_fixture(&keys_dir, "bob", "bob-pass")?;
let file_key = FileKey::generate().unwrap();
let body_a = x25519::wrap(&file_key, &pub_a)?;
let body_b = x25519::wrap(&file_key, &pub_b)?;
let entries = [
RecipientEntry::native(NativeRecipientType::X25519, body_a.to_vec())?,
RecipientEntry::native(NativeRecipientType::X25519, body_b.to_vec())?,
];
let payload = b"two-x25519 round trip";
let fcr = tmp.path().join("multi.fcr");
build_multi_recipient_fcr(&entries, &file_key, payload, &fcr)?;
for (label, private_key, pass) in [("alice", &priv_a, &pass_a), ("bob", &priv_b, &pass_b)] {
let dec_dir = tmp.path().join(format!("decrypted-{label}"));
fs::create_dir_all(&dec_dir)?;
recipient_decrypt(&fcr, &dec_dir, private_key, pass)?;
let restored = fs::read(dec_dir.join("data.txt"))?;
assert_eq!(
restored, payload,
"{label} should decrypt the same plaintext"
);
}
Ok(())
}
#[test]
fn multi_x25519_attempt_all_visits_every_slot() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let keys_dir = tmp.path().join("keys");
let (pub_a, priv_a, pass_a) = keypair_fixture(&keys_dir, "alice", "alice-pass")?;
let file_key = FileKey::generate().unwrap();
let body_a = x25519::wrap(&file_key, &pub_a)?;
let valid_slot = RecipientEntry::native(NativeRecipientType::X25519, body_a.to_vec())?;
let malformed_slot = RecipientEntry {
type_name: x25519::TYPE_NAME.to_string(),
recipient_flags: 0,
body: vec![0u8; x25519::BODY_LENGTH - 4],
};
let entries = [valid_slot, malformed_slot];
let fcr = tmp.path().join("multi-attempt-all.fcr");
build_multi_recipient_fcr(&entries, &file_key, b"x", &fcr)?;
let dec_dir = tmp.path().join("decrypted");
fs::create_dir_all(&dec_dir)?;
let err = recipient_decrypt(&fcr, &dec_dir, &priv_a, &pass_a).unwrap_err();
match err {
CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry) => Ok(()),
other => panic!(
"expected MalformedRecipientEntry from slot-2 inspection (proves attempt-all); got {other:?}"
),
}
}
#[test]
fn multi_x25519_all_zero_ephemeral_after_valid_is_file_fatal() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let keys_dir = tmp.path().join("keys");
let (pub_a, priv_a, pass_a) = keypair_fixture(&keys_dir, "alice", "alice-pass")?;
let file_key = FileKey::generate().unwrap();
let body_a = x25519::wrap(&file_key, &pub_a)?;
let valid_slot = RecipientEntry::native(NativeRecipientType::X25519, body_a.to_vec())?;
let mut malformed_body = vec![0u8; x25519::BODY_LENGTH];
malformed_body[x25519::BODY_LENGTH - 1] = 0xAB;
let malformed_slot = RecipientEntry::native(NativeRecipientType::X25519, malformed_body)?;
let entries = [valid_slot, malformed_slot];
let fcr = tmp.path().join("zero-ephemeral-after.fcr");
build_multi_recipient_fcr(&entries, &file_key, b"x", &fcr)?;
let dec_dir = tmp.path().join("decrypted");
fs::create_dir_all(&dec_dir)?;
match recipient_decrypt(&fcr, &dec_dir, &priv_a, &pass_a) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry)) => Ok(()),
other => panic!("all-zero shared secret in slot 2 must be file-fatal, got {other:?}"),
}
}
#[test]
fn multi_x25519_all_zero_ephemeral_before_valid_is_file_fatal() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let keys_dir = tmp.path().join("keys");
let (pub_a, priv_a, pass_a) = keypair_fixture(&keys_dir, "alice", "alice-pass")?;
let file_key = FileKey::generate().unwrap();
let body_a = x25519::wrap(&file_key, &pub_a)?;
let valid_slot = RecipientEntry::native(NativeRecipientType::X25519, body_a.to_vec())?;
let mut malformed_body = vec![0u8; x25519::BODY_LENGTH];
malformed_body[x25519::BODY_LENGTH - 1] = 0xAB;
let malformed_slot = RecipientEntry::native(NativeRecipientType::X25519, malformed_body)?;
let entries = [malformed_slot, valid_slot];
let fcr = tmp.path().join("zero-ephemeral-before.fcr");
build_multi_recipient_fcr(&entries, &file_key, b"x", &fcr)?;
let dec_dir = tmp.path().join("decrypted");
fs::create_dir_all(&dec_dir)?;
match recipient_decrypt(&fcr, &dec_dir, &priv_a, &pass_a) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry)) => Ok(()),
other => panic!("all-zero shared secret in slot 1 must be file-fatal, got {other:?}"),
}
}
#[test]
fn single_x25519_all_zero_ephemeral_is_file_fatal() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let keys_dir = tmp.path().join("keys");
let (_pub_a, priv_a, pass_a) = keypair_fixture(&keys_dir, "alice", "alice-pass")?;
let file_key = FileKey::generate().unwrap();
let mut malformed_body = vec![0u8; x25519::BODY_LENGTH];
malformed_body[x25519::BODY_LENGTH - 1] = 0xAB;
let entries = [RecipientEntry::native(
NativeRecipientType::X25519,
malformed_body,
)?];
let fcr = tmp.path().join("single-zero-ephemeral.fcr");
build_multi_recipient_fcr(&entries, &file_key, b"x", &fcr)?;
let dec_dir = tmp.path().join("decrypted");
fs::create_dir_all(&dec_dir)?;
match recipient_decrypt(&fcr, &dec_dir, &priv_a, &pass_a) {
Err(CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry)) => Ok(()),
other => panic!("single all-zero-ephemeral file must be file-fatal, got {other:?}"),
}
}
#[test]
fn multi_x25519_plus_unknown_non_critical_skips_unknown() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let keys_dir = tmp.path().join("keys");
let (pub_a, priv_a, pass_a) = keypair_fixture(&keys_dir, "alice", "alice-pass")?;
let file_key = FileKey::generate().unwrap();
let body_a = x25519::wrap(&file_key, &pub_a)?;
let unknown_entry = RecipientEntry {
type_name: "example.com/unknown".to_string(),
recipient_flags: 0,
body: vec![0xCDu8; 64],
};
let entries = [
unknown_entry,
RecipientEntry::native(NativeRecipientType::X25519, body_a.to_vec())?,
];
let payload = b"skip unknown non-critical";
let fcr = tmp.path().join("skip.fcr");
build_multi_recipient_fcr(&entries, &file_key, payload, &fcr)?;
let dec_dir = tmp.path().join("decrypted");
fs::create_dir_all(&dec_dir)?;
recipient_decrypt(&fcr, &dec_dir, &priv_a, &pass_a)?;
let restored = fs::read(dec_dir.join("data.txt"))?;
assert_eq!(restored, payload);
Ok(())
}
#[test]
fn multi_unknown_critical_rejected_before_any_unwrap() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let keys_dir = tmp.path().join("keys");
let (pub_a, priv_a, pass_a) = keypair_fixture(&keys_dir, "alice", "alice-pass")?;
let file_key = FileKey::generate().unwrap();
let body_a = x25519::wrap(&file_key, &pub_a)?;
let unknown_critical = RecipientEntry {
type_name: "example.com/critical".to_string(),
recipient_flags: RECIPIENT_FLAG_CRITICAL,
body: vec![0u8; 32],
};
let entries = [
RecipientEntry::native(NativeRecipientType::X25519, body_a.to_vec())?,
unknown_critical,
];
let fcr = tmp.path().join("critical.fcr");
build_multi_recipient_fcr(&entries, &file_key, b"x", &fcr)?;
let dec_dir = tmp.path().join("decrypted");
fs::create_dir_all(&dec_dir)?;
match recipient_decrypt(&fcr, &dec_dir, &priv_a, &pass_a) {
Err(CryptoError::UnknownCriticalRecipient { ref type_name })
if type_name == "example.com/critical" =>
{
Ok(())
}
other => {
panic!("expected UnknownCriticalRecipient(example.com/critical), got {other:?}")
}
}
}
#[test]
fn multi_argon2id_plus_x25519_rejected_as_mixed() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let keys_dir = tmp.path().join("keys");
let (pub_a, priv_a, pass_a) = keypair_fixture(&keys_dir, "alice", "alice-pass")?;
let file_key = FileKey::generate().unwrap();
let body_x = x25519::wrap(&file_key, &pub_a)?;
let synthetic_argon2id = RecipientEntry {
type_name: argon2id::TYPE_NAME.to_string(),
recipient_flags: 0,
body: vec![0u8; argon2id::BODY_LENGTH],
};
let entries = [
synthetic_argon2id,
RecipientEntry::native(NativeRecipientType::X25519, body_x.to_vec())?,
];
let fcr = tmp.path().join("mixed.fcr");
build_multi_recipient_fcr(&entries, &file_key, b"x", &fcr)?;
let dec_dir = tmp.path().join("decrypted");
fs::create_dir_all(&dec_dir)?;
match recipient_decrypt(&fcr, &dec_dir, &priv_a, &pass_a) {
Err(CryptoError::IncompatibleRecipients { type_name, policy })
if type_name == argon2id::TYPE_NAME && policy == MixingPolicy::Exclusive =>
{
Ok(())
}
other => panic!("expected IncompatibleRecipients(argon2id, Exclusive), got {other:?}"),
}
}
#[test]
fn multi_unknown_body_at_local_cap_decrypts() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let keys_dir = tmp.path().join("keys");
let (pub_a, priv_a, pass_a) = keypair_fixture(&keys_dir, "alice", "alice-pass")?;
let file_key = FileKey::generate().unwrap();
let body_a = x25519::wrap(&file_key, &pub_a)?;
let unknown_at_cap = RecipientEntry {
type_name: "example.com/at-cap".to_string(),
recipient_flags: 0,
body: vec![0xAB; format::BODY_LEN_LOCAL_CAP_DEFAULT as usize],
};
let entries = [
unknown_at_cap,
RecipientEntry::native(NativeRecipientType::X25519, body_a.to_vec())?,
];
let fcr = tmp.path().join("at-cap.fcr");
build_multi_recipient_fcr(&entries, &file_key, b"at-cap payload", &fcr)?;
let dec_dir = tmp.path().join("decrypted");
fs::create_dir_all(&dec_dir)?;
recipient_decrypt(&fcr, &dec_dir, &priv_a, &pass_a)?;
Ok(())
}
#[test]
fn multi_x25519_decoy_unwrap_returns_mac_failed_after_unwrap() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let keys_dir = tmp.path().join("keys");
let (pub_a, priv_a, pass_a) = keypair_fixture(&keys_dir, "alice", "alice-pass")?;
let (pub_b, _priv_b, _pass_b) = keypair_fixture(&keys_dir, "bob", "bob-pass")?;
let real_file_key = FileKey::generate().unwrap();
let decoy_file_key = FileKey::generate().unwrap();
let body_a = x25519::wrap(&decoy_file_key, &pub_a)?;
let body_b = x25519::wrap(&decoy_file_key, &pub_b)?;
let entries = [
RecipientEntry::native(NativeRecipientType::X25519, body_a.to_vec())?,
RecipientEntry::native(NativeRecipientType::X25519, body_b.to_vec())?,
];
let fcr = tmp.path().join("decoy-unwrap.fcr");
build_multi_recipient_fcr(&entries, &real_file_key, b"payload", &fcr)?;
let dec_dir = tmp.path().join("decrypted");
fs::create_dir_all(&dec_dir)?;
match recipient_decrypt(&fcr, &dec_dir, &priv_a, &pass_a) {
Err(CryptoError::HeaderMacFailedAfterUnwrap { ref type_name })
if type_name == x25519::TYPE_NAME =>
{
Ok(())
}
other => panic!("expected HeaderMacFailedAfterUnwrap(x25519), got {other:?}"),
}
}
#[test]
fn encrypt_rejects_multi_passphrase_recipient_list() -> Result<(), CryptoError> {
let pass = SecretString::from("pass".to_string());
let kdf_params = crate::crypto::kdf::KdfParams::test_fast_default();
let r1 = argon2id::PassphraseRecipient {
passphrase: &pass,
kdf_params,
};
let r2 = argon2id::PassphraseRecipient {
passphrase: &pass,
kdf_params,
};
let recipients = [r1, r2];
let tmp = tempfile::TempDir::new().unwrap();
let input = tmp.path().join("data.txt");
fs::write(&input, b"x")?;
let out_dir = tmp.path().join("out");
fs::create_dir_all(&out_dir)?;
let err = encrypt(
&recipients,
ArchiveLimits::default(),
&input,
&out_dir,
None,
&|_| {},
)
.unwrap_err();
match err {
CryptoError::IncompatibleRecipients {
ref type_name,
policy: MixingPolicy::Exclusive,
} if type_name == argon2id::TYPE_NAME => Ok(()),
other => panic!("expected IncompatibleRecipients(argon2id, Exclusive), got {other:?}"),
}
}
#[test]
fn decrypt_rejects_passphrase_file_with_x25519_credential() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let keys_dir = tmp.path().join("keys");
let (_pub_a, priv_a, pass_a) = keypair_fixture(&keys_dir, "alice", "alice-pass")?;
let synthetic = RecipientEntry::native(
NativeRecipientType::Argon2id,
vec![0u8; argon2id::BODY_LENGTH],
)?;
let file_key = FileKey::generate().unwrap();
let fcr = tmp.path().join("passphrase.fcr");
build_multi_recipient_fcr(&[synthetic], &file_key, b"x", &fcr)?;
let dec_dir = tmp.path().join("decrypted");
fs::create_dir_all(&dec_dir)?;
match recipient_decrypt(&fcr, &dec_dir, &priv_a, &pass_a) {
Err(CryptoError::DecryptorModeMismatch { expected, found })
if expected == UnauthenticatedRecipientMode::PublicKey
&& found == UnauthenticatedRecipientMode::Passphrase =>
{
Ok(())
}
other => panic!(
"expected DecryptorModeMismatch(expected=PublicKey, found=Passphrase), got {other:?}"
),
}
}
#[test]
fn decrypt_rejects_recipient_file_with_passphrase_credential() -> Result<(), CryptoError> {
let tmp = tempfile::TempDir::new().unwrap();
let keys_dir = tmp.path().join("keys");
let (pub_a, _priv_a, _pass_a) = keypair_fixture(&keys_dir, "alice", "alice-pass")?;
let file_key = FileKey::generate().unwrap();
let body_a = x25519::wrap(&file_key, &pub_a)?;
let entries = [RecipientEntry::native(
NativeRecipientType::X25519,
body_a.to_vec(),
)?];
let fcr = tmp.path().join("recipient.fcr");
build_multi_recipient_fcr(&entries, &file_key, b"x", &fcr)?;
let dec_dir = tmp.path().join("decrypted");
fs::create_dir_all(&dec_dir)?;
let pass = SecretString::from("doesn't-matter".to_string());
let credential = argon2id::PassphraseCredential {
passphrase: &pass,
kdf_limit: None,
};
let err = decrypt(
&credential,
&fcr,
&dec_dir,
ArchiveLimits::default(),
HeaderReadLimits::default(),
IncompleteOutputPolicy::default(),
&|_| {},
)
.unwrap_err();
match err {
CryptoError::DecryptorModeMismatch { expected, found }
if expected == UnauthenticatedRecipientMode::Passphrase
&& found == UnauthenticatedRecipientMode::PublicKey =>
{
Ok(())
}
other => panic!(
"expected DecryptorModeMismatch(expected=Passphrase, found=PublicKey), got {other:?}"
),
}
}
#[test]
fn encrypt_rejects_empty_recipient_list() -> Result<(), CryptoError> {
let recipients: [argon2id::PassphraseRecipient; 0] = [];
let tmp = tempfile::TempDir::new().unwrap();
let input = tmp.path().join("data.txt");
fs::write(&input, b"x")?;
let out_dir = tmp.path().join("out");
fs::create_dir_all(&out_dir)?;
let err = encrypt(
&recipients,
ArchiveLimits::default(),
&input,
&out_dir,
None,
&|_| {},
)
.unwrap_err();
match err {
CryptoError::EmptyRecipientList => Ok(()),
other => panic!("expected EmptyRecipientList, got {other:?}"),
}
}
}