ferrocrypt 0.3.0-rc.3

Recipient-oriented file and directory encryption: passphrase (Argon2id) and X25519 public-key recipients, XChaCha20-Poly1305 STREAM payloads, HKDF-SHA3-256 / HMAC-SHA3-256 key derivation and authentication.
Documentation
//! Replays the committed `testvectors/suite/` edge-case corpus.
//!
//! Reads `testvectors/suite/manifest.tsv` and runs every row through the
//! public API exactly as an independent implementation would: decrypt
//! each `.fcr` with the listed credential, read each `public.key`, or
//! structurally validate each `private.key`, and require the listed outcome.
//! Successful `decrypt` rows must reproduce `plaintext.txt` byte-for-byte;
//! successful key-file rows must parse or validate without error. `error`
//! rows must fail with both the listed typed error class and the exact
//! user-facing message.
//!
//! The corpus is generated by the ignored `suite_vector_gen` unit test
//! in the library crate, which needs crate internals to craft the
//! must-reject bytes. This test deliberately uses only public API and
//! only committed files, so a failure means either the reader's
//! behaviour drifted or the committed corpus no longer tells the truth.

use std::fs;
use std::path::{Path, PathBuf};

use ferrocrypt::secrecy::SecretString;
use ferrocrypt::{
    CryptoError, Decryptor, FormatDefect, InvalidKdfParams, PrivateKey, PublicKey,
    UnsupportedVersion, validate_private_key_file,
};

const TEST_WORKSPACE: &str = "tests/workspace_testvector_suite";

fn suite_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("testvectors/suite")
}

#[ctor::dtor]
fn cleanup() {
    ferrocrypt_test_support::remove_per_process_workspace(TEST_WORKSPACE);
}

/// One parsed `manifest.tsv` row.
struct Row {
    file: String,
    action: String,
    credential: String,
    expect: String,
    error_class: String,
    error_message: String,
}

fn parse_manifest(suite: &Path) -> Vec<Row> {
    let text = fs::read_to_string(suite.join("manifest.tsv")).expect("read manifest.tsv");
    text.lines()
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .map(|line| {
            let fields: Vec<&str> = line.split('\t').collect();
            assert_eq!(fields.len(), 6, "manifest row must have 6 fields: {line}");
            Row {
                file: fields[0].to_string(),
                action: fields[1].to_string(),
                credential: fields[2].to_string(),
                expect: fields[3].to_string(),
                error_class: fields[4].to_string(),
                error_message: fields[5].to_string(),
            }
        })
        .collect()
}

