Skip to content

Latest commit

 

History

History
1268 lines (931 loc) · 64.3 KB

File metadata and controls

1268 lines (931 loc) · 64.3 KB

ferrocrypt-lib Code Structure

Status: Canonical structural reference
Scope: ferrocrypt-lib/src/ public API, module layout, security boundaries, and ownership of format, cryptographic, archive, key, and filesystem responsibilities.


Table of contents

  1. Architecture overview
  2. Source layout
  3. Top-level modules
  4. crypto/
  5. recipient/
  6. key/
  7. archive/
  8. fs/
  9. Public API shape
  10. Single sources of truth
  11. Dependency direction
  12. Decryption security ordering
  13. Public error wording
  14. Extension and non-goal boundaries
  15. Architectural invariants

1. Architecture overview

FerroCrypt is organized around a single file-encryption protocol pipeline. The v1 file model is:

one random file_key
one encrypted payload
one or more typed recipient entries that wrap the same file_key

The library is therefore recipient-oriented rather than mode-oriented. Passphrase encryption and public-key encryption are different recipient schemes over the same protocol pipeline, not separate encrypted-file formats or independent orchestration paths.

The architecture has these primary layers:

public API
   ↓
protocol pipeline
   ↓
container + recipient schemes + key formats + archive + filesystem staging
   ↓
format constants + cryptographic primitives

The core structural rules are:

  1. There is one encrypt/decrypt orchestration path. protocol.rs owns the high-level operation flow for both passphrase and public-key encryption.

  2. Recipient schemes are first-class components. Passphrase Argon2id and X25519 public-key support are implemented as native recipient schemes under recipient/native/.

  3. The encrypted file container is separate from cryptographic algorithms. container.rs owns the .fcr container layout around the encrypted header, header MAC, and encrypted payload. It does not implement scheme-specific cryptography.

  4. Cryptographic primitives have explicit owners. Reusable key types, KDF validation, HKDF, HMAC, AEAD, payload streaming, and TLV parsing live under crypto/.

  5. Archive handling is isolated from encryption logic. FerroCrypt Archive (FCA) wire format, manifest serialization, path-grammar validation, tree-shape validation, archive limits, encode / decode, and platform-specific extraction hardening live under archive/.

  6. Filesystem mechanics are separate from archive semantics. Atomic output, staging, and general path helpers live under fs/.

  7. Unknown recipient entries remain structurally parseable and authenticated. The file format supports external recipient names. The parser preserves and authenticates unknown non-critical recipient entries as opaque data, while public third-party crypto extension traits remain outside the stable API surface.


2. Source layout

ferrocrypt-lib/src/
├── lib.rs
├── api.rs
├── protocol.rs
├── error.rs
├── format.rs
├── container.rs
│
├── crypto/
│   ├── mod.rs
│   ├── keys.rs
│   ├── kdf.rs
│   ├── hkdf.rs
│   ├── mac.rs
│   ├── aead.rs
│   ├── stream.rs
│   └── tlv.rs
│
├── recipient/
│   ├── mod.rs
│   ├── entry.rs
│   ├── name.rs
│   ├── policy.rs
│   └── native/
│       ├── mod.rs
│       ├── argon2id.rs
│       └── x25519.rs
│
├── key/
│   ├── mod.rs
│   ├── public.rs
│   ├── private.rs
│   └── files.rs
│
├── archive/
│   ├── mod.rs
│   ├── format.rs
│   ├── model.rs
│   ├── limits.rs
│   ├── path.rs
│   ├── tree.rs
│   ├── encode.rs
│   ├── decode.rs
│   └── platform.rs
│
├── fs/
│   ├── mod.rs
│   ├── atomic.rs
│   └── paths.rs
│
└── fuzz_exports.rs

Each file represents a stable responsibility boundary. File size is not the organizing principle; ownership, auditability, and prevention of duplicated security logic are the organizing principles.


3. Top-level modules

3.1 lib.rs

lib.rs is the crate façade.

It contains:

  • crate-level documentation;
  • public re-exports;
  • feature gates;
  • public constants re-exported from their owning modules.

It does not contain:

  • cryptographic operations;
  • format parsing;
  • recipient parsing;
  • archive encoding or extraction;
  • direct filesystem writes;
  • end-to-end encryption or decryption orchestration.

Normal public operations enter through api.rs and are executed through protocol.rs.

3.2 api.rs

api.rs owns public ergonomic wrappers and compatibility-facing API functions.

It contains:

  • public Encryptor and Decryptor constructors or re-exports;
  • generate_key_pair;
  • probe_recipient_mode (cheap structural probe; not a security claim);
  • default_encrypted_filename;
  • validate_public_key_file;
  • validate_private_key_file;
  • compatibility shims retained by the public API.

api.rs translates stable public value types into internal protocol inputs. It does not derive keys, compute MACs, parse recipient bodies, extract archives, or emit low-level protocol progress events directly.

3.3 protocol.rs

protocol.rs owns the high-level FerroCrypt operation flow. It is the only module that coordinates all security-sensitive stages of one encryption or decryption operation.

During encryption, protocol.rs coordinates:

  1. file-key generation;
  2. stream nonce generation;
  3. recipient-scheme file-key wrapping;
  4. authenticated-header construction;
  5. archive encoding;
  6. payload stream encryption;
  7. staged output finalization;
  8. progress event emission.

During decryption, protocol.rs coordinates:

  1. container prefix and encrypted-header reading;
  2. structural recipient parsing;
  3. recipient mixing-rule enforcement;
  4. local resource-cap enforcement;
  5. recipient-scheme file-key unwrapping;
  6. header MAC verification with each candidate FileKey;
  7. authenticated TLV validation;
  8. payload key derivation;
  9. payload stream decryption;
  10. archive decoding and safe extraction;
  11. staged output finalization;
  12. progress event emission.

protocol.rs defines the internal recipient-scheme abstraction boundary:

pub(crate) trait RecipientScheme {
    const TYPE_NAME: &'static str;
    const MIXING_RULE: NativeMixingRule;

    fn wrap_file_key(
        &self,
        file_key: &FileKey,
        on_event: &dyn Fn(&ProgressEvent),
    ) -> Result<RecipientBody, CryptoError>;
}

pub(crate) trait DecryptionCredential {
    const TYPE_NAME: &'static str;
    const EXPECTED_MODE: UnauthenticatedRecipientMode;

    fn unwrap_file_key(
        &self,
        body: &[u8],
        on_event: &dyn Fn(&ProgressEvent),
    ) -> Result<Option<FileKey>, CryptoError>;
}

Rules:

  • These traits are pub(crate).
  • They are an internal deduplication and dispatch boundary, not a stable public plugin API.
  • Scheme implementations return or accept recipient body bytes; they do not construct full headers.
  • Recipient schemes do not compute or verify header MACs.
  • A recipient unwrap is successful only after the candidate FileKey verifies the authenticated header MAC.
  • The orchestrator threads a single &dyn Fn(&ProgressEvent) callback into each scheme. Schemes whose KDF step is expensive (Argon2id) emit ProgressEvent::DerivingPassphraseWrapKey from inside wrap / unwrap immediately before the KDF call — that is, after structural validation and resource-cap checks have passed. Schemes whose wrap / unwrap is sub-millisecond (X25519) MUST ignore the callback so cheap operations never lie about a long pause. The private.key Argon2id boundary is owned separately by key::private::open_private_key, which emits ProgressEvent::UnlockingPrivateKey at its own work boundary; protocol::decrypt does NOT emit a DerivingKey-style event from the orchestrator.

