Status: Canonical structural reference
Scope: ferrocrypt-lib/src/ public API, module layout, security boundaries, and ownership of format, cryptographic, archive, key, and filesystem responsibilities.
- Architecture overview
- Source layout
- Top-level modules
crypto/recipient/key/archive/fs/- Public API shape
- Single sources of truth
- Dependency direction
- Decryption security ordering
- Public error wording
- Extension and non-goal boundaries
- Architectural invariants
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:
-
There is one encrypt/decrypt orchestration path.
protocol.rsowns the high-level operation flow for both passphrase and public-key encryption. -
Recipient schemes are first-class components. Passphrase Argon2id and X25519 public-key support are implemented as native recipient schemes under
recipient/native/. -
The encrypted file container is separate from cryptographic algorithms.
container.rsowns the.fcrcontainer layout around the encrypted header, header MAC, and encrypted payload. It does not implement scheme-specific cryptography. -
Cryptographic primitives have explicit owners. Reusable key types, KDF validation, HKDF, HMAC, AEAD, payload streaming, and TLV parsing live under
crypto/. -
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/. -
Filesystem mechanics are separate from archive semantics. Atomic output, staging, and general path helpers live under
fs/. -
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.
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.
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.
api.rs owns public ergonomic wrappers and compatibility-facing API functions.
It contains:
- public
EncryptorandDecryptorconstructors 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.
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:
- file-key generation;
- stream nonce generation;
- recipient-scheme file-key wrapping;
- authenticated-header construction;
- archive encoding;
- payload stream encryption;
- staged output finalization;
- progress event emission.
During decryption, protocol.rs coordinates:
- container prefix and encrypted-header reading;
- structural recipient parsing;
- recipient mixing-rule enforcement;
- local resource-cap enforcement;
- recipient-scheme file-key unwrapping;
- header MAC verification with each candidate
FileKey; - authenticated TLV validation;
- payload key derivation;
- payload stream decryption;
- archive decoding and safe extraction;
- staged output finalization;
- 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
FileKeyverifies the authenticated header MAC. - The orchestrator threads a single
&dyn Fn(&ProgressEvent)callback into each scheme. Schemes whose KDF step is expensive (Argon2id) emitProgressEvent::DerivingPassphraseWrapKeyfrom insidewrap/unwrapimmediately 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. Theprivate.keyArgon2id boundary is owned separately bykey::private::open_private_key, which emitsProgressEvent::UnlockingPrivateKeyat its own work boundary;protocol::decryptdoes NOT emit aDerivingKey-style event from the orchestrator.
format.rs owns byte-level wire constants and fixed structures.
It contains:
- magic bytes;
- the
.fcrouter file version byte (FCR_FILE_VERSION); - the
KeypairSuiteenum and the single shared support gate (keypair_suite_is_supported), bothpub(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 typedUnsupportedVersiondiagnostics. The forward direction (suite → wire byte) is defined here onKeypairSuite::public_key_version/KeypairSuite::private_key_version, both compile-forced exhaustive matches; the reverse direction (wire byte → suite) is also centralised here askeypair_suite_from_public_key_versionandkeypair_suite_from_private_key_version(bothpub(crate)), backed by the parameterised inner helperkeypair_suite_from_wire_version_withso adding a new suite is a single match arm covering both artefact domains. The two reverse mappers return a small crate-internalKeypairVersionRejection(Reserved/Older/Newer) that the consumers inkey/public.rsandkey/private.rstranslate into their domain-specificCryptoErrorvariants — 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.
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 || payloadreading 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.
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
FileKeymeans 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.
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.
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/.
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, andHeaderKeyare 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.
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.
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.
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.
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.
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.
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, declaredlenfits in the region and<= max_value_len), strict ascending tag order, reserved-tag rejection. Returns parsed entries with cachedTlvClass. Does not enforce a critical-tag policy.reject_unknown_critical(tlvs) -> Result<()>— the v1.0 policy wrapper. Rejects anyTlvClass::Criticalentry asUnknownCriticalTagbecause 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. Combinesscan_tlv_regionandreject_unknown_criticalfor callers that don't need the parsed entries. Used by every v1.0 caller (FCR header,private.key, FCAarchive_ext, FCAentry_ext).classify_tlv_tag(tag) -> Result<TlvClass>— pure tag classification, rejects the two reserved values.validate_tlv(ext_bytes)— public convenience function. Callsvalidate_no_known_criticalwithEXT_LEN_MAXfor both region and value caps. Used by.fcrheader andprivate.keycallers.
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 forprivate.key, outer.fcrpayload AEAD for FCA). - Unknown critical TLVs reject after authentication.
- Code must not act on unauthenticated TLV metadata.
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.
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:
RecipientEntryfor parsed entries;RecipientBodyfor scheme body bytes plus type name;- canonical recipient-entry serialization. Production code uses
RecipientEntry::to_bytes_checked, which runs every ruleparse_oneenforces (validate_type_name_grammar, reserved-flag bits,body.len() <= BODY_LEN_MAX) before emitting bytes.container::build_encrypted_headerroutes through it so an entry the matching reader would reject cannot be serialised. The infallibleto_bytesis 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.rsconstructs or serializesRecipientEntryframing. - Generic recipient-entry code never parses, normalizes, or interprets unsupported recipient bodies.
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: returnstruewhennamehas the shape of a reserved FerroCrypt native type (no/, plus a reserved native prefix in["mlkem", "pq", "hpke", "tag", "xwing", "kem"]or the reservedtagsuffix perFORMAT.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.
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
NativeMixingRuletype and its named constructors (exclusive,public_key_mixable,post_quantum); - defining the public
MixingPolicydiagnostic 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
UnauthenticatedRecipientModeviaNativeRecipientType::recipient_modesoclassify_recipient_modeis registry-driven (no hard-codedargon2id/x25519switches in the classifier); - classifying parsed headers as passphrase, public-key, unsupported, or mixed;
- preserving unknown non-critical entries as opaque authenticated data.
Rules:
argon2idisNativeMixingRule::SingleEntry(must appear alone; no compatibility class — cardinality is the only constraint).x25519isNativeMixingRule::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) declareNativeMixingRule::Class { name: POST_QUANTUM_CLASS }and project toMixingPolicy::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_modearms onNativeRecipientType).
A separate recipient registry module is introduced only when a reviewed public plugin-registration API exists.
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::DerivingPassphraseWrapKeyat the actual Argon2id call boundary (after structural validation andKdfLimitresource-cap checks have passed, immediately beforederive_passphrase_wrap_key); RecipientSchemeimplementation;DecryptionCredentialimplementation for a passphrase credential;- tests and vectors for the native passphrase scheme.
It does not:
- build full
.fcrheaders; - compute header MACs;
- parse TLVs;
- write files;
- emit any other progress event (no
Encrypting/Decrypting/UnlockingPrivateKey/GeneratingKeyPairfrom this module); - perform archive encoding or extraction.
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 perFORMAT.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)intokey::private::open_private_keyso theUnlockingPrivateKeyevent fires at the actual Argon2id boundary, not at this wrapper; RecipientSchemeimplementation (ignores the progress callback — X25519 wrap is sub-millisecond);DecryptionCredentialimplementation (ignores the progress callback — X25519 unwrap is sub-millisecond, and the expensiveprivate.keyArgon2id ran before the slot loop inopen_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.
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.
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 thinmap_errwrapper over the centralisedkeypair_suite_from_public_key_versioninformat.rs— this layer picks the public-key error variants (MalformedPublicKey,OlderPublicKey,NewerPublicKey) and routes the suite through the shared support gate informat.rs(FORMAT.md§7); - the writer's current logical version (
PUBLIC_KEY_VERSION, derived fromWRITER_KEYPAIR_SUITE); - internal SHA3-256 checksum handling;
- canonical lowercase enforcement;
- public recipient fingerprinting;
public.keytext 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_fileandfrom_recipient_stringrecover the suite from the wire-version byte during decode and store it on the value;from_bytescarries no suite marker on its input and tags the value withWRITER_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.
key/private.rs owns the private key file format.
It contains:
private.keybinary layout;- the private-key wire-version constants (
PRIVATE_KEY_VERSIONderived fromWRITER_KEYPAIR_SUITE;PRIVATE_KEY_V1_VERSIONderived fromKeypairSuite::V1) and the private-flavoured wire-version-to-suite translation, which is now a thinmap_errwrapper over the centralisedkeypair_suite_from_private_key_versioninformat.rs— this layer picks the private-key error variants (MalformedPrivateKey,OlderKey,NewerKey) and routes the suite through the shared support gate informat.rs(FORMAT.md§8); - cleartext private-key header parsing;
- passphrase-wrapped secret encryption;
- passphrase-wrapped secret decryption;
- writer-side and reader-side
ext_bytesTLV validation.seal_private_keyrunsvalidate_tlvonext_bytesafter the structural length cap and before AEAD work, so a sealedprivate.keyis one the matching reader will accept.open_private_keyruns the same check afteropen_with_aadsucceeds, 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::UnlockingPrivateKeyat the actual Argon2id call boundary insideopen_private_key(after structural header parsing, the caller'sKdfLimitresource-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_keyis silent: keygen owns its own outerGeneratingKeyPairevent.
It does not contain X25519-specific recipient policy. The X25519 recipient module verifies that decrypted secret material corresponds to X25519 public material.
key/files.rs owns filesystem-level key helpers.
It contains:
- default filenames
public.keyandprivate.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.
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.
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 thearchive_ext_lenfield),FCA_ENTRY_FIXED_SIZE = 18(includes the per-entryentry_ext_lenfield),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.).
archive/model.rs owns the FCA model types.
It contains:
FcaHeader— parsed header summary (entry_count,archive_ext_len,manifest_len,total_file_bytes);ArchiveEntryKind—File/Directoryenum;ArchiveEntry—path_utf8,mode,size, opaqueentry_ext: Vec<u8>carrying the per-entry TLV region (empty for v1 writers, populated by the parser for v1.x readers), plus a writer-onlysource_path: Option<PathBuf>set by the metadata pass so the content pass can reopen no-follow;Manifest—entries,total_file_bytes,root_name,root_is_file.
Readers leave source_path as None; writers set it.
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::MAXbecause the on-diskpath_lenfield isu16); - maximum serialized manifest byte length (includes per-entry TLV regions);
- maximum
archive_extbyte length (default 64 KiB); - maximum
entry_extbyte 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.
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 hostComponentthat is notNormal;- 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.
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_byteswithinmax_total_plaintext_bytes.
Order-independent (HashMap-based parent lookup), so non-canonical manifest orders satisfying the tree shape are accepted per spec §10.
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
ArchiveLimitscaps; - source files whose size or type changes between the metadata pass and the content pass.
The writer is two-pass:
- Metadata pass — recursive
fs::read_dirwalk that builds aManifestwith 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. - Content pass — for each file entry in canonical manifest order, reopens the source file with
O_NOFOLLOW(Unix) orsymlink_metadata+File::open(non-Unix), refreshes metadata from the open handle, requires the source is still a regular file withlen() == manifest size, and streams exactly the declared size viacopy_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.
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:
- parse and validate the FCA fixed header;
- read exactly
archive_ext_lenbytes; - validate the archive-level TLV region;
- read exactly
manifest_lenbytes; - parse the manifest, including each per-entry extension region;
- validate every per-entry TLV region;
- validate the complete manifest (entry count, total bytes, paths, duplicates, tree shape, parents present, resource caps, critical extension support);
- pre-check the final output name with
symlink_metadata(so a dangling symlink at the final name counts as occupied); - open
output_diras acap-stddirectory handle; - reject pre-existing
.incompleteoutput at first create; - create
{root}.incomplete(file or directory); - stream file contents in manifest order via
copy_exact_n; - verify archive EOF (no trailing bytes);
- apply descendant directory modes deepest-first;
- promote
{root}.incompleteto{root}via no-clobber rename; - 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; - 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.
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 trustedcap_std::fs::Dirfor the user-suppliedoutput_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 throughcap_fs_ext::DirExt::open_dir_nofollow;finalize_dir_open— Windows-onlyFILE_ATTRIBUTE_REPARSE_POINTpost-check called after every successful directory open, so junctions / mount points fail closed (cap-fs-ext alone refuses entries whereis_symlink()is true, butis_symlink()returnsfalsefor junctions — the bitmask post-check is what catches them);create_file_at—OpenOptions::create_new(true)plusOpenOptionsFollowExt::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 viasuper::PERMISSION_BITS_MASK;INITIAL_FILE_CREATE_MODE— restrictive0o600initial 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 at0o600throughout staging and across the rename, with the manifest mode applied post-rename viadecode::apply_root_file_modeso 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.
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/.
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 viarustix::renameat_with(..., RenameFlags::NOREPLACE)on Linux and macOS, best-effortsymlink_metadata+std::fs::renameon Windows because no safe atomic no-replace directory rename is available there under#![forbid(unsafe_code)];
- file roots (encryption output, key generation, single-file
decrypt promotion) go through
- same-directory staging;
- cleanup on encryption failure;
.incompletebehavior on decryption failure.
Atomic output is a library guarantee. It is not a CLI-only concern.
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 bykey/public.rs::read_public_key,recipient/native/x25519.rs::open_x25519_private_key, andapi::validate_private_key_fileto 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.
The public API is value-oriented. Callers construct typed encryptors, decryptors, keys, and identities rather than selecting independent mode-specific orchestration functions.
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_passphrasecreates exactly oneargon2idrecipient.with_public_keyis a convenience wrapper aroundwith_public_keysfor one public recipient.with_public_keyssupports 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
Encryptorproduces.fcrfiles a default-configuredDecryptorcan read.writeenforces this via the same helpers the reader uses (single source of truth per rule — see "Centralized cap enforcement" below):api::preflight_header_write_limitschecks all three axes ofHeaderReadLimitsagainst the exact header the writer will emit:recipient_count, per-entrybody_len(canonical native value fromNativeRecipientType::body_len()), and the computedheader_len. Tightening any axis below the writer's natural output rejects with the corresponding typed*CapExceededvariant.- For the passphrase path,
KdfParams::validate_for_writeruns the samevalidate_structuralthe reader runs (lanes,time_cost,mem_costagainst v1 absolute bounds + the Argon2mem_cost ≥ ARGON2_MIN_MEM_COST_PER_LANE × lanesfloor) and thenenforce_limitagainstKdfLimit. Above-structural params reject withInvalidKdfParams::*; above-resource-cap reject withKdfResourceCapExceeded. The same rule chain applies toKeyPairGenerator::writefor the passphrase that sealsprivate.key. - The X25519 path never runs Argon2id during encrypt, so
kdf_limithas no effect onwith_public_key/with_public_keysflows. - To go above any default, the caller raises both sides explicitly:
Encryptor::header_read_limits/Encryptor::kdf_limit/KeyPairGenerator::kdf_limiton the writer;Decryptor::open_with_limitsplus*::header_read_limits/*::kdf_limiton the reader. - All checks fire after
validate_passphraseand before any filesystem syscall or Argon2id work, so misconfiguration surfaces fast.
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.
#[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.
PublicKey supports:
from_key_file;from_recipient_string;from_byteswhere 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.
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.rsandfs/.
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].
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.
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.
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.rsdepends only onerror.rsand thecrypto/primitive layer (crypto/macandcrypto/keys), the latter for the typedcompute_header_mac/verify_header_macwrappers; it does not depend on any higher-layer module.crypto/*does not depend onprotocol.rs,archive/*, orfs/*.recipient/native/*does not callcontainer.rsorarchive/*.archive/*does not know about recipients, keys, or encrypted-header structure.archive/*andrecipient/native/*may depend onfs/*for filesystem helpers;fs/*must not depend on archives, recipients, or cryptographic keys.key/private.rsdoes not know about archive handling or output paths.key/public.rsandkey/private.rsdo not perform end-to-end encryption or decryption.fs/*does not know about recipient schemes or cryptographic keys.lib.rsdoes not call low-level cryptographic functions directly.
Decryption must preserve this order:
- Read prefix.
- Reject bad magic, version, kind, flags, or header length.
- Read header and header MAC.
- Structurally parse header and recipient entries.
- Reject malformed flags, unknown critical recipients, and illegal mixing.
- Apply local resource caps.
- Attempt supported recipient entries.
- Verify header MAC with each candidate
FileKey. - Validate authenticated TLV bytes only after successful header MAC verification.
- Derive the payload key.
- Decrypt the payload stream.
- Decode the archive with path and resource checks before filesystem writes.
- 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.
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.
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:
-
Arbitrary caller-owned
Read/Writestreaming encryption. FerroCrypt guarantees depend on path preflight, archive caps, staging, and atomic finalization. -
A simple in-memory whole-file API. Whole-file plaintext or ciphertext buffers do not match FerroCrypt’s file-encryption and streaming-payload design.
-
Async I/O. Async support would expand the security-sensitive surface and is not part of the canonical structure.
-
Localization in the library. The library returns typed errors. CLI and desktop layers own localization of user-facing strings.
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.