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}