Skip to main content

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}