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, append_report};
use crate::format;
use crate::fs::paths::{
KEY_FILE_LABEL, OUTPUT_LABEL, encryption_base_name, open_input_file, reject_occupied,
};
use crate::key::files::{PRIVATE_KEY_FILENAME, PUBLIC_KEY_FILENAME};
use crate::recipient::entry::{RecipientBody, RecipientEntry};
#[cfg(test)]
use crate::recipient::policy::MixingPolicy;
use crate::recipient::policy::{
NativeMixingRule, NativeRecipientType, classify_recipient_mode, count_supported_recipients,
};
use crate::{ProgressEvent, UnauthenticatedRecipientMode};
pub(crate) trait RecipientScheme {
const TYPE_NAME: &'static str;
const MIXING_RULE: NativeMixingRule;
fn validate_for_write(&self) -> Result<(), CryptoError>;
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) const WRITE_EXT_BYTES: &[u8] = b"";
pub(crate) fn encrypt<R: RecipientScheme>(
recipients: Vec<R>,
archive_limits: ArchiveLimits,
header_read_limits: HeaderReadLimits,
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 native_type = NativeRecipientType::from_type_name(R::TYPE_NAME)
.ok_or(crate::error::internal_invariant!("unknown native scheme"))?;
preflight_header_write_limits(
header_read_limits,
recipients.len(),
native_type,
WRITE_EXT_BYTES,
)?;
for recipient in &recipients {
recipient.validate_for_write()?;
}
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);
drop(recipients);
let built = build_encrypted_header(
&entries,
WRITE_EXT_BYTES,
stream_nonce,
payload_key,
&header_key,
)?;
drop(header_key);
let header_len = u32::try_from(built.header_bytes.len()).unwrap_or(u32::MAX);
header_read_limits.enforce_header_len(header_len)?;
header_read_limits.enforce_header_mac_work(entries.len(), header_len)?;
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) fn preflight_header_write_limits(
limits: HeaderReadLimits,
recipient_count: usize,
native: NativeRecipientType,
ext_bytes: &[u8],
) -> Result<(), CryptoError> {
let type_name = native.type_name();
let body_len = native.body_len();
let count_u16: u16 = u16::try_from(recipient_count).unwrap_or(u16::MAX);
limits.enforce_recipient_count(count_u16)?;
let body_len_u32: u32 = u32::try_from(body_len).unwrap_or(u32::MAX);
limits.enforce_recipient_body_len(body_len_u32)?;
let overflow_err = || CryptoError::HeaderLenCapExceeded {
header_len: u32::MAX,
local_cap: limits.max_header_len,
};
let per_entry = (crate::recipient::entry::ENTRY_HEADER_SIZE as u64)
.checked_add(type_name.len() as u64)
.and_then(|v| v.checked_add(body_len as u64))
.ok_or_else(overflow_err)?;
let total_entries = (recipient_count as u64)
.checked_mul(per_entry)
.ok_or_else(overflow_err)?;
let ext_len = u64::try_from(ext_bytes.len()).unwrap_or(u64::MAX);
let header_len_u64 = (format::HEADER_FIXED_SIZE as u64)
.checked_add(total_entries)
.and_then(|v| v.checked_add(ext_len))
.ok_or_else(overflow_err)?;
let header_len = u32::try_from(header_len_u64).unwrap_or(u32::MAX);
limits.enforce_header_len(header_len)?;
limits.enforce_header_mac_work(recipient_count, header_len)?;
Ok(())
}
pub(crate) fn classify_recipients_within_limits(
parsed: &ParsedEncryptedHeader,
limits: HeaderReadLimits,
) -> Result<UnauthenticatedRecipientMode, CryptoError> {
let mode = classify_recipient_mode(&parsed.recipient_entries)?;
limits.enforce_header_mac_work(
count_supported_recipients(&parsed.recipient_entries),
parsed.header_len(),
)?;
Ok(mode)
}
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> {
Self::from_file(open_input_file(input_path)?, header_read_limits)
}
pub(crate) fn from_file(
mut encrypted_file: fs::File,
header_read_limits: HeaderReadLimits,
) -> Result<Self, CryptoError> {
use std::io::{Seek, SeekFrom};
encrypted_file
.seek(SeekFrom::Start(0))
.map_err(CryptoError::Io)?;
let parsed = read_encrypted_header(&mut encrypted_file, header_read_limits)?;
let mode = classify_recipients_within_limits(&parsed, header_read_limits)?;
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);
}
drop(credential);
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: crate::passphrase::Passphrase,
kdf_params: &crate::crypto::kdf::KdfParams,
kdf_limit: Option<&crate::crypto::kdf::KdfLimit>,
output_dir: &Path,
on_event: &dyn Fn(&ProgressEvent),
) -> Result<(PathBuf, PathBuf, String, String), CryptoError> {
generate_key_pair_with_seams(
passphrase,
kdf_params,
kdf_limit,
output_dir,
on_event,
|_, _| Ok(()),
|_, _| Ok(()),
)
}
fn generate_key_pair_with_seams(
passphrase: crate::passphrase::Passphrase,
kdf_params: &crate::crypto::kdf::KdfParams,
kdf_limit: Option<&crate::crypto::kdf::KdfLimit>,
output_dir: &Path,
on_event: &dyn Fn(&ProgressEvent),
before_commit: impl FnOnce(&Path, &Path) -> std::io::Result<()>,
after_commit: impl FnOnce(&Path, &Path) -> std::io::Result<()>,
) -> Result<(PathBuf, PathBuf, String, String), CryptoError> {
use std::io::Write as _;
use crate::fs::atomic;
use crate::key::private::seal_private_key;
use crate::key::public::{encode_recipient_string, fingerprint_hex};
use crate::recipient::native::x25519;
kdf_params.validate_for_write(kdf_limit)?;
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);
drop(passphrase);
let recipient_string = encode_recipient_string(x25519::TYPE_NAME, &public_material)?;
let fingerprint = fingerprint_hex(x25519::TYPE_NAME, &public_material)?;
let committed_dir = crate::fs::atomic::OutputDir::open(output_dir).map_err(CryptoError::Io)?;
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)?;
committed_dir.confirm_staged(&private_tmp)?;
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)?;
committed_dir.confirm_staged(&public_tmp)?;
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())?;
before_commit(private_tmp.path(), public_tmp.path())?;
let (private_finalized, public_finalized) = commit_key_pair_files(
private_tmp,
public_tmp,
&private_key_path,
&public_key_path,
&committed_dir,
)?;
after_commit(&private_key_path, &public_key_path)?;
let private_confirmed = private_finalized.confirm_reported_path(&private_key_path);
let public_confirmed = public_finalized.confirm_reported_path(&public_key_path);
match (private_confirmed, public_confirmed) {
(Ok(()), Ok(())) => {}
(Err(error), Ok(())) => {
return Err(append_report(
error,
&also_complete_report(PUBLIC_KEY_FILENAME),
));
}
(Ok(()), Err(error)) => {
return Err(append_report(
error,
&also_complete_report(PRIVATE_KEY_FILENAME),
));
}
(Err(private), Err(public)) => {
return Err(append_report(private, &public.to_string()));
}
}
Ok((
private_key_path,
public_key_path,
recipient_string,
fingerprint,
))
}
fn commit_key_pair_files(
private_tmp: tempfile::NamedTempFile,
public_tmp: tempfile::NamedTempFile,
private_key_path: &Path,
public_key_path: &Path,
committed_dir: &crate::fs::atomic::OutputDir,
) -> Result<
(
crate::fs::atomic::FinalizedFile,
crate::fs::atomic::FinalizedFile,
),
CryptoError,
> {
commit_key_pair_files_with_barrier(
private_tmp,
public_tmp,
private_key_path,
public_key_path,
committed_dir,
flush_committed_dir,
)
}
fn flush_committed_dir(
committed_dir: &crate::fs::atomic::OutputDir,
output_dir: &Path,
) -> std::io::Result<()> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let _ = output_dir;
committed_dir.flush_durable()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = committed_dir;
crate::fs::atomic::sync_dir_durable(output_dir)
}
}
fn commit_key_pair_files_with_barrier(
private_tmp: tempfile::NamedTempFile,
public_tmp: tempfile::NamedTempFile,
private_key_path: &Path,
public_key_path: &Path,
committed_dir: &crate::fs::atomic::OutputDir,
sync_output_dir: impl Fn(&crate::fs::atomic::OutputDir, &Path) -> std::io::Result<()>,
) -> Result<
(
crate::fs::atomic::FinalizedFile,
crate::fs::atomic::FinalizedFile,
),
CryptoError,
> {
commit_key_pair_files_with_barrier_and_public_finalizer(
private_tmp,
public_tmp,
private_key_path,
public_key_path,
committed_dir,
sync_output_dir,
crate::fs::atomic::finalize_file,
)
}
fn commit_key_pair_files_with_barrier_and_public_finalizer(
private_tmp: tempfile::NamedTempFile,
public_tmp: tempfile::NamedTempFile,
private_key_path: &Path,
public_key_path: &Path,
committed_dir: &crate::fs::atomic::OutputDir,
sync_output_dir: impl Fn(&crate::fs::atomic::OutputDir, &Path) -> std::io::Result<()>,
finalize_public: impl FnOnce(
tempfile::NamedTempFile,
&Path,
&str,
&crate::fs::atomic::OutputDir,
) -> Result<
crate::fs::atomic::FinalizedFile,
crate::fs::atomic::FinalizeFileError,
>,
) -> Result<
(
crate::fs::atomic::FinalizedFile,
crate::fs::atomic::FinalizedFile,
),
CryptoError,
> {
use crate::fs::atomic::{self};
use crate::fs::paths::parent_or_cwd;
let output_dir = parent_or_cwd(private_key_path);
let private_finalized =
match atomic::finalize_file(private_tmp, private_key_path, KEY_FILE_LABEL, committed_dir) {
Ok(finalized) => finalized,
Err(error) => {
let committed = error.committed();
let error = error.into_crypto_error();
if committed {
let public_tmp_path = public_tmp.path().to_path_buf();
let cleanup = committed_dir.remove_staged_if_retained(public_tmp);
return Err(atomic::with_rollback_report(
error,
cleanup,
&public_tmp_path,
));
}
return Err(error);
}
};
if let Err(e) = sync_output_dir(committed_dir, output_dir) {
let rollback =
committed_dir.remove_published_if_retained(private_key_path, private_finalized);
let error = atomic::with_rollback_report(CryptoError::Io(e), rollback, private_key_path);
let public_tmp_path = public_tmp.path().to_path_buf();
let cleanup = committed_dir.remove_staged_if_retained(public_tmp);
return Err(atomic::with_rollback_report(
error,
cleanup,
&public_tmp_path,
));
}
let public_finalized =
match finalize_public(public_tmp, public_key_path, KEY_FILE_LABEL, committed_dir) {
Ok(finalized) => finalized,
Err(error) => {
if error.committed() {
return Err(append_report(
error.into_crypto_error(),
&sibling_report(
&private_finalized,
private_key_path,
also_complete_report(PRIVATE_KEY_FILENAME),
),
));
}
let rollback =
committed_dir.remove_published_if_retained(private_key_path, private_finalized);
return Err(atomic::with_rollback_report(
error.into_crypto_error(),
rollback,
private_key_path,
));
}
};
if let Err(e) = sync_output_dir(committed_dir, output_dir) {
let rollback =
committed_dir.remove_published_if_retained(public_key_path, public_finalized);
let kept = sibling_report(
&private_finalized,
private_key_path,
kept_report(PRIVATE_KEY_FILENAME),
);
return Err(atomic::with_rollback_report(
append_report(CryptoError::Io(e), &kept),
rollback,
public_key_path,
));
}
Ok((private_finalized, public_finalized))
}
fn sibling_report(
finalized: &crate::fs::atomic::FinalizedFile,
path: &Path,
confirmed: String,
) -> String {
match finalized.confirm_reported_path(path) {
Ok(()) => confirmed,
Err(error) => error.to_string(),
}
}
fn also_complete_report(file_name: &str) -> String {
format!("{file_name} is also complete")
}
fn kept_report(file_name: &str) -> String {
format!("{file_name} is complete and was kept")
}
#[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::limits::KeyReadLimits;
use crate::key::public::read_public_key;
use crate::passphrase::Passphrase;
use crate::recipient::entry::RECIPIENT_FLAG_CRITICAL;
use crate::recipient::native::{argon2id, x25519};
use crate::recipient::policy::NativeRecipientType;
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, Passphrase), CryptoError> {
let dir = keys_dir.join(label);
fs::create_dir_all(&dir)?;
let (private_key_path, public_key_path, _recipient, _fingerprint) = generate_key_pair(
Passphrase::new(pass),
&crate::crypto::kdf::KdfParams::test_fast_default(),
None,
&dir,
&|_| {},
)?;
let pub_bytes = read_public_key(&public_key_path, KeyReadLimits::default())?.bytes;
Ok((pub_bytes, private_key_path, Passphrase::new(pass)))
}
fn recipient_decrypt(
fcr: &Path,
dec_dir: &Path,
private_key_path: &Path,
pass: &Passphrase,
) -> 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 credential_is_dropped_before_the_payload_phase() -> Result<(), CryptoError> {
use std::cell::Cell;
use std::rc::Rc;
struct DropRecording<'a> {
inner: argon2id::PassphraseCredential<'a>,
dropped: Rc<Cell<bool>>,
}
impl Drop for DropRecording<'_> {
fn drop(&mut self) {
self.dropped.set(true);
}
}
impl DecryptionCredential for DropRecording<'_> {
const TYPE_NAME: &'static str = argon2id::TYPE_NAME;
const EXPECTED_MODE: UnauthenticatedRecipientMode =
UnauthenticatedRecipientMode::Passphrase;
fn unwrap_file_key(
&self,
body: &[u8],
on_event: &dyn Fn(&ProgressEvent),
) -> Result<Option<FileKey>, CryptoError> {
self.inner.unwrap_file_key(body, on_event)
}
}
let tmp = tempfile::TempDir::new()?;
let input = tmp.path().join("data.txt");
fs::write(&input, b"payload phase must not hold the credential")?;
const DROP_ORDER_PASSPHRASE: &str = "drop-order-test-passphrase";
let pass = Passphrase::new(DROP_ORDER_PASSPHRASE);
let fcr = encrypt(
vec![argon2id::PassphraseRecipient {
passphrase: Passphrase::new(DROP_ORDER_PASSPHRASE),
kdf_params: crate::crypto::kdf::KdfParams::test_fast_default(),
kdf_limit: crate::KdfLimit::default(),
}],
ArchiveLimits::default(),
HeaderReadLimits::default(),
&input,
tmp.path(),
None,
&|_| {},
)?;
let dropped = Rc::new(Cell::new(false));
let dropped_at_payload_start = Rc::new(Cell::new(false));
let credential = DropRecording {
inner: argon2id::PassphraseCredential {
passphrase: pass,
kdf_limit: None,
},
dropped: Rc::clone(&dropped),
};
let dec_dir = tmp.path().join("decrypted");
fs::create_dir_all(&dec_dir)?;
let observer = {
let dropped = Rc::clone(&dropped);
let observed = Rc::clone(&dropped_at_payload_start);
move |event: &ProgressEvent| {
if matches!(event, ProgressEvent::Decrypting) {
observed.set(dropped.get());
}
}
};
decrypt(
credential,
&fcr,
&dec_dir,
ArchiveLimits::default(),
HeaderReadLimits::default(),
IncompleteOutputPolicy::default(),
&observer,
)?;
assert!(
dropped_at_payload_start.get(),
"the credential must be scrubbed before the payload is decrypted"
);
Ok(())
}
#[test]
fn recipients_are_dropped_before_the_payload_phase() -> Result<(), CryptoError> {
use std::cell::Cell;
use std::rc::Rc;
struct DropRecording {
inner: argon2id::PassphraseRecipient,
dropped: Rc<Cell<bool>>,
}
impl Drop for DropRecording {
fn drop(&mut self) {
self.dropped.set(true);
}
}
impl RecipientScheme for DropRecording {
const TYPE_NAME: &'static str = argon2id::TYPE_NAME;
const MIXING_RULE: NativeMixingRule =
<argon2id::PassphraseRecipient as RecipientScheme>::MIXING_RULE;
fn validate_for_write(&self) -> Result<(), CryptoError> {
self.inner.validate_for_write()
}
fn wrap_file_key(
&self,
file_key: &FileKey,
on_event: &dyn Fn(&ProgressEvent),
) -> Result<RecipientBody, CryptoError> {
self.inner.wrap_file_key(file_key, on_event)
}
}
let tmp = tempfile::TempDir::new()?;
let input = tmp.path().join("data.txt");
fs::write(&input, b"payload phase must not hold the credential")?;
let dropped = Rc::new(Cell::new(false));
let dropped_at_payload_start = Rc::new(Cell::new(false));
let recipient = DropRecording {
inner: argon2id::PassphraseRecipient {
passphrase: Passphrase::new("encrypt-drop-order-test-passphrase"),
kdf_params: crate::crypto::kdf::KdfParams::test_fast_default(),
kdf_limit: crate::KdfLimit::default(),
},
dropped: Rc::clone(&dropped),
};
let observer = {
let dropped = Rc::clone(&dropped);
let observed = Rc::clone(&dropped_at_payload_start);
move |event: &ProgressEvent| {
if matches!(event, ProgressEvent::Encrypting) {
observed.set(dropped.get());
}
}
};
encrypt(
vec![recipient],
ArchiveLimits::default(),
HeaderReadLimits::default(),
&input,
tmp.path(),
None,
&observer,
)?;
assert!(
dropped_at_payload_start.get(),
"the recipient must be scrubbed before the payload is encrypted"
);
Ok(())
}
#[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: argon2id::test_body_with_valid_kdf_params(),
};
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 kdf_params = crate::crypto::kdf::KdfParams::test_fast_default();
let recipient = |text: &str| argon2id::PassphraseRecipient {
passphrase: Passphrase::new(text),
kdf_params,
kdf_limit: crate::crypto::kdf::KdfLimit::default(),
};
let recipients = vec![recipient("pass"), recipient("pass")];
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(),
HeaderReadLimits::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,
argon2id::test_body_with_valid_kdf_params(),
)?;
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 = Passphrase::new("doesn't-matter");
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: Vec<argon2id::PassphraseRecipient> = Vec::new();
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(),
HeaderReadLimits::default(),
&input,
&out_dir,
None,
&|_| {},
)
.unwrap_err();
match err {
CryptoError::EmptyRecipientList => Ok(()),
other => panic!("expected EmptyRecipientList, got {other:?}"),
}
}
#[test]
fn encrypt_rejects_recipient_count_above_local_cap() -> Result<(), CryptoError> {
let (_secret, public) = x25519::generate_keypair()?;
let over = HeaderReadLimits::RECIPIENT_COUNT_DEFAULT + 1;
let recipients: Vec<x25519::X25519Recipient> = (0..over)
.map(|_| x25519::X25519Recipient {
recipient_public_key_bytes: &public,
})
.collect();
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(),
HeaderReadLimits::default(),
&input,
&out_dir,
None,
&|_| {},
)
.unwrap_err();
match err {
CryptoError::RecipientCountCapExceeded { count, local_cap } => {
assert_eq!(count, over);
assert_eq!(local_cap, HeaderReadLimits::RECIPIENT_COUNT_DEFAULT);
Ok(())
}
other => panic!("expected RecipientCountCapExceeded, got {other:?}"),
}
}
#[test]
fn encrypt_rejects_kdf_memory_above_write_policy() -> Result<(), CryptoError> {
use crate::crypto::kdf::{KdfLimit, KdfParams};
let recipient = argon2id::PassphraseRecipient {
passphrase: Passphrase::new("pass"),
kdf_params: KdfParams {
mem_cost: KdfLimit::MEM_COST_KIB_STRUCTURAL_MAX,
time_cost: 1,
lanes: 1,
},
kdf_limit: KdfLimit::default(),
};
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(
vec![recipient],
ArchiveLimits::default(),
HeaderReadLimits::default(),
&input,
&out_dir,
None,
&|_| {},
)
.unwrap_err();
match err {
CryptoError::KdfResourceCapExceeded {
mem_cost_kib,
local_cap_kib,
} => {
assert_eq!(mem_cost_kib, KdfLimit::MEM_COST_KIB_STRUCTURAL_MAX);
assert_eq!(local_cap_kib, KdfLimit::MEM_COST_KIB_DEFAULT);
Ok(())
}
other => panic!("expected KdfResourceCapExceeded, got {other:?}"),
}
}
#[test]
fn preflight_counts_the_extension_region_in_the_header_length() {
let exact = u32::try_from(
format::HEADER_FIXED_SIZE
+ crate::recipient::entry::ENTRY_HEADER_SIZE
+ argon2id::TYPE_NAME.len()
+ NativeRecipientType::Argon2id.body_len(),
)
.unwrap();
let limits = crate::HeaderReadLimits::default().max_header_len(exact);
preflight_header_write_limits(limits, 1, NativeRecipientType::Argon2id, b"")
.expect("the exact header length must be accepted");
let err = preflight_header_write_limits(limits, 1, NativeRecipientType::Argon2id, b"x")
.expect_err("one extension byte must exceed the exact cap");
assert!(
matches!(err, CryptoError::HeaderLenCapExceeded { .. }),
"got: {err:?}"
);
}
#[test]
fn generate_key_pair_rejects_kdf_memory_below_write_floor() {
use crate::crypto::kdf::KdfParams;
let tmp = tempfile::TempDir::new().unwrap();
let keys_dir = tmp.path().join("keys");
let pass = Passphrase::new("pass");
let weak = KdfParams {
mem_cost: KdfParams::MIN_WRITE_MEM_COST - 1,
time_cost: 1,
lanes: 1,
};
let err = generate_key_pair(pass, &weak, None, &keys_dir, &|_| {}).unwrap_err();
match err {
CryptoError::KdfBelowWriteFloor { .. } => {}
other => panic!("expected KdfBelowWriteFloor, got {other:?}"),
}
assert!(
!keys_dir.exists(),
"gate must fire before the output directory is created"
);
}
#[cfg(unix)]
#[test]
fn keygen_rejects_either_key_path_replaced_after_commit() {
use crate::crypto::kdf::KdfParams;
let tmp = tempfile::TempDir::new().unwrap();
let kdf_params = KdfParams::test_fast_default();
for target in [PRIVATE_KEY_FILENAME, PUBLIC_KEY_FILENAME] {
let output_dir = tmp.path().join(target);
let target_path = output_dir.join(target);
let committed_path = output_dir.join(format!("{target}.committed"));
let err = generate_key_pair_with_seams(
Passphrase::new("passphrase"),
&kdf_params,
None,
&output_dir,
&|_| {},
|_, _| Ok(()),
|private_path, public_path| {
let path = if target == PRIVATE_KEY_FILENAME {
private_path
} else {
public_path
};
fs::rename(path, &committed_path)?;
fs::write(path, b"replacement")
},
)
.expect_err("a replaced key path must not be returned");
let other = if target == PRIVATE_KEY_FILENAME {
PUBLIC_KEY_FILENAME
} else {
PRIVATE_KEY_FILENAME
};
assert!(
matches!(
&err,
CryptoError::InvalidInput(message)
if message.contains("reported path changed")
&& message.ends_with(&format!("; {other} is also complete"))
),
"{target} replacement must be reported explicitly, with {other} as complete, got: {err}"
);
assert_ne!(
fs::read(&committed_path).unwrap(),
b"replacement",
"the committed {target} must survive under its new name"
);
assert_eq!(fs::read(&target_path).unwrap(), b"replacement");
}
}
#[cfg(unix)]
#[test]
fn keygen_reports_both_keys_as_complete_when_a_path_cannot_be_confirmed() {
use std::os::unix::fs::PermissionsExt;
use crate::crypto::kdf::KdfParams;
let tmp = tempfile::TempDir::new().unwrap();
let output_dir = tmp.path().join("keys");
let private_key_path = output_dir.join(PRIVATE_KEY_FILENAME);
let public_key_path = output_dir.join(PUBLIC_KEY_FILENAME);
let outcome = generate_key_pair_with_seams(
Passphrase::new("passphrase"),
&KdfParams::test_fast_default(),
None,
&output_dir,
&|_| {},
|_, _| Ok(()),
|_, _| fs::set_permissions(&output_dir, fs::Permissions::from_mode(0o000)),
);
fs::set_permissions(&output_dir, fs::Permissions::from_mode(0o755)).unwrap();
match outcome {
Ok(_) => {}
Err(CryptoError::Io(error)) => {
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
let message = error.to_string();
let unconfirmed = |path: &Path| {
format!(
"Output is complete, but its reported path could not be confirmed: {}: ",
crate::error::sanitize_path_for_display(path)
)
};
assert!(
message.starts_with(&unconfirmed(&private_key_path))
&& message.contains(&format!("; {}", unconfirmed(&public_key_path)))
&& !message.contains("is also complete"),
"both key files must be reported as complete and unconfirmed, got: {message}"
);
}
Err(other) => panic!("the denial must be reported as I/O, got: {other}"),
}
for name in [PRIVATE_KEY_FILENAME, PUBLIC_KEY_FILENAME] {
assert!(
output_dir.join(name).is_file(),
"the committed {name} must stay"
);
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn keygen_refuses_an_output_directory_swapped_in_after_staging() {
use crate::crypto::kdf::KdfParams;
let tmp = tempfile::TempDir::new().unwrap();
let output_dir = tmp.path().join("keys");
let replacement = tmp.path().join("replacement");
let moved = tmp.path().join("keys.moved");
fs::create_dir(&replacement).unwrap();
let err = generate_key_pair_with_seams(
Passphrase::new("passphrase"),
&KdfParams::test_fast_default(),
None,
&output_dir,
&|_| {},
|private_staged, public_staged| {
for staged in [private_staged, public_staged] {
let name = staged.file_name().expect("a staged file has a name");
fs::rename(staged, replacement.join(name))?;
}
fs::rename(&output_dir, &moved)?;
fs::rename(&replacement, &output_dir)
},
|_, _| Ok(()),
)
.expect_err("staged files moved out of the anchored directory must refuse the commit");
for dir in [&output_dir, &moved] {
for name in [PRIVATE_KEY_FILENAME, PUBLIC_KEY_FILENAME] {
assert!(
!dir.join(name).exists(),
"{} must receive no {name}: {err}",
dir.display()
);
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn keygen_commits_into_the_directory_it_began_in_across_a_swap() {
use crate::crypto::kdf::KdfParams;
let tmp = tempfile::TempDir::new().unwrap();
let output_dir = tmp.path().join("keys");
let replacement = tmp.path().join("replacement");
let moved = tmp.path().join("keys.moved");
fs::create_dir(&replacement).unwrap();
let err = generate_key_pair_with_seams(
Passphrase::new("passphrase"),
&KdfParams::test_fast_default(),
None,
&output_dir,
&|_| {},
|_, _| {
fs::rename(&output_dir, &moved)?;
fs::rename(&replacement, &output_dir)
},
|_, _| Ok(()),
)
.expect_err("the reported paths no longer name the key files");
assert!(
matches!(&err, CryptoError::InvalidInput(message) if message.contains("reported path changed")),
"the path change must be reported explicitly, got: {err}"
);
assert_eq!(
leftover_entries(&output_dir, ""),
Vec::<std::ffi::OsString>::new(),
"the substituted directory must receive nothing"
);
assert!(
moved.join(PRIVATE_KEY_FILENAME).is_file(),
"the private key belongs in the directory the run began in"
);
assert_eq!(
leftover_entries(&moved, PRIVATE_KEY_FILENAME),
Vec::<std::ffi::OsString>::new(),
"the original directory must contain only the committed private key"
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn keygen_reports_a_public_staging_file_it_cannot_remove_through_the_anchor() {
use crate::crypto::kdf::KdfParams;
let tmp = tempfile::TempDir::new().unwrap();
let output_dir = tmp.path().join("keys");
let replacement = tmp.path().join("replacement");
let moved = tmp.path().join("keys.moved");
fs::create_dir(&replacement).unwrap();
let err = generate_key_pair_with_seams(
Passphrase::new("passphrase"),
&KdfParams::test_fast_default(),
None,
&output_dir,
&|_| {},
|_, public_staged| {
let public_name = public_staged
.file_name()
.expect("a staged public key has a name");
fs::rename(public_staged, replacement.join(public_name))?;
fs::rename(&output_dir, &moved)?;
fs::rename(&replacement, &output_dir)
},
|_, _| Ok(()),
)
.expect_err("an unconfirmed staged sibling must be reported");
let message = err.to_string();
assert!(
message.contains("reported path changed"),
"the committed private key's path change must remain the primary error: {message}"
);
assert!(
message.contains("could not be confirmed"),
"the surviving staged public key must be reported: {message}"
);
assert!(moved.join(PRIVATE_KEY_FILENAME).is_file());
assert!(!moved.join(PUBLIC_KEY_FILENAME).exists());
let replacement_entries = leftover_entries(&output_dir, "");
assert_eq!(replacement_entries.len(), 1, "got: {replacement_entries:?}");
let staged_name = replacement_entries[0].to_string_lossy();
assert!(
staged_name.starts_with(".ferrocrypt-public_key-") && staged_name.ends_with(".tmp"),
"the reported survivor must be the staged public key, got: {staged_name}"
);
}
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,
&crate::fs::atomic::OutputDir::open(dir).unwrap(),
)
.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,
&crate::fs::atomic::OutputDir::open(dir).unwrap(),
)
.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(&crate::fs::atomic::OutputDir, &Path) -> std::io::Result<()> {
move |_committed_dir, 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,
&crate::fs::atomic::OutputDir::open(dir).unwrap(),
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:?}"
);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn keygen_first_barrier_failure_cleans_the_original_directory_after_a_swap() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("keys");
let replacement = tmp.path().join("replacement");
let moved = tmp.path().join("keys.moved");
fs::create_dir(&dir).unwrap();
fs::create_dir(&replacement).unwrap();
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 anchor = crate::fs::atomic::OutputDir::open(&dir).unwrap();
let err = commit_key_pair_files_with_barrier(
private_tmp,
public_tmp,
&private_key_path,
&public_key_path,
&anchor,
|_, _| {
fs::rename(&dir, &moved)?;
fs::rename(&replacement, &dir)?;
Err(std::io::Error::other("injected directory flush failure"))
},
)
.expect_err("the injected first-barrier failure must fail key generation");
assert_eq!(err.to_string(), "injected directory flush failure");
assert_eq!(
leftover_entries(&dir, ""),
Vec::<std::ffi::OsString>::new(),
"the replacement directory must receive nothing"
);
assert_eq!(
leftover_entries(&moved, ""),
Vec::<std::ffi::OsString>::new(),
"both files this run wrote must be removed from the original directory"
);
}
#[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,
&crate::fs::atomic::OutputDir::open(dir).unwrap(),
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_eq!(
err.to_string(),
"injected directory flush failure; private.key is complete and was kept",
"the error must say which key file remains"
);
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_post_commit_public_failure_preserves_both_keys() {
use crate::fs::atomic;
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 err = commit_key_pair_files_with_barrier_and_public_finalizer(
private_tmp,
public_tmp,
&private_key_path,
&public_key_path,
&crate::fs::atomic::OutputDir::open(dir).unwrap(),
|_, _| Ok(()),
|tmp, path, label, anchor| {
let _committed = atomic::finalize_file(tmp, path, label, anchor)
.expect("the injected error must follow a real commit");
Err(atomic::FinalizeFileError::after_commit_for_test(
CryptoError::Io(std::io::Error::other(
"Output is complete, but an injected check failed",
)),
))
},
)
.expect_err("the injected post-commit failure must be returned");
assert_eq!(
err.to_string(),
"Output is complete, but an injected check failed; private.key is also complete"
);
assert_eq!(fs::read(&private_key_path).unwrap(), b"private bytes");
assert_eq!(fs::read(&public_key_path).unwrap(), b"public bytes");
assert_eq!(
fs::read_dir(dir).unwrap().count(),
2,
"only the two committed key files may remain"
);
}
#[derive(Clone, Copy, Debug)]
enum Interference {
Moved,
Replaced,
Linked,
}
impl Interference {
const ALL: [Self; 3] = [Self::Moved, Self::Replaced, Self::Linked];
fn apply(self, key: &Path) -> std::io::Result<()> {
match self {
Self::Moved => fs::rename(key, key.with_extension("moved")),
Self::Replaced => {
fs::rename(key, key.with_extension("moved"))?;
fs::write(key, SWAPPED_KEY_BYTES)
}
Self::Linked => fs::hard_link(key, key.with_extension("link")),
}
}
fn expected_clause(self, name: &str) -> String {
match self {
Self::Moved => {
format!(
"a complete {name} was committed, and its removal could not be confirmed"
)
}
Self::Replaced => format!(
"a complete {name} was committed, but the entry at its name was replaced and left in place, and the committed file may remain under another name"
),
Self::Linked => format!(
"a complete {name} was committed and then removed, but it had 2 filesystem names, so a copy may remain under another name"
),
}
}
fn assert_left(self, key: &Path, bytes: &[u8]) {
let other_name = match self {
Self::Moved | Self::Replaced => key.with_extension("moved"),
Self::Linked => key.with_extension("link"),
};
assert_eq!(
fs::read(&other_name).unwrap(),
bytes,
"{self:?}: the complete key file must survive under its other name"
);
match self {
Self::Replaced => assert_eq!(
fs::read(key).unwrap(),
SWAPPED_KEY_BYTES,
"the replacement must be left in place"
),
Self::Moved | Self::Linked => {
assert!(!key.exists(), "{self:?}: the final name must hold nothing");
}
}
}
}
fn assert_no_staged_temporary(dir: &Path) {
let staged: Vec<_> = leftover_entries(dir, "")
.into_iter()
.filter(|name| name.to_string_lossy().starts_with(".ferrocrypt-"))
.collect();
assert!(
staged.is_empty(),
"no staged temporary may remain, found {staged:?}"
);
}
#[test]
fn keygen_first_barrier_failure_reports_a_committed_private_key_it_could_not_remove() {
for interference in Interference::ALL {
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 err = commit_key_pair_files_with_barrier(
private_tmp,
public_tmp,
&private_key_path,
&public_key_path,
&crate::fs::atomic::OutputDir::open(dir).unwrap(),
|_, _| {
assert!(
private_key_path.is_file(),
"the flush must run after private.key is committed, or this test proves nothing"
);
interference.apply(&private_key_path)?;
Err(std::io::Error::other("injected directory flush failure"))
},
)
.expect_err("a failed directory flush after private.key must fail the commit");
assert!(
matches!(err, CryptoError::Io(_)),
"{interference:?}: the I/O class must be kept, got {err:?}"
);
assert_eq!(
err.to_string(),
format!(
"injected directory flush failure; {}",
interference.expected_clause(PRIVATE_KEY_FILENAME)
),
"{interference:?}"
);
interference.assert_left(&private_key_path, b"private bytes");
assert!(
!public_key_path.exists(),
"{interference:?}: public.key must never appear"
);
assert_no_staged_temporary(dir);
}
}
#[test]
fn keygen_public_commit_failure_reports_a_committed_private_key_it_could_not_remove() {
for interference in Interference::ALL {
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_with_barrier(
private_tmp,
public_tmp,
&private_key_path,
&public_key_path,
&crate::fs::atomic::OutputDir::open(dir).unwrap(),
|_, _| interference.apply(&private_key_path),
)
.expect_err("occupied public.key name must fail the commit");
assert!(
matches!(err, CryptoError::InvalidInput(_)),
"{interference:?}: the already-exists class must be kept, got {err:?}"
);
let message = err.to_string();
let clause = interference.expected_clause(PRIVATE_KEY_FILENAME);
assert!(
message.starts_with("Key file already exists")
&& message.ends_with(&format!("; {clause}")),
"{interference:?}: the cause and the rollback report must both be stated, got: {message}"
);
interference.assert_left(&private_key_path, b"private bytes");
assert_eq!(
fs::read(&public_key_path).unwrap(),
b"occupant",
"{interference:?}: the occupant of the public.key name must be untouched"
);
assert_no_staged_temporary(dir);
}
}
#[test]
fn keygen_final_barrier_failure_reports_a_committed_public_key_it_could_not_remove() {
for interference in Interference::ALL {
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 calls = std::cell::Cell::new(0);
let err = commit_key_pair_files_with_barrier(
private_tmp,
public_tmp,
&private_key_path,
&public_key_path,
&crate::fs::atomic::OutputDir::open(dir).unwrap(),
|_, _| {
calls.set(calls.get() + 1);
if calls.get() == 1 {
return Ok(());
}
interference.apply(&public_key_path)?;
Err(std::io::Error::other("injected directory flush failure"))
},
)
.expect_err("a failed directory flush after public.key must fail the commit");
assert!(
matches!(err, CryptoError::Io(_)),
"{interference:?}: the I/O class must be kept, got {err:?}"
);
assert_eq!(
err.to_string(),
format!(
"injected directory flush failure; private.key is complete and was kept; {}",
interference.expected_clause(PUBLIC_KEY_FILENAME)
),
"{interference:?}"
);
assert_eq!(
fs::read(&private_key_path).unwrap(),
b"private bytes",
"{interference:?}: private.key must be kept"
);
interference.assert_left(&public_key_path, b"public bytes");
assert_no_staged_temporary(dir);
}
}
#[cfg(unix)]
fn swap_output_dir(dir: &Path, moved: &Path, names: &[&str]) -> std::io::Result<()> {
fs::rename(dir, moved)?;
fs::create_dir(dir)?;
for name in names {
fs::write(dir.join(name), SWAPPED_KEY_BYTES)?;
}
Ok(())
}
const SWAPPED_KEY_BYTES: &[u8] = b"replacement";
#[cfg(unix)]
fn changed_path_report(path: &Path) -> String {
format!(
"Output is complete but its reported path changed: {}",
crate::error::sanitize_path_for_display(path)
)
}
#[cfg(unix)]
#[test]
fn keygen_does_not_vouch_for_either_key_path_after_a_directory_swap() {
use crate::crypto::kdf::KdfParams;
let tmp = tempfile::TempDir::new().unwrap();
let output_dir = tmp.path().join("keys");
let moved = tmp.path().join("keys.moved");
let names = [PRIVATE_KEY_FILENAME, PUBLIC_KEY_FILENAME];
let err = generate_key_pair_with_seams(
Passphrase::new("passphrase"),
&KdfParams::test_fast_default(),
None,
&output_dir,
&|_| {},
|_, _| Ok(()),
|_, _| swap_output_dir(&output_dir, &moved, &names),
)
.expect_err("a swapped output directory must not be reported as success");
let message = err.to_string();
for name in names {
assert!(
message.contains(&changed_path_report(&output_dir.join(name))),
"{name} must be reported at a changed path, got: {message}"
);
assert_ne!(fs::read(moved.join(name)).unwrap(), SWAPPED_KEY_BYTES);
}
assert!(
!message.contains("is also complete"),
"a replaced path must not be vouched for, got: {message}"
);
}
#[cfg(unix)]
#[test]
fn keygen_post_commit_public_failure_does_not_vouch_for_a_swapped_private_path() {
use crate::fs::atomic;
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("keys");
fs::create_dir(&dir).unwrap();
let moved = tmp.path().join("keys.moved");
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 err = commit_key_pair_files_with_barrier_and_public_finalizer(
private_tmp,
public_tmp,
&private_key_path,
&public_key_path,
&atomic::OutputDir::open(&dir).unwrap(),
|_, _| Ok(()),
|tmp, path, label, anchor| {
let _committed = atomic::finalize_file(tmp, path, label, anchor)
.expect("the injected error must follow a real commit");
swap_output_dir(&dir, &moved, &[PRIVATE_KEY_FILENAME])
.expect("the output directory must be swapped");
Err(atomic::FinalizeFileError::after_commit_for_test(
CryptoError::Io(std::io::Error::other(
"Output is complete, but an injected check failed",
)),
))
},
)
.expect_err("the injected post-commit failure must be returned");
assert_eq!(
err.to_string(),
format!(
"Output is complete, but an injected check failed; {}",
changed_path_report(&private_key_path)
)
);
assert_eq!(
fs::read(moved.join(PRIVATE_KEY_FILENAME)).unwrap(),
b"private bytes"
);
assert_eq!(
fs::read(moved.join(PUBLIC_KEY_FILENAME)).unwrap(),
b"public bytes"
);
}
#[cfg(unix)]
#[test]
fn keygen_barrier_failure_after_public_commit_does_not_vouch_for_a_swapped_private_path() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("keys");
fs::create_dir(&dir).unwrap();
let moved = tmp.path().join("keys.moved");
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 calls = std::cell::Cell::new(0);
let err = commit_key_pair_files_with_barrier(
private_tmp,
public_tmp,
&private_key_path,
&public_key_path,
&crate::fs::atomic::OutputDir::open(&dir).unwrap(),
|_, _| {
calls.set(calls.get() + 1);
if calls.get() == 1 {
return Ok(());
}
swap_output_dir(&dir, &moved, &[PRIVATE_KEY_FILENAME])?;
Err(std::io::Error::other("injected directory flush failure"))
},
)
.expect_err("a failed directory flush after public.key must fail the commit");
assert_eq!(
err.to_string(),
format!(
"injected directory flush failure; {}",
changed_path_report(&private_key_path)
)
);
assert_eq!(
fs::read(moved.join(PRIVATE_KEY_FILENAME)).unwrap(),
b"private bytes"
);
assert!(!moved.join(PUBLIC_KEY_FILENAME).exists());
}
#[cfg(unix)]
#[test]
fn keygen_rollback_removes_the_committed_key_not_a_substituted_path() {
let tmp = tempfile::TempDir::new().unwrap();
let out = tmp.path().join("out");
let unrelated = tmp.path().join("elsewhere");
let moved_out = tmp.path().join("out.moved");
fs::create_dir(&out).unwrap();
fs::create_dir(&unrelated).unwrap();
fs::write(unrelated.join(PRIVATE_KEY_FILENAME), b"unrelated key").unwrap();
let private_key_path = out.join(PRIVATE_KEY_FILENAME);
let public_key_path = out.join(PUBLIC_KEY_FILENAME);
let private_tmp = staged_key_tempfile(&out, b"private bytes");
let public_tmp = staged_key_tempfile(&out, b"public bytes");
let committed_before_swap = std::rc::Rc::new(std::cell::Cell::new(false));
let substitute_output_dir = {
let (out, unrelated, moved_out) = (out.clone(), unrelated.clone(), moved_out.clone());
let committed = committed_before_swap.clone();
move |_: &crate::fs::atomic::OutputDir, _: &Path| -> std::io::Result<()> {
committed.set(out.join(PRIVATE_KEY_FILENAME).exists());
fs::rename(&out, &moved_out)?;
std::os::unix::fs::symlink(&unrelated, &out)?;
Err(std::io::Error::other("injected directory flush failure"))
}
};
commit_key_pair_files_with_barrier(
private_tmp,
public_tmp,
&private_key_path,
&public_key_path,
&crate::fs::atomic::OutputDir::open(&out).unwrap(),
substitute_output_dir,
)
.expect_err("the injected directory flush failure must fail the commit");
assert!(
committed_before_swap.get(),
"the flush must run after private.key is committed, or this test proves nothing"
);
assert_eq!(
fs::read(unrelated.join(PRIVATE_KEY_FILENAME)).unwrap(),
b"unrelated key",
"a same-named file in a substituted directory must not be removed"
);
assert!(
!moved_out.join(PRIVATE_KEY_FILENAME).exists(),
"the private.key this run committed must still be removed"
);
}
#[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,
&crate::fs::atomic::OutputDir::open(dir).unwrap(),
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");
}
}