fcrypt 0.3.3

Cross-platform CLI for password-based file encryption and decryption
use std::fs::{self, File};
use std::io::{self, BufReader, BufWriter, Seek, SeekFrom, Write};
use std::path::PathBuf;
use tempfile::NamedTempFile;

use crate::asym::cli::AssymDecryptArgs;
use crate::asym::{envelope, keys, pqc, sign};
use crate::error::{AppError, Result};
use crate::format::opaque;
use crate::sym::crypto::DEFAULT_CHUNK_SIZE;
use crate::sym::pathing;

pub fn decrypt_file<F>(args: &AssymDecryptArgs, on_progress: F) -> Result<PathBuf>
where
    F: FnMut(u64),
{
    Ok(decrypt_file_with_diagnostics(args, on_progress)?.output)
}

pub struct DecryptOutcome {
    pub output: PathBuf,
    pub skipped_identity_files: Vec<PathBuf>,
    pub recipient_key_expired: bool,
    pub signer_key_expired: bool,
}

pub fn decrypt_file_with_diagnostics<F>(
    args: &AssymDecryptArgs,
    on_progress: F,
) -> Result<DecryptOutcome>
where
    F: FnMut(u64),
{
    pqc::ensure_enabled()?;

    let output = args
        .output
        .clone()
        .map(Ok)
        .unwrap_or_else(|| pathing::asym_decryption_output_path(&args.input))?;
    if output.exists() && !args.force {
        return Err(AppError::OutputExists(output));
    }

    let mut input_file = File::open(&args.input)?;
    let encrypted_len = input_file.metadata()?.len();
    let verification_key = resolve_verification_key(args)?;
    let signer_key_expired = verification_key
        .as_ref()
        .is_some_and(keys::SigningPublicKeyBundle::is_expired);
    verify_signature_policy(
        args,
        verification_key.as_ref(),
        &mut input_file,
        encrypted_len,
    )?;
    input_file.seek(SeekFrom::Start(0))?;
    let (identities, skipped_identity_files) = resolve_identities(args)?;
    let recipient_key_expired = args.identity.is_some()
        && identities
            .first()
            .is_some_and(keys::RecipientSecretKeyBundle::is_expired);

    decrypt_open_file(
        input_file,
        encrypted_len,
        &identities,
        verification_key.as_ref(),
        &output,
        args.force,
        on_progress,
    )?;
    Ok(DecryptOutcome {
        output,
        skipped_identity_files,
        recipient_key_expired,
        signer_key_expired,
    })
}

fn decrypt_open_file<F>(
    input_file: File,
    encrypted_len: u64,
    identities: &[keys::RecipientSecretKeyBundle],
    verification_key: Option<&keys::SigningPublicKeyBundle>,
    output: &std::path::Path,
    force: bool,
    on_progress: F,
) -> Result<()>
where
    F: FnMut(u64),
{
    let output_dir = envelope::output_parent_dir(output);
    fs::create_dir_all(&output_dir)?;
    let mut temp_output = NamedTempFile::new_in(&output_dir)?;
    let metadata;
    {
        let mut reader = BufReader::with_capacity(DEFAULT_CHUNK_SIZE.max(64 * 1024), input_file);
        let mut writer =
            BufWriter::with_capacity(DEFAULT_CHUNK_SIZE.max(64 * 1024), temp_output.as_file_mut());
        metadata = opaque::decrypt_pqc_stream(
            &mut reader,
            &mut writer,
            encrypted_len,
            identities,
            on_progress,
        )?;
        writer.flush()?;
    }

    enforce_signature_requirement(&metadata, verification_key)?;
    temp_output.as_file_mut().sync_all()?;
    envelope::persist_temp_file(temp_output, output, force)
}

fn enforce_signature_requirement(
    metadata: &opaque::PqcDecryptMetadata,
    verification_key: Option<&keys::SigningPublicKeyBundle>,
) -> Result<()> {
    let Some(required_signer_key_id) = &metadata.required_signer_key_id else {
        return Ok(());
    };
    let verification_key = verification_key.ok_or(AppError::SignatureVerificationKeyRequired)?;
    if verification_key.key_id != *required_signer_key_id {
        return Err(AppError::SignatureVerificationFailed);
    }
    Ok(())
}

fn resolve_identities(
    args: &AssymDecryptArgs,
) -> Result<(Vec<keys::RecipientSecretKeyBundle>, Vec<PathBuf>)> {
    if let Some(identity) = &args.identity {
        return Ok((vec![keys::read_recipient_secret_key(identity)?], Vec::new()));
    }

    let keys_dir = args
        .keys_dir
        .clone()
        .map(Ok)
        .unwrap_or_else(|| pathing::asym_default_keys_dir_for_encrypted_input(&args.input))?;
    let loaded = keys::read_recipient_secret_keys_with_diagnostics(&keys_dir)?;
    Ok((loaded.identities, loaded.skipped_paths))
}