/// Checks that `e` is the typed variant the manifest's `error_class`
/// column names. The class labels are the manifest's stable vocabulary;
/// extend this mapping when the corpus grows a new class.
fn error_matches(class: &str, e: &CryptoError) -> bool {
    match class {
        "InvalidFormat(BadMagic)" => {
            matches!(e, CryptoError::InvalidFormat(FormatDefect::BadMagic))
        }
        "InvalidFormat(WrongKind)" => {
            matches!(
                e,
                CryptoError::InvalidFormat(FormatDefect::WrongKind { .. })
            )
        }
        "InvalidFormat(MalformedHeader)" => {
            matches!(e, CryptoError::InvalidFormat(FormatDefect::MalformedHeader))
        }
        "InvalidFormat(MalformedPayloadStream)" => matches!(
            e,
            CryptoError::InvalidFormat(FormatDefect::MalformedPayloadStream)
        ),
        "InvalidFormat(OversizedHeader)" => matches!(
            e,
            CryptoError::InvalidFormat(FormatDefect::OversizedHeader { .. })
        ),
        "InvalidFormat(Truncated)" => {
            matches!(e, CryptoError::InvalidFormat(FormatDefect::Truncated))
        }
        "InvalidFormat(MalformedTlv)" => {
            matches!(e, CryptoError::InvalidFormat(FormatDefect::MalformedTlv))
        }
        "InvalidFormat(UnknownCriticalTag)" => matches!(
            e,
            CryptoError::InvalidFormat(FormatDefect::UnknownCriticalTag { .. })
        ),
        "InvalidFormat(MalformedRecipientEntry)" => matches!(
            e,
            CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry)
        ),
        "InvalidFormat(RecipientFlagsReserved)" => matches!(
            e,
            CryptoError::InvalidFormat(FormatDefect::RecipientFlagsReserved)
        ),
        "InvalidFormat(MalformedPublicKey)" => matches!(
            e,
            CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)
        ),
        "InvalidFormat(NotAKeyFile)" => {
            matches!(e, CryptoError::InvalidFormat(FormatDefect::NotAKeyFile))
        }
        "InvalidFormat(WrongKeyFileType)" => {
            matches!(
                e,
                CryptoError::InvalidFormat(FormatDefect::WrongKeyFileType)
            )
        }
        "InvalidFormat(MalformedPrivateKey)" => matches!(
            e,
            CryptoError::InvalidFormat(FormatDefect::MalformedPrivateKey)
        ),
        "UnsupportedVersion(NewerKey)" => matches!(
            e,
            CryptoError::UnsupportedVersion(UnsupportedVersion::NewerKey { .. })
        ),
        "UnsupportedVersion(NewerPublicKey)" => matches!(
            e,
            CryptoError::UnsupportedVersion(UnsupportedVersion::NewerPublicKey { .. })
        ),
        "KeyFileUnlockFailed" => matches!(e, CryptoError::KeyFileUnlockFailed),
        "MalformedArchive" => matches!(e, CryptoError::MalformedArchive { .. }),
        "InvalidFormat(UnsupportedArchiveVersion)" => matches!(
            e,
            CryptoError::InvalidFormat(FormatDefect::UnsupportedArchiveVersion { .. })
        ),
        "UnsupportedVersion(NewerFile)" => matches!(
            e,
            CryptoError::UnsupportedVersion(UnsupportedVersion::NewerFile { .. })
        ),
        "InvalidKdfParams(MemoryCost)" => matches!(
            e,
            CryptoError::InvalidKdfParams(InvalidKdfParams::MemoryCost(_))
        ),
        "InvalidKdfParams(Parallelism)" => matches!(
            e,
            CryptoError::InvalidKdfParams(InvalidKdfParams::Parallelism(_))
        ),
        "InvalidKdfParams(TimeCost)" => matches!(
            e,
            CryptoError::InvalidKdfParams(InvalidKdfParams::TimeCost(_))
        ),
        "KdfResourceCapExceeded" => matches!(e, CryptoError::KdfResourceCapExceeded { .. }),
        "PayloadTruncated" => matches!(e, CryptoError::PayloadTruncated),
        "PayloadTampered" => matches!(e, CryptoError::PayloadTampered),
        "HeaderTampered" => matches!(e, CryptoError::HeaderTampered),
        "HeaderMacFailedAfterUnwrap(x25519)" => matches!(
            e,
            CryptoError::HeaderMacFailedAfterUnwrap { type_name } if type_name == "x25519"
        ),
        "RecipientUnwrapFailed(argon2id)" => matches!(
            e,
            CryptoError::RecipientUnwrapFailed { type_name } if type_name == "argon2id"
        ),
        "RecipientUnwrapFailed(x25519)" => matches!(
            e,
            CryptoError::RecipientUnwrapFailed { type_name } if type_name == "x25519"
        ),
        // The unknown recipient's name is a corpus choice, so read it
        // from the manifest rather than repeating it here.
        _ if class.starts_with("UnknownCriticalRecipient(") && class.ends_with(')') => {
            let expected = &class["UnknownCriticalRecipient(".len()..class.len() - 1];
            matches!(e, CryptoError::UnknownCriticalRecipient { type_name } if type_name == expected)
        }
        "NoSupportedRecipient" => matches!(e, CryptoError::NoSupportedRecipient),
        "IncompatibleRecipients(argon2id)" => matches!(
            e,
            CryptoError::IncompatibleRecipients { type_name, .. } if type_name == "argon2id"
        ),
        "InvalidInput" => matches!(e, CryptoError::InvalidInput(_)),
        other => panic!("manifest names an error class this test does not map: {other}"),
    }
}

/// Runs one manifest row's attempt. `Ok(())` means the action fully
/// succeeded (for `decrypt`, the output landed in `out`).
fn attempt(suite: &Path, row: &Row, out: &Path) -> Result<(), CryptoError> {
    match row.action.as_str() {
        "decrypt" => {
            let decryptor = Decryptor::open(suite.join(&row.file))?;
            match decryptor {
                Decryptor::Passphrase(d) => {
                    let literal = row
                        .credential
                        .strip_prefix("passphrase:")
                        .unwrap_or_else(|| {
                            panic!(
                                "{}: passphrase file needs a passphrase credential, got `{}`",
                                row.file, row.credential
                            )
                        });
                    d.decrypt(SecretString::from(literal.to_string()), out, |_| {})?;
                }
                Decryptor::PrivateKey(d) => {
                    let rest = row
                        .credential
                        .strip_prefix("private-key:")
                        .unwrap_or_else(|| {
                            panic!(
                                "{}: recipient file needs a private-key credential, got `{}`",
                                row.file, row.credential
                            )
                        });
                    let (key_path, unlock) = rest
                        .split_once(",unlock=")
                        .expect("private-key credential carries an unlock passphrase");
                    d.decrypt(
                        PrivateKey::from_key_file(suite.join(key_path)),
                        SecretString::from(unlock.to_string()),
                        out,
                        |_| {},
                    )?;
                }
                other => panic!("{}: unexpected decryptor variant {other:?}", row.file),
            }
            Ok(())
        }
        "read-public-key" => PublicKey::from_key_file(suite.join(&row.file)).validate(),
        "validate-private-key" => validate_private_key_file(suite.join(&row.file)),
        other => panic!("{}: unknown manifest action `{other}`", row.file),
    }
}

/// Replays every manifest row and collects mismatches, so one run
/// reports every divergence instead of stopping at the first.
#[test]
fn suite_manifest_holds() {
    let suite = suite_dir();
    let rows = parse_manifest(&suite);
    assert!(!rows.is_empty(), "manifest.tsv has no rows");
    let expected_plaintext = fs::read(suite.join("plaintext.txt")).expect("read plaintext.txt");

    let mut failures = Vec::new();
    for (index, row) in rows.iter().enumerate() {
        // Per-process subtree, so a concurrent `cargo test` invocation
        // of this binary cannot delete files this run is using.
        let out = ferrocrypt_test_support::per_process_workspace(TEST_WORKSPACE)
            .join(format!("row-{index}"));
        if out.exists() {
            fs::remove_dir_all(&out).expect("clean row output dir");
        }
        fs::create_dir_all(&out).expect("create row output dir");

        let outcome = attempt(&suite, row, &out);
        match (row.expect.as_str(), outcome) {
            ("ok", Ok(())) => match row.action.as_str() {
                "decrypt" => {
                    let produced = fs::read(out.join("plaintext.txt"));
                    match produced {
                        Ok(bytes) if bytes == expected_plaintext => {}
                        Ok(_) => {
                            failures.push(format!("{}: decrypted plaintext differs", row.file));
                        }
                        Err(e) => failures.push(format!(
                            "{}: decrypt succeeded but plaintext.txt is missing: {e}",
                            row.file
                        )),
                    }
                }
                "read-public-key" | "validate-private-key" => {}
                other => panic!("{}: unknown manifest action `{other}`", row.file),
            },
            ("ok", Err(e)) => {
                failures.push(format!("{}: expected success, got `{e}` ({e:?})", row.file));
            }
            ("error", Err(e)) => {
                if !error_matches(&row.error_class, &e) {
                    failures.push(format!(
                        "{}: expected class {}, got {e:?}",
                        row.file, row.error_class
                    ));
                }
                if e.to_string() != row.error_message {
                    failures.push(format!(
                        "{}: expected message `{}`, got `{e}`",
                        row.file, row.error_message
                    ));
                }
            }
            ("error", Ok(())) => {
                failures.push(format!(
                    "{}: expected {} to fail, but it succeeded",
                    row.file, row.error_class
                ));
            }
            (other, _) => panic!("{}: unknown expect value `{other}`", row.file),
        }
    }

    assert!(
        failures.is_empty(),
        "suite corpus diverged:\n{}",
        failures.join("\n")
    );
}

/// FORMAT.md §3.7 step 8: a native recipient body of the wrong length
/// is rejected by [`Decryptor::open`] itself. No credential can be
/// supplied at that point, so the expensive private-key unlock (and its
/// `UnlockingPrivateKey` progress event) is unreachable for these
/// fixtures — the regression this test pins.
#[test]
fn invalid_length_fixtures_reject_at_open_before_any_credential() {
    let suite = suite_dir();
    for fixture in [
        "cases/x25519-invalid-length.fcr",
        "cases/x25519-invalid-length-second-slot.fcr",
        "cases/argon2id-invalid-length.fcr",
    ] {
        match Decryptor::open(suite.join(fixture)) {
            Err(CryptoError::InvalidFormat(FormatDefect::MalformedRecipientEntry)) => {}
            other => panic!("{fixture}: expected MalformedRecipientEntry at open, got {other:?}"),
        }
    }
}

/// Every committed case or key file must be exercised by at least one
/// manifest row, either as the row's action target or as its private-key
/// credential. A fixture without a row is dead weight, and a row that
/// references a missing fixture is a broken promise.
#[test]
fn manifest_covers_every_fixture_file() {
    let suite = suite_dir();
    let rows = parse_manifest(&suite);

    let mut referenced: std::collections::HashSet<String> =
        rows.iter().map(|row| row.file.clone()).collect();
    for row in &rows {
        if let Some(rest) = row.credential.strip_prefix("private-key:") {
            let (key_path, _) = rest
                .split_once(",unlock=")
                .expect("private-key credential carries an unlock passphrase");
            referenced.insert(key_path.to_string());
        }
    }

    for name in &referenced {
        assert!(
            suite.join(name).is_file(),
            "manifest references a missing fixture: {name}"
        );
    }

    for directory in ["cases", "keys"] {
        for entry in fs::read_dir(suite.join(directory)).expect("read fixture directory") {
            let entry = entry.expect("fixture directory entry");
            let file_name = entry.file_name();
            let name = file_name.to_string_lossy();
            // Skip filesystem litter such as .DS_Store; it is not corpus content.
            if name.starts_with('.') {
                continue;
            }
            assert!(
                entry.file_type().expect("fixture file type").is_file(),
                "fixture directory contains a non-file entry: {}",
                entry.path().display()
            );
            let name = format!("{directory}/{name}");
            assert!(
                referenced.contains(&name),
                "committed fixture file has no manifest row: {name}"
            );
        }
    }
}