Skip to main content

ferrocrypt/
api.rs

1//! Public façade for FerroCrypt's encrypt / decrypt / keygen API.
2//!
3//! `api.rs` translates stable public types ([`Encryptor`], [`Decryptor`],
4//! [`PublicKey`], [`PrivateKey`]) into internal protocol-level calls. It
5//! does not derive keys, build headers, compute MACs, or fire progress
6//! events directly; that work lives in [`crate::protocol`] and the
7//! supporting modules. The module boundary is enforced by visibility:
8//! `api.rs` is the only module that surfaces public-API types beyond
9//! the helpers re-exported from [`crate`].
10//!
11//! ## Recipient model
12//!
13//! - `Encryptor::with_passphrase` — exactly one `argon2id` recipient
14//!   (`MixingPolicy::Exclusive`).
15//! - `Encryptor::with_public_key` / `with_public_keys` — one or more
16//!   `x25519` recipients (`MixingPolicy::PublicKeyMixable`). Empty
17//!   lists reject as [`CryptoError::EmptyRecipientList`]; lists that
18//!   mix incompatible scheme policies (impossible today, where every
19//!   [`PublicKey`] is X25519) reject as
20//!   [`CryptoError::IncompatibleRecipients`].
21//!
22//! ## Decryptor type-safety
23//!
24//! [`Decryptor::open`] inspects the file's recipient list without
25//! cryptographic work and returns a typed variant: [`Decryptor::Passphrase`]
26//! for files sealed with a passphrase, [`Decryptor::PrivateKey`] for files
27//! sealed to public keys. Each variant's `decrypt` method accepts only the
28//! credentials that variant can use, so a mismatched-credential call — for
29//! example, passing a [`PrivateKey`] to a passphrase-sealed file — is a compile
30//! error rather than a runtime "no supported recipient" failure.
31
32use std::path::{Path, PathBuf};
33
34use secrecy::{ExposeSecret as _, SecretString};
35
36use crate::archive::{self, ArchiveLimits, IncompleteOutputPolicy};
37use crate::container::{self, HeaderReadLimits};
38use crate::crypto::kdf::{KdfLimit, KdfParams};
39use crate::error::{FormatDefect, sanitize_path_for_display};
40use crate::format;
41use crate::fs::paths;
42use crate::key::files::KeyFileKind;
43use crate::key::limits::KeyReadLimits;
44use crate::key::private::PrivateKey;
45use crate::key::public::PublicKey;
46use crate::protocol;
47use crate::recipient;
48use crate::recipient::policy::NativeRecipientType;
49use crate::{
50    AuthenticatedRecipientMode, CryptoError, DecryptOutcome, ENCRYPTED_EXTENSION, EncryptOutcome,
51    KeyGenOutcome, ProgressEvent, UnauthenticatedRecipientMode,
52};
53
54// ─── Encryptor ─────────────────────────────────────────────────────────────
55
56/// Builder-style entry point for encryption.
57///
58/// Pick the recipient kind via the constructor — passphrase
59/// ([`Encryptor::with_passphrase`]) or one or more public keys
60/// ([`Encryptor::with_public_key`], [`Encryptor::with_public_keys`]).
61/// Then optionally set an explicit output path
62/// ([`Encryptor::save_as`]) or override archive resource caps
63/// ([`Encryptor::archive_limits`]). Finalize with
64/// [`Encryptor::write`], which streams plaintext through the FCA
65/// archive layer + XChaCha20-Poly1305 STREAM-BE32 directly to disk.
66///
67/// # Examples
68///
69/// Passphrase:
70///
71/// ```no_run
72/// use ferrocrypt::{Encryptor, secrecy::SecretString};
73/// let pass = SecretString::from("correct horse battery staple".to_string());
74/// let outcome = Encryptor::with_passphrase(pass)
75///     .write("./payload", "./out", |ev| eprintln!("{ev}"))?;
76/// println!("Encrypted to {}", outcome.output_path.display());
77/// # Ok::<(), ferrocrypt::CryptoError>(())
78/// ```
79///
80/// Single public-key recipient:
81///
82/// ```no_run
83/// use ferrocrypt::{Encryptor, PublicKey};
84/// let pk = PublicKey::from_key_file("./keys/public.key");
85/// let outcome = Encryptor::with_public_key(pk)
86///     .write("./payload", "./out", |ev| eprintln!("{ev}"))?;
87/// # Ok::<(), ferrocrypt::CryptoError>(())
88/// ```
89///
90/// Multiple public-key recipients:
91///
92/// ```no_run
93/// use ferrocrypt::{Encryptor, PublicKey};
94/// let alice = PublicKey::from_key_file("./alice/public.key");
95/// let bob = PublicKey::from_key_file("./bob/public.key");
96/// let outcome = Encryptor::with_public_keys([alice, bob])?
97///     .write("./payload", "./out", |ev| eprintln!("{ev}"))?;
98/// # Ok::<(), ferrocrypt::CryptoError>(())
99/// ```
100#[derive(Debug)]
101#[non_exhaustive]
102pub struct Encryptor {
103    state: EncryptorState,
104    save_as: Option<PathBuf>,
105    archive_limits: Option<ArchiveLimits>,
106    kdf_params: Option<KdfParams>,
107    header_read_limits: Option<HeaderReadLimits>,
108    kdf_limit: Option<KdfLimit>,
109}
110
111#[derive(Debug)]
112enum EncryptorState {
113    Passphrase(SecretString),
114    Recipients(Vec<PublicKey>),
115}
116
117impl Encryptor {
118    /// Configures passphrase encryption.
119    ///
120    /// The resulting `.fcr` contains exactly one native `argon2id`
121    /// recipient. The passphrase is checked against the fixed bound
122    /// of 1 to 4,096 bytes when [`Encryptor::write`] runs;
123    /// constructing this builder is infallible.
124    pub fn with_passphrase(passphrase: SecretString) -> Self {
125        Self {
126            state: EncryptorState::Passphrase(passphrase),
127            save_as: None,
128            archive_limits: None,
129            kdf_params: None,
130            header_read_limits: None,
131            kdf_limit: None,
132        }
133    }
134
135    /// Configures encryption to one public-key recipient.
136    ///
137    /// This is a convenience wrapper around [`Encryptor::with_public_keys`]
138    /// for the common single-recipient case. As with that constructor,
139    /// public-key encryption does not authenticate the sender.
140    pub fn with_public_key(public_key: PublicKey) -> Self {
141        Self {
142            state: EncryptorState::Recipients(vec![public_key]),
143            save_as: None,
144            archive_limits: None,
145            kdf_params: None,
146            header_read_limits: None,
147            kdf_limit: None,
148        }
149    }
150
151    /// Configures encryption to one or more public-key recipients.
152    ///
153    /// Each recipient entry wraps the same per-file `file_key`
154    /// independently, so any listed recipient can decrypt the resulting
155    /// `.fcr` with their matching private key.
156    ///
157    /// # Security
158    ///
159    /// Public-key encryption controls who can decrypt the file, not who
160    /// created it. Anyone with a recipient's public key can produce a file
161    /// for that recipient. Use a separate signing layer if recipients must
162    /// verify sender identity.
163    ///
164    /// # Default-decrypt round-trip
165    ///
166    /// By default the writer caps `public_keys.len()` at the same
167    /// [`HeaderReadLimits::RECIPIENT_COUNT_DEFAULT`] (64) the reader
168    /// applies via [`HeaderReadLimits::default`], so a default-configured
169    /// [`Decryptor::open`] can read every file the default
170    /// `Encryptor` produces. Lists above the default reject with
171    /// [`CryptoError::RecipientCountCapExceeded`] at
172    /// [`Encryptor::write`] time. To produce a file with more
173    /// recipients, the caller must opt in via
174    /// [`Encryptor::header_read_limits`] with a raised
175    /// recipient-count limit; the receiving decryptor must be opened
176    /// via [`Decryptor::open_with_limits`] with matching limits.
177    ///
178    /// # Errors
179    ///
180    /// Returns [`CryptoError::EmptyRecipientList`] if the iterator is empty.
181    /// All public keys in the current implementation are X25519 recipients;
182    /// future key kinds may add additional mixing-policy checks.
183    pub fn with_public_keys(
184        public_keys: impl IntoIterator<Item = PublicKey>,
185    ) -> Result<Self, CryptoError> {
186        let public_keys: Vec<PublicKey> = public_keys.into_iter().collect();
187        if public_keys.is_empty() {
188            return Err(CryptoError::EmptyRecipientList);
189        }
190        // Today PublicKey is always X25519 (PublicKeyMixable). When future
191        // PublicKey variants carry different mixing policies, the check
192        // expands here; protocol::encrypt re-checks as defense-in-depth.
193        Ok(Self {
194            state: EncryptorState::Recipients(public_keys),
195            save_as: None,
196            archive_limits: None,
197            kdf_params: None,
198            header_read_limits: None,
199            kdf_limit: None,
200        })
201    }
202
203    /// Sets the encrypted output file path.
204    ///
205    /// When set, this path is used instead of the default
206    /// `{output_dir}/{stem}.fcr` destination chosen by [`Encryptor::write`].
207    pub fn save_as(mut self, path: impl AsRef<Path>) -> Self {
208        self.save_as = Some(path.as_ref().to_path_buf());
209        self
210    }
211
212    /// Overrides the default archive resource caps applied during
213    /// the writer-side preflight. Useful for callers operating on
214    /// trusted trees that legitimately exceed the defaults.
215    ///
216    /// # Default-decrypt round-trip
217    ///
218    /// Raising `archive_limits` above [`ArchiveLimits::default`] can
219    /// produce a `.fcr` whose archive payload exceeds what a
220    /// default-configured [`PassphraseDecryptor`] /
221    /// [`PrivateKeyDecryptor`] will extract. The receiving decryptor
222    /// must be configured via
223    /// [`PassphraseDecryptor::archive_limits`] /
224    /// [`PrivateKeyDecryptor::archive_limits`] with limits that match
225    /// (or exceed) the file's actual content. Lowering
226    /// `archive_limits` only tightens what the encrypt-side preflight
227    /// accepts and never breaks default round-trip.
228    pub fn archive_limits(mut self, limits: ArchiveLimits) -> Self {
229        self.archive_limits = Some(limits);
230        self
231    }
232
233    /// Overrides the Argon2id parameters used by the passphrase recipient
234    /// (`Encryptor::with_passphrase`). Has no effect on public-key
235    /// (`Encryptor::with_public_key` / `with_public_keys`) flows, which use
236    /// X25519 ECDH and never run Argon2id during encryption.
237    ///
238    /// If unset, the writer uses [`KdfParams::default`] (1 GiB memory,
239    /// time_cost 4, parallelism 4). A `params.mem_cost` below the 19 MiB
240    /// production memory floor rejects at [`Encryptor::write`] time with
241    /// [`CryptoError::KdfBelowWriteFloor`], so a caller cannot seal a `.fcr`
242    /// with weak Argon2id memory; the floor is hard and has no override.
243    ///
244    /// # Default-decrypt round-trip
245    ///
246    /// `kdf_params` is also checked at [`Encryptor::write`] time against
247    /// the writer's [`KdfLimit`] policy. By default, memory is capped at
248    /// 1 GiB while time cost and lanes are capped at the format maximum,
249    /// so [`KdfParams::default`] is accepted. A value above the effective
250    /// policy rejects with the matching typed cap error. To write a `.fcr`
251    /// with memory above 1 GiB, or to use a deliberately tightened time-cost
252    /// or lane policy, configure [`Encryptor::kdf_limit`] and set a compatible
253    /// [`PassphraseDecryptor::kdf_limit`] on the receiving decryptor before
254    /// calling [`PassphraseDecryptor::decrypt`].
255    pub fn kdf_params(mut self, params: KdfParams) -> Self {
256        self.kdf_params = Some(params);
257        self
258    }
259
260    /// Sets the writer-side header limits.
261    ///
262    /// By default the writer caps the header shape at the same
263    /// [`HeaderReadLimits`] values the default reader uses, so a default
264    /// [`Decryptor::open`] can read every file the default `Encryptor`
265    /// produces. This builder raises or tightens those writer-side caps;
266    /// the receiving decryptor must be opened via
267    /// [`Decryptor::open_with_limits`] with limits that are at least as
268    /// permissive.
269    ///
270    /// All three axes are checked before encryption work begins:
271    /// `recipient_count`, canonical native recipient `body_len`, and the
272    /// exact `header_len` that the writer will emit (`ext_len = 0` for
273    /// current writers). Tightening any axis below the emitted header shape
274    /// rejects at [`Encryptor::write`] time with the same typed cap error
275    /// the reader would later return.
276    pub fn header_read_limits(mut self, limits: HeaderReadLimits) -> Self {
277        self.header_read_limits = Some(limits);
278        self
279    }
280
281    /// Sets the writer-side KDF resource policy for passphrase encryption.
282    ///
283    /// The policy caps Argon2id memory cost, time cost, and lane count before
284    /// any encryption work begins. The default policy accepts
285    /// [`KdfParams::default`] and rejects memory above 1 GiB unless the caller
286    /// opts into a higher memory cap. Time cost and lanes default to the
287    /// format maximum, so they only reject when the caller tightens them.
288    ///
289    /// Use this builder together with [`Encryptor::kdf_params`] to raise the
290    /// memory ceiling or to tighten time cost or lanes. The receiving
291    /// passphrase decryptor must be configured via
292    /// [`PassphraseDecryptor::kdf_limit`] with a policy that accepts the same
293    /// parameters.
294    ///
295    /// Has no effect on public-key (`Encryptor::with_public_key` /
296    /// `with_public_keys`) flows, which never run Argon2id during
297    /// encryption.
298    pub fn kdf_limit(mut self, limit: KdfLimit) -> Self {
299        self.kdf_limit = Some(limit);
300        self
301    }
302
303    /// Encrypts `input` and writes the resulting `.fcr` file.
304    ///
305    /// `input` may be a regular file or a directory. Directory inputs are
306    /// encoded as a FerroCrypt Archive (FCA) payload before payload encryption.
307    /// The default destination is `{output_dir}/{stem}.fcr`; use
308    /// [`Encryptor::save_as`] to supply an explicit output file path.
309    ///
310    /// # Errors
311    ///
312    /// Returns [`CryptoError::InvalidInput`] for invalid input paths, output
313    /// conflicts, unsupported archive entries, empty or too-long passphrases,
314    /// archive cap violations, or invalid KDF settings. Returns [`CryptoError::Io`] for
315    /// filesystem failures. Returns authentication or internal crypto errors if
316    /// key wrapping or payload streaming fails.
317    pub fn write(
318        self,
319        input: impl AsRef<Path>,
320        output_dir: impl AsRef<Path>,
321        on_event: impl Fn(&ProgressEvent),
322    ) -> Result<EncryptOutcome, CryptoError> {
323        let input = input.as_ref();
324        let output_dir = output_dir.as_ref();
325        let archive_limits = self.archive_limits.unwrap_or_default();
326        let header_read_limits = self.header_read_limits.unwrap_or_default();
327        let kdf_limit = self.kdf_limit.unwrap_or_default();
328        // Resolved once: the value the preflight validates below is by
329        // construction the value serialized into the recipient body.
330        let kdf_params = self.kdf_params.unwrap_or_default();
331        let save_as = self.save_as.as_deref();
332
333        // Check the fixed passphrase byte-length bound before filesystem
334        // work. A value outside the bound is a caller error, and reporting it
335        // first avoids unnecessary syscalls.
336        if let EncryptorState::Passphrase(p) = &self.state {
337            validate_passphrase(p)?;
338        }
339
340        // Writer caps mirror reader defaults via the same `enforce_*`
341        // helpers the reader uses, so default-encrypt and default-
342        // decrypt cannot drift. Adding a new cap = add one method on
343        // the limit type; both sides pick it up.
344        match &self.state {
345            EncryptorState::Recipients(rs) => {
346                preflight_header_write_limits(
347                    header_read_limits,
348                    rs.len(),
349                    NativeRecipientType::X25519,
350                )?;
351            }
352            EncryptorState::Passphrase(_) => {
353                preflight_header_write_limits(
354                    header_read_limits,
355                    1,
356                    NativeRecipientType::Argon2id,
357                )?;
358                kdf_params.validate_for_write(Some(&kdf_limit))?;
359            }
360        }
361
362        archive::validate_encrypt_input(input)?;
363
364        let output_path = match self.state {
365            EncryptorState::Passphrase(passphrase) => {
366                let recipient = recipient::argon2id::PassphraseRecipient {
367                    passphrase: &passphrase,
368                    kdf_params,
369                };
370                protocol::encrypt(
371                    std::slice::from_ref(&recipient),
372                    archive_limits,
373                    input,
374                    output_dir,
375                    save_as,
376                    &on_event,
377                )?
378            }
379            EncryptorState::Recipients(public_keys) => {
380                // Resolve each PublicKey to its 32-byte material once.
381                // The local Vec owns the bytes; X25519Recipient borrows
382                // from it for the lifetime of this match arm.
383                let public_key_bytes_vec: Vec<[u8; 32]> = public_keys
384                    .iter()
385                    .map(|pk| pk.to_bytes())
386                    .collect::<Result<_, _>>()?;
387                let recipients: Vec<recipient::x25519::X25519Recipient> = public_key_bytes_vec
388                    .iter()
389                    .map(|bytes| recipient::x25519::X25519Recipient {
390                        recipient_public_key_bytes: bytes,
391                    })
392                    .collect();
393                protocol::encrypt(
394                    &recipients,
395                    archive_limits,
396                    input,
397                    output_dir,
398                    save_as,
399                    &on_event,
400                )?
401            }
402        };
403
404        Ok(EncryptOutcome { output_path })
405    }
406}
407
408// ─── Decryptor ─────────────────────────────────────────────────────────────
409
410/// Type-safe entry point for decryption.
411///
412/// [`Decryptor::open`] reads the `.fcr` header without cryptographic work and
413/// returns the variant that matches the file's recipient kind. Each variant
414/// takes only the credentials it can use:
415///
416/// - [`Decryptor::Passphrase`] takes a passphrase.
417/// - [`Decryptor::PrivateKey`] takes a [`PrivateKey`] plus its unlock
418///   passphrase.
419///
420/// A mismatched-credential call — e.g. trying to decrypt a passphrase-sealed
421/// file with a [`PrivateKey`] — is therefore a compile error rather than a
422/// runtime [`CryptoError::DecryptorModeMismatch`] failure.
423#[derive(Debug)]
424#[non_exhaustive]
425pub enum Decryptor {
426    /// File is sealed with a passphrase. Decrypt via
427    /// [`PassphraseDecryptor::decrypt`].
428    Passphrase(PassphraseDecryptor),
429    /// File is sealed to one or more public-key recipients. Decrypt
430    /// via [`PrivateKeyDecryptor::decrypt`].
431    PrivateKey(PrivateKeyDecryptor),
432}
433
434impl Decryptor {
435    /// Probes the `.fcr` header (cheap structural read; no recipient
436    /// unwrap, no MAC, no payload bytes touched) and returns the
437    /// matching variant.
438    ///
439    /// # Errors
440    ///
441    /// Returns [`CryptoError::InputPath`] if `input` does not exist and
442    /// [`CryptoError::InvalidInput`] if `input` is a directory. Files that do
443    /// not contain a FerroCrypt header return [`CryptoError::InvalidFormat`]
444    /// with [`FormatDefect::BadMagic`]. Malformed headers, unsupported
445    /// versions, unknown critical recipients, and illegal recipient mixes return
446    /// their corresponding `CryptoError` or [`FormatDefect`] variants.
447    pub fn open(input: impl AsRef<Path>) -> Result<Self, CryptoError> {
448        Self::open_inner(input.as_ref(), None)
449    }
450
451    /// Same as [`Decryptor::open`] but uses the supplied
452    /// [`HeaderReadLimits`] for the structural header read instead of
453    /// the conservative defaults.
454    ///
455    /// Callers handling files whose recipient strings, recipient
456    /// counts, or header lengths legitimately exceed the defaults
457    /// (for example, forward-compatible files with larger future recipient
458    /// bodies) should construct a `HeaderReadLimits` via the builder
459    /// methods and pass it here. The same limits are stashed on the
460    /// returned variant so the second header read inside
461    /// [`PassphraseDecryptor::decrypt`] / [`PrivateKeyDecryptor::decrypt`]
462    /// uses them too — callers do not need to set them twice.
463    ///
464    /// # Errors
465    ///
466    /// Returns the same errors as [`Decryptor::open`], but applies the supplied
467    /// [`HeaderReadLimits`] during structural header parsing.
468    pub fn open_with_limits(
469        input: impl AsRef<Path>,
470        header_read_limits: HeaderReadLimits,
471    ) -> Result<Self, CryptoError> {
472        Self::open_inner(input.as_ref(), Some(header_read_limits))
473    }
474
475    fn open_inner(
476        input: &Path,
477        header_read_limits: Option<HeaderReadLimits>,
478    ) -> Result<Self, CryptoError> {
479        let input = input.to_path_buf();
480        if input.is_dir() {
481            return Err(CryptoError::InvalidInput(format!(
482                "Cannot decrypt a directory: {}",
483                sanitize_path_for_display(&input)
484            )));
485        }
486        let mode =
487            probe_recipient_mode_with_limits(&input, header_read_limits.unwrap_or_default())?
488                .ok_or(CryptoError::InvalidFormat(FormatDefect::BadMagic))?;
489        match mode {
490            UnauthenticatedRecipientMode::Passphrase => Ok(Self::Passphrase(PassphraseDecryptor {
491                input,
492                kdf_limit: None,
493                archive_limits: None,
494                header_read_limits,
495                incomplete_output_policy: None,
496            })),
497            UnauthenticatedRecipientMode::PublicKey => Ok(Self::PrivateKey(PrivateKeyDecryptor {
498                input,
499                kdf_limit: None,
500                key_read_limits: None,
501                archive_limits: None,
502                header_read_limits,
503                incomplete_output_policy: None,
504            })),
505        }
506    }
507}
508
509/// Decryptor for passphrase-sealed `.fcr` files. Returned from
510/// [`Decryptor::open`] when the file's recipient list classifies as
511/// [`UnauthenticatedRecipientMode::Passphrase`].
512#[derive(Debug)]
513#[non_exhaustive]
514pub struct PassphraseDecryptor {
515    input: PathBuf,
516    kdf_limit: Option<KdfLimit>,
517    archive_limits: Option<ArchiveLimits>,
518    header_read_limits: Option<HeaderReadLimits>,
519    incomplete_output_policy: Option<IncompleteOutputPolicy>,
520}
521
522impl PassphraseDecryptor {
523    /// Sets the maximum Argon2id memory cost accepted from the file header.
524    ///
525    /// If unset, the decrypt path applies [`KdfLimit::default`].
526    pub fn kdf_limit(mut self, limit: KdfLimit) -> Self {
527        self.kdf_limit = Some(limit);
528        self
529    }
530
531    /// Overrides the default archive resource caps applied during
532    /// extraction. Must match (or exceed) the limits the writer used —
533    /// a file produced with [`Encryptor::archive_limits`] above the
534    /// default cannot be decrypted under [`ArchiveLimits::default`].
535    pub fn archive_limits(mut self, limits: ArchiveLimits) -> Self {
536        self.archive_limits = Some(limits);
537        self
538    }
539
540    /// Overrides the header-read caps applied while parsing the
541    /// `.fcr` header during decrypt. The same limits used at
542    /// [`Decryptor::open`] / [`Decryptor::open_with_limits`] are
543    /// carried into the variant; this builder lets callers tighten or
544    /// loosen them between open and decrypt for advanced flows.
545    pub fn header_read_limits(mut self, limits: HeaderReadLimits) -> Self {
546        self.header_read_limits = Some(limits);
547        self
548    }
549
550    /// Sets the policy that governs the `.incomplete` working tree
551    /// when this decrypt fails.
552    ///
553    /// Defaults to [`IncompleteOutputPolicy::DeleteOnError`]: a failed
554    /// decrypt leaves no plaintext residue under `output_dir`. Pass
555    /// [`IncompleteOutputPolicy::RetainOnError`] for backup-recovery
556    /// or forensic flows where partial output is more useful than no
557    /// output. See [`IncompleteOutputPolicy::RetainOnError`] for the
558    /// truncation-prefix caveat callers must understand before acting
559    /// on a retained partial.
560    pub fn incomplete_output_policy(mut self, policy: IncompleteOutputPolicy) -> Self {
561        self.incomplete_output_policy = Some(policy);
562        self
563    }
564
565    /// Decrypts this passphrase-sealed `.fcr` into `output_dir`.
566    ///
567    /// The passphrase is checked against the fixed bound of 1 to
568    /// 4,096 bytes, then used to unwrap the file's `argon2id` recipient. The
569    /// recovered candidate file key is accepted only after the header MAC
570    /// verifies. On success, the decrypted file or directory is promoted
571    /// into `output_dir` and returned in [`DecryptOutcome::output_path`].
572    ///
573    /// # Errors
574    ///
575    /// Returns [`CryptoError::InvalidInput`] for an empty or too-long
576    /// passphrase, archive cap violations, output conflicts, or unsafe archived
577    /// paths. Returns [`CryptoError::KdfResourceCapExceeded`] for rejected KDF costs.
578    /// Returns [`CryptoError::InvalidFormat`] if the encrypted container or
579    /// authenticated payload stream is structurally malformed. Returns
580    /// authentication errors such as [`CryptoError::RecipientUnwrapFailed`],
581    /// [`CryptoError::HeaderTampered`], [`CryptoError::PayloadTampered`], or
582    /// [`CryptoError::PayloadTruncated`] when credentials are wrong or the file
583    /// is modified. Returns [`CryptoError::InputPath`] if the encrypted file
584    /// no longer exists, and [`CryptoError::Io`] for other filesystem
585    /// failures.
586    pub fn decrypt(
587        self,
588        passphrase: SecretString,
589        output_dir: impl AsRef<Path>,
590        on_event: impl Fn(&ProgressEvent),
591    ) -> Result<DecryptOutcome, CryptoError> {
592        validate_passphrase(&passphrase)?;
593        let credential = recipient::argon2id::PassphraseCredential {
594            passphrase: &passphrase,
595            kdf_limit: self.kdf_limit.as_ref(),
596        };
597        let archive_limits = self.archive_limits.unwrap_or_default();
598        let header_read_limits = self.header_read_limits.unwrap_or_default();
599        let incomplete_output_policy = self.incomplete_output_policy.unwrap_or_default();
600        // No early progress event here. `protocol::decrypt` parses the
601        // header structurally before any KDF runs; if the file is
602        // malformed or mode-mismatched, no event should fire. The
603        // `DerivingPassphraseWrapKey` event fires from inside
604        // `argon2id::unwrap` once the slot loop reaches a structurally
605        // valid `argon2id` body whose `kdf_params` are within the
606        // resource cap — i.e. immediately before Argon2id actually runs.
607        let output_path = protocol::decrypt(
608            &credential,
609            &self.input,
610            output_dir.as_ref(),
611            archive_limits,
612            header_read_limits,
613            incomplete_output_policy,
614            &on_event,
615        )?;
616        Ok(DecryptOutcome {
617            output_path,
618            recipient_mode: AuthenticatedRecipientMode::passphrase(),
619        })
620    }
621}
622
623/// Decryptor for public-key-sealed `.fcr` files. Returned from
624/// [`Decryptor::open`] when the file's recipient list classifies as
625/// [`UnauthenticatedRecipientMode::PublicKey`].
626#[derive(Debug)]
627#[non_exhaustive]
628pub struct PrivateKeyDecryptor {
629    input: PathBuf,
630    kdf_limit: Option<KdfLimit>,
631    key_read_limits: Option<KeyReadLimits>,
632    archive_limits: Option<ArchiveLimits>,
633    header_read_limits: Option<HeaderReadLimits>,
634    incomplete_output_policy: Option<IncompleteOutputPolicy>,
635}
636
637impl PrivateKeyDecryptor {
638    /// Sets the maximum Argon2id memory cost accepted when unlocking
639    /// `private.key`.
640    ///
641    /// If unset, the decrypt path applies [`KdfLimit::default`].
642    pub fn kdf_limit(mut self, limit: KdfLimit) -> Self {
643        self.kdf_limit = Some(limit);
644        self
645    }
646
647    /// Overrides the key-file caps applied while reading the supplied
648    /// `private.key`.
649    ///
650    /// If unset, the decrypt path applies [`KeyReadLimits::default`].
651    /// Raise [`KeyReadLimits::max_private_key_wrapped_secret_len`] for a
652    /// key file whose wrapped secret legitimately exceeds the default.
653    pub fn key_read_limits(mut self, limits: KeyReadLimits) -> Self {
654        self.key_read_limits = Some(limits);
655        self
656    }
657
658    /// Overrides the default archive resource caps applied during
659    /// extraction. Must match (or exceed) the limits the writer used —
660    /// a file produced with [`Encryptor::archive_limits`] above the
661    /// default cannot be decrypted under [`ArchiveLimits::default`].
662    pub fn archive_limits(mut self, limits: ArchiveLimits) -> Self {
663        self.archive_limits = Some(limits);
664        self
665    }
666
667    /// Overrides the header-read caps applied while parsing the
668    /// `.fcr` header during decrypt. The same limits used at
669    /// [`Decryptor::open`] / [`Decryptor::open_with_limits`] are
670    /// carried into the variant; this builder lets callers tighten or
671    /// loosen them between open and decrypt for advanced flows.
672    pub fn header_read_limits(mut self, limits: HeaderReadLimits) -> Self {
673        self.header_read_limits = Some(limits);
674        self
675    }
676
677    /// Sets the policy that governs the `.incomplete` working tree
678    /// when this decrypt fails.
679    ///
680    /// Defaults to [`IncompleteOutputPolicy::DeleteOnError`]: a failed
681    /// decrypt leaves no plaintext residue under `output_dir`. Pass
682    /// [`IncompleteOutputPolicy::RetainOnError`] for backup-recovery
683    /// or forensic flows where partial output is more useful than no
684    /// output. See [`IncompleteOutputPolicy::RetainOnError`] for the
685    /// truncation-prefix caveat callers must understand before acting
686    /// on a retained partial.
687    pub fn incomplete_output_policy(mut self, policy: IncompleteOutputPolicy) -> Self {
688        self.incomplete_output_policy = Some(policy);
689        self
690    }
691
692    /// Decrypts this public-key-recipient `.fcr` into `output_dir`.
693    ///
694    /// `private_key` must reference a FerroCrypt `private.key` file. The
695    /// private key is unlocked with `private_key_passphrase`, then the
696    /// decryptor tries the supported `x25519` recipient slots until one yields
697    /// a candidate file key that verifies the header MAC. On success, the
698    /// decrypted file or directory is promoted into `output_dir` and returned
699    /// in [`DecryptOutcome::output_path`].
700    ///
701    /// # Errors
702    ///
703    /// Returns [`CryptoError::InvalidInput`] for an empty or too-long
704    /// `private_key_passphrase`, archive cap violations, output conflicts, or
705    /// unsafe archived paths.
706    /// Returns [`CryptoError::InvalidFormat`] if the private key, encrypted
707    /// container, or authenticated payload stream is structurally malformed;
708    /// returns [`CryptoError::KeyFileUnlockFailed`] if the private key is
709    /// tampered or protected by a different passphrase.
710    /// Returns [`CryptoError::KdfResourceCapExceeded`] for rejected
711    /// `private.key` KDF costs. Returns [`CryptoError::UnsupportedKeyType`]
712    /// if the private key wraps a key type this build does not support.
713    /// Returns authentication errors such as
714    /// [`CryptoError::RecipientUnwrapFailed`],
715    /// [`CryptoError::HeaderMacFailedAfterUnwrap`],
716    /// [`CryptoError::NoSupportedRecipient`], [`CryptoError::PayloadTampered`],
717    /// or [`CryptoError::PayloadTruncated`]. `RecipientUnwrapFailed` means the
718    /// private key matched no supported recipient slot, or a recipient body was
719    /// modified; `NoSupportedRecipient` means the file contains no recipient
720    /// type this build can process. Returns [`CryptoError::InputPath`] if the
721    /// encrypted file or the private key file does not exist, and
722    /// [`CryptoError::Io`] for other filesystem failures.
723    pub fn decrypt(
724        self,
725        private_key: PrivateKey,
726        private_key_passphrase: SecretString,
727        output_dir: impl AsRef<Path>,
728        on_event: impl Fn(&ProgressEvent),
729    ) -> Result<DecryptOutcome, CryptoError> {
730        validate_passphrase(&private_key_passphrase)?;
731        let archive_limits = self.archive_limits.unwrap_or_default();
732        let header_read_limits = self.header_read_limits.unwrap_or_default();
733        let incomplete_output_policy = self.incomplete_output_policy.unwrap_or_default();
734
735        // Open and classify the encrypted file before unlocking the
736        // private key, then retain that open file for decryption.
737        // Structural errors must be reported before Argon2id starts.
738        // Keeping one file open also prevents a path replacement during
739        // the unlock from changing which bytes are decrypted.
740        let session = protocol::DecryptSession::open(&self.input, header_read_limits)?;
741        if session.mode() != UnauthenticatedRecipientMode::PublicKey {
742            return Err(CryptoError::DecryptorModeMismatch {
743                expected: UnauthenticatedRecipientMode::PublicKey,
744                found: session.mode(),
745            });
746        }
747
748        // No early progress event here. `open_x25519_private_key` first
749        // applies the generic binary-shape, resource-cap, type-name
750        // grammar, and passphrase-length gates. Only immediately before
751        // Argon2id does `key::private::open_private_key` emit
752        // `UnlockingPrivateKey`. Those pre-KDF failures and a concrete
753        // public/private key-file mix-up therefore emit no event. Verdicts
754        // that require authenticated bytes, including
755        // `UnsupportedKeyType`, occur after the event.
756        let private_key_bytes = recipient::native::x25519::open_x25519_private_key(
757            private_key.key_file_path(),
758            &private_key_passphrase,
759            self.kdf_limit.as_ref(),
760            self.key_read_limits.unwrap_or_default(),
761            &on_event,
762        )?;
763        let decryption_credential = recipient::x25519::X25519Credential { private_key_bytes };
764        let output_path = protocol::decrypt_session(
765            &decryption_credential,
766            session,
767            output_dir.as_ref(),
768            archive_limits,
769            incomplete_output_policy,
770            &on_event,
771        )?;
772        Ok(DecryptOutcome {
773            output_path,
774            recipient_mode: AuthenticatedRecipientMode::public_key(),
775        })
776    }
777}
778
779// ─── Key generation ─────────────────────────────────────────────────────────
780
781/// Builder for X25519 key-pair generation.
782///
783/// Mirrors the [`Encryptor`] builder pattern: pick the passphrase at
784/// construction, optionally override the Argon2id parameters used to
785/// seal the resulting `private.key`, then call [`KeyPairGenerator::write`]
786/// with the destination directory.
787///
788/// The free function [`generate_key_pair`] is a thin convenience wrapper
789/// around this builder for callers that do not need to override KDF
790/// parameters.
791#[derive(Debug)]
792#[non_exhaustive]
793pub struct KeyPairGenerator {
794    passphrase: SecretString,
795    kdf_params: Option<KdfParams>,
796    kdf_limit: Option<KdfLimit>,
797}
798
799impl KeyPairGenerator {
800    /// Constructs a key-pair generator with the passphrase that will be
801    /// used to seal the resulting `private.key`. The passphrase is
802    /// checked against the fixed bound of 1 to 4,096 bytes when
803    /// [`KeyPairGenerator::write`] runs; constructing this builder is
804    /// infallible.
805    pub fn with_passphrase(passphrase: SecretString) -> Self {
806        Self {
807            passphrase,
808            kdf_params: None,
809            kdf_limit: None,
810        }
811    }
812
813    /// Overrides the Argon2id parameters used to seal `private.key`.
814    /// If unset, the generator uses [`KdfParams::default`] (1 GiB memory,
815    /// time_cost 4, parallelism 4).
816    ///
817    /// Same production memory floor as [`Encryptor::kdf_params`]: a
818    /// `params.mem_cost` below the 19 MiB production memory floor rejects
819    /// at [`KeyPairGenerator::write`] time with
820    /// [`CryptoError::KdfBelowWriteFloor`], so a caller cannot seal a
821    /// `private.key` with weak Argon2id memory; the floor is hard and has
822    /// no override.
823    ///
824    /// # Default-decrypt round-trip
825    ///
826    /// `kdf_params` is also checked at [`KeyPairGenerator::write`] time
827    /// against the writer's [`KdfLimit`] policy. By default, memory is capped
828    /// at 1 GiB while time cost and lanes are capped at the format maximum,
829    /// so [`KdfParams::default`] is accepted. A value above the effective
830    /// policy rejects with the matching typed cap error. To write a
831    /// `private.key` with memory above 1 GiB, or to use a deliberately
832    /// tightened time-cost or lane policy, configure
833    /// [`KeyPairGenerator::kdf_limit`] and configure the unlocking
834    /// [`PrivateKeyDecryptor`] with a compatible
835    /// [`PrivateKeyDecryptor::kdf_limit`].
836    pub fn kdf_params(mut self, params: KdfParams) -> Self {
837        self.kdf_params = Some(params);
838        self
839    }
840
841    /// Sets the writer-side KDF resource policy for sealing `private.key`.
842    ///
843    /// The policy caps Argon2id memory cost, time cost, and lane count before
844    /// key generation begins. The default policy accepts [`KdfParams::default`]
845    /// and rejects memory above 1 GiB unless the caller opts into a higher
846    /// memory cap. Time cost and lanes default to the format maximum, so
847    /// they only reject when the caller tightens them.
848    ///
849    /// Use this builder together with [`KeyPairGenerator::kdf_params`] to raise
850    /// the memory ceiling or to tighten time cost or lanes. The receiving
851    /// [`PrivateKeyDecryptor`] must be configured via
852    /// [`PrivateKeyDecryptor::kdf_limit`] with a policy that accepts the same
853    /// parameters.
854    pub fn kdf_limit(mut self, limit: KdfLimit) -> Self {
855        self.kdf_limit = Some(limit);
856        self
857    }
858
859    /// Generates the X25519 key pair and writes `private.key` +
860    /// `public.key` into `output_dir`.
861    ///
862    /// Both files are written and synced before either receives its final
863    /// name. `private.key` is committed first, and the output directory is
864    /// flushed after each commit. This order prevents process interruption
865    /// from leaving a usable `public.key` without its matching `private.key`.
866    ///
867    /// On filesystems that support directory flushing, the same guarantee
868    /// covers power loss, and a successful return means the two files and
869    /// their directory entries have reached stable storage. Other filesystems
870    /// depend on their own ordering after power loss.
871    ///
872    /// # Errors
873    ///
874    /// Returns [`CryptoError::InvalidInput`] if the passphrase is empty or too
875    /// long, KDF parameters are outside the accepted writer policy, or either key file
876    /// already exists. Returns [`CryptoError::Io`] for filesystem failures,
877    /// including a directory flush failure. If the flush after committing
878    /// `public.key` fails, the method removes `public.key` but keeps
879    /// `private.key`. Removing both without a successful directory flush could
880    /// leave only the public key after power loss. A remaining `private.key`
881    /// is safe to delete.
882    pub fn write(
883        self,
884        output_dir: impl AsRef<Path>,
885        on_event: impl Fn(&ProgressEvent),
886    ) -> Result<KeyGenOutcome, CryptoError> {
887        validate_passphrase(&self.passphrase)?;
888        let kdf_params = self.kdf_params.unwrap_or_default();
889        let kdf_limit = self.kdf_limit.unwrap_or_default();
890        // Writer caps mirror reader defaults via the same structural +
891        // resource KDF validation the reader uses, so a `private.key`
892        // produced here is unlocked by a default
893        // `PrivateKeyDecryptor::decrypt`. To go above default, the
894        // caller raises both sides explicitly.
895        kdf_params.validate_for_write(Some(&kdf_limit))?;
896        let (private_key_path, public_key_path, fingerprint) = protocol::generate_key_pair(
897            &self.passphrase,
898            &kdf_params,
899            output_dir.as_ref(),
900            &on_event,
901        )?;
902        Ok(KeyGenOutcome {
903            private_key_path,
904            public_key_path,
905            fingerprint,
906        })
907    }
908}
909
910/// Generates and stores an X25519 key pair for public-key
911/// (recipient) encryption.
912///
913/// Writes `private.key` (passphrase-wrapped at rest) and `public.key`
914/// (UTF-8 `fcr1…` recipient string) into `output_dir`. Returns the
915/// final paths plus the SHA3-256 fingerprint of the public key.
916///
917/// Thin convenience wrapper around [`KeyPairGenerator`]. Callers that
918/// need to override Argon2id parameters should use the builder directly:
919/// `KeyPairGenerator::with_passphrase(pass).kdf_params(p).write(dir, ev)`.
920///
921/// # Errors
922///
923/// Returns the same errors as [`KeyPairGenerator::write`].
924///
925/// # Examples
926///
927/// ```no_run
928/// use ferrocrypt::{generate_key_pair, secrecy::SecretString};
929/// let pass = SecretString::from("protect-my-key".to_string());
930/// let outcome = generate_key_pair("./keys", pass, |ev| eprintln!("{ev}"))?;
931/// println!("Fingerprint: {}", outcome.fingerprint);
932/// # Ok::<(), ferrocrypt::CryptoError>(())
933/// ```
934pub fn generate_key_pair(
935    output_dir: impl AsRef<Path>,
936    passphrase: SecretString,
937    on_event: impl Fn(&ProgressEvent),
938) -> Result<KeyGenOutcome, CryptoError> {
939    KeyPairGenerator::with_passphrase(passphrase).write(output_dir, on_event)
940}
941
942// ─── Recipient-mode probe ───────────────────────────────────────────────────
943
944/// Cheap structural probe of an `.fcr` file's recipient list. **Not a
945/// security claim.**
946///
947/// Performs a single bounded header parse on one file handle (no path reopen
948/// between magic check and header read). It does not run a KDF, perform a
949/// private-key operation, prompt for credentials, verify the header MAC, or
950/// decrypt payload bytes. Allocations are bounded by [`HeaderReadLimits`].
951///
952/// A positive result is **not** evidence that the file is authentic,
953/// decryptable, untampered, or well-formed beyond the structural shape
954/// required to classify it. A canonical header that would later fail
955/// recipient unwrap or MAC verify still returns `Ok(Some(_))` here — those
956/// checks require running the full decrypt. Use only for UI / routing hints;
957/// for an authenticated mode value see [`AuthenticatedRecipientMode`] on
958/// [`DecryptOutcome`].
959///
960/// Returns `Ok(None)` if the path is a directory, the file is empty, or the
961/// first 4 bytes are not the FerroCrypt magic. These cases mean "this isn't
962/// a FerroCrypt file at all" — callers route to plaintext encrypt.
963///
964/// Returns `Ok(Some(UnauthenticatedRecipientMode))` when the prefix matches
965/// and the header parses and classifies cleanly. The mode is derived from
966/// the recipient list: exactly one native `argon2id` recipient maps to
967/// [`UnauthenticatedRecipientMode::Passphrase`], and one or more supported
968/// `x25519` recipients with no `argon2id` recipient map to
969/// [`UnauthenticatedRecipientMode::PublicKey`].
970///
971/// Returns [`CryptoError::InvalidFormat`] when the magic matches but the
972/// prefix or header is malformed (bad version / kind / flags, oversized
973/// `header_len`, malformed recipient entries, etc.). The probe therefore
974/// enforces the same structural invariants the decrypt path would, so
975/// corrupt or attacker-modified files surface their specific diagnostic
976/// at probe time.
977///
978/// Returns typed recipient-classification errors when the recipient list is
979/// structurally valid but cannot be classified: unknown critical recipients,
980/// illegal passphrase mixing, or no supported native recipient.
981///
982/// # Errors
983///
984/// Returns [`CryptoError::InputPath`] if the file does not exist, and
985/// [`CryptoError::Io`] for other open or read failures. Returns
986/// [`CryptoError::InvalidInput`] if the path is not a regular file (for
987/// example a FIFO or device node) — such inputs are refused without
988/// blocking. Returns [`CryptoError::InvalidFormat`] when the magic matches
989/// but the prefix, header, recipient entries, or recipient mixing policy are
990/// malformed or unsupported. Returns cap-exceeded variants when the declared
991/// header shape exceeds [`HeaderReadLimits::default`].
992pub fn probe_recipient_mode(
993    file_path: impl AsRef<Path>,
994) -> Result<Option<UnauthenticatedRecipientMode>, CryptoError> {
995    probe_recipient_mode_with_limits(file_path, HeaderReadLimits::default())
996}
997
998/// Same as [`probe_recipient_mode`] but uses the supplied
999/// [`HeaderReadLimits`] for the structural header read instead of the
1000/// conservative defaults.
1001///
1002/// Use this when probing files whose recipient strings, recipient counts,
1003/// or header lengths legitimately exceed the default local caps (for example,
1004/// forward-compatible files with larger future recipient bodies). All other
1005/// behavior — directory short-circuit, magic-byte fast path, typed-error
1006/// surface, and "not a security claim" semantics — is identical.
1007///
1008/// # Errors
1009///
1010/// Returns the same errors as [`probe_recipient_mode`], but applies the
1011/// supplied [`HeaderReadLimits`] instead of the default caps.
1012pub fn probe_recipient_mode_with_limits(
1013    file_path: impl AsRef<Path>,
1014    limits: HeaderReadLimits,
1015) -> Result<Option<UnauthenticatedRecipientMode>, CryptoError> {
1016    use std::io::{Read, Seek, SeekFrom};
1017    let path = file_path.as_ref();
1018
1019    // Handle directories before opening the path so all platforms return the
1020    // same result. Unix may open a directory and fail later at `read()` with
1021    // `IsADirectory`; Windows refuses the open up front and reports access
1022    // denied, which is indistinguishable from a real permission error here.
1023    if path.is_dir() {
1024        return Ok(None);
1025    }
1026
1027    // `open_input_file` refuses FIFOs, sockets, and device nodes
1028    // without blocking — `File::open` on an attacker-placed FIFO would
1029    // otherwise block the probe inside `open(2)` indefinitely.
1030    let mut file = paths::open_input_file(path)?;
1031
1032    // Peek the 4-byte magic. Anything that doesn't claim to be a
1033    // FerroCrypt file (empty, too short, wrong magic) routes to
1034    // plaintext-encrypt as `Ok(None)`. Once magic matches, `Ok(None)`
1035    // is no longer reachable: a magic-claiming file must surface as
1036    // a valid header or a typed structural error.
1037    let mut magic_buf = [0u8; format::MAGIC_SIZE];
1038    let mut filled = 0;
1039    while filled < magic_buf.len() {
1040        match file.read(&mut magic_buf[filled..]) {
1041            Ok(0) => break,
1042            Ok(n) => filled += n,
1043            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
1044            // Defensive: on Unix, a TOCTOU race could swap the pre-checked
1045            // path for a directory between `is_dir()` and `File::open()`.
1046            // Keep the runtime handler so the race is still classified
1047            // correctly instead of surfacing as a generic I/O error.
1048            Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => return Ok(None),
1049            Err(e) => return Err(CryptoError::Io(e)),
1050        }
1051    }
1052    if filled < magic_buf.len() || magic_buf != format::MAGIC {
1053        return Ok(None);
1054    }
1055
1056    // Magic matched. Rewind the same handle and run the structural
1057    // reader against the full prefix + header. Using `seek` instead
1058    // of dropping and re-opening avoids both an extra syscall and a
1059    // TOCTOU window where the path could be swapped between checks.
1060    file.seek(SeekFrom::Start(0))?;
1061    let parsed = container::read_encrypted_header(&mut file, limits)?;
1062
1063    // Structural classification only. `classify_recipient_mode`
1064    // does not verify the header MAC or run any recipient unwrap.
1065    let mode = recipient::classify_recipient_mode(&parsed.recipient_entries)?;
1066    Ok(Some(mode))
1067}
1068
1069// ─── Filename + key-file helpers ────────────────────────────────────────────
1070
1071/// Returns the default encrypted filename for a given input path.
1072///
1073/// For example, a regular file named `secrets.txt` maps to `secrets.fcr`; a
1074/// directory named `secrets` maps to `secrets.fcr`.
1075///
1076/// # Errors
1077///
1078/// Returns [`CryptoError::InvalidInput`] if the path has no usable file name or
1079/// contains a non-UTF-8 file name.
1080pub fn default_encrypted_filename(input_path: impl AsRef<Path>) -> Result<String, CryptoError> {
1081    let base_name = paths::encryption_base_name(input_path)?;
1082    Ok(format!("{}.{}", base_name, ENCRYPTED_EXTENSION))
1083}
1084
1085/// Validates that a file is a well-formed FerroCrypt `private.key` file.
1086///
1087/// Checks the cleartext structure: magic bytes, version, key-file kind,
1088/// flags, length fields, X25519 type name, X25519 public-material length, and
1089/// total file size. This does **not** attempt to decrypt the key and does not
1090/// require a passphrase. If the caller accidentally points this at a text
1091/// `public.key`, [`FormatDefect::WrongKeyFileType`] is returned instead of a
1092/// generic key-file parse error.
1093///
1094/// # Errors
1095///
1096/// Returns [`CryptoError::InputPath`] if the file does not exist, and
1097/// [`CryptoError::Io`] for other read failures. Returns
1098/// [`CryptoError::InvalidFormat`] or [`CryptoError::UnsupportedVersion`] if the
1099/// file is not a supported private key, is malformed, or is a public key. Returns
1100/// [`CryptoError::UnsupportedKeyType`] for a well-formed private key of a key
1101/// type this build does not support.
1102pub fn validate_private_key_file(key_file: impl AsRef<Path>) -> Result<(), CryptoError> {
1103    // No resource policy of its own: this validates structure only, so
1104    // it reads whatever a structurally valid file declares.
1105    let data = crate::key::private::read_private_key_file(
1106        key_file.as_ref(),
1107        crate::key::private::PRIVATE_KEY_WRAPPED_SECRET_LEN_MAX,
1108    )?;
1109    if matches!(
1110        KeyFileKind::classify(&data, KeyReadLimits::default()),
1111        KeyFileKind::Public
1112    ) {
1113        return Err(CryptoError::InvalidFormat(FormatDefect::WrongKeyFileType));
1114    }
1115    recipient::native::x25519::validate_private_key_shape(&data)
1116}
1117
1118/// Validates that a file is a well-formed FerroCrypt `public.key`
1119/// text file.
1120///
1121/// Checks the canonical `fcr1…` recipient string grammar, including
1122/// Bech32 checksum, HRP, typed payload lengths, type name, key-material
1123/// length, and internal SHA3-256 checksum. Does **not** require a
1124/// passphrase. If the caller accidentally points this at a binary
1125/// `private.key`, [`FormatDefect::WrongKeyFileType`] is returned instead of a
1126/// UTF-8 decode error.
1127///
1128/// Companion to [`validate_private_key_file`]. Applies
1129/// [`KeyReadLimits::default`]; for a key whose recipient string exceeds the
1130/// default length cap, call
1131/// `PublicKey::from_key_file_with_limits(path, limits).validate()` instead.
1132///
1133/// # Errors
1134///
1135/// Returns [`CryptoError::InputPath`] if the file does not exist, and
1136/// [`CryptoError::Io`] for other read failures. Returns
1137/// [`CryptoError::InvalidFormat`] or
1138/// [`CryptoError::RecipientStringCapExceeded`] if the text file or recipient
1139/// string is malformed, too large for local policy, or is a private key.
1140/// Returns [`CryptoError::UnsupportedVersion`] for a public key from an
1141/// unsupported keypair suite. Returns
1142/// [`CryptoError::UnsupportedKeyType`] for a valid public key of a
1143/// key type this build does not support.
1144pub fn validate_public_key_file(key_file: impl AsRef<Path>) -> Result<(), CryptoError> {
1145    PublicKey::from_key_file(key_file).validate()
1146}
1147
1148// ─── Internal validators ────────────────────────────────────────────────────
1149
1150/// Enforces the exact `.fcr` header shape the writer will emit against
1151/// the caller-supplied [`HeaderReadLimits`]. Mirrors the reader-side cap
1152/// checks in `container::read_encrypted_header`, but runs before any KDF,
1153/// ECDH, or output-file work.
1154///
1155/// Takes a [`NativeRecipientType`] rather than a `(type_name, body_len)`
1156/// pair so the type-name / body-length pair is bound by the registry
1157/// (impossible for a caller to mix `argon2id`'s name with `x25519`'s
1158/// body length), and so adding a future native recipient updates the
1159/// preflight automatically through the registry's accessors.
1160fn preflight_header_write_limits(
1161    limits: HeaderReadLimits,
1162    recipient_count: usize,
1163    native: NativeRecipientType,
1164) -> Result<(), CryptoError> {
1165    let type_name = native.type_name();
1166    let body_len = native.body_len();
1167
1168    // `RECIPIENT_COUNT_MAX = 4096` fits u16; saturating cast keeps the
1169    // cap diagnostic honest in the theoretical case of an in-memory
1170    // list above u16::MAX, while still surfacing the cap-exceeded
1171    // variant before later structural checks.
1172    let count_u16: u16 = u16::try_from(recipient_count).unwrap_or(u16::MAX);
1173    limits.enforce_recipient_count(count_u16)?;
1174
1175    // body_len is bounded by the canonical native `BODY_LENGTH` (≤ 116
1176    // today), so the saturating cast cannot fire. Defensive
1177    // fallback for a hypothetical future plugin recipient with a body
1178    // above `u32::MAX`: `enforce_recipient_body_len` rejects against
1179    // the per-entry cap (≤ `BODY_LEN_STRUCTURAL_MAX = 16 MiB`), so
1180    // `u32::MAX` always trips the cap.
1181    let body_len_u32: u32 = u32::try_from(body_len).unwrap_or(u32::MAX);
1182    limits.enforce_recipient_body_len(body_len_u32)?;
1183
1184    // Compute the exact `header_len` the writer will emit
1185    // (`header_fixed + recipient_count * per_entry`, with `ext_len = 0`
1186    // for current writers) and check it against the cap. All checked
1187    // arithmetic funnels into one shared overflow error.
1188    let overflow_err = || CryptoError::HeaderLenCapExceeded {
1189        header_len: u32::MAX,
1190        local_cap: limits.max_header_len,
1191    };
1192    let per_entry = (recipient::entry::ENTRY_HEADER_SIZE as u64)
1193        .checked_add(type_name.len() as u64)
1194        .and_then(|v| v.checked_add(body_len as u64))
1195        .ok_or_else(overflow_err)?;
1196    let total_entries = (recipient_count as u64)
1197        .checked_mul(per_entry)
1198        .ok_or_else(overflow_err)?;
1199    let header_len_u64 = (format::HEADER_FIXED_SIZE as u64)
1200        .checked_add(total_entries)
1201        .ok_or_else(overflow_err)?;
1202    let header_len = u32::try_from(header_len_u64).unwrap_or(u32::MAX);
1203    limits.enforce_header_len(header_len)?;
1204
1205    Ok(())
1206}
1207
1208/// Enforces the `FORMAT.md` §2.2 passphrase byte-length bound (1 to
1209/// [`MAX_PASSPHRASE_LEN_BYTES`](crate::crypto::kdf::MAX_PASSPHRASE_LEN_BYTES),
1210/// inclusive) at the public boundary, so an out-of-bound passphrase is
1211/// rejected before any input or archive work. Shares
1212/// [`crate::crypto::kdf::check_passphrase_len`] with the pre-Argon2id
1213/// gates, so the boundary and the key-derivation paths cannot drift.
1214pub(crate) fn validate_passphrase(passphrase: &SecretString) -> Result<(), CryptoError> {
1215    crate::crypto::kdf::check_passphrase_len(passphrase.expose_secret().as_bytes())
1216}