fn verify_signature_policy(
    args: &AssymDecryptArgs,
    verification_key: Option<&keys::SigningPublicKeyBundle>,
    input_file: &mut File,
    encrypted_len: u64,
) -> Result<()> {
    if let Some(verification_key) = verification_key {
        return sign::verify_detached_signature(
            &args.input,
            verification_key,
            input_file,
            encrypted_len,
        );
    }
    Ok(())
}

fn resolve_verification_key(
    args: &AssymDecryptArgs,
) -> Result<Option<keys::SigningPublicKeyBundle>> {
    if let Some(verify_key) = &args.verify {
        return keys::read_signing_public_key(verify_key).map(Some);
    }
    if args.require_signature {
        return Err(AppError::SignatureVerificationKeyRequired);
    }

    let detached_path = envelope::detached_signature_path(&args.input)?;
    match fs::symlink_metadata(detached_path) {
        Ok(_) => Err(AppError::SignatureVerificationKeyRequired),
        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
        Err(error) => Err(AppError::Io(error)),
    }
}

#[cfg(all(test, feature = "pqc", unix))]
mod tests {
    use super::*;
    use crate::asym::cli::AssymEncryptArgs;
    use crate::asym::{encrypt, keys};
    use crate::sym::crypto::CryptoConfig;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn verified_file_handle_is_decrypted_after_path_replacement() {
        let dir = tempdir().expect("tempdir must be created");
        let recipient_public = dir.path().join("recipient.pub");
        let recipient_secret = dir.path().join("recipient.sec");
        let signing_public = dir.path().join("signing.pub");
        let signing_secret = dir.path().join("signing.sec");
        let keys_dir = dir.path().join("keys");
        let generated = keys::generate_named_key_pair_files(&keys_dir, "alice", None, false)
            .expect("key pair must be generated");
        fs::rename(generated.recipient_public_path, &recipient_public)
            .expect("recipient public key must be moved");
        fs::rename(generated.recipient_secret_path, &recipient_secret)
            .expect("recipient secret key must be moved");
        fs::rename(generated.signing_public_path, &signing_public)
            .expect("signing public key must be moved");
        fs::rename(generated.signing_secret_path, &signing_secret)
            .expect("signing secret key must be moved");

        let signed_plaintext = dir.path().join("signed.txt");
        let replacement_plaintext = dir.path().join("replacement.txt");
        let encrypted = dir.path().join("signed.bin");
        let replacement = dir.path().join("replacement.bin");
        let output = dir.path().join("output.txt");
        fs::write(&signed_plaintext, b"signed plaintext").expect("plaintext must be written");
        fs::write(&replacement_plaintext, b"replacement plaintext")
            .expect("replacement must be written");

        let config = CryptoConfig { chunk_size: 64 };
        encrypt::encrypt_file(
            &AssymEncryptArgs {
                input: signed_plaintext,
                output: Some(encrypted.clone()),
                recipient_public: Some(recipient_public.clone()),
                keys_dir: None,
                sign: false,
                sign_key: Some(signing_secret),
                force: false,
            },
            &config,
            |_| {},
        )
        .expect("signed file must be encrypted");
        encrypt::encrypt_file(
            &AssymEncryptArgs {
                input: replacement_plaintext,
                output: Some(replacement.clone()),
                recipient_public: Some(recipient_public),
                keys_dir: None,
                sign: false,
                sign_key: None,
                force: false,
            },
            &config,
            |_| {},
        )
        .expect("replacement file must be encrypted");

        let args = AssymDecryptArgs {
            input: encrypted.clone(),
            output: Some(output.clone()),
            identity: Some(recipient_secret.clone()),
            keys_dir: None,
            verify: Some(signing_public),
            require_signature: true,
            force: false,
        };
        let mut verified_file = File::open(&encrypted).expect("ciphertext must open");
        let encrypted_len = verified_file
            .metadata()
            .expect("ciphertext metadata must read")
            .len();
        let verification_key = keys::read_signing_public_key(
            args.verify
                .as_ref()
                .expect("verification key path must exist"),
        )
        .expect("verification key must load");
        verify_signature_policy(
            &args,
            Some(&verification_key),
            &mut verified_file,
            encrypted_len,
        )
        .expect("original open file must verify");

        fs::rename(&replacement, &encrypted).expect("path must be replaced after verification");
        let identities = vec![keys::read_recipient_secret_key(&recipient_secret)
            .expect("recipient secret key must be readable")];
        decrypt_open_file(
            verified_file,
            encrypted_len,
            &identities,
            Some(&verification_key),
            &output,
            false,
            |_| {},
        )
        .expect("the verified open file must decrypt");

        assert_eq!(
            fs::read(output).expect("output must read"),
            b"signed plaintext"
        );
    }
}