ferrocrypt/lib.rs
1//! # ferrocrypt
2//!
3//! High-level file encryption for files and directories.
4//!
5//! FerroCrypt writes `.fcr` files using one recipient-oriented container:
6//! one random per-file key, one streamed authenticated payload, and one or more
7//! typed recipient entries that independently wrap the same file key. The public
8//! API exposes this through [`Encryptor`] and [`Decryptor`] rather than through
9//! low-level cryptographic building blocks.
10//!
11//! ## Design goals
12//!
13//! - **Recipient-oriented encryption**: passphrases and public keys are native
14//! recipient schemes over the same `.fcr` container.
15//! - **Path-based file workflows**: archiving, streaming encryption, staging,
16//! and output naming are handled by the library.
17//! - **Typed routing**: [`Decryptor::open`] inspects the recipient list and
18//! returns a passphrase or private-key decryptor variant.
19//! - **Typed diagnostics**: operations return [`CryptoError`] values with
20//! structured format, KDF, recipient, authentication, and I/O failures.
21//!
22//! ## Quick start (passphrase recipient)
23//! ```rust,no_run
24//! use ferrocrypt::{Decryptor, Encryptor, CryptoError, secrecy::SecretString};
25//!
26//! # fn run() -> Result<(), CryptoError> {
27//! let passphrase = SecretString::from("correct horse battery staple".to_string());
28//!
29//! // Encrypt
30//! let encrypted = Encryptor::with_passphrase(passphrase.clone())
31//! .write("./secrets", "./out", |ev| eprintln!("{ev}"))?;
32//! println!("Encrypted to {}", encrypted.output_path.display());
33//!
34//! // Decrypt
35//! let restored = match Decryptor::open(&encrypted.output_path)? {
36//! Decryptor::Passphrase(d) => d.decrypt(passphrase, "./restored", |ev| eprintln!("{ev}"))?,
37//! Decryptor::PrivateKey(_) => unreachable!("we just encrypted with a passphrase"),
38//! _ => unreachable!("Decryptor is non_exhaustive; only Passphrase and PrivateKey exist today"),
39//! };
40//! println!("Decrypted to {}", restored.output_path.display());
41//! # Ok(()) }
42//! # fn main() { run().unwrap(); }
43//! ```
44//!
45//! ## Quick start (public-key recipients)
46//! ```rust,no_run
47//! use ferrocrypt::{
48//! Decryptor, Encryptor, generate_key_pair, PublicKey, PrivateKey,
49//! CryptoError, secrecy::SecretString,
50//! };
51//!
52//! # fn run() -> Result<(), CryptoError> {
53//! // 1) Generate X25519 keypair
54//! let passphrase = SecretString::from("my-key-pass".to_string());
55//! let keys = generate_key_pair("./keys", passphrase.clone(), |ev| eprintln!("{ev}"))?;
56//! println!("Fingerprint: {}", keys.fingerprint);
57//!
58//! // 2) Encrypt with the recipient's public key (no passphrase required)
59//! let encrypted = Encryptor::with_public_key(PublicKey::from_key_file(&keys.public_key_path))
60//! .write("./payload", "./out", |ev| eprintln!("{ev}"))?;
61//!
62//! // 3) Decrypt with the recipient's private key + passphrase
63//! let restored = match Decryptor::open(&encrypted.output_path)? {
64//! Decryptor::PrivateKey(d) => d.decrypt(
65//! PrivateKey::from_key_file(&keys.private_key_path),
66//! passphrase,
67//! "./restored",
68//! |ev| eprintln!("{ev}"),
69//! )?,
70//! Decryptor::Passphrase(_) => unreachable!("we just encrypted to a public key"),
71//! _ => unreachable!("Decryptor is non_exhaustive; only Passphrase and PrivateKey exist today"),
72//! };
73//! println!("Decrypted to {}", restored.output_path.display());
74//! # Ok(()) }
75//! # fn main() { run().unwrap(); }
76//! ```
77//!
78//! ## Choosing a recipient path
79//!
80//! - **Passphrase recipient**: use [`Encryptor::with_passphrase`] when the
81//! same passphrase should encrypt and decrypt the file. The resulting `.fcr`
82//! contains exactly one native `argon2id` recipient.
83//! - **Public-key recipient**: use [`Encryptor::with_public_key`] or
84//! [`Encryptor::with_public_keys`] when the sender should encrypt to one or
85//! more public recipient keys. Decryption requires a matching [`PrivateKey`]
86//! file and that key file's passphrase. This does not authenticate the sender.
87//!
88//! ## Format compatibility
89//!
90//! FerroCrypt stores four independent version bytes: `.fcr` outer-container
91//! version `0x01`, FCA archive version `0x01`, public-key encoding version
92//! `0x01`, and private-key encoding version `0x01`. The compatibility
93//! guarantee starts with the first stable `0.3.0` release. During the
94//! `0.3.0` pre-release series (`-alpha.N`, `-beta.N`, `-rc.N`), the format
95//! is not yet frozen: files written by a pre-release or by `main` carry no
96//! cross-version guarantee and may fail to decrypt under a later pre-release.
97//!
98//! Stable `0.3.0` establishes the `0.3.0` compatibility baseline
99//! (`FORMAT.md` §11.4): artifacts that use only the four stored versions
100//! above stay readable under every later release. A future release may
101//! introduce a new version in any one domain; that adds support without
102//! removing baseline read support.
103//!
104//! Older pre-`0.3.0` files and key pairs use a different format family and, for
105//! historical hybrid encryption, a different key-agreement stack. To migrate
106//! older data, decrypt it with the release that created it, then re-encrypt it
107//! with the current release.
108//!
109//! ## API stability
110//!
111//! The on-disk format is versioned independently from the crate; see
112//! *Format compatibility* above for its stability guarantee.
113//! The public Rust API ([`Encryptor`], [`Decryptor`], [`PublicKey`],
114//! [`PrivateKey`], the error types) is pre-1.0; it may change in minor releases
115//! (0.x → 0.y), while patch releases (0.x.y → 0.x.z) preserve it. See the
116//! repository [`CHANGELOG.md`](https://github.com/alexylon/ferrocrypt/blob/main/CHANGELOG.md)
117//! for release notes.
118//!
119//! ## Security notes
120//!
121//! - All cryptographic operations depend on a secure OS RNG; ensure the target
122//! platform provides one.
123//! - Sender authentication is out of scope; public-key encryption identifies who
124//! can decrypt, not who encrypted.
125//! - Ciphertext integrity is enforced; modification or wrong keys yield
126//! [`CryptoError`] results rather than corrupted plaintext.
127//! - Passphrase and key-pair writing reject Argon2id memory below a fixed
128//! 19 MiB floor with [`CryptoError::KdfBelowWriteFloor`], so a `.fcr` or
129//! `private.key` cannot be sealed with a weak passphrase work factor by
130//! accident. The floor applies only when writing; decryption accepts any
131//! structurally valid file.
132//! - FerroCrypt authenticates each file as written, but it does not detect
133//! replay or rollback to an older valid `.fcr`; use external versioning or a
134//! freshness check if that matters.
135//! - FerroCrypt encrypts file contents and, for directory inputs, internal
136//! names, tree structure, and per-file sizes. It does not hide the total
137//! ciphertext length (an approximate plaintext-size signal), recipient count,
138//! or that the file is a FerroCrypt `.fcr` container.
139//! - This crate is **not** third-party audited and is not advertised as
140//! compliance-certified.
141//!
142//! ## Error handling
143//!
144//! Every fallible operation returns `Result<T, CryptoError>`. See
145//! [`CryptoError`] for variant meanings and remediation hints.
146//!
147//! ## License
148//! Licensed under GPL-3.0-only. See the LICENSE file in the repository.
149
150#![forbid(unsafe_code)]
151#![warn(missing_docs)]
152#![warn(rustdoc::broken_intra_doc_links)]
153#![warn(rustdoc::bare_urls)]
154
155use std::path::PathBuf;
156
157/// The version of this crate, captured from `Cargo.toml` at compile time.
158///
159/// Exposed as a single source of truth so dependents — notably the FerroCrypt
160/// desktop app — can show the version that the release process actually bumps,
161/// rather than each crate carrying its own copy that can drift.
162pub const VERSION: &str = env!("CARGO_PKG_VERSION");
163
164pub use crate::api::{
165 Decryptor, Encryptor, KeyPairGenerator, PassphraseDecryptor, PrivateKeyDecryptor,
166 default_encrypted_filename, generate_key_pair, probe_recipient_mode,
167 probe_recipient_mode_with_limits, validate_private_key_file, validate_public_key_file,
168};
169pub use crate::archive::{ArchiveLimits, IncompleteOutputPolicy};
170pub use crate::container::HeaderReadLimits;
171pub use crate::crypto::kdf::{ARGON2_SALT_SIZE, KDF_PARAMS_SIZE, KdfLimit, KdfParams};
172pub use crate::error::{CryptoError, FormatDefect, InvalidKdfParams, UnsupportedVersion};
173pub use crate::format::{ENCRYPTED_EXTENSION, FCR_FILE_VERSION, MAGIC};
174pub use crate::key::files::{PRIVATE_KEY_FILENAME, PUBLIC_KEY_FILENAME};
175pub use crate::key::limits::KeyReadLimits;
176pub use crate::key::private::{PRIVATE_KEY_V1_VERSION, PRIVATE_KEY_VERSION};
177pub use crate::key::public::{PUBLIC_KEY_V1_VERSION, PUBLIC_KEY_VERSION};
178pub use crate::recipient::policy::MixingPolicy;
179
180pub use secrecy;
181
182/// Result of a cheap structural probe of an `.fcr` file's recipient list.
183///
184/// **Not a security claim.** [`probe_recipient_mode`] performs no KDF, no
185/// private-key operation, no credential prompt, no header-MAC verification,
186/// and no payload decryption. A positive result is not evidence that the file
187/// is authentic, decryptable, untampered, or well-formed beyond the structural
188/// shape required to classify it. Use only for UI / routing hints.
189///
190/// For an authenticated mode value — produced only after a recipient unwraps
191/// and the header MAC verifies — see [`AuthenticatedRecipientMode`] on
192/// [`DecryptOutcome`].
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194#[non_exhaustive]
195pub enum UnauthenticatedRecipientMode {
196 /// File contains exactly one native `argon2id` recipient and is decrypted
197 /// with a passphrase.
198 Passphrase,
199 /// File contains one or more native `x25519` public-key recipients and is
200 /// decrypted with a matching [`PrivateKey`].
201 PublicKey,
202}
203
204impl std::fmt::Display for UnauthenticatedRecipientMode {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 let label = match self {
207 Self::Passphrase => "passphrase",
208 Self::PublicKey => "public-key",
209 };
210 f.write_str(label)
211 }
212}
213
214impl UnauthenticatedRecipientMode {
215 /// Name of the credential the caller must supply to decrypt a file in
216 /// this mode. Used by the `DecryptorModeMismatch` error wording so the
217 /// message tells the caller which credential to switch to instead of
218 /// leaving them to infer it from the recipient kind.
219 pub(crate) const fn credential_name(self) -> &'static str {
220 match self {
221 Self::Passphrase => "a passphrase",
222 Self::PublicKey => "a private key",
223 }
224 }
225}
226
227/// Recipient mode established by a successful authenticated decrypt.
228///
229/// Constructed only inside the decrypt path after a recipient unwraps **and**
230/// the header MAC verifies. Cannot be forged from an
231/// [`UnauthenticatedRecipientMode`]: the wrapping struct has a private field
232/// and a `pub(crate)` constructor, so external callers can match on the
233/// exposed [`AuthenticatedRecipientModeKind`] but cannot manufacture a value
234/// that claims to be authenticated.
235///
236/// Surfaced on [`DecryptOutcome::recipient_mode`].
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub struct AuthenticatedRecipientMode {
239 kind: AuthenticatedRecipientModeKind,
240}
241
242/// Public, forward-compatible discriminant for
243/// [`AuthenticatedRecipientMode`]. The variant carries no authentication
244/// authority on its own — only an [`AuthenticatedRecipientMode`] value does,
245/// and that wrapper is unforgeable outside the crate.
246///
247/// `#[non_exhaustive]` keeps the door open for future native recipient kinds
248/// (post-quantum, hardware-backed) without breaking downstream `match`
249/// arms; callers must include a `_` wildcard arm. This is match
250/// ergonomics, not exhaustive matching.
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252#[non_exhaustive]
253pub enum AuthenticatedRecipientModeKind {
254 /// File was sealed with a passphrase recipient (`argon2id`).
255 Passphrase,
256 /// File was sealed to one or more public-key recipients (`x25519`).
257 PublicKey,
258}
259
260impl AuthenticatedRecipientMode {
261 pub(crate) const fn passphrase() -> Self {
262 Self {
263 kind: AuthenticatedRecipientModeKind::Passphrase,
264 }
265 }
266
267 pub(crate) const fn public_key() -> Self {
268 Self {
269 kind: AuthenticatedRecipientModeKind::PublicKey,
270 }
271 }
272
273 /// Returns the public discriminant for `match` ergonomics.
274 pub const fn kind(&self) -> AuthenticatedRecipientModeKind {
275 self.kind
276 }
277
278 /// `true` if the file authenticated as a passphrase recipient.
279 pub const fn is_passphrase(&self) -> bool {
280 matches!(self.kind, AuthenticatedRecipientModeKind::Passphrase)
281 }
282
283 /// `true` if the file authenticated as a public-key recipient.
284 pub const fn is_public_key(&self) -> bool {
285 matches!(self.kind, AuthenticatedRecipientModeKind::PublicKey)
286 }
287}
288
289impl std::fmt::Display for AuthenticatedRecipientModeKind {
290 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291 let label = match self {
292 Self::Passphrase => "passphrase",
293 Self::PublicKey => "public-key",
294 };
295 f.write_str(label)
296 }
297}
298
299impl std::fmt::Display for AuthenticatedRecipientMode {
300 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301 self.kind.fmt(f)
302 }
303}
304
305/// Structured progress signal emitted during encrypt, decrypt, and key
306/// generation.
307///
308/// Callers receive a reference to a `ProgressEvent` through the closure
309/// passed to each operation. The enum is `#[non_exhaustive]` so future
310/// phases (per-entry archive progress, byte counters, domain-specific
311/// stages) can be added without a breaking change — match arms in caller
312/// code must include a `_` wildcard.
313///
314/// For quick rendering, `ProgressEvent` implements [`std::fmt::Display`]
315/// with stable user-facing wording. Consumers that want richer UX
316/// (localization, phase-based icons, percent progress once available)
317/// can `match` on the variants.
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319#[non_exhaustive]
320pub enum ProgressEvent {
321 /// Argon2id is about to run to derive the passphrase wrap key for
322 /// an `argon2id` recipient. Emitted at the work boundary inside the
323 /// recipient module — after structural validation and resource-cap
324 /// checks, immediately before the KDF call. Fires zero times for a
325 /// pure public-key (X25519) `.fcr` and zero times when a malformed
326 /// `.fcr` is rejected before any KDF runs. May block for multiple
327 /// seconds.
328 DerivingPassphraseWrapKey,
329 /// Argon2id is about to run to unlock a `private.key` file. Emitted
330 /// at the work boundary inside the key-file reader — after structural
331 /// validation and resource-cap checks, immediately before the KDF
332 /// call. Fires zero times when the `private.key` is malformed and
333 /// rejected before any KDF runs. May block for multiple seconds.
334 UnlockingPrivateKey,
335 /// Encrypting a payload. Emitted once per encrypt call.
336 Encrypting,
337 /// Decrypting a payload. Emitted once per decrypt call.
338 Decrypting,
339 /// Generating an X25519 key pair. Covers the entire generation flow,
340 /// including the Argon2id-driven sealing of `private.key`. The library
341 /// does not emit a nested [`Self::DerivingPassphraseWrapKey`] inside
342 /// keygen; this event already signals the long pause.
343 GeneratingKeyPair,
344}
345
346impl std::fmt::Display for ProgressEvent {
347 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348 let msg = match self {
349 Self::DerivingPassphraseWrapKey => "Deriving passphrase key\u{2026}",
350 Self::UnlockingPrivateKey => "Unlocking private key\u{2026}",
351 Self::Encrypting => "Encrypting\u{2026}",
352 Self::Decrypting => "Decrypting\u{2026}",
353 Self::GeneratingKeyPair => "Generating key pair\u{2026}",
354 };
355 f.write_str(msg)
356 }
357}
358
359pub use crate::key::private::PrivateKey;
360pub use crate::key::public::PublicKey;
361
362/// Successful outcome of an [`Encryptor::write`] call.
363#[derive(Debug, Clone, PartialEq, Eq)]
364#[non_exhaustive]
365pub struct EncryptOutcome {
366 /// Path of the resulting `.fcr` file.
367 pub output_path: PathBuf,
368}
369
370/// Successful outcome of [`PassphraseDecryptor::decrypt`] or
371/// [`PrivateKeyDecryptor::decrypt`].
372#[derive(Debug, Clone, PartialEq, Eq)]
373#[non_exhaustive]
374pub struct DecryptOutcome {
375 /// Path to the extracted file or directory.
376 pub output_path: PathBuf,
377 /// Recipient mode established by the authenticated decrypt: recipient
378 /// unwrap plus header MAC verification. This value is unforgeable by
379 /// callers; only the decrypt path can construct it. Distinct from the
380 /// cheap pre-authentication [`UnauthenticatedRecipientMode`] returned by
381 /// [`probe_recipient_mode`].
382 pub recipient_mode: AuthenticatedRecipientMode,
383}
384
385/// Successful outcome of [`generate_key_pair`] or
386/// [`KeyPairGenerator::write`].
387#[derive(Debug, Clone, PartialEq, Eq)]
388#[non_exhaustive]
389pub struct KeyGenOutcome {
390 /// Path to the generated private key file.
391 pub private_key_path: PathBuf,
392 /// Path to the generated public key file.
393 pub public_key_path: PathBuf,
394 /// SHA3-256 fingerprint of the public key (64-char hex string).
395 pub fingerprint: String,
396}
397
398mod api;
399mod archive;
400mod container;
401mod crypto;
402mod error;
403mod format;
404mod fs;
405mod key;
406mod protocol;
407mod recipient;
408
409#[cfg(feature = "fuzzing")]
410pub mod fuzz_exports;
411
412#[cfg(test)]
413mod suite_vector_gen;
414
415/// Decodes a Bech32 recipient string (`fcr1…`) into raw X25519 public-key
416/// material.
417///
418/// Validates HRP, BIP 173 checksum, internal SHA3-256 checksum,
419/// payload structural fields, type-name grammar, the
420/// [`KeyReadLimits::RECIPIENT_STRING_CHARS_DEFAULT`] recipient-string
421/// cap, and the X25519 payload constraints:
422/// `type_name == "x25519"` and exactly 32 bytes of non-zero,
423/// canonically encoded key material (`FORMAT.md` §2.4 / §7).
424///
425/// This is the low-level primitive; most callers should prefer
426/// [`PublicKey::from_recipient_string`] or
427/// `"fcr1…".parse::<PublicKey>()`, which wrap this function and yield
428/// a typed [`PublicKey`].
429///
430/// # Errors
431///
432/// Returns [`CryptoError::InvalidFormat`] with
433/// [`FormatDefect::MalformedPublicKey`] for non-ASCII, uppercase, invalid
434/// Bech32, or wrong-HRP strings, for malformed typed payloads and checksum
435/// mismatches, and for all-zero or non-canonical X25519 public keys; a payload
436/// type name that is not valid UTF-8 or breaks the type-name grammar reports
437/// [`FormatDefect::MalformedTypeName`] instead. Returns
438/// [`CryptoError::UnsupportedVersion`] for a recipient string from an
439/// unsupported keypair suite. Returns [`CryptoError::UnsupportedKeyType`] for a
440/// valid recipient string of a key type this build does not support. Returns
441/// [`CryptoError::RecipientStringCapExceeded`] when the input exceeds the local
442/// recipient-string cap.
443pub fn decode_recipient_string(recipient_string: &str) -> Result<[u8; 32], CryptoError> {
444 key::public::decode_x25519_recipient(recipient_string)
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 /// Routes a `.fcr` file with a single `argon2id` recipient as
452 /// `UnauthenticatedRecipientMode::Passphrase`, mirroring the "exactly one
453 /// argon2id => Passphrase" classification rule. Builds the file
454 /// via `container::build_encrypted_header` so the test exercises
455 /// the same byte path the real encrypt would write.
456 #[test]
457 fn probe_recipient_mode_routes_argon2id_recipient_as_passphrase() {
458 let header_key =
459 crypto::keys::HeaderKey::from_bytes_for_tests([0x42u8; crypto::mac::HMAC_KEY_SIZE]);
460 let payload_key = crypto::keys::PayloadKey::from_bytes_for_tests(
461 [0u8; crypto::keys::ENCRYPTION_KEY_SIZE],
462 );
463 let stream_nonce = [0x07u8; crypto::stream::STREAM_NONCE_SIZE];
464 let entry = recipient::RecipientEntry::native(
465 recipient::policy::NativeRecipientType::Argon2id,
466 vec![0u8; recipient::argon2id::BODY_LENGTH],
467 )
468 .unwrap();
469 let built = container::build_encrypted_header(
470 &[entry],
471 b"",
472 stream_nonce,
473 payload_key,
474 &header_key,
475 )
476 .unwrap();
477
478 let tmp = tempfile::NamedTempFile::new().unwrap();
479 let mut bytes = Vec::new();
480 bytes.extend_from_slice(&built.prefix_bytes);
481 bytes.extend_from_slice(&built.header_bytes);
482 bytes.extend_from_slice(&built.header_mac);
483 std::fs::write(tmp.path(), &bytes).unwrap();
484
485 assert_eq!(
486 probe_recipient_mode(tmp.path()).unwrap(),
487 Some(UnauthenticatedRecipientMode::Passphrase)
488 );
489 }
490
491 /// Routes a `.fcr` file with one `x25519` recipient as
492 /// `UnauthenticatedRecipientMode::PublicKey`.
493 #[test]
494 fn probe_recipient_mode_routes_x25519_recipient_as_public_key() {
495 let header_key =
496 crypto::keys::HeaderKey::from_bytes_for_tests([0x42u8; crypto::mac::HMAC_KEY_SIZE]);
497 let payload_key = crypto::keys::PayloadKey::from_bytes_for_tests(
498 [0u8; crypto::keys::ENCRYPTION_KEY_SIZE],
499 );
500 let stream_nonce = [0x07u8; crypto::stream::STREAM_NONCE_SIZE];
501 // 0x11 filler keeps the ephemeral-key field non-zero and
502 // canonical, so the body passes the structural preflight.
503 let entry = recipient::RecipientEntry::native(
504 recipient::policy::NativeRecipientType::X25519,
505 vec![0x11u8; recipient::x25519::BODY_LENGTH],
506 )
507 .unwrap();
508 let built = container::build_encrypted_header(
509 &[entry],
510 b"",
511 stream_nonce,
512 payload_key,
513 &header_key,
514 )
515 .unwrap();
516
517 let tmp = tempfile::NamedTempFile::new().unwrap();
518 let mut bytes = Vec::new();
519 bytes.extend_from_slice(&built.prefix_bytes);
520 bytes.extend_from_slice(&built.header_bytes);
521 bytes.extend_from_slice(&built.header_mac);
522 std::fs::write(tmp.path(), &bytes).unwrap();
523
524 assert_eq!(
525 probe_recipient_mode(tmp.path()).unwrap(),
526 Some(UnauthenticatedRecipientMode::PublicKey)
527 );
528 }
529
530 /// A non-FerroCrypt file (first 4 bytes are not `FCR\0`) must
531 /// route to `Ok(None)` so the encrypt path can treat it as
532 /// plaintext. The strict-detection refactor must not regress
533 /// this.
534 #[test]
535 fn probe_recipient_mode_returns_none_for_non_fcr_file() {
536 let plaintext = b"this is just a regular text file with no magic at all";
537 let tmp = tempfile::NamedTempFile::new().unwrap();
538 std::fs::write(tmp.path(), plaintext).unwrap();
539 assert_eq!(probe_recipient_mode(tmp.path()).unwrap(), None);
540 }
541
542 /// An empty file must route to `Ok(None)` (0 bytes < 4 magic
543 /// bytes; the magic test fails and detection returns None).
544 #[test]
545 fn probe_recipient_mode_returns_none_for_empty_file() {
546 let tmp = tempfile::NamedTempFile::new().unwrap();
547 std::fs::write(tmp.path(), b"").unwrap();
548 assert_eq!(probe_recipient_mode(tmp.path()).unwrap(), None);
549 }
550
551 /// Lock in the exact user-facing Display text for every `ProgressEvent`
552 /// variant. CLI and desktop surface `{event}` directly, so a silent
553 /// wording change would be a visible UX regression and a format-level
554 /// regression for any tooling that parses the strings. If a message
555 /// needs to change, update this test in the same commit so the intent
556 /// is reviewable.
557 #[test]
558 fn progress_events_display_exact_strings() {
559 assert_eq!(
560 ProgressEvent::DerivingPassphraseWrapKey.to_string(),
561 "Deriving passphrase key\u{2026}"
562 );
563 assert_eq!(
564 ProgressEvent::UnlockingPrivateKey.to_string(),
565 "Unlocking private key\u{2026}"
566 );
567 assert_eq!(ProgressEvent::Encrypting.to_string(), "Encrypting\u{2026}");
568 assert_eq!(ProgressEvent::Decrypting.to_string(), "Decrypting\u{2026}");
569 assert_eq!(
570 ProgressEvent::GeneratingKeyPair.to_string(),
571 "Generating key pair\u{2026}"
572 );
573 }
574
575 /// `decode_recipient_string` shares the canonical `decode_x25519_recipient`
576 /// path with `PublicKey::from_recipient_string`, so it must inherit
577 /// the all-zero input rejection. Pin the contract directly at the
578 /// free-function entry so a future refactor that bypasses the
579 /// canonical decoder cannot let a degenerate value through this surface.
580 #[test]
581 fn decode_recipient_rejects_all_zero_public_key() {
582 let s = key::public::encode_recipient_string_unchecked(
583 recipient::x25519::TYPE_NAME,
584 &[0u8; 32],
585 )
586 .unwrap();
587 match decode_recipient_string(&s) {
588 Err(CryptoError::InvalidFormat(FormatDefect::MalformedPublicKey)) => {}
589 other => panic!("expected MalformedPublicKey, got {other:?}"),
590 }
591 }
592
593 /// Pin `UnauthenticatedRecipientMode`'s rendered strings at the type's
594 /// home so a future variant rename or label tweak surfaces here, not only
595 /// via the indirect `DecryptorModeMismatch` Display test in `error.rs`.
596 /// `#[non_exhaustive]` is irrelevant inside the defining crate — adding a
597 /// new variant produces a compile error here until the test handles it.
598 #[test]
599 fn unauthenticated_recipient_mode_display_pinned_strings() {
600 assert_eq!(
601 UnauthenticatedRecipientMode::Passphrase.to_string(),
602 "passphrase"
603 );
604 assert_eq!(
605 UnauthenticatedRecipientMode::PublicKey.to_string(),
606 "public-key"
607 );
608 match UnauthenticatedRecipientMode::Passphrase {
609 UnauthenticatedRecipientMode::Passphrase | UnauthenticatedRecipientMode::PublicKey => {}
610 }
611 }
612
613 /// Mirror the `UnauthenticatedRecipientMode` Display pin for the
614 /// authenticated kind discriminant. The strings are deliberately
615 /// identical to the unauthenticated side so downstream rendering
616 /// (logs, audit trails) does not need to branch on authentication
617 /// state. The two enums (`UnauthenticatedRecipientMode` and
618 /// `AuthenticatedRecipientModeKind`) keep separate Display impls so
619 /// either side can drift without dragging the other; the wrapper
620 /// `AuthenticatedRecipientMode::Display` delegates to its `kind`, so
621 /// the wrapper inherits whatever the discriminant prints.
622 #[test]
623 fn authenticated_recipient_mode_display_pinned_strings() {
624 assert_eq!(
625 AuthenticatedRecipientModeKind::Passphrase.to_string(),
626 "passphrase"
627 );
628 assert_eq!(
629 AuthenticatedRecipientModeKind::PublicKey.to_string(),
630 "public-key"
631 );
632 // Wrapper delegates to its kind.
633 assert_eq!(
634 AuthenticatedRecipientMode::passphrase().to_string(),
635 "passphrase"
636 );
637 assert_eq!(
638 AuthenticatedRecipientMode::public_key().to_string(),
639 "public-key"
640 );
641 }
642
643 /// Lock in the kind discriminator so the public exhaustive-match surface
644 /// stays a stable shape: callers use `kind()` to switch on the variant
645 /// without touching the sealed wrapper.
646 #[test]
647 fn authenticated_recipient_mode_kind_round_trips() {
648 assert_eq!(
649 AuthenticatedRecipientMode::passphrase().kind(),
650 AuthenticatedRecipientModeKind::Passphrase
651 );
652 assert_eq!(
653 AuthenticatedRecipientMode::public_key().kind(),
654 AuthenticatedRecipientModeKind::PublicKey
655 );
656 assert!(AuthenticatedRecipientMode::passphrase().is_passphrase());
657 assert!(!AuthenticatedRecipientMode::passphrase().is_public_key());
658 assert!(AuthenticatedRecipientMode::public_key().is_public_key());
659 assert!(!AuthenticatedRecipientMode::public_key().is_passphrase());
660 }
661}