3.4 format.rs

format.rs owns byte-level wire constants and fixed structures.

It contains:

  • magic bytes;
  • the .fcr outer file version byte (FCR_FILE_VERSION);
  • the KeypairSuite enum and the single shared support gate (keypair_suite_is_supported), both pub(crate) — internal compatibility machinery whose shape may change across releases. External observers depend on the stable version constants (FCR_FILE_VERSION, PUBLIC_KEY_VERSION, PRIVATE_KEY_VERSION, *_V1_VERSION) and the typed UnsupportedVersion diagnostics. The forward direction (suite → wire byte) is defined here on KeypairSuite::public_key_version / KeypairSuite::private_key_version, both compile-forced exhaustive matches; the reverse direction (wire byte → suite) is also centralised here as keypair_suite_from_public_key_version and keypair_suite_from_private_key_version (both pub(crate)), backed by the parameterised inner helper keypair_suite_from_wire_version_with so adding a new suite is a single match arm covering both artefact domains. The two reverse mappers return a small crate-internal KeypairVersionRejection (Reserved / Older / Newer) that the consumers in key/public.rs and key/private.rs translate into their domain-specific CryptoError variants — encryption-time recipient acceptance and decryption-time private-key acceptance are therefore decided by one predicate and one mapping table and cannot drift (FORMAT.md §11);
  • the writer's logical suite (WRITER_KEYPAIR_SUITE);
  • kind bytes;
  • field sizes;
  • maximum structural sizes;
  • fixed prefix and header parsing;
  • fixed prefix and header serialization;
  • header MAC input definition.

format.rs does not contain:

  • file I/O;
  • archive logic;
  • recipient-specific body parsing;
  • cryptographic key derivation;
  • end-to-end operation flow.

format.rs is the closest Rust representation of the FerroCrypt file format specification. It remains deterministic, small, and directly comparable to the binary format definition.

3.5 container.rs

container.rs owns the .fcr encrypted file container around the encrypted header and encrypted payload.

It contains:

  • HeaderReadLimits (public, #[non_exhaustive], builder methods clamp at the v1 structural maxima);
  • parsed encrypted-header structures;
  • build_encrypted_header;
  • read_encrypted_header;
  • authenticated-header assembly;
  • structural container validation;
  • top-level prefix || header || header_mac || payload reading and writing.

container.rs owns container assembly and parsing. Header MAC computation and verification go through the typed wrappers in format.rs; container.rs does not implement HMAC directly. It does not own Argon2id behavior, X25519 behavior, payload-stream cryptography, archive semantics, public key file formats, or private key file formats.

3.6 error.rs

error.rs owns the library error taxonomy.

Errors remain centralized because they form a coherent diagnostic namespace. Public errors must be precise, stable, and careful not to overstate what cryptographic verification can prove.

Error variants that carry data carry typed structured data, such as FormatDefect, UnsupportedVersion, InvalidKdfParams, the MixingPolicy diagnostic projection (with a structured Custom { compatibility_class } payload for non-shorthand classes), named integer fields for resource caps, and owned type_name strings for per-recipient diagnostics. Consumers can pattern-match on error shapes without substring comparisons.

Diagnostic rules:

  • A passphrase recipient open failure means “wrong passphrase or recipient entry tampered,” not definitely “incorrect passphrase”.
  • A private-key unlock failure means “wrong passphrase or private key file tampered,” not definitely one or the other.
  • An X25519 recipient failure means “no matching credential, wrong key, or recipient entry tampered,” unless a later authenticated step proves a more specific class.
  • A header MAC failure after recovering a candidate FileKey means the recovered key did not authenticate the header. It does not by itself prove whether the credential, recipient body, or header bytes were modified.

Public error names may be compatibility-oriented, but their display text must preserve this ambiguity.

3.7 fuzz_exports.rs

fuzz_exports.rs exposes internal parser and validation entry points needed by fuzz targets.

It is not part of the stable public API. It must not become an alternate implementation path for parsing, validation, cryptography, or archive handling.


4. crypto/

crypto/ owns reusable cryptographic building blocks and typed secrets. It contains primitives and key types that are shared by the protocol, recipient schemes, key formats, and payload stream handling.

crypto/ does not depend on protocol.rs, archive/, or fs/.

4.1 crypto/keys.rs

crypto/keys.rs owns typed encryption keys and file-key derivation.

It contains:

  • FileKey;
  • PayloadKey;
  • HeaderKey;
  • file-key generation;
  • payload subkey derivation;
  • header subkey derivation;
  • zeroization boundaries.

Rules:

  • FileKey, PayloadKey, and HeaderKey are strong newtypes.
  • Constructors are private or pub(crate).
  • Callers borrow key bytes only through narrow methods such as expose().
  • Header MAC code accepts HeaderKey, not raw bytes.
  • Payload stream code accepts PayloadKey, not raw bytes.
  • It must be impossible to pass a payload key to header-MAC code without an explicit type error.

4.2 crypto/kdf.rs

crypto/kdf.rs owns KDF parameter types and validation.

It contains:

  • KdfParams;
  • KdfLimit;
  • Argon2id parameter validation;
  • local resource-cap checks.

Argon2id parameter parsing and validation have exactly one source of truth. Argon2id execution for passphrase-recipient wrapping may call through this module or through recipient/native/argon2id.rs, but resource-cap checks and parameter-validation logic are not duplicated.

4.3 crypto/hkdf.rs

crypto/hkdf.rs owns HKDF-SHA3-256 adapters and non-scheme-specific domain separation.

It contains:

  • HKDF-SHA3-256 helper functions;
  • shared HKDF wrappers;
  • domain-separated labels that are not specific to a recipient scheme.

Recipient-specific HKDF info strings live with their recipient scheme. Header, payload, and private-key derivation labels live with the modules that own those derivations.

4.4 crypto/mac.rs

crypto/mac.rs owns HMAC-SHA3-256 helpers.

It contains:

  • generic HMAC-SHA3-256 computation helpers;
  • generic HMAC-SHA3-256 verification helpers;
  • constant-time MAC comparison where applicable.

The primitives in crypto/mac.rs accept raw byte keys so they remain reusable. Header MAC type safety is enforced by the typed compute_header_mac and verify_header_mac wrappers in format.rs, which accept &HeaderKey and call these generic primitives.

4.5 crypto/aead.rs

crypto/aead.rs owns XChaCha20-Poly1305 helpers and nonce utilities.

It contains:

  • AEAD seal helpers;
  • AEAD open helpers;
  • nonce generation utilities;
  • nonce parsing and validation helpers where applicable.

Common AEAD behavior is not duplicated in Argon2id recipients, X25519 recipients, private-key handling, or payload-stream code.

4.6 crypto/stream.rs

crypto/stream.rs owns STREAM-BE32 payload encryption and decryption.

It contains:

  • payload chunk-size rules;
  • counter rules;
  • final-flag behavior;
  • payload encryptor reader/writer adapters;
  • payload decryptor reader/writer adapters;
  • trailing-data detection;
  • truncation detection.

Payload streaming uses PayloadKey. It does not know about recipient schemes, key files, archive paths, or output finalization.

4.7 crypto/tlv.rs

crypto/tlv.rs owns the shared TLV grammar for every FerroCrypt extension region: .fcr header ext_bytes, private.key ext_bytes, FCA archive_ext, and FCA per-entry entry_ext.

The module exposes:

  • scan_tlv_region(bytes, max_region_len, max_value_len) -> Vec<RawTlv> — the parsing primitive. Validates structural framing (each entry header fits, declared len fits in the region and <= max_value_len), strict ascending tag order, reserved-tag rejection. Returns parsed entries with cached TlvClass. Does not enforce a critical-tag policy.
  • reject_unknown_critical(tlvs) -> Result<()> — the v1.0 policy wrapper. Rejects any TlvClass::Critical entry as UnknownCriticalTag because v1.0 defines no known critical tags in any region. Future versions that define known criticals will iterate the scanned TLVs against a registry instead.
  • validate_no_known_critical(bytes, max_region_len, max_value_len) -> Result<()> — the v1.0 single-call helper. Combines scan_tlv_region and reject_unknown_critical for callers that don't need the parsed entries. Used by every v1.0 caller (FCR header, private.key, FCA archive_ext, FCA entry_ext).
  • classify_tlv_tag(tag) -> Result<TlvClass> — pure tag classification, rejects the two reserved values.
  • validate_tlv(ext_bytes) — public convenience function. Calls validate_no_known_critical with EXT_LEN_MAX for both region and value caps. Used by .fcr header and private.key callers.

Rules:

  • Each containing region (FCR header, private-key, FCA archive-level, FCA per-entry) has its own tag namespace; the structural rules are shared.
  • TLV validation occurs only after the appropriate authentication succeeds (header MAC for .fcr, AEAD-AAD for private.key, outer .fcr payload AEAD for FCA).
  • Unknown critical TLVs reject after authentication.
  • Code must not act on unauthenticated TLV metadata.

5. recipient/

recipient/ owns generic recipient-entry handling, recipient type-name validation, recipient mixing policy, and native recipient-scheme implementations.

Recipient entries are authenticated header data. Unsupported recipient entries remain opaque unless and until a supported scheme claims and parses their body.

5.1 recipient/entry.rs

recipient/entry.rs owns the generic v1 recipient entry framing:

type_name_len:u16
recipient_flags:u16
body_len:u32
type_name
body

It contains:

  • RecipientEntry for parsed entries;
  • RecipientBody for scheme body bytes plus type name;
  • canonical recipient-entry serialization. Production code uses RecipientEntry::to_bytes_checked, which runs every rule parse_one enforces (validate_type_name_grammar, reserved-flag bits, body.len() <= BODY_LEN_MAX) before emitting bytes. container::build_encrypted_header routes through it so an entry the matching reader would reject cannot be serialised. The infallible to_bytes is gated #[cfg(test)] and used only by tests that intentionally produce out-of-spec bytes.
  • strict framing parsing;
  • unknown-body opacity.

Rules:

  • Recipient schemes produce RecipientBody, not full header entries.
  • Only recipient/entry.rs constructs or serializes RecipientEntry framing.
  • Generic recipient-entry code never parses, normalizes, or interprets unsupported recipient bodies.

5.2 recipient/name.rs

recipient/name.rs owns recipient type-name validation. The §3.3 byte-level grammar and the §3.3.1 namespace policy are exposed as two distinct validators so that wire-format parsing stays forward-compatible while plugin-supplied names are held to the stricter policy:

  • validate_type_name_grammar(name) — the §3.3 byte-level grammar (1..=255 bytes, lowercase ASCII, allowed character set, no leading/trailing punctuation, no ..///). All in-tree wire-format readers and writers (recipient/entry.rs, key/public.rs, key/private.rs) call this and only this. The grammar deliberately accepts unknown short native names so a future FerroCrypt version can introduce a new native recipient type without breaking forward-compatible parsing in older readers.
  • is_reserved_native_name(name) — internal building block: returns true when name has the shape of a reserved FerroCrypt native type (no /, plus a reserved native prefix in ["mlkem", "pq", "hpke", "tag", "xwing", "kem"] or the reserved tag suffix per FORMAT.md §3.3.1).
  • validate_external_type_name(name) — runs the grammar check, then enforces the §3.3.1 namespace policy: the name MUST contain / and MUST NOT impersonate a reserved native shape. v1 ships no public plugin / third-party recipient registration surface, so this validator currently has no in-tree caller; it exists so the §3.3.1 policy is enforceable the moment such a surface is added.

is_reserved_native_name and validate_external_type_name are pub(crate) until a plugin-facing API needs them; only validate_type_name_grammar and TYPE_NAME_MAX_LEN are re-exported through recipient::mod.

5.3 recipient/policy.rs

recipient/policy.rs owns recipient mixing-rule enforcement, the public diagnostic projection, and native-scheme classification.

It contains two layers — an internal enforcement type and a public diagnostic projection:

// Internal enforcement representation. `pub(crate)`; never appears on
// the wire and is not part of the stable public API. The two variants
// are structurally distinct so cardinality and class-equality
// enforcement modes are mutually exclusive at the type level — a
// `SingleEntry` rule has no class field, so two single-entry rules
// cannot accidentally compare as compatible.
pub(crate) enum NativeMixingRule {
    SingleEntry,
    Class { name: &'static str },
}

// Public diagnostic projection of `NativeMixingRule`, surfaced via
// `CryptoError::IncompatibleRecipients`. New compatibility classes
// surface through `Custom` without adding fixed enum variants.
#[non_exhaustive]
pub enum MixingPolicy {
    Exclusive,
    PublicKeyMixable,
    Custom { compatibility_class: &'static str },
}

The #[non_exhaustive] attribute on MixingPolicy lets future variants be added without a breaking change. New native compatibility classes surface as MixingPolicy::Custom { compatibility_class: "<class>" } and do not require new fixed variants.

Responsibilities:

  • defining the internal NativeMixingRule type and its named constructors (exclusive, public_key_mixable, post_quantum);
  • defining the public MixingPolicy diagnostic projection;
  • enforcing mixing rules before expensive operations (cardinality bit + compatibility-class equality, both before any KDF or private-key work);
  • mapping type names to supported native scheme metadata;
  • declaring each native type's UnauthenticatedRecipientMode via NativeRecipientType::recipient_mode so classify_recipient_mode is registry-driven (no hard-coded argon2id / x25519 switches in the classifier);
  • classifying parsed headers as passphrase, public-key, unsupported, or mixed;
  • preserving unknown non-critical entries as opaque authenticated data.

Rules:

  • argon2id is NativeMixingRule::SingleEntry (must appear alone; no compatibility class — cardinality is the only constraint).
  • x25519 is NativeMixingRule::Class { name: PUBLIC_KEY_CLASS } (no cardinality constraint, mixes only with other entries declaring the same class).
  • Native PQ recipients (e.g. the upcoming x25519-mlkem768) declare NativeMixingRule::Class { name: POST_QUANTUM_CLASS } and project to MixingPolicy::Custom { compatibility_class: "postquantum" }.
  • Unknown non-critical recipients are ignored for class comparison but still count wherever the format says they count, including exclusive passphrase recipient checks.
  • Mixing rules are enforced before expensive KDF or private-key operations.
  • Native-scheme classification and mixing enforcement are kept together because every native scheme addition requires coordinated changes to both (mixing_rule + recipient_mode arms on NativeRecipientType).

A separate recipient registry module is introduced only when a reviewed public plugin-registration API exists.

5.4 recipient/native/argon2id.rs

recipient/native/argon2id.rs owns the native passphrase recipient scheme.

It contains:

  • Argon2id recipient body layout;
  • Argon2id recipient body length validation;
  • KDF invocation for passphrase recipient wrapping and opening;
  • wrap-key derivation;
  • file-key seal/open logic;
  • scheme-specific validation;
  • emission of ProgressEvent::DerivingPassphraseWrapKey at the actual Argon2id call boundary (after structural validation and KdfLimit resource-cap checks have passed, immediately before derive_passphrase_wrap_key);
  • RecipientScheme implementation;
  • DecryptionCredential implementation for a passphrase credential;
  • tests and vectors for the native passphrase scheme.

It does not:

  • build full .fcr headers;
  • compute header MACs;
  • parse TLVs;
  • write files;
  • emit any other progress event (no Encrypting / Decrypting / UnlockingPrivateKey / GeneratingKeyPair from this module);
  • perform archive encoding or extraction.

5.5 recipient/native/x25519.rs

recipient/native/x25519.rs owns the native X25519 public-key scheme.

It contains:

  • X25519 recipient body layout;
  • X25519 recipient body length validation;
  • ephemeral key handling;
  • all-zero shared-secret rejection (file-fatal InvalidFormat(MalformedRecipientEntry) on the decrypt side per FORMAT.md §2.4 / §4.2; the credential adapter propagates it instead of collapsing to the slot-skip channel reserved for AEAD failures);
  • wrap-key derivation;
  • file-key seal/open logic;
  • X25519 key-pair generation logic;
  • public-key recipient conversion for X25519;
  • private-key unlock glue for X25519 (open_x25519_private_key), which threads &dyn Fn(&ProgressEvent) into key::private::open_private_key so the UnlockingPrivateKey event fires at the actual Argon2id boundary, not at this wrapper;
  • RecipientScheme implementation (ignores the progress callback — X25519 wrap is sub-millisecond);
  • DecryptionCredential implementation (ignores the progress callback — X25519 unwrap is sub-millisecond, and the expensive private.key Argon2id ran before the slot loop in open_x25519_private_key);
  • tests and vectors for the native X25519 scheme.

It does not own the generic private.key binary layout. Generic private-key file structure belongs to key/private.rs.


6. key/

key/ owns public and private key file formats and filesystem-level key helpers.

The canonical public value types are:

pub struct PublicKey  { /* opaque typed public key */ }
pub struct PrivateKey { /* opaque typed private key */ }

These names follow Rust cryptographic convention.

6.1 key/public.rs

key/public.rs owns the public recipient key text format.

It contains:

  • Bech32 recipient string encoding;
  • Bech32 recipient string decoding;
  • HRP validation;
  • public-key wire-version-byte (PUBLIC_KEY_VERSION, PUBLIC_KEY_V1_VERSION) and the public-flavoured wire-version-to-suite translation, which is now a thin map_err wrapper over the centralised keypair_suite_from_public_key_version in format.rs — this layer picks the public-key error variants (MalformedPublicKey, OlderPublicKey, NewerPublicKey) and routes the suite through the shared support gate in format.rs (FORMAT.md §7);
  • the writer's current logical version (PUBLIC_KEY_VERSION, derived from WRITER_KEYPAIR_SUITE);
  • internal SHA3-256 checksum handling;
  • canonical lowercase enforcement;
  • public recipient fingerprinting;
  • public.key text validation;
  • construction and serialization support for PublicKey.

PublicKey supports:

  • loading from a key file;
  • parsing from a recipient string;
  • construction from bytes where supported by the public API;
  • fingerprint generation;
  • canonical recipient string output.

Every PublicKey ingress path stores or recovers the [KeypairSuite] (crate-internal) the key belongs to:

  • from_key_file and from_recipient_string recover the suite from the wire-version byte during decode and store it on the value;
  • from_bytes carries no suite marker on its input and tags the value with WRITER_KEYPAIR_SUITE, so raw bytes cannot resurrect a public key from a non-writer suite.

PublicKey::to_recipient_string re-encodes using the suite the value was constructed with, not the current writer suite, so a recipient string round-trips byte-identically as long as its suite is still supported by this build.

6.2 key/private.rs

key/private.rs owns the private key file format.

It contains:

  • private.key binary layout;
  • the private-key wire-version constants (PRIVATE_KEY_VERSION derived from WRITER_KEYPAIR_SUITE; PRIVATE_KEY_V1_VERSION derived from KeypairSuite::V1) and the private-flavoured wire-version-to-suite translation, which is now a thin map_err wrapper over the centralised keypair_suite_from_private_key_version in format.rs — this layer picks the private-key error variants (MalformedPrivateKey, OlderKey, NewerKey) and routes the suite through the shared support gate in format.rs (FORMAT.md §8);
  • cleartext private-key header parsing;
  • passphrase-wrapped secret encryption;
  • passphrase-wrapped secret decryption;
  • writer-side and reader-side ext_bytes TLV validation. seal_private_key runs validate_tlv on ext_bytes after the structural length cap and before AEAD work, so a sealed private.key is one the matching reader will accept. open_private_key runs the same check after open_with_aad succeeds, so the validator always operates on authenticated bytes. Recipient-specific adapters (e.g. recipient/native/x25519) no longer re-validate;
  • generic typed secret material returned to recipient schemes;
  • construction and loading support for PrivateKey;
  • emission of ProgressEvent::UnlockingPrivateKey at the actual Argon2id call boundary inside open_private_key (after structural header parsing, the caller's KdfLimit resource-cap check, the wrapped-secret-length cap, the total-length check, and type-name grammar validation have all passed). A structurally malformed key file or one that exceeds either cap is rejected with no event emitted. seal_private_key is silent: keygen owns its own outer GeneratingKeyPair event.

It does not contain X25519-specific recipient policy. The X25519 recipient module verifies that decrypted secret material corresponds to X25519 public material.

6.3 key/files.rs

key/files.rs owns filesystem-level key helpers.

It contains:

  • default filenames public.key and private.key;
  • key-file classification;
  • key-file read wrappers;
  • key-file write wrappers;
  • staging for generated key files.

Key-file staging uses filesystem helpers from fs/ and does not duplicate atomic-output behavior.


7. archive/

archive/ owns the FerroCrypt Archive (FCA) v1 wire format and directory/file payload semantics. The byte-level FCA spec lives in ferrocrypt-lib/FORMAT.md §9.

Archive handling is security-critical. Wire-format constants, model types, resource limits, path-grammar validation, tree-shape validation, encoding, decoding, and platform-specific extraction hardening are separated so each review surface is explicit.

7.1 archive/format.rs

archive/format.rs owns the FCA wire format.

It contains:

  • wire-format constants (FCA_MAGIC = b"FCA\0", FCA_VERSION = 0x01, FCA_HEADER_SIZE = 27 (includes the archive_ext_len field), FCA_ENTRY_FIXED_SIZE = 18 (includes the per-entry entry_ext_len field), KIND_FILE = 0x01, KIND_DIR = 0x02, PERMISSION_BITS_MASK = 0o777);
  • big-endian integer helpers used by both header and manifest serialization;
  • header parse/build (parse_fca_header / write_fca_header);
  • manifest serialize/parse (checked_manifest_len / serialize_manifest / parse_manifest_bytes);
  • copy_exact_n, the shared exact-size byte copier used by both encode (source file → encrypted stream) and decode (encrypted stream → output file).

checked_manifest_len runs BEFORE allocation: an over-cap manifest is rejected without growing a Vec first. parse_manifest_bytes calls validate_fca_path and validate_manifest_tree so a successfully-parsed Manifest is fully validated.

serialize_manifest runs the writer-side validate_manifest_for_write gate before emitting any bytes — validate_fca_path per entry, the Directory entries have size == 0 invariant, the manifest.total_file_bytes equals checked_add sum of File entry sizes invariant (mirroring the reader's "Archive total-bytes mismatch" rejection), and the same validate_manifest_tree the reader runs. A Manifest the matching reader would reject cannot leak out as bytes. Adversarial reader-side tests use the test-only serialize_manifest_unchecked to construct synthetic FCA bytes (multi-root, missing parent, etc.).

7.2 archive/model.rs

archive/model.rs owns the FCA model types.

It contains:

  • FcaHeader — parsed header summary (entry_count, archive_ext_len, manifest_len, total_file_bytes);
  • ArchiveEntryKindFile / Directory enum;
  • ArchiveEntrypath_utf8, mode, size, opaque entry_ext: Vec<u8> carrying the per-entry TLV region (empty for v1 writers, populated by the parser for v1.x readers), plus a writer-only source_path: Option<PathBuf> set by the metadata pass so the content pass can reopen no-follow;
  • Manifestentries, total_file_bytes, root_name, root_is_file.

Readers leave source_path as None; writers set it.

7.3 archive/limits.rs

archive/limits.rs owns ArchiveLimits and archive resource-cap helpers.

ArchiveLimits covers:

  • maximum entry count;
  • maximum total regular-file content (logical sum);
  • maximum path depth;
  • maximum per-path UTF-8 byte length (capped by u16::MAX because the on-disk path_len field is u16);
  • maximum serialized manifest byte length (includes per-entry TLV regions);
  • maximum archive_ext byte length (default 64 KiB);
  • maximum entry_ext byte length per entry (default 64 KiB);
  • maximum cumulative per-entry TLV bytes (default 64 MiB);
  • maximum single TLV value byte length (default 16 MiB).

Cap helpers (enforce_per_entry_caps, enforce_total_bytes_cap) are shared by encrypt-side preflight and decrypt-side enforcement. Encrypt-side preflight and decrypt-side enforcement must agree: the encrypt side must not produce archives that the decrypt side rejects under default limits.

7.4 archive/path.rs

archive/path.rs owns the FCA path grammar — the single shared writer/reader validator (the spec §19.3 symmetry guarantee).

It rejects:

  • empty path;
  • absolute path / leading /;
  • trailing /;
  • repeated /;
  • NUL byte;
  • backslash;
  • . and .. components, and any host Component that is not Normal;
  • ASCII control bytes (0x00..=0x1F);
  • Windows-reserved characters (<, >, :, ", |, ?, *);
  • trailing dot or trailing space in any component;
  • Windows-reserved device names (CON, PRN, AUX, NUL, CLOCK$, COM1..9, LPT1..9), including in extension stems (CON.txt, LPT9.bin), under ASCII-case-insensitive comparison;
  • byte-length cap exceeded;
  • depth cap exceeded.

ascii_case_collision_key lowercases ASCII A–Z (not locale-sensitive) for tree-side duplicate detection in tree.rs.

This is one of the most security-sensitive modules. It must be heavily tested, including adversarial path cases.

7.5 archive/tree.rs

archive/tree.rs owns FCA manifest tree-shape validation.

validate_manifest_tree enforces:

  • non-empty entry list;
  • single top-level root;
  • if root is a file, exactly one entry;
  • if root is a directory, the root entry MUST be present and every non-root entry's parent MUST be present as a directory entry;
  • no entry under a file path;
  • no exact-duplicate paths;
  • no ASCII-case-insensitive duplicate paths;
  • declared total_file_bytes within max_total_plaintext_bytes.

Order-independent (HashMap-based parent lookup), so non-canonical manifest orders satisfying the tree shape are accepted per spec §10.

7.6 archive/encode.rs

archive/encode.rs owns the FCA writer: source-tree traversal (metadata pass) and content-streaming pass.

It rejects:

  • input symlinks (live or dangling);
  • inputs that are not regular files or directories;
  • symlinks, FIFOs, sockets, devices, Windows reparse points encountered during directory traversal;
  • paths violating the FCA grammar (validate_fca_path);
  • trees that exceed ArchiveLimits caps;
  • source files whose size or type changes between the metadata pass and the content pass.

The writer is two-pass:

  1. Metadata pass — recursive fs::read_dir walk that builds a Manifest with FCA-canonical paths, modes, sizes, and source paths. Caps (entry count, total bytes, depth, path-bytes, manifest-size) apply progressively. The result is sorted by (component_count, path_utf8) per spec §10 for deterministic output.
  2. Content pass — for each file entry in canonical manifest order, reopens the source file with O_NOFOLLOW (Unix) or symlink_metadata + File::open (non-Unix), refreshes metadata from the open handle, requires the source is still a regular file with len() == manifest size, and streams exactly the declared size via copy_exact_n. Source mutation between passes is handled per spec §15.5.

Hardlinks are archived as independent regular-file contents (no link identity is stored). Setuid/setgid/sticky bits are stripped on write via PERMISSION_BITS_MASK.

7.7 archive/decode.rs

archive/decode.rs owns the FCA reader: header + manifest parse with full validation, then content extraction via the hardened cap-std platform backend.

The reader pipeline matches FORMAT.md §9.11. Steps 1–8 MUST complete before any filesystem output:

  1. parse and validate the FCA fixed header;
  2. read exactly archive_ext_len bytes;
  3. validate the archive-level TLV region;
  4. read exactly manifest_len bytes;
  5. parse the manifest, including each per-entry extension region;
  6. validate every per-entry TLV region;
  7. validate the complete manifest (entry count, total bytes, paths, duplicates, tree shape, parents present, resource caps, critical extension support);
  8. pre-check the final output name with symlink_metadata (so a dangling symlink at the final name counts as occupied);
  9. open output_dir as a cap-std directory handle;
  10. reject pre-existing .incomplete output at first create;
  11. create {root}.incomplete (file or directory);
  12. stream file contents in manifest order via copy_exact_n;
  13. verify archive EOF (no trailing bytes);
  14. apply descendant directory modes deepest-first;
  15. promote {root}.incomplete to {root} via no-clobber rename;
  16. apply the root entry's stored mode AFTER promotion. For directory roots this is macOS compatibility (a non-search-permitted root mode would block the rename); for regular-file roots this prevents a permissive manifest mode (e.g. 0o644) from being briefly visible at the staged or final name while the file still holds plaintext;
  17. return the final output path.

unarchive accepts an [IncompleteOutputPolicy] from the caller. The default ([IncompleteOutputPolicy::DeleteOnError]) best-effort removes the staged .incomplete working tree on any decrypt failure; [IncompleteOutputPolicy::RetainOnError] preserves it. Cleanup tracks only roots THIS run created — mkdir_strict / create_file_at push created_incomplete_roots only when they actually created the working name, so a pre-existing .incomplete from a prior failed run rejects with Previous .incomplete exists and is preserved across the retry. Cleanup helper cleanup_incomplete_via_handle routes by symlink_metadata on the SAME cap_std::fs::Dir handle opened for extraction (symlinks removed as symlinks; directories via remove_dir_all, which since Rust 1.71 is TOCTOU-hardened on Unix and does not follow descendant symlinks). Anchoring to the capability handle rather than re-resolving output_dir by path means a path swap of output_dir between failed extraction and cleanup cannot redirect remove_* to a different directory. All I/O errors are swallowed so the original CryptoError is the value the caller sees.

7.8 archive/platform.rs

archive/platform.rs owns the unified capability-based extraction backend used on every supported OS (Linux / macOS / Windows). Built on cap-std plus cap-fs-ext.

Invariant:

Any symlink — or, on Windows, any NTFS reparse point including junctions and mount points — in an extraction path is an extraction error.

It contains:

  • open_anchor — bootstraps the trusted cap_std::fs::Dir for the user-supplied output_dir; the caller's chosen path IS the trust boundary so no no-follow check applies to it;
  • ensure_dir, mkdir_strict, walk_to_parent, open_dir_at_rel — every directory open routed through cap_fs_ext::DirExt::open_dir_nofollow;
  • finalize_dir_open — Windows-only FILE_ATTRIBUTE_REPARSE_POINT post-check called after every successful directory open, so junctions / mount points fail closed (cap-fs-ext alone refuses entries where is_symlink() is true, but is_symlink() returns false for junctions — the bitmask post-check is what catches them);
  • create_file_atOpenOptions::create_new(true) plus OpenOptionsFollowExt::follow(FollowSymlinks::No) for atomic O_EXCL-style create that refuses every leaf symlink, dangling or live;
  • chmod_file_handle, chmod_dir_handle — handle-based permission application; never path-based, so a substituted symlink between extract and chmod cannot redirect the operation. Special bits are stripped via super::PERMISSION_BITS_MASK;
  • INITIAL_FILE_CREATE_MODE — restrictive 0o600 initial mode applied at create time on Unix. Descendant files are chmod'd to the manifest mode after the payload is written (inside the 0o700 staged root). Single-file roots stay at 0o600 throughout staging and across the rename, with the manifest mode applied post-rename via decode::apply_root_file_mode so a wider final mode is never briefly visible. Effective on Unix only; ignored on Windows.

Path validation and filesystem writes remain separate so race-hardening logic is auditable.

The backend uses cap-std and cap-fs-ext from the Bytecode Alliance — the same crates that back wasmtime's WASI sandbox. ferrocrypt itself contains no unsafe; all direct syscall surface lives in those audited dependencies. cap-std layers on rustix (Linux/macOS) and windows-sys (Windows) internally.


8. fs/

fs/ owns local filesystem mechanics unrelated to archive-payload semantics.

Archive-specific path rules live in archive/path.rs; general output-path and staging mechanics live in fs/.

8.1 fs/atomic.rs

fs/atomic.rs owns atomic output behavior.

It contains:

  • temporary output name generation;
  • no-clobber finalization, split by root shape:
    • file roots (encryption output, key generation, single-file decrypt promotion) go through tempfile::*::persist_noclobber — atomic no-replace on every supported platform, Windows included;
    • directory roots (decrypt promotion when the archive root is a directory) go through rename_no_clobber — atomic via rustix::renameat_with(..., RenameFlags::NOREPLACE) on Linux and macOS, best-effort symlink_metadata + std::fs::rename on Windows because no safe atomic no-replace directory rename is available there under #![forbid(unsafe_code)];
  • same-directory staging;
  • cleanup on encryption failure;
  • .incomplete behavior on decryption failure.

Atomic output is a library guarantee. It is not a CLI-only concern.

8.2 fs/paths.rs

fs/paths.rs owns general path helpers.

It contains:

  • encrypted filename derivation;
  • base-name extraction;
  • user-path error mapping;
  • occupied-path / dangling-symlink rejection (path_occupied, reject_occupied) — lstat-based "is anything here?" preflight used by encrypt and keygen output prechecks so a stale symlink rejects in milliseconds instead of after Argon2id;
  • bounded file reads (read_file_capped) — Read::take(cap + 1) then over-cap rejection, used by key/public.rs::read_public_key, recipient/native/x25519.rs::open_x25519_private_key, and api::validate_private_key_file to refuse multi-gigabyte attacker-controlled key files before any allocation;
  • general path normalization required outside archive semantics.

It does not enforce FCA archive path rules. Archive path rules belong only to archive/path.rs.


9. Public API shape

The public API is value-oriented. Callers construct typed encryptors, decryptors, keys, and identities rather than selecting independent mode-specific orchestration functions.

9.1 Encryption

pub struct Encryptor { /* opaque */ }

impl Encryptor {
    pub fn with_passphrase(passphrase: SecretString) -> Self;

    pub fn with_public_key(recipient: PublicKey) -> Self;

    pub fn with_public_keys(
        recipients: impl IntoIterator<Item = PublicKey>,
    ) -> Result<Self, CryptoError>;

    pub fn save_as(self, path: impl AsRef<Path>) -> Self;

    pub fn archive_limits(self, limits: ArchiveLimits) -> Self;

    pub fn header_read_limits(self, limits: HeaderReadLimits) -> Self;

    pub fn kdf_params(self, params: KdfParams) -> Self;

    pub fn kdf_limit(self, limit: KdfLimit) -> Self;

    pub fn write(
        self,
        input: impl AsRef<Path>,
        output_dir: impl AsRef<Path>,
        on_event: impl Fn(&ProgressEvent),
    ) -> Result<EncryptOutcome, CryptoError>;
}

Rules:

  • with_passphrase creates exactly one argon2id recipient.
  • with_public_key is a convenience wrapper around with_public_keys for one public recipient.
  • with_public_keys supports the multi-recipient file format directly.
  • Recipient mixing is checked during construction.
  • Empty recipient lists reject immediately.
  • The API remains path-based because FerroCrypt security guarantees depend on archive preflight, streaming encryption, staging, and atomic finalization.
  • Writer caps mirror reader defaults. A default-configured Encryptor produces .fcr files a default-configured Decryptor can read. write enforces this via the same helpers the reader uses (single source of truth per rule — see "Centralized cap enforcement" below):
    • api::preflight_header_write_limits checks all three axes of HeaderReadLimits against the exact header the writer will emit: recipient_count, per-entry body_len (canonical native value from NativeRecipientType::body_len()), and the computed header_len. Tightening any axis below the writer's natural output rejects with the corresponding typed *CapExceeded variant.
    • For the passphrase path, KdfParams::validate_for_write runs the same validate_structural the reader runs (lanes, time_cost, mem_cost against v1 absolute bounds + the Argon2 mem_cost ≥ ARGON2_MIN_MEM_COST_PER_LANE × lanes floor) and then enforce_limit against KdfLimit. Above-structural params reject with InvalidKdfParams::*; above-resource-cap reject with KdfResourceCapExceeded. The same rule chain applies to KeyPairGenerator::write for the passphrase that seals private.key.
    • The X25519 path never runs Argon2id during encrypt, so kdf_limit has no effect on with_public_key / with_public_keys flows.
    • To go above any default, the caller raises both sides explicitly: Encryptor::header_read_limits / Encryptor::kdf_limit / KeyPairGenerator::kdf_limit on the writer; Decryptor::open_with_limits plus *::header_read_limits / *::kdf_limit on the reader.
    • All checks fire after validate_passphrase and before any filesystem syscall or Argon2id work, so misconfiguration surfaces fast.

Centralized cap enforcement

Every per-cap if value > cap { return Err(...) } lives in one method on the type that owns the cap. Both reader and writer call the same helper, so a cap value, its diagnostic, and its check semantics cannot drift.

Cap / rule Source of truth (constant) Enforcement helper Reader call site Writer call site
prefix.header_len (resource cap) HeaderReadLimits::HEADER_LEN_DEFAULT (= format::HEADER_LEN_LOCAL_CAP_DEFAULT) HeaderReadLimits::enforce_header_len container::read_encrypted_header api::preflight_header_write_limits (called from Encryptor::write) — checks the exact header_len the writer will emit against the cap
header_fixed.recipient_count (resource cap) HeaderReadLimits::RECIPIENT_COUNT_DEFAULT HeaderReadLimits::enforce_recipient_count container::read_encrypted_header api::preflight_header_write_limits
Per-entry body_len (resource cap) HeaderReadLimits::RECIPIENT_BODY_LEN_DEFAULT HeaderReadLimits::enforce_recipient_body_len (writer); inline check in RecipientEntry::parse_one (reader; recipient/entry.rs sits below container.rs in the dep graph, so the helper can't be called from there without a cycle — same comparison, same RecipientBodyCapExceeded variant) RecipientEntry::parse_one api::preflight_header_write_limits (called against canonical NativeRecipientType::body_len())
header_fixed structural rules (header_flags == 0, 1 <= recipient_count <= MAX, ext_len <= MAX, entries_len + ext_len + HEADER_FIXED_SIZE == header_len) format::check_* private helpers + format::EXT_LEN_MAX / RECIPIENT_COUNT_MAX HeaderFixed::validate_structural HeaderFixed::parse (after wire-byte parse) container::build_encrypted_header (after constructing the HeaderFixed value from typed inputs)
Argon2id structural rules (lanes ∈ [1, MAX_LANES], time_cost ∈ [1, MAX_TIME_COST], mem_cost ∈ [ARGON2_MIN_MEM_COST_PER_LANE × lanes, MAX_MEM_COST]) KdfParams::MAX_* constants + crypto::kdf::ARGON2_MIN_MEM_COST_PER_LANE KdfParams::validate_structural KdfParams::from_bytes_structural (after wire-byte parse) KdfParams::validate_for_write (called from Encryptor::write and KeyPairGenerator::write)
Argon2id mem_cost (resource cap, on top of structural) KdfParams::DEFAULT_MEM_COST / KdfLimit::default() KdfParams::enforce_limit KdfParams::from_bytes (calls enforce_limit after structural parse) KdfParams::validate_for_write (calls enforce_limit after validate_structural)
Archive max_entry_count, max_total_plaintext_bytes, max_path_depth archive::limits::ArchiveLimits defaults archive::limits::enforce_per_entry_caps, archive::limits::enforce_total_bytes_cap archive::decode::extract_entries (unified) archive::encode::archive (iterative walker)

Adding a new cap or wire-format rule = add the field/constant on the source-of-truth type, add one method (enforce_* for caps, validate_* for grouped structural rules), call it from both reader and writer sites. The compiler can't let you forget either side because the call sites are by name.

9.2 Decryption

#[non_exhaustive]
pub enum Decryptor {
    Passphrase(PassphraseDecryptor),
    PrivateKey(PrivateKeyDecryptor),
}

impl Decryptor {
    pub fn open(input: impl AsRef<Path>) -> Result<Self, CryptoError>;
}

pub struct PassphraseDecryptor { /* opaque */ }

impl PassphraseDecryptor {
    pub fn kdf_limit(self, limit: KdfLimit) -> Self;

    pub fn archive_limits(self, limits: ArchiveLimits) -> Self;

    pub fn header_read_limits(self, limits: HeaderReadLimits) -> Self;

    pub fn incomplete_output_policy(self, policy: IncompleteOutputPolicy) -> Self;

    pub fn decrypt(
        self,
        passphrase: SecretString,
        output_dir: impl AsRef<Path>,
        on_event: impl Fn(&ProgressEvent),
    ) -> Result<DecryptOutcome, CryptoError>;
}

pub struct PrivateKeyDecryptor { /* opaque */ }

impl PrivateKeyDecryptor {
    pub fn kdf_limit(self, limit: KdfLimit) -> Self;

    pub fn archive_limits(self, limits: ArchiveLimits) -> Self;

    pub fn header_read_limits(self, limits: HeaderReadLimits) -> Self;

    pub fn incomplete_output_policy(self, policy: IncompleteOutputPolicy) -> Self;

    pub fn decrypt(
        self,
        private_key: PrivateKey,
        private_key_passphrase: SecretString,
        output_dir: impl AsRef<Path>,
        on_event: impl Fn(&ProgressEvent),
    ) -> Result<DecryptOutcome, CryptoError>;
}

archive_limits on the decrypt side mirrors Encryptor::archive_limits on the encrypt side. Both default to [ArchiveLimits::default] when unset; symmetry between encrypt-side preflight and decrypt-side extraction is the caller's responsibility — a .fcr produced under elevated encrypt caps can only be round-tripped by passing the same elevated value to the corresponding decryptor.

incomplete_output_policy defaults to [IncompleteOutputPolicy::DeleteOnError]: a failed decrypt removes the staged .incomplete plaintext so no authenticated-but-incomplete output lingers under output_dir. [IncompleteOutputPolicy::RetainOnError] preserves the staged tree for backup-recovery / forensic flows; callers that opt in MUST treat retained partials as a potentially attacker-chosen prefix (FerroCrypt's STREAM-BE32 payload only detects truncation when the final chunk arrives, so an attacker can choose any chunk-aligned prefix that the recovered plaintext represents).

Preferred public concepts are Passphrase and Recipient. Internals are not organized around Symmetric and Hybrid because those names describe historical modes rather than the recipient-entry model.

9.3 Keys and identities

PublicKey supports:

  • from_key_file;
  • from_recipient_string;
  • from_bytes where supported;
  • fingerprint;
  • canonical to_recipient_string() output.

PrivateKey supports:

  • from_key_file;
  • validated private-key loading;
  • typed dispatch to its native recipient scheme after passphrase unlock.

9.4 Key generation

pub fn generate_key_pair(
    output_dir: impl AsRef<Path>,
    passphrase: SecretString,
    on_event: impl Fn(&ProgressEvent),
) -> Result<KeyGenOutcome, CryptoError>;

pub struct KeyPairGenerator { /* opaque */ }

impl KeyPairGenerator {
    pub fn with_passphrase(passphrase: SecretString) -> Self;

    pub fn kdf_params(self, params: KdfParams) -> Self;

    pub fn kdf_limit(self, limit: KdfLimit) -> Self;

    pub fn write(
        self,
        output_dir: impl AsRef<Path>,
        on_event: impl Fn(&ProgressEvent),
    ) -> Result<KeyGenOutcome, CryptoError>;
}

Ownership split:

  • X25519 key generation lives in recipient/native/x25519.rs.
  • Key serialization lives in key/.
  • Key-file staging lives in key/files.rs and fs/.

KeyPairGenerator mirrors Encryptor's reader-aligned cap rule for the passphrase that seals private.key: kdf_params.mem_cost <= kdf_limit.max_mem_cost_kib (default 1 GiB) is enforced at write time before Argon2id runs. Above-default mem_cost rejects with CryptoError::KdfResourceCapExceeded; the unlocking [PrivateKeyDecryptor] must be configured via [PrivateKeyDecryptor::kdf_limit] with a matching [KdfLimit].

9.5 Recipient-mode probe

pub fn probe_recipient_mode(
    path: impl AsRef<Path>,
) -> Result<Option<UnauthenticatedRecipientMode>, CryptoError>;

The canonical concepts are:

#[non_exhaustive]
pub enum UnauthenticatedRecipientMode {
    Passphrase,
    PublicKey,
}

pub struct AuthenticatedRecipientMode { /* sealed */ }

#[non_exhaustive]
pub enum AuthenticatedRecipientModeKind {
    Passphrase,
    PublicKey,
}

probe_recipient_mode performs a single bounded header parse on one file handle (no path reopen between magic check and header read). It runs no KDF, no private-key operation, no header-MAC verification, and no payload decryption. Its output is not a security claim; it is suitable only for UI / routing hints.

AuthenticatedRecipientMode is the post-decrypt counterpart: it is constructed only inside the decrypt path after a recipient unwraps and the header MAC verifies, and surfaces on DecryptOutcome::recipient_mode. The wrapping struct's field is private and there is no From<UnauthenticatedRecipientMode> impl, so external callers cannot fabricate a value that claims authentication. Callers switch on the variant via kind() (or the is_passphrase / is_public_key accessors).

Compatibility names may exist in the public API, but internal structure and documentation use passphrase and public-key (recipient) terminology.


10. Single sources of truth

Each security-sensitive concern has exactly one owner.

Concern Owner
Wire constants and fixed structs format.rs
Keypair compatibility suite (KeypairSuite, WRITER_KEYPAIR_SUITE, keypair_suite_is_supported) — single shared support gate for both public.key and private.key parsers format.rs
Keypair wire-version reverse mapping (keypair_suite_from_public_key_version, keypair_suite_from_private_key_version, returning KeypairVersionRejection) — single source of truth for 0x00 reserved-byte rejection and writer-relative older/newer classification across both artefact domains; consumers in key/public.rs and key/private.rs translate the rejection into their domain-specific error variants format.rs
.fcr header/container assembly container.rs
File-key generation crypto/keys.rs
Payload/header subkey derivation crypto/keys.rs
Header MAC computation, verification, and input definition Typed wrappers in format.rs, backed by generic primitives in crypto/mac.rs, called by container.rs and protocol.rs
STREAM-BE32 payload rules crypto/stream.rs
Argon2id parameter validation crypto/kdf.rs
Recipient-entry framing recipient/entry.rs
Recipient type-name grammar recipient/name.rs
Mixing policy and native-scheme classification recipient/policy.rs
Argon2id recipient body semantics recipient/native/argon2id.rs
X25519 recipient body semantics recipient/native/x25519.rs
Public recipient string format key/public.rs
Private key binary format key/private.rs
Key-file filesystem helpers key/files.rs
Safe archive path validation archive/path.rs
Archive resource limits archive/limits.rs
Archive encoding archive/encode.rs
Archive decoding archive/decode.rs
Platform extraction hardening archive/platform.rs
Atomic output fs/atomic.rs
General filesystem path helpers fs/paths.rs
Public API translation api.rs
End-to-end operation flow protocol.rs

No second implementation of these concerns may exist.


11. Dependency direction

The intended dependency graph is:

lib.rs
  ↓
api.rs
  ↓
protocol.rs
  ├── container.rs → format.rs
  ├── recipient/* → crypto/*
  ├── key/* → crypto/* + recipient/name.rs
  ├── archive/*
  └── fs/*

Dependency rules:

  • format.rs depends only on error.rs and the crypto/ primitive layer (crypto/mac and crypto/keys), the latter for the typed compute_header_mac / verify_header_mac wrappers; it does not depend on any higher-layer module.
  • crypto/* does not depend on protocol.rs, archive/*, or fs/*.
  • recipient/native/* does not call container.rs or archive/*.
  • archive/* does not know about recipients, keys, or encrypted-header structure.
  • archive/* and recipient/native/* may depend on fs/* for filesystem helpers; fs/* must not depend on archives, recipients, or cryptographic keys.
  • key/private.rs does not know about archive handling or output paths.
  • key/public.rs and key/private.rs do not perform end-to-end encryption or decryption.
  • fs/* does not know about recipient schemes or cryptographic keys.
  • lib.rs does not call low-level cryptographic functions directly.

12. Decryption security ordering

Decryption must preserve this order:

  1. Read prefix.
  2. Reject bad magic, version, kind, flags, or header length.
  3. Read header and header MAC.
  4. Structurally parse header and recipient entries.
  5. Reject malformed flags, unknown critical recipients, and illegal mixing.
  6. Apply local resource caps.
  7. Attempt supported recipient entries.
  8. Verify header MAC with each candidate FileKey.
  9. Validate authenticated TLV bytes only after successful header MAC verification.
  10. Derive the payload key.
  11. Decrypt the payload stream.
  12. Decode the archive with path and resource checks before filesystem writes.
  13. Promote staged output only after successful authenticated decryption and extraction.

No refactor may move TLV interpretation, archive writes, or payload plaintext release before the relevant authentication step.


13. Public error wording

Public errors must be precise without claiming certainty that cryptographic verification cannot provide.

Use wording such as:

  • “wrong passphrase or tampered recipient entry”;
  • “private key passphrase is wrong or the private key file was tampered with”;
  • “no matching credential or recipient entry was modified”;
  • “header authenticated by a recovered file key failed verification”.

Do not use names or display messages that imply FerroCrypt can distinguish wrong credentials from tampering when the AEAD or HMAC result cannot prove that distinction.


14. Extension and non-goal boundaries

The file format supports external recipient names, and the implementation preserves unknown recipient entries as authenticated opaque data where permitted by policy.

The stable public API does not expose a third-party crypto plugin trait. Public plugin registration requires a separate security design, conformance tests, documentation, and review.

The stable public API also does not expose:

  1. Arbitrary caller-owned Read/Write streaming encryption. FerroCrypt guarantees depend on path preflight, archive caps, staging, and atomic finalization.

  2. A simple in-memory whole-file API. Whole-file plaintext or ciphertext buffers do not match FerroCrypt’s file-encryption and streaming-payload design.

  3. Async I/O. Async support would expand the security-sensitive surface and is not part of the canonical structure.

  4. Localization in the library. The library returns typed errors. CLI and desktop layers own localization of user-facing strings.


15. Architectural invariants

The following invariants define the long-term structure of the library:

  • FerroCrypt is file encryption, not generic message encryption.
  • Payloads are streamed; callers do not need whole plaintext or ciphertext buffers.
  • Headers are authenticated before authenticated metadata is interpreted.
  • Plaintext is not released before the relevant authentication checks succeed.
  • Recipients are typed entries in one protocol, not separate protocol modes.
  • Passphrase and X25519 support are native recipient schemes.
  • Unknown non-critical recipient entries remain opaque authenticated data.
  • Strong Rust newtypes protect file keys, payload keys, and header keys from misuse.
  • Archive path validation is isolated and heavily tested.
  • Filesystem finalization is staged and atomic.
  • Error messages preserve cryptographic ambiguity.
  • Public extension surfaces are added only after explicit security review.
  • Each security-sensitive concern has a single owner and no duplicate implementation.