use std::fs;
use std::path::{Path, PathBuf};
use crate::archive::{ArchiveLimits, IncompleteOutputPolicy, prepare_archive, unarchive};
use crate::container::{
HeaderReadLimits, ParsedEncryptedHeader, 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::{
KEY_FILE_LABEL, OUTPUT_LABEL, encryption_base_name, open_input_file, 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_LABEL)?;
let prepared = prepare_archive(input_path, archive_limits)?;
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(prepared, output_dir, output_file, &base_name, &built)
}
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(crate::error::internal_invariant!("unknown native scheme"))?;
RecipientEntry::native(ty, body.bytes)
}
pub(crate) struct DecryptSession {
encrypted_file: fs::File,
parsed: ParsedEncryptedHeader,
mode: UnauthenticatedRecipientMode,
}
impl DecryptSession {
pub(crate) fn open(
input_path: &Path,
header_read_limits: HeaderReadLimits,
) -> Result<Self, CryptoError> {
let mut encrypted_file = open_input_file(input_path)?;
let parsed = read_encrypted_header(&mut encrypted_file, header_read_limits)?;
let mode = classify_recipient_mode(&parsed.recipient_entries)?;
Ok(Self {
encrypted_file,
parsed,
mode,
})
}
pub(crate) fn mode(&self) -> UnauthenticatedRecipientMode {
self.mode
}
}
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 session = DecryptSession::open(input_path, header_read_limits)?;
decrypt_session(
credential,
session,
output_dir,
archive_limits,
incomplete_output_policy,
on_event,
)
}
pub(crate) fn decrypt_session<I: DecryptionCredential>(
credential: &I,
session: DecryptSession,
output_dir: &Path,
archive_limits: ArchiveLimits,
incomplete_output_policy: IncompleteOutputPolicy,
on_event: &dyn Fn(&ProgressEvent),
) -> Result<PathBuf, CryptoError> {
let DecryptSession {
encrypted_file,
parsed,
mode,
} = session;
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 {
match (mode, had_unwrap) {
(_, false) => CryptoError::RecipientUnwrapFailed {
type_name: type_name.to_string(),
},
(UnauthenticatedRecipientMode::Passphrase, true) => CryptoError::HeaderTampered,
(UnauthenticatedRecipientMode::PublicKey, true) => {
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_LABEL)?;
reject_occupied(&public_key_path, KEY_FILE_LABEL)?;
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 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)?;
atomic::sync_file_durable(private_tmp.as_file())?;
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")?;
atomic::sync_file_durable(public_tmp.as_file())?;
commit_key_pair_files(private_tmp, public_tmp, &private_key_path, &public_key_path)?;
let fingerprint = fingerprint_hex(x25519::TYPE_NAME, &public_material)?;
Ok((private_key_path, public_key_path, fingerprint))
}
fn commit_key_pair_files(
private_tmp: tempfile::NamedTempFile,
public_tmp: tempfile::NamedTempFile,
private_key_path: &Path,
public_key_path: &Path,
) -> Result<(), CryptoError> {
commit_key_pair_files_with_barrier(
private_tmp,
public_tmp,
private_key_path,
public_key_path,
crate::fs::atomic::sync_dir_durable,
)
}
fn commit_key_pair_files_with_barrier(
private_tmp: tempfile::NamedTempFile,
public_tmp: tempfile::NamedTempFile,
private_key_path: &Path,
public_key_path: &Path,
sync_output_dir: impl Fn(&Path) -> std::io::Result<()>,
) -> Result<(), CryptoError> {
use crate::fs::atomic;
use crate::fs::paths::parent_or_cwd;
let output_dir = parent_or_cwd(private_key_path);
atomic::finalize_file(private_tmp, private_key_path, KEY_FILE_LABEL)?;
if let Err(e) = sync_output_dir(output_dir) {
let _ = fs::remove_file(private_key_path);
return Err(CryptoError::Io(e));
}
if let Err(e) = atomic::finalize_file(public_tmp, public_key_path, KEY_FILE_LABEL) {
let _ = fs::remove_file(private_key_path);
return Err(e);
}
if let Err(e) = sync_output_dir(output_dir) {
let _ = fs::remove_file(public_key_path);
return Err(CryptoError::Io(e));
}
Ok(())
}
#[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::files::{PRIVATE_KEY_FILENAME, PUBLIC_KEY_FILENAME};
use crate::key::limits::KeyReadLimits;
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_fcr_with_raw_payload(
entries: &[RecipientEntry],
file_key: &FileKey,
payload: &[u8],
path: &Path,
) -> Result<(), CryptoError> {
use std::io::Write;
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();
let mut writer =
payload_encryptor(&built.payload_key, &built.stream_nonce, &mut payload_buf);
writer.write_all(payload).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 build_multi_recipient_fcr(
entries: &[RecipientEntry],
file_key: &FileKey,
plaintext: &[u8],
path: &Path,
) -> Result<(), CryptoError> {
let fca_bytes = {
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;
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 fca = write_fca_header(
Vec::new(),
1,
0,
manifest_bytes.len() as u32,
plaintext.len() as u64,
)?;
fca.extend_from_slice(&manifest_bytes);
fca.extend_from_slice(plaintext);
fca
};
build_fcr_with_raw_payload(entries, file_key, &fca_bytes, path)
}
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, KeyReadLimits::default())?.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,
KeyReadLimits::default(),
&|_| {},
)?;
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 truncated_fca_payload_rejects_as_malformed_archive() -> Result<(), CryptoError> {
use crate::archive::format::{FCA_MAGIC, FCA_VERSION, write_fca_header};
use crate::archive::reasons::{
ARCHIVE_EXT_REGION_TRUNCATED, FIXED_HEADER_TRUNCATED, MANIFEST_REGION_TRUNCATED,
};
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 = x25519::wrap(&file_key, &pub_a)?;
let entries = [RecipientEntry::native(
NativeRecipientType::X25519,
body.to_vec(),
)?];
let short_header = {
let mut fca = Vec::new();
fca.extend_from_slice(FCA_MAGIC);
fca.push(FCA_VERSION);
fca.extend_from_slice(&0u16.to_be_bytes());
fca.extend_from_slice(&[0u8; 3]);
fca
};
let short_ext = {
let mut fca = write_fca_header(Vec::new(), 1, 8, 50, 10)?;
fca.extend_from_slice(&[0u8; 3]);
fca
};
let short_manifest = {
let mut fca = write_fca_header(Vec::new(), 1, 0, 50, 10)?;
fca.extend_from_slice(&[0u8; 20]);
fca
};
let cases: [(&str, Vec<u8>, &str); 3] = [
("fixed-header", short_header, FIXED_HEADER_TRUNCATED),
("archive-ext", short_ext, ARCHIVE_EXT_REGION_TRUNCATED),
("manifest", short_manifest, MANIFEST_REGION_TRUNCATED),
];
for (label, fca_payload, want_reason) in cases {
let fcr = tmp.path().join(format!("{label}.fcr"));
build_fcr_with_raw_payload(&entries, &file_key, &fca_payload, &fcr)?;
let dec_dir = tmp.path().join(format!("decrypted-{label}"));
fs::create_dir_all(&dec_dir)?;
let err = recipient_decrypt(&fcr, &dec_dir, &priv_a, &pass_a).unwrap_err();
match err {
CryptoError::MalformedArchive { reason } => {
assert_eq!(reason, want_reason, "wrong truncation reason for {label}");
}
other => panic!("{label}: expected MalformedArchive, got {other:?}"),
}
}
Ok(())
}
#[test]
fn multi_x25519_wrong_length_second_slot_rejects_in_preflight() -> 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-wrong-length.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 the preflight for slot 2; 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 ephemeral in slot 2 must be file-fatal, got {other:?}"),
}
}
#[test]
fn multi_x25519_small_order_ephemeral_after_valid_is_file_fatal() -> Result<(), CryptoError> {
const SMALL_ORDER_U: [u8; x25519::PUBLIC_KEY_SIZE] = [
0xe0, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, 0xfa, 0xf1, 0x9f,
0xc4, 0x6a, 0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32, 0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16,
0x5f, 0x49, 0xb8, 0x00,
];
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![0xABu8; x25519::BODY_LENGTH];
malformed_body[..x25519::PUBLIC_KEY_SIZE].copy_from_slice(&SMALL_ORDER_U);
let malformed_slot = RecipientEntry::native(NativeRecipientType::X25519, malformed_body)?;
let entries = [valid_slot, malformed_slot];
let fcr = tmp.path().join("small-order-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!("small-order ephemeral 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_no_matching_recipient_surfaces_recipient_unwrap_failed()
-> 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 entries = [RecipientEntry::native(
NativeRecipientType::X25519,
body_a.to_vec(),
)?];
let fcr = tmp.path().join("alice-only.fcr");
build_multi_recipient_fcr(&entries, &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_b, &pass_b) {
Err(CryptoError::RecipientUnwrapFailed { ref type_name })
if type_name == x25519::TYPE_NAME =>
{
Ok(())
}
other => panic!("expected RecipientUnwrapFailed(x25519), got {other:?}"),
}
}
#[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 multi_x25519_continues_past_mac_failure_to_later_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 real_file_key = FileKey::generate().unwrap();
let decoy_file_key = FileKey::generate().unwrap();
let body_decoy = x25519::wrap(&decoy_file_key, &pub_a)?;
let body_real = x25519::wrap(&real_file_key, &pub_a)?;
let entries = [
RecipientEntry::native(NativeRecipientType::X25519, body_decoy.to_vec())?,
RecipientEntry::native(NativeRecipientType::X25519, body_real.to_vec())?,
];
let payload = b"decrypt must reach slot 2";
let fcr = tmp.path().join("mac-fail-then-ok.fcr");
build_multi_recipient_fcr(&entries, &real_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)?;
assert_eq!(
fs::read(dec_dir.join("data.txt"))?,
payload,
"slot 2 must decrypt after slot 1's MAC failure"
);
Ok(())
}
#[test]
fn multi_x25519_later_mac_failure_does_not_undo_earlier_success() -> 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 real_file_key = FileKey::generate().unwrap();
let decoy_file_key = FileKey::generate().unwrap();
let body_real = x25519::wrap(&real_file_key, &pub_a)?;
let body_decoy = x25519::wrap(&decoy_file_key, &pub_a)?;
let entries = [
RecipientEntry::native(NativeRecipientType::X25519, body_real.to_vec())?,
RecipientEntry::native(NativeRecipientType::X25519, body_decoy.to_vec())?,
];
let payload = b"first slot wins";
let fcr = tmp.path().join("ok-then-mac-fail.fcr");
build_multi_recipient_fcr(&entries, &real_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)?;
assert_eq!(fs::read(dec_dir.join("data.txt"))?, payload);
Ok(())
}
#[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:?}"),
}
}
fn staged_key_tempfile(dir: &Path, bytes: &[u8]) -> tempfile::NamedTempFile {
use std::io::Write as _;
let mut tmp = tempfile::Builder::new()
.prefix(".ferrocrypt-keygen-test-")
.suffix(".tmp")
.tempfile_in(dir)
.unwrap();
tmp.as_file_mut().write_all(bytes).unwrap();
tmp
}
fn leftover_entries(dir: &Path, keep: &str) -> Vec<std::ffi::OsString> {
fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name())
.filter(|name| name != keep)
.collect()
}
#[test]
fn keygen_public_commit_failure_removes_private_key() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let private_key_path = dir.join(PRIVATE_KEY_FILENAME);
let public_key_path = dir.join(PUBLIC_KEY_FILENAME);
fs::write(&public_key_path, b"occupant").unwrap();
let private_tmp = staged_key_tempfile(dir, b"private bytes");
let public_tmp = staged_key_tempfile(dir, b"public bytes");
let err =
commit_key_pair_files(private_tmp, public_tmp, &private_key_path, &public_key_path)
.expect_err("occupied public.key name must fail the commit");
assert!(
matches!(err, CryptoError::InvalidInput(_)),
"expected the typed already-exists rejection, got {err:?}"
);
assert!(
!private_key_path.exists(),
"private.key must not remain after the public.key commit failed"
);
assert_eq!(
fs::read(&public_key_path).unwrap(),
b"occupant",
"pre-existing occupant of the public.key name must be untouched"
);
let leftovers = leftover_entries(dir, PUBLIC_KEY_FILENAME);
assert!(
leftovers.is_empty(),
"no staged tempfiles may remain, found {leftovers:?}"
);
}
#[test]
fn keygen_private_commit_failure_publishes_nothing() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let private_key_path = dir.join(PRIVATE_KEY_FILENAME);
let public_key_path = dir.join(PUBLIC_KEY_FILENAME);
fs::write(&private_key_path, b"occupant").unwrap();
let private_tmp = staged_key_tempfile(dir, b"private bytes");
let public_tmp = staged_key_tempfile(dir, b"public bytes");
let err =
commit_key_pair_files(private_tmp, public_tmp, &private_key_path, &public_key_path)
.expect_err("occupied private.key name must fail the commit");
assert!(
matches!(err, CryptoError::InvalidInput(_)),
"expected the typed already-exists rejection, got {err:?}"
);
assert!(
!public_key_path.exists(),
"public.key must never appear when the private.key commit failed"
);
assert_eq!(
fs::read(&private_key_path).unwrap(),
b"occupant",
"pre-existing occupant of the private.key name must be untouched"
);
let leftovers = leftover_entries(dir, PRIVATE_KEY_FILENAME);
assert!(
leftovers.is_empty(),
"no staged tempfiles may remain, found {leftovers:?}"
);
}
#[derive(Debug, PartialEq, Eq)]
struct BarrierCall {
dir: PathBuf,
private_committed: bool,
public_committed: bool,
}
fn counting_barrier(
fail_on: Option<u32>,
seen: std::rc::Rc<std::cell::RefCell<Vec<BarrierCall>>>,
) -> impl Fn(&Path) -> std::io::Result<()> {
move |dir| {
seen.borrow_mut().push(BarrierCall {
dir: dir.to_path_buf(),
private_committed: dir.join(PRIVATE_KEY_FILENAME).exists(),
public_committed: dir.join(PUBLIC_KEY_FILENAME).exists(),
});
if Some(seen.borrow().len() as u32) == fail_on {
Err(std::io::Error::other("injected directory flush failure"))
} else {
Ok(())
}
}
}
#[test]
fn keygen_barrier_failure_after_private_commit_publishes_nothing() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let private_key_path = dir.join(PRIVATE_KEY_FILENAME);
let public_key_path = dir.join(PUBLIC_KEY_FILENAME);
let private_tmp = staged_key_tempfile(dir, b"private bytes");
let public_tmp = staged_key_tempfile(dir, b"public bytes");
let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let err = commit_key_pair_files_with_barrier(
private_tmp,
public_tmp,
&private_key_path,
&public_key_path,
counting_barrier(Some(1), seen.clone()),
)
.expect_err("a failed directory flush after private.key must fail the commit");
assert_eq!(
err.to_string(),
"injected directory flush failure",
"the directory flush error must surface unchanged, got {err:?}"
);
assert!(
matches!(err, CryptoError::Io(_)),
"directory flush failure must map to the I/O error class, got {err:?}"
);
assert!(
!private_key_path.exists(),
"private.key must be removed when its directory flush failed"
);
assert!(
!public_key_path.exists(),
"public.key must never appear when the private.key directory flush failed"
);
let leftovers = leftover_entries(dir, "");
assert!(
leftovers.is_empty(),
"no key files or staged tempfiles may remain, found {leftovers:?}"
);
}
#[test]
fn keygen_barrier_failure_after_public_commit_keeps_only_private_key() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let private_key_path = dir.join(PRIVATE_KEY_FILENAME);
let public_key_path = dir.join(PUBLIC_KEY_FILENAME);
let private_tmp = staged_key_tempfile(dir, b"private bytes");
let public_tmp = staged_key_tempfile(dir, b"public bytes");
let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let err = commit_key_pair_files_with_barrier(
private_tmp,
public_tmp,
&private_key_path,
&public_key_path,
counting_barrier(Some(2), seen.clone()),
)
.expect_err("a failed directory flush after public.key must fail the commit");
assert!(
matches!(err, CryptoError::Io(_)),
"directory flush failure must map to the I/O error class, got {err:?}"
);
assert!(
!public_key_path.exists(),
"public.key must be removed when the final directory flush failed"
);
assert_eq!(
fs::read(&private_key_path).unwrap(),
b"private bytes",
"private.key must be kept when the final directory flush failed"
);
let leftovers = leftover_entries(dir, PRIVATE_KEY_FILENAME);
assert!(
leftovers.is_empty(),
"nothing besides private.key may remain, found {leftovers:?}"
);
}
#[test]
fn keygen_commit_flushes_output_directory_after_each_commit() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let private_key_path = dir.join(PRIVATE_KEY_FILENAME);
let public_key_path = dir.join(PUBLIC_KEY_FILENAME);
let private_tmp = staged_key_tempfile(dir, b"private bytes");
let public_tmp = staged_key_tempfile(dir, b"public bytes");
let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
commit_key_pair_files_with_barrier(
private_tmp,
public_tmp,
&private_key_path,
&public_key_path,
counting_barrier(None, seen.clone()),
)
.expect("commit must succeed when directory flushing succeeds");
assert_eq!(
*seen.borrow(),
vec![
BarrierCall {
dir: dir.to_path_buf(),
private_committed: true,
public_committed: false,
},
BarrierCall {
dir: dir.to_path_buf(),
private_committed: true,
public_committed: true,
},
],
"the output directory must be flushed after each of the two \
commits, and private.key must commit before public.key"
);
assert_eq!(fs::read(&private_key_path).unwrap(), b"private bytes");
assert_eq!(fs::read(&public_key_path).unwrap(), b"public bytes");
}
}