Skip to main content

secure_gate/
fixed.rs

1//! Stack-allocated wrapper for fixed-size secrets.
2//!
3//! [`Fixed<T>`] is a zero-cost wrapper that enforces explicit, auditable access to
4//! sensitive data stored inline on the stack. It is the primary secret type for
5//! fixed-length material such as cryptographic keys, nonces, and seeds.
6//!
7//! # Security invariants
8//!
9//! - **No `Deref`, `AsRef`, or `Copy`** — the inner value cannot leak through
10//!   implicit conversions.
11//! - **`Debug` always prints `[REDACTED]`** — secrets never appear in logs or
12//!   panic messages.
13//! - **Unconditional zeroization on drop** — the inner `T` is overwritten with
14//!   zeroes when the wrapper is dropped, even on error paths.
15//! - **Opt-in `Clone`** — requires `T: CloneableSecret` and the `cloneable` feature.
16//! - **Opt-in `Serialize`** — requires the `SerializableSecret` marker and the
17//!   `serde-serialize`/`serde-deserialize` features.
18//! - **Avoid move-by-value for long-lived secrets.** Each move of a `Fixed<T>`
19//!   bitwise-copies the bytes to a new location and leaves the original stack
20//!   slot uncleared until a later frame overwrites it. Pass `&Fixed<T>` /
21//!   `&mut Fixed<T>` by reference, keep the wrapper short-scope, or use
22//!   [`Dynamic<T>`](crate::Dynamic) for long-lived material (heap-only — no
23//!   stack residue surface). See `SECURITY.md` § "Inherent Rust Limitations".
24//!
25//! # Construction
26//!
27//! | Constructor | Notes |
28//! |---|---|
29//! | [`Fixed::new(value)`](Fixed::new) | Ergonomic default; `const fn`. |
30//! | [`Fixed::new_with(f)`](Fixed::new_with) | Scoped — preferred for stack-residue minimization. |
31//! | [`Fixed::try_new_with(f)`](Fixed::try_new_with) | Scoped, for a fill that can fail; wipes on `Err`. |
32//!
33//! Prefer [`new_with`](Fixed::new_with) in high-assurance code: it writes directly
34//! into the wrapper's storage, avoiding the intermediate stack copy that `new` may
35//! produce.
36//!
37//! # 3-tier access model
38//!
39//! ```rust
40//! use secure_gate::{Fixed, RevealSecret, RevealSecretMut};
41//!
42//! let mut secret = Fixed::new([1u8, 2, 3, 4]);
43//!
44//! // Tier 1 — scoped (preferred): borrow is confined to the closure.
45//! let sum = secret.with_secret(|arr| arr.iter().sum::<u8>());
46//! assert_eq!(sum, 10);
47//!
48//! // Tier 2 — direct: returns a reference. Use as an escape hatch.
49//! let first: u8 = secret.expose_secret()[0];
50//! assert_eq!(first, 1);
51//!
52//! // Tier 1 mutable — scoped mutation (preferred over Tier 2 mutable).
53//! secret.with_secret_mut(|arr| arr[0] = 0xFF);
54//!
55//! // Tier 3 — owned: consumes the wrapper for final use.
56//! let owned = secret.into_inner();
57//! ```
58//!
59//! # Warning: no `static` secrets
60//!
61//! `Drop` does not run on `static` items. Placing a `Fixed` in a `static` or
62//! `lazy_static!` will **skip zeroization**. Always use stack or heap allocation.
63//!
64//! Also ensure your profile sets `panic = "unwind"` — `panic = "abort"` skips
65//! destructors and therefore skips zeroization.
66//!
67//! # Import path
68//!
69//! All public items are re-exported at the crate root. Use:
70//!
71//! ```rust
72//! use secure_gate::Fixed;
73//! ```
74//!
75//! Not `secure_gate::fixed::Fixed`.
76//!
77//! # See also
78//!
79//! - [`Dynamic<T>`](crate::Dynamic) — heap-allocated alternative for variable-length
80//!   secrets (passwords, API keys, ciphertexts). Requires the `alloc` feature.
81//!
82//! # Examples
83//!
84//! ```rust
85//! use secure_gate::{Fixed, RevealSecret};
86//!
87//! let secret = Fixed::new([1u8, 2, 3, 4]);
88//! let sum = secret.with_secret(|arr| arr.iter().sum::<u8>());
89//! assert_eq!(sum, 10);
90//! ```
91
92use crate::RevealSecret;
93use crate::RevealSecretMut;
94
95#[cfg(all(feature = "encoding-base32", feature = "alloc"))]
96use crate::traits::encoding::base32::ToBase32;
97#[cfg(all(feature = "encoding-base64", feature = "alloc"))]
98use crate::traits::encoding::base64_url::ToBase64Url;
99#[cfg(all(feature = "encoding-bech32", feature = "alloc"))]
100use crate::traits::encoding::bech32::ToBech32;
101#[cfg(all(feature = "encoding-bech32", feature = "alloc"))]
102use crate::traits::encoding::bech32m::ToBech32m;
103#[cfg(all(feature = "encoding-hex", feature = "alloc"))]
104use crate::traits::encoding::hex::ToHex;
105
106#[cfg(feature = "rand")]
107use rand::{rngs::OsRng, TryCryptoRng, TryRngCore};
108use zeroize::Zeroize;
109
110/// Drains a validated Bech32/Bech32m payload into a stack buffer.
111///
112/// Shared by the four `try_from_bech32*` constructors. Copies exactly `N`
113/// payload bytes into a `Zeroizing<[u8; N]>`; keeps counting (without storing)
114/// past `N` so the length-mismatch error reports the exact decoded length.
115/// The iteration count is bounded by the checksum-validated input string, so
116/// oversized inputs cost at most one bounded pass. Works without `alloc`.
117#[cfg(feature = "encoding-bech32")]
118fn drain_bech32_payload<const N: usize>(
119    checked: &bech32::primitives::decode::CheckedHrpstring<'_>,
120) -> Result<zeroize::Zeroizing<[u8; N]>, crate::error::Bech32Error> {
121    let mut buf = zeroize::Zeroizing::new([0u8; N]);
122    let mut count = 0usize;
123    for byte in checked.byte_iter() {
124        if count < N {
125            buf[count] = byte;
126        }
127        count += 1;
128    }
129    if count != N {
130        return Err(crate::error::Bech32Error::InvalidLength {
131            expected: N,
132            got: count,
133        });
134    }
135    Ok(buf)
136    // On the error path, buf is zeroized on drop.
137}
138
139/// Zero-cost stack-allocated wrapper for fixed-size secrets.
140///
141/// `Fixed<T>` stores a `T: Zeroize` value inline and unconditionally zeroizes it
142/// on drop. There is no `Deref`, `AsRef`, or `Copy` — every access is explicit
143/// through [`RevealSecret`] or [`RevealSecretMut`].
144///
145/// # Examples
146///
147/// ```rust
148/// use secure_gate::{Fixed, RevealSecret};
149///
150/// // Create a secret key.
151/// let key = Fixed::new([0xABu8; 32]);
152///
153/// // Scoped access — the borrow cannot escape the closure.
154/// let first = key.with_secret(|k| k[0]);
155/// assert_eq!(first, 0xAB);
156///
157/// // Debug is always redacted.
158/// assert_eq!(format!("{:?}", key), "[REDACTED]");
159/// ```
160///
161/// # Constructors
162///
163/// | Constructor | Feature | Notes |
164/// |---|---|---|
165/// | [`new(value)`](Self::new) | — | `const fn`, ergonomic default |
166/// | [`new_with(f)`](Self::new_with) | — | Scoped; preferred for stack-residue minimization |
167/// | [`try_new_with(f)`](Self::try_new_with) | — | Scoped, fallible fill; zeroizes the partial write on `Err` |
168/// | [`From<[u8; N]>`](#impl-From<%5Bu8;+N%5D>-for-Fixed<%5Bu8;+N%5D>) | — | Equivalent to `new` |
169/// | [`TryFrom<&[u8]>`](#impl-TryFrom<%26%5Bu8%5D>-for-Fixed<%5Bu8;+N%5D>) | — | Length-checked slice conversion |
170/// | [`try_from_hex`](Self::try_from_hex) | `encoding-hex` | Constant-time hex decoding |
171/// | [`try_from_base32`](Self::try_from_base32) | `encoding-base32` | Constant-time Base32 decoding |
172/// | [`try_from_base64url`](Self::try_from_base64url) | `encoding-base64` | Constant-time Base64url decoding |
173/// | [`try_from_bech32`](Self::try_from_bech32) | `encoding-bech32` | HRP-validated Bech32 decoding |
174/// | [`try_from_bech32_unchecked`](Self::try_from_bech32_unchecked) | `encoding-bech32` | Bech32 without HRP check |
175/// | [`try_from_bech32m`](Self::try_from_bech32m) | `encoding-bech32` | HRP-validated Bech32m decoding |
176/// | [`try_from_bech32m_unchecked`](Self::try_from_bech32m_unchecked) | `encoding-bech32` | Bech32m without HRP check |
177/// | [`from_random()`](Self::from_random) | `rand` | System RNG |
178/// | [`from_rng(rng)`](Self::from_rng) | `rand` | Custom RNG |
179///
180/// # What `T` may be
181///
182/// [`Fixed::new`] requires [`FixedStorage`](crate::FixedStorage) on the inner type: a
183/// type that owns no buffer whose capacity can change. This is what makes the documented
184/// claim that `Fixed` has no reallocation surface true rather than aspirational. It used
185/// to be bounded only by `Zeroize`, so `Fixed<Vec<u8>>`, `Fixed<String>` and even
186/// `Fixed<[Vec<u8>; 2]>` compiled and abandoned an unwiped buffer holding the whole
187/// secret on any capacity change. Arrays, the primitives, tuples, `Option` and boxed
188/// slices satisfy the marker already; a custom inner type writes one line. For a
189/// growable payload use [`Dynamic`](crate::Dynamic), which documents the residue and
190/// offers one safe growth path.
191///
192/// The bound is on the constructor rather than on the struct, so naming
193/// `Fixed<Vec<u8>>` still compiles and no value of it can exist — the same shape as the
194/// zero-size guard below.
195///
196/// # Zero-size
197///
198/// A value of zero size has nothing to protect, so a `Fixed` cannot be built around
199/// one. [`new`](Self::new) and [`new_with`](Self::new_with) each assert a non-zero size
200/// in a `const`, and every other constructor in the table above funnels through those
201/// two, so `Fixed<[u8; 0]>` — and any other zero-sized inner type — is a compile error
202/// however it is reached. The assertion is evaluated at monomorphization rather than at
203/// the declaration, so it fires at the first concrete zero-sized construction, including
204/// one reached through generic code; generic code that is never instantiated with a
205/// zero-sized `T` is unaffected, and naming such a type without constructing one still
206/// compiles.
207///
208/// The diagnostic names the offending type in the failing constant's path — for example
209/// `Fixed::<[u8; 0]>::NON_ZERO_SIZED` — and a `while instantiating` note repeats the span of
210/// the `new` call the monomorphization reached — the error's own span is the assertion
211/// here, not in your code. The note does not name the instantiation that caused it: for a generic `fn build<const N: usize>()`, calling `build::<0>()` reports
212/// against the `Fixed::new` line inside `build`, and the `::<0>` call site appears nowhere.
213///
214/// **Two limits on when it fires, both measured.** It is raised during codegen, so
215/// `cargo check` does not report it. And it only fires for a *codegen root*: a non-generic
216/// `#[inline]` function in a library is not one, and every method the newtype macros
217/// generate carries an inline attribute, so a library whose only zero-sized constructions sit
218/// behind `#[inline]` or generic code builds, tests and publishes clean — the error then
219/// surfaces in each downstream crate that instantiates it, blaming this crate and the
220/// dependency's macro invocation rather than the consumer's call.
221///
222/// Root-ness is a property of the compiler and the profile, not of the attribute alone, so
223/// removing `#[inline]` does not reliably restore the error. Measured: on 1.85 a library
224/// exporting a plain non-generic `pub fn empty() -> Empty { Empty::new([]) }` fails
225/// `cargo build` and `cargo test` but passes `cargo build --release`, because the
226/// cross-crate-inlining heuristic added in 1.75 drops a small function from the exported
227/// root set in an optimized build; on 1.70 the same crate fails in both profiles. Treat
228/// instantiating the type in a test or a binary as the only reliable check.
229///
230/// Stated precisely: no *value* of a zero-sized `Fixed` can exist at runtime, because
231/// nothing can construct one, and a binary or test that tries fails to compile. The guard
232/// does not stop a library from exporting an unusable zero-sized API. Neither limit is a
233/// choice — a condition on a generic parameter has nothing to evaluate until that parameter
234/// is known, and nothing on stable Rust moves it earlier.
235///
236/// [`Dynamic`](crate::Dynamic) has no compile-time counterpart, for a reason about its
237/// payload rather than its own size: whether a `Vec` or `String` is empty is a runtime
238/// property, so there is nothing for a compile-time check to decide. A statically
239/// zero-sized inner type is the gap that leaves — `Dynamic<Zst>` constructs where
240/// `Fixed<Zst>` does not.
241///
242/// # RustCrypto integration
243///
244/// RustCrypto's `BlockEncrypt`/`BlockDecrypt` take `&mut GenericArray<u8, U16>`, and
245/// `GenericArray::from_mut_slice` **borrows** its argument instead of copying it. That
246/// is what lets a block cipher run *inside* the wrapper, so the plaintext never
247/// materializes in memory nothing is obliged to wipe:
248///
249/// ```rust
250/// use aes::cipher::{generic_array::GenericArray, BlockDecrypt, KeyInit};
251/// use aes::Aes128;
252/// use secure_gate::{Fixed, RevealSecretMut};
253///
254/// let cipher = Aes128::new(GenericArray::from_slice(&[0x42u8; 16]));
255/// let mut block: Fixed<[u8; 16]> = Fixed::new([0u8; 16]);
256///
257/// // `from_mut_slice` reborrows the wrapper's own storage, so zeroize-on-drop still
258/// // covers every path out of this scope — early returns and panics included.
259/// block.with_secret_mut(|b| cipher.decrypt_block(GenericArray::from_mut_slice(b)));
260/// ```
261///
262/// The shape type inference nudges you toward is the footgun. It compiles, it is the
263/// easy thing to reach for, and it defeats the wrapper:
264///
265/// ```rust
266/// # use aes::cipher::{generic_array::GenericArray, BlockDecrypt, KeyInit};
267/// # use aes::Aes128;
268/// # use secure_gate::{Fixed, RevealSecret};
269/// # let cipher = Aes128::new(GenericArray::from_slice(&[0x42u8; 16]));
270/// # let block: Fixed<[u8; 16]> = Fixed::new([0u8; 16]);
271/// // Copies the secret OUT. `copy` is an ordinary stack value: no zeroization on
272/// // drop, and it holds plaintext-equivalent bytes for the rest of the scope.
273/// let mut copy = block.with_secret(|b| aes::Block::from(*b));
274/// cipher.decrypt_block(&mut copy);
275/// ```
276///
277/// The same reasoning applies to [`Dynamic`](crate::Dynamic)`<Vec<u8>>` through
278/// [`with_secret_mut`](crate::RevealSecretMut::with_secret_mut), and to the
279/// slice-at-a-time APIs (`decrypt_blocks`, `apply_keystream`), which take
280/// `&mut [Block]` and `&mut [u8]` and so borrow just as well.
281///
282/// # See also
283///
284/// - [`RevealSecret`] / [`RevealSecretMut`] — the 3-tier access traits.
285/// - [`new_with`](Self::new_with) — scoped constructor preferred over [`new`](Self::new).
286///
287/// # Note
288///
289/// `const fn new` compiles in `static` position, but **must not** be used there
290/// because `Drop` does not run on statics, which means zeroization is skipped.
291#[must_use = "Fixed<T> holds secret material; dropping it on the floor usually indicates a bug — bind with `let _name = ...` or chain a method call"]
292pub struct Fixed<T: zeroize::Zeroize> {
293    inner: T,
294}
295
296impl<T: zeroize::Zeroize> Fixed<T> {
297    // Post-monomorphization guard: a zero-sized `T` has nothing to protect. Fires at the
298    // first concrete construction, never at the declaration. An associated `const` rather
299    // than an inline `const {}` block so the same hunk builds on the 0.8 line's MSRV 1.70.
300    const NON_ZERO_SIZED: () = assert!(
301        ::core::mem::size_of::<T>() > 0,
302        "secure-gate: Fixed<T> cannot hold a zero-sized value; there is nothing to protect"
303    );
304
305    /// Creates a new [`Fixed<T>`] by wrapping a value.
306    ///
307    /// This is a `const fn`, so it can be evaluated at compile time. However,
308    /// **do not** use it to initialize `static` items — `Drop` does not run on
309    /// statics, so zeroization would be skipped.
310    ///
311    /// Prefer [`new_with`](Fixed::new_with) when `T: `[`SentinelValue`](crate::SentinelValue)
312    /// and minimizing stack residue matters: `new` may leave an intermediate copy
313    /// of `value` on the caller's stack frame.
314    ///
315    /// A zero-sized `T` is rejected at compile time; see [Zero-size](#zero-size).
316    ///
317    /// # Examples
318    ///
319    /// ```rust
320    /// use secure_gate::{Fixed, RevealSecret, SecretLen};
321    ///
322    /// let secret = Fixed::new([0u8; 32]);
323    /// assert_eq!(secret.len(), 32);
324    /// ```
325    #[inline(always)]
326    pub const fn new(value: T) -> Self
327    where
328        T: crate::FixedStorage,
329    {
330        // Binding the unit-valued const is what forces it to be evaluated; clippy reads
331        // that as a pointless binding. Kept explicit because the 1.70 lint (the 0.8
332        // line's MSRV) fires on every spelling that still triggers the evaluation.
333        #[allow(clippy::let_unit_value)]
334        let () = Self::NON_ZERO_SIZED;
335        Fixed { inner: value }
336    }
337
338    /// Writes directly into the wrapper's storage via a user-supplied closure,
339    /// eliminating the intermediate stack copy that [`new`](Self::new) may produce.
340    ///
341    /// The slot is initialized to [`T::sentinel_value()`](crate::SentinelValue::sentinel_value)
342    /// before the closure runs. Prefer this over [`new(value)`](Self::new) when
343    /// minimizing stack residue matters (long-lived keys, high-assurance environments).
344    ///
345    /// # The slot's starting content is a contract, not an accident
346    ///
347    /// For `T = [E; N]` with `E: Default` — every array this crate ever hands you —
348    /// [`SentinelValue`](crate::SentinelValue)'s own contract fixes that starting value
349    /// at `[E::default(); N]`, all-zero for the byte and integer arrays this type is
350    /// built around. That is a promise `SentinelValue` makes on purpose (see its
351    /// "Provided implementations" contract), not a side effect of how the sentinel
352    /// happens to be built today, so a caller may depend on it exactly as it depends on
353    /// any other documented behavior.
354    ///
355    /// Concretely: a closure that writes fewer than `N` elements — a
356    /// [`SlotWriter`](crate::SlotWriter) doing RFC 9180-style concatenation that falls
357    /// short of the slot, a key derivation shorter than its buffer — leaves the
358    /// untouched tail at zero rather than at whatever bytes happened to be on the
359    /// stack. Code that treats that tail as zero-padding, instead of re-zeroing it
360    /// itself before returning, is relying on this guarantee; the
361    /// [`SlotWriter`](crate::SlotWriter) documentation's "40-bit key, zero-padded to
362    /// 16" example is exactly that reliance, asserted in a doctest.
363    ///
364    /// Requires [`FixedStorage`](crate::FixedStorage) and
365    /// [`SentinelValue`](crate::SentinelValue) on `T`. Arrays of a `Default`
366    /// element already have both; a custom inner type needs the same pair
367    /// `into_inner` already asks for.
368    ///
369    /// # `T` must be inferable at the call site
370    ///
371    /// This constructor is generic over `T`, so the closure's parameter type is
372    /// not known until `T` is. A return type alone does not resolve it in time:
373    /// `fn k() -> Fixed<[u8; 32]> { Fixed::new_with(|a| a.fill(1)) }` fails with
374    /// **E0282**, `type annotations needed for &mut _`, because the closure body
375    /// is checked before the expected return type reaches `T`. Name the type in
376    /// either position — both compile, pick whichever reads better:
377    ///
378    /// ```rust
379    /// use secure_gate::Fixed;
380    ///
381    /// // On the type:
382    /// let a = Fixed::<[u8; 32]>::new_with(|x| x.fill(1));
383    /// // Or on the closure parameter:
384    /// let b = Fixed::new_with(|x: &mut [u8; 32]| x.fill(1));
385    /// # let _ = (a, b);
386    /// ```
387    ///
388    /// Before 0.8.0-rc.13 this method was inherent to `Fixed<[u8; N]>`, where the
389    /// parameter was always a byte array and inference had nothing to resolve. A
390    /// newtype built by [`fixed_newtype!`](crate::fixed_newtype) is unaffected in
391    /// both arms: its generated `new_with` has a concrete parameter type.
392    ///
393    /// # Security rationale
394    ///
395    /// With [`Fixed::new(value)`](Self::new), the caller first builds `value` on
396    /// its own stack frame, then moves it into the wrapper. The compiler *may*
397    /// elide the copy, but this is not guaranteed — leaving a plaintext residue
398    /// on the stack. `new_with` avoids this by giving the closure a mutable
399    /// reference to the wrapper's *own* storage, so the secret is never placed
400    /// anywhere else.
401    ///
402    /// # Examples
403    ///
404    /// ```rust
405    /// use secure_gate::{Fixed, RevealSecret};
406    ///
407    /// // Fill from a closure — no intermediate stack copy.
408    /// let secret = Fixed::<[u8; 4]>::new_with(|arr| arr.fill(0xAB));
409    /// assert_eq!(secret.expose_secret(), &[0xAB; 4]);
410    ///
411    /// // Copy from an existing slice.
412    /// let src = [1u8, 2, 3, 4];
413    /// let secret = Fixed::<[u8; 4]>::new_with(|arr| arr.copy_from_slice(&src));
414    /// ```
415    ///
416    /// A non-byte array uses the same constructor:
417    ///
418    /// ```rust
419    /// use secure_gate::{Fixed, RevealSecret};
420    ///
421    /// let poly = Fixed::<[i16; 4]>::new_with(|c| c.fill(7));
422    /// assert_eq!(poly.expose_secret(), &[7i16; 4]);
423    /// ```
424    ///
425    /// # See also
426    ///
427    /// - [`try_new_with`](Self::try_new_with) — the same thing when the fill can
428    ///   fail, rather than routing the error out through a captured local.
429    /// - [`Dynamic::new_with`](crate::Dynamic::new_with) — the heap-allocated
430    ///   equivalent (requires `alloc`).
431    ///
432    /// If the secret will outlive the current function, prefer
433    /// [`Dynamic<T>`](crate::Dynamic) over moving `Fixed<T>` around — each
434    /// move-by-value leaves residue in the previous stack slot.
435    #[inline(always)]
436    pub fn new_with<F>(f: F) -> Self
437    where
438        T: crate::FixedStorage + crate::SentinelValue,
439        F: FnOnce(&mut T),
440    {
441        // Binding the unit-valued const is what forces it to be evaluated; clippy reads
442        // that as a pointless binding. Kept explicit because the 1.70 lint (the 0.8
443        // line's MSRV) fires on every spelling that still triggers the evaluation.
444        #[allow(clippy::let_unit_value)]
445        let () = Self::NON_ZERO_SIZED;
446        let mut this = Self {
447            inner: <T as crate::SentinelValue>::sentinel_value(),
448        };
449        f(&mut this.inner);
450        this
451    }
452
453    /// Writes directly into the wrapper's storage via a closure that may fail.
454    ///
455    /// The fallible sibling of [`new_with`](Self::new_with), and identical to it in
456    /// every respect but the closure's return type. Reach for it whenever filling the
457    /// secret can fail — reading from a file, a socket, a device — so that writing in
458    /// place and handling the error stay one expression.
459    ///
460    /// On `Err` the partially written slot is **zeroized before the error is
461    /// returned**: the wrapper exists for the whole of the closure's run, so
462    /// [`Drop`](Fixed#impl-Drop-for-Fixed<T>) wipes whatever the closure managed to
463    /// write. A failed fill leaves nothing behind, in memory or in the return value.
464    ///
465    /// On `Ok`, the slot the closure saw started at the same value
466    /// [`new_with`](Self::new_with)'s does —
467    /// [`T::sentinel_value()`](crate::SentinelValue::sentinel_value), which for `T =
468    /// [E; N]` with `E: Default` is `[E::default(); N]`, all-zero for the byte and
469    /// integer arrays this type is built around. That starting value is a contract of
470    /// [`SentinelValue`](crate::SentinelValue)'s, documented on `new_with` above, not an
471    /// incidental one — so a closure here that only partially fills the slot before
472    /// returning `Ok` leaves the same reliable zero tail behind, on the same guarantee.
473    ///
474    /// Requires [`FixedStorage`](crate::FixedStorage) and
475    /// [`SentinelValue`](crate::SentinelValue) on `T`, exactly as `new_with` does.
476    ///
477    /// # Why `try_`, given the rest of this type
478    ///
479    /// Elsewhere here `try_` marks a constructor that *parses* untrusted input —
480    /// [`try_from_hex`](Self::try_from_hex), [`try_from_base32`](Self::try_from_base32),
481    /// `TryFrom<&[u8]>` — while [`from_rng`](Self::from_rng) is fallible and keeps
482    /// `from_`. This method widens the prefix to its ordinary Rust meaning, the one
483    /// `try_reserve` and `try_lock` carry: **`try_` marks a constructor that can fail**,
484    /// whether because it validates input or because the caller's fill can.
485    ///
486    /// # `T` must be inferable at the call site
487    ///
488    /// Generic over `T`, so the closure's parameter type is not known until `T` is —
489    /// the same constraint [`new_with`](Self::new_with) documents, with the same
490    /// **E0282** if nothing names it. A return type alone does not resolve it. Name
491    /// the type in either position:
492    ///
493    /// ```rust
494    /// use secure_gate::Fixed;
495    /// # #[derive(Debug)] struct ShortRead;
496    ///
497    /// // On the type:
498    /// let a = Fixed::<[u8; 32]>::try_new_with(|x| { x.fill(1); Ok::<(), ShortRead>(()) })?;
499    /// // Or on the closure parameter:
500    /// let b = Fixed::try_new_with(|x: &mut [u8; 32]| { x.fill(1); Ok::<(), ShortRead>(()) })?;
501    /// # let _ = (a, b);
502    /// # Ok::<(), ShortRead>(())
503    /// ```
504    ///
505    /// # Errors
506    ///
507    /// Returns whatever the closure returns. The error type is the caller's; this
508    /// method neither inspects nor wraps it.
509    ///
510    /// # Examples
511    ///
512    /// Filling from a reader, which is the case this exists for — the bytes land in
513    /// the wrapper's own storage and never occupy a caller-owned buffer:
514    ///
515    /// ```rust
516    /// # #[cfg(feature = "std")] {
517    /// use secure_gate::{Fixed, RevealSecret};
518    /// use std::io::Read;
519    ///
520    /// fn read_key<R: Read>(r: &mut R) -> std::io::Result<Fixed<[u8; 32]>> {
521    ///     Fixed::<[u8; 32]>::try_new_with(|slot| r.read_exact(slot))
522    /// }
523    ///
524    /// let key = read_key(&mut [7u8; 32].as_slice()).unwrap();
525    /// assert_eq!(key.expose_secret(), &[7u8; 32]);
526    ///
527    /// // A short read fails, and yields no secret at all.
528    /// assert!(read_key(&mut [7u8; 8].as_slice()).is_err());
529    /// # }
530    /// ```
531    ///
532    /// # See also
533    ///
534    /// - [`new_with`](Self::new_with) — the infallible form; prefer it when the fill
535    ///   cannot fail.
536    #[inline(always)]
537    pub fn try_new_with<F, E>(f: F) -> Result<Self, E>
538    where
539        T: crate::FixedStorage + crate::SentinelValue,
540        F: FnOnce(&mut T) -> Result<(), E>,
541    {
542        // Binding the unit-valued const is what forces it to be evaluated; clippy reads
543        // that as a pointless binding. Kept explicit because the 1.70 lint (the 0.8
544        // line's MSRV) fires on every spelling that still triggers the evaluation.
545        #[allow(clippy::let_unit_value)]
546        let () = Self::NON_ZERO_SIZED;
547        let mut this = Self {
548            inner: <T as crate::SentinelValue>::sentinel_value(),
549        };
550        // On the error path `this` is dropped here, so `Fixed::drop` zeroizes whatever
551        // the closure wrote before it gave up. That is the whole reason the wrapper is
552        // built before the fill rather than after it.
553        f(&mut this.inner)?;
554        Ok(this)
555    }
556}
557
558/// Converts a byte array into a [`Fixed`] wrapper (equivalent to [`Fixed::new`]).
559///
560/// # Examples
561///
562/// ```rust
563/// use secure_gate::Fixed;
564///
565/// let secret: Fixed<[u8; 4]> = [1u8, 2, 3, 4].into();
566/// ```
567impl<const N: usize> From<[u8; N]> for Fixed<[u8; N]> {
568    #[inline(always)]
569    fn from(arr: [u8; N]) -> Self {
570        Self::new(arr)
571    }
572}
573
574/// Converts a byte slice into `Fixed<[u8; N]>`, failing if the length does not
575/// match `N`.
576///
577/// Internally uses [`Fixed::new_with`] so the secret is written directly into
578/// the wrapper's storage.
579///
580/// # Errors
581///
582/// Returns [`FromSliceError::InvalidLength`](crate::error::FromSliceError) when
583/// `slice.len() != N`.
584///
585/// # Examples
586///
587/// ```rust
588/// use secure_gate::{Fixed, RevealSecret};
589///
590/// // Success — exact length.
591/// let data = [0xFFu8; 4];
592/// let secret = Fixed::<[u8; 4]>::try_from(data.as_slice()).unwrap();
593/// assert_eq!(secret.expose_secret()[0], 0xFF);
594///
595/// // Failure — wrong length.
596/// let short = [0u8; 2];
597/// assert!(Fixed::<[u8; 4]>::try_from(short.as_slice()).is_err());
598/// ```
599impl<const N: usize> core::convert::TryFrom<&[u8]> for Fixed<[u8; N]> {
600    type Error = crate::error::FromSliceError;
601
602    fn try_from(slice: &[u8]) -> Result<Self, Self::Error> {
603        if slice.len() != N {
604            return Err(crate::error::FromSliceError::InvalidLength {
605                expected: N,
606                got: slice.len(),
607            });
608        }
609        Ok(Self::new_with(|arr| arr.copy_from_slice(slice)))
610    }
611}
612
613/// Hex encoding and decoding for `Fixed<[u8; N]>`.
614///
615/// Encoding uses a constant-time backend (`base16ct`). Decoding works with or without
616/// the `alloc` feature — on no-alloc targets the bytes are decoded directly into a
617/// `Zeroizing<[u8; N]>` stack buffer.
618#[cfg(feature = "encoding-hex")]
619impl<const N: usize> Fixed<[u8; N]> {
620    /// Decodes a hex string (lowercase, uppercase, or mixed) into `Fixed<[u8; N]>`.
621    ///
622    /// Uses a constant-time backend (`base16ct`) for both paths.
623    ///
624    /// - **With `alloc`**: decodes into a `Zeroizing<Vec<u8>>` then copies onto the stack.
625    ///   The temporary heap buffer is zeroed on drop even if an error occurs.
626    /// - **Without `alloc`**: decodes directly into a `Zeroizing<[u8; N]>` stack buffer.
627    ///   No heap allocation occurs.
628    ///
629    /// # Errors
630    ///
631    /// - [`HexError::InvalidHex`](crate::HexError::InvalidHex) — non-hex characters or odd-length input.
632    /// - [`HexError::InvalidLength`](crate::HexError::InvalidLength) — decoded byte count does not equal `N`.
633    ///
634    /// # Examples
635    ///
636    /// ```rust
637    /// # #[cfg(feature = "encoding-hex")]
638    /// # {
639    /// use secure_gate::{Fixed, RevealSecret, ToHex};
640    ///
641    /// // Round-trip: encode then decode.
642    /// let original = Fixed::new([0xDE, 0xAD, 0xBE, 0xEF]);
643    /// # #[cfg(feature = "alloc")]
644    /// # {
645    /// let hex_str = original.to_hex();
646    /// let decoded = Fixed::<[u8; 4]>::try_from_hex(&hex_str).unwrap();
647    /// assert_eq!(decoded.expose_secret(), &[0xDE, 0xAD, 0xBE, 0xEF]);
648    /// # }
649    ///
650    /// // Wrong length fails.
651    /// assert!(Fixed::<[u8; 2]>::try_from_hex("deadbeef").is_err());
652    /// # }
653    /// ```
654    pub fn try_from_hex(hex: &str) -> Result<Self, crate::error::HexError> {
655        #[cfg(feature = "alloc")]
656        {
657            use zeroize::Zeroizing;
658            let bytes = Zeroizing::new(
659                base16ct::mixed::decode_vec(hex.as_bytes())
660                    .map_err(|_| crate::error::HexError::InvalidHex)?,
661            );
662            if bytes.len() != N {
663                return Err(crate::error::HexError::InvalidLength {
664                    expected: N,
665                    got: bytes.len(),
666                });
667            }
668            Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
669        }
670        #[cfg(not(feature = "alloc"))]
671        {
672            use zeroize::Zeroizing;
673            // no-alloc path: decode directly into a stack buffer; no heap allocation
674            // base16ct::mixed accepts upper, lower, and mixed-case hex
675            let mut buf = Zeroizing::new([0u8; N]);
676            let decoded = base16ct::mixed::decode(hex.as_bytes(), &mut *buf)
677                .map_err(|_| crate::error::HexError::InvalidHex)?;
678            if decoded.len() != N {
679                return Err(crate::error::HexError::InvalidLength {
680                    expected: N,
681                    got: decoded.len(),
682                });
683            }
684            Ok(Self::new_with(|arr| arr.copy_from_slice(decoded)))
685            // buf is zeroized on drop (both success and error paths)
686        }
687    }
688}
689
690/// Base32 encoding and decoding for `Fixed<[u8; N]>` (RFC 4648 §6, uppercase, unpadded).
691///
692/// Encoding uses a constant-time backend (`base32ct`). Decoding works with or without
693/// the `alloc` feature — on no-alloc targets the bytes are decoded directly into a
694/// `Zeroizing<[u8; N]>` stack buffer.
695#[cfg(feature = "encoding-base32")]
696impl<const N: usize> Fixed<[u8; N]> {
697    /// Decodes an uppercase, unpadded Base32 string (RFC 4648 §6 alphabet) into
698    /// `Fixed<[u8; N]>`.
699    ///
700    /// Uses a constant-time backend (`base32ct`) on both paths.
701    ///
702    /// - **With `alloc`**: decodes into a `Zeroizing<Vec<u8>>` then copies onto the stack.
703    /// - **Without `alloc`**: decodes directly into a `Zeroizing<[u8; N]>` stack buffer.
704    ///
705    /// Decoding is strict about the alphabet, case, padding and length, but lenient
706    /// about non-canonical trailing bits: unused bits in the final group are ignored
707    /// rather than rejected, so `"MZ"` and `"MY"` both decode to `[0x66]`. Decoding is
708    /// therefore not injective — two distinct strings can yield the same bytes, and
709    /// only encoder-produced strings are canonical. Do not use the decoded value to
710    /// decide that two encoded strings were equal.
711    ///
712    /// # Errors
713    ///
714    /// - [`Base32Error::InvalidBase32`](crate::Base32Error::InvalidBase32) — wrong alphabet
715    ///   or case, `=` padding, or a length that is impossible for unpadded Base32. Without
716    ///   `alloc` an input that decodes to *more* than `N` bytes also lands here rather than in
717    ///   `InvalidLength`, because the fixed stack buffer cannot hold the overflow to measure it.
718    /// - [`Base32Error::InvalidLength`](crate::Base32Error::InvalidLength) — decoded byte
719    ///   count does not equal `N`.
720    ///
721    /// # Examples
722    ///
723    /// ```rust
724    /// # #[cfg(feature = "encoding-base32")]
725    /// # {
726    /// use secure_gate::{Fixed, RevealSecret, ToBase32};
727    ///
728    /// # #[cfg(feature = "alloc")]
729    /// # {
730    /// // Round-trip.
731    /// let original = Fixed::new([0xDE, 0xAD, 0xBE, 0xEF]);
732    /// let encoded = original.to_base32();
733    /// assert_eq!(&*encoded, "32W353Y");
734    /// let decoded = Fixed::<[u8; 4]>::try_from_base32(&encoded).unwrap();
735    /// assert_eq!(decoded.expose_secret(), &[0xDE, 0xAD, 0xBE, 0xEF]);
736    /// # }
737    /// # }
738    /// ```
739    pub fn try_from_base32(s: &str) -> Result<Self, crate::error::Base32Error> {
740        #[cfg(feature = "alloc")]
741        {
742            use base32ct::{Base32UpperUnpadded, Encoding};
743            use zeroize::Zeroizing;
744            // base32ct 0.2 panics on impossible trailing-block lengths; see
745            // `traits::decoding::base32::encoded_len_is_decodable`.
746            if !crate::traits::decoding::base32::encoded_len_is_decodable(s.len()) {
747                return Err(crate::error::Base32Error::InvalidBase32);
748            }
749            let bytes = Zeroizing::new(
750                Base32UpperUnpadded::decode_vec(s)
751                    .map_err(|_| crate::error::Base32Error::InvalidBase32)?,
752            );
753            if bytes.len() != N {
754                return Err(crate::error::Base32Error::InvalidLength {
755                    expected: N,
756                    got: bytes.len(),
757                });
758            }
759            Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
760        }
761        #[cfg(not(feature = "alloc"))]
762        {
763            use base32ct::{Base32UpperUnpadded, Encoding};
764            use zeroize::Zeroizing;
765            // base32ct 0.2 panics on impossible trailing-block lengths; see
766            // `traits::decoding::base32::encoded_len_is_decodable`.
767            if !crate::traits::decoding::base32::encoded_len_is_decodable(s.len()) {
768                return Err(crate::error::Base32Error::InvalidBase32);
769            }
770            let mut buf = Zeroizing::new([0u8; N]);
771            let decoded = Base32UpperUnpadded::decode(s, &mut *buf)
772                .map_err(|_| crate::error::Base32Error::InvalidBase32)?;
773            if decoded.len() != N {
774                return Err(crate::error::Base32Error::InvalidLength {
775                    expected: N,
776                    got: decoded.len(),
777                });
778            }
779            Ok(Self::new_with(|arr| arr.copy_from_slice(decoded)))
780            // buf is zeroized on drop (both success and error paths)
781        }
782    }
783}
784
785/// Base64url encoding and decoding for `Fixed<[u8; N]>`.
786///
787/// Encoding uses a constant-time backend (`base64ct`). Decoding works with or without
788/// the `alloc` feature — on no-alloc targets the bytes are decoded directly into a
789/// `Zeroizing<[u8; N]>` stack buffer.
790#[cfg(feature = "encoding-base64")]
791impl<const N: usize> Fixed<[u8; N]> {
792    /// Decodes an unpadded Base64url string (RFC 4648, URL-safe alphabet) into
793    /// `Fixed<[u8; N]>`.
794    ///
795    /// Uses a constant-time backend (`base64ct`) on both paths.
796    ///
797    /// - **With `alloc`**: decodes into a `Zeroizing<Vec<u8>>` then copies onto the stack.
798    /// - **Without `alloc`**: decodes directly into a `Zeroizing<[u8; N]>` stack buffer.
799    ///
800    /// # Errors
801    ///
802    /// - [`Base64Error::InvalidBase64`](crate::Base64Error::InvalidBase64) — non-base64 characters or invalid padding.
803    /// - [`Base64Error::InvalidLength`](crate::Base64Error::InvalidLength) — decoded byte count does not equal `N`.
804    ///
805    /// # Examples
806    ///
807    /// ```rust
808    /// # #[cfg(feature = "encoding-base64")]
809    /// # {
810    /// use secure_gate::{Fixed, RevealSecret, ToBase64Url};
811    ///
812    /// # #[cfg(feature = "alloc")]
813    /// # {
814    /// // Round-trip.
815    /// let original = Fixed::new([0xDE, 0xAD, 0xBE, 0xEF]);
816    /// let encoded = original.to_base64url();
817    /// let decoded = Fixed::<[u8; 4]>::try_from_base64url(&encoded).unwrap();
818    /// assert_eq!(decoded.expose_secret(), &[0xDE, 0xAD, 0xBE, 0xEF]);
819    /// # }
820    /// # }
821    /// ```
822    pub fn try_from_base64url(s: &str) -> Result<Self, crate::error::Base64Error> {
823        #[cfg(feature = "alloc")]
824        {
825            use base64ct::{Base64UrlUnpadded, Encoding};
826            use zeroize::Zeroizing;
827            let bytes = Zeroizing::new(
828                Base64UrlUnpadded::decode_vec(s)
829                    .map_err(|_| crate::error::Base64Error::InvalidBase64)?,
830            );
831            if bytes.len() != N {
832                return Err(crate::error::Base64Error::InvalidLength {
833                    expected: N,
834                    got: bytes.len(),
835                });
836            }
837            Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
838        }
839        #[cfg(not(feature = "alloc"))]
840        {
841            use base64ct::{Base64UrlUnpadded, Encoding};
842            use zeroize::Zeroizing;
843            let mut buf = Zeroizing::new([0u8; N]);
844            let decoded = Base64UrlUnpadded::decode(s, &mut *buf)
845                .map_err(|_| crate::error::Base64Error::InvalidBase64)?;
846            if decoded.len() != N {
847                return Err(crate::error::Base64Error::InvalidLength {
848                    expected: N,
849                    got: decoded.len(),
850                });
851            }
852            Ok(Self::new_with(|arr| arr.copy_from_slice(decoded)))
853            // buf is zeroized on drop (both success and error paths)
854        }
855    }
856}
857
858/// Bech32 (BIP-173) encoding and decoding for `Fixed<[u8; N]>`.
859///
860/// The plain constructors accept strings up to
861/// [`BECH32_CODE_LENGTH`](crate::BECH32_CODE_LENGTH); the `_sized::<C>` constructors
862/// take the code length as a parameter for larger payloads. For Bitcoin address formats
863/// use `ToBech32m` and stay within BIP-173's 90-character convention.
864#[cfg(feature = "encoding-bech32")]
865impl<const N: usize> Fixed<[u8; N]> {
866    /// Decodes a Bech32 (BIP-173) string into `Fixed<[u8; N]>`, validating that the HRP
867    /// matches `expected_hrp` (case-insensitive).
868    ///
869    /// HRP comparison is non-constant-time — this is intentional, as the HRP is public
870    /// metadata, not secret material. Timing leaks on HRP mismatch are acceptable because
871    /// the HRP is not secret. Prefer this over
872    /// [`try_from_bech32_unchecked`](Self::try_from_bech32_unchecked) to prevent
873    /// cross-protocol confusion attacks.
874    ///
875    /// Works without `alloc` — decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
876    pub fn try_from_bech32(s: &str, expected_hrp: &str) -> Result<Self, crate::error::Bech32Error> {
877        Self::try_from_bech32_sized::<{ crate::BECH32_CODE_LENGTH }>(s, expected_hrp)
878    }
879
880    /// Like [`try_from_bech32`](Self::try_from_bech32), accepting strings up to `C`
881    /// characters.
882    ///
883    /// `C` is the bech32 *code length* -- the cap on the whole encoded string -- and is
884    /// independent of `N`, the decoded byte count this wrapper holds. Pass the `C` the
885    /// string was encoded with, or any larger value. See
886    /// [`Bech32Sized`](crate::Bech32Sized) for what `C` above
887    /// [`BECH32_CODE_LENGTH`](crate::BECH32_CODE_LENGTH) costs.
888    ///
889    /// Works without `alloc` -- decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
890    pub fn try_from_bech32_sized<const C: usize>(
891        s: &str,
892        expected_hrp: &str,
893    ) -> Result<Self, crate::error::Bech32Error> {
894        use crate::traits::encoding::bech32::Bech32Sized;
895        use bech32::primitives::decode::CheckedHrpstring;
896        let checked = CheckedHrpstring::new::<Bech32Sized<C>>(s)
897            .map_err(|_| crate::error::Bech32Error::OperationFailed)?;
898        // HRP check before any payload byte is materialized (case-insensitive
899        // comparison — timing leak is acceptable since HRP is public metadata)
900        if !checked.hrp().as_str().eq_ignore_ascii_case(expected_hrp) {
901            return Err(crate::error::Bech32Error::UnexpectedHrp);
902        }
903        let buf = drain_bech32_payload::<N>(&checked)?;
904        Ok(Self::new_with(|arr| arr.copy_from_slice(&*buf)))
905        // buf is zeroized on drop
906    }
907
908    /// Decodes a Bech32 (BIP-173) string into `Fixed<[u8; N]>` without validating the HRP.
909    ///
910    /// Any valid HRP is accepted as long as the checksum is valid and the payload length
911    /// equals `N`. Use [`try_from_bech32`](Self::try_from_bech32) in security-critical code
912    /// to prevent cross-protocol confusion attacks.
913    ///
914    /// Works without `alloc` — decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
915    pub fn try_from_bech32_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
916        Self::try_from_bech32_unchecked_sized::<{ crate::BECH32_CODE_LENGTH }>(s)
917    }
918
919    /// Like [`try_from_bech32_unchecked`](Self::try_from_bech32_unchecked), accepting
920    /// strings up to `C` characters.
921    pub fn try_from_bech32_unchecked_sized<const C: usize>(
922        s: &str,
923    ) -> Result<Self, crate::error::Bech32Error> {
924        use crate::traits::encoding::bech32::Bech32Sized;
925        use bech32::primitives::decode::CheckedHrpstring;
926        let checked = CheckedHrpstring::new::<Bech32Sized<C>>(s)
927            .map_err(|_| crate::error::Bech32Error::OperationFailed)?;
928        let buf = drain_bech32_payload::<N>(&checked)?;
929        Ok(Self::new_with(|arr| arr.copy_from_slice(&*buf)))
930        // buf is zeroized on drop
931    }
932}
933
934/// Bech32m (BIP-350) encoding and decoding for `Fixed<[u8; N]>`.
935///
936/// The plain constructors accept strings up to
937/// [`BECH32_CODE_LENGTH`](crate::BECH32_CODE_LENGTH); the `_sized::<C>` constructors
938/// take the code length as a parameter. Bitcoin address tooling expects BIP-173's
939/// 90-character cap, which is narrower than either and is the caller's to respect.
940#[cfg(feature = "encoding-bech32")]
941impl<const N: usize> Fixed<[u8; N]> {
942    /// Decodes a Bech32m (BIP-350) string into `Fixed<[u8; N]>`, validating that the HRP
943    /// matches `expected_hrp` (case-insensitive).
944    ///
945    /// HRP comparison is non-constant-time — this is intentional, as the HRP is public
946    /// metadata, not secret material. Timing leaks on HRP mismatch are acceptable because
947    /// the HRP is not secret. Prefer this over
948    /// [`try_from_bech32m_unchecked`](Self::try_from_bech32m_unchecked) to prevent
949    /// cross-protocol confusion attacks.
950    ///
951    /// Works without `alloc` — decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
952    pub fn try_from_bech32m(
953        s: &str,
954        expected_hrp: &str,
955    ) -> Result<Self, crate::error::Bech32Error> {
956        Self::try_from_bech32m_sized::<{ crate::BECH32_CODE_LENGTH }>(s, expected_hrp)
957    }
958
959    /// Like [`try_from_bech32m`](Self::try_from_bech32m), accepting strings up to `C`
960    /// characters.
961    ///
962    /// `C` is the bech32m *code length*, independent of `N`, the decoded byte count.
963    /// See [`Bech32mSized`](crate::Bech32mSized) for what `C` above
964    /// [`BECH32_CODE_LENGTH`](crate::BECH32_CODE_LENGTH) costs.
965    ///
966    /// Works without `alloc` -- decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
967    pub fn try_from_bech32m_sized<const C: usize>(
968        s: &str,
969        expected_hrp: &str,
970    ) -> Result<Self, crate::error::Bech32Error> {
971        use crate::traits::encoding::bech32m::Bech32mSized;
972        use bech32::primitives::decode::CheckedHrpstring;
973        let checked = CheckedHrpstring::new::<Bech32mSized<C>>(s)
974            .map_err(|_| crate::error::Bech32Error::OperationFailed)?;
975        // HRP check before any payload byte is materialized (case-insensitive
976        // comparison — timing leak is acceptable since HRP is public metadata)
977        if !checked.hrp().as_str().eq_ignore_ascii_case(expected_hrp) {
978            return Err(crate::error::Bech32Error::UnexpectedHrp);
979        }
980        let buf = drain_bech32_payload::<N>(&checked)?;
981        Ok(Self::new_with(|arr| arr.copy_from_slice(&*buf)))
982        // buf is zeroized on drop
983    }
984
985    /// Decodes a Bech32m (BIP-350) string into `Fixed<[u8; N]>` without validating the HRP.
986    ///
987    /// Any valid HRP is accepted as long as the checksum is valid and the payload length
988    /// equals `N`. Use [`try_from_bech32m`](Self::try_from_bech32m) in security-critical
989    /// code to prevent cross-protocol confusion attacks.
990    ///
991    /// Works without `alloc` — decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
992    pub fn try_from_bech32m_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
993        Self::try_from_bech32m_unchecked_sized::<{ crate::BECH32_CODE_LENGTH }>(s)
994    }
995
996    /// Like [`try_from_bech32m_unchecked`](Self::try_from_bech32m_unchecked), accepting
997    /// strings up to `C` characters.
998    pub fn try_from_bech32m_unchecked_sized<const C: usize>(
999        s: &str,
1000    ) -> Result<Self, crate::error::Bech32Error> {
1001        use crate::traits::encoding::bech32m::Bech32mSized;
1002        use bech32::primitives::decode::CheckedHrpstring;
1003        let checked = CheckedHrpstring::new::<Bech32mSized<C>>(s)
1004            .map_err(|_| crate::error::Bech32Error::OperationFailed)?;
1005        let buf = drain_bech32_payload::<N>(&checked)?;
1006        Ok(Self::new_with(|arr| arr.copy_from_slice(&*buf)))
1007        // buf is zeroized on drop
1008    }
1009}
1010
1011/// Hex encoding for `Fixed<[u8; N]>` — same constant-time backend as the
1012/// [`ToHex`] blanket impl on the inner bytes; delegates via `with_secret`.
1013///
1014/// Bring the trait into scope to call these: `use secure_gate::ToHex;`.
1015///
1016/// ```rust
1017/// use secure_gate::{Fixed, ToHex};
1018///
1019/// let key = Fixed::new([0xABu8; 4]);
1020/// assert_eq!(&*key.to_hex(), "abababab");
1021/// assert_eq!(&*key.to_hex_upper(), "ABABABAB");
1022/// // Both return an `EncodedSecret` — the encoded form wipes itself on drop.
1023/// ```
1024#[cfg(all(feature = "encoding-hex", feature = "alloc"))]
1025impl<const N: usize> ToHex for Fixed<[u8; N]> {
1026    #[inline]
1027    fn to_hex(&self) -> crate::EncodedSecret {
1028        self.with_secret(|s| s.to_hex())
1029    }
1030
1031    #[inline]
1032    fn to_hex_upper(&self) -> crate::EncodedSecret {
1033        self.with_secret(|s| s.to_hex_upper())
1034    }
1035}
1036
1037/// Base32 encoding for `Fixed<[u8; N]>`; delegates via `with_secret`.
1038///
1039/// Bring the trait into scope to call these: `use secure_gate::ToBase32;`.
1040///
1041/// ```rust
1042/// use secure_gate::{Fixed, ToBase32};
1043///
1044/// let key = Fixed::new([0xABu8; 4]);
1045/// assert_eq!(&*key.to_base32(), "VOV2XKY");
1046/// ```
1047#[cfg(all(feature = "encoding-base32", feature = "alloc"))]
1048impl<const N: usize> ToBase32 for Fixed<[u8; N]> {
1049    #[inline]
1050    fn to_base32(&self) -> crate::EncodedSecret {
1051        self.with_secret(|s| s.to_base32())
1052    }
1053}
1054
1055/// Base64url encoding for `Fixed<[u8; N]>`; delegates via `with_secret`.
1056///
1057/// Bring the trait into scope to call these: `use secure_gate::ToBase64Url;`.
1058///
1059/// ```rust
1060/// use secure_gate::{Fixed, ToBase64Url};
1061///
1062/// let key = Fixed::new([0xABu8; 4]);
1063/// assert_eq!(&*key.to_base64url(), "q6urqw");
1064/// ```
1065#[cfg(all(feature = "encoding-base64", feature = "alloc"))]
1066impl<const N: usize> ToBase64Url for Fixed<[u8; N]> {
1067    #[inline]
1068    fn to_base64url(&self) -> crate::EncodedSecret {
1069        self.with_secret(|s| s.to_base64url())
1070    }
1071}
1072
1073/// Bech32 encoding for `Fixed<[u8; N]>`; delegates via `with_secret`.
1074///
1075/// Bring the trait into scope to call these: `use secure_gate::ToBech32;`.
1076#[cfg(all(feature = "encoding-bech32", feature = "alloc"))]
1077impl<const N: usize> ToBech32 for Fixed<[u8; N]> {
1078    #[inline]
1079    fn try_to_bech32(
1080        &self,
1081        hrp: &str,
1082        case: crate::Case,
1083    ) -> Result<crate::EncodedSecret, crate::error::Bech32Error> {
1084        self.with_secret(|s| s.try_to_bech32(hrp, case))
1085    }
1086
1087    #[inline]
1088    fn try_to_bech32_sized<const C: usize>(
1089        &self,
1090        hrp: &str,
1091        case: crate::Case,
1092    ) -> Result<crate::EncodedSecret, crate::error::Bech32Error> {
1093        self.with_secret(|s| s.try_to_bech32_sized::<C>(hrp, case))
1094    }
1095}
1096
1097/// Bech32m encoding for `Fixed<[u8; N]>`; delegates via `with_secret`.
1098///
1099/// Bring the trait into scope to call these: `use secure_gate::ToBech32m;`.
1100#[cfg(all(feature = "encoding-bech32", feature = "alloc"))]
1101impl<const N: usize> ToBech32m for Fixed<[u8; N]> {
1102    #[inline]
1103    fn try_to_bech32m(
1104        &self,
1105        hrp: &str,
1106        case: crate::Case,
1107    ) -> Result<crate::EncodedSecret, crate::error::Bech32Error> {
1108        self.with_secret(|s| s.try_to_bech32m(hrp, case))
1109    }
1110
1111    #[inline]
1112    fn try_to_bech32m_sized<const C: usize>(
1113        &self,
1114        hrp: &str,
1115        case: crate::Case,
1116    ) -> Result<crate::EncodedSecret, crate::error::Bech32Error> {
1117        self.with_secret(|s| s.try_to_bech32m_sized::<C>(hrp, case))
1118    }
1119}
1120
1121/// Explicit access to immutable [`Fixed<[T; N]>`] contents.
1122impl<T: zeroize::Zeroize> RevealSecret for Fixed<T> {
1123    type Inner = T;
1124
1125    #[inline(always)]
1126    fn with_secret<F, R>(&self, f: F) -> R
1127    where
1128        F: FnOnce(&T) -> R,
1129    {
1130        f(&self.inner)
1131    }
1132
1133    #[inline(always)]
1134    fn expose_secret(&self) -> &T {
1135        &self.inner
1136    }
1137
1138    /// Consumes `self` and transfers ownership of the plain `[T; N]`.
1139    ///
1140    /// Zero cost — no allocation. The sentinel placed in `self.inner` is
1141    /// `[T::default(); N]` via [`crate::SentinelValue`] (already zeroed for `u8`),
1142    /// so `Fixed::drop` zeroizes an already-zero array — a harmless no-op.
1143    /// Works for **any** array length `N` (not limited to 32 like `Default`).
1144    ///
1145    /// See [`RevealSecret::into_inner`] for the full contract, including the
1146    /// `SentinelValue` bound rationale. Protection ends at this call: the array you
1147    /// get back is plain — not wiped on drop, and its `Debug` is not redacted.
1148    #[inline(always)]
1149    fn into_inner(mut self) -> T
1150    where
1151        Self: Sized,
1152        Self::Inner: Sized + crate::SentinelValue + zeroize::Zeroize,
1153    {
1154        // Replace inner with the sentinel so Fixed::drop zeroizes a harmless
1155        // placeholder while the caller receives the real secret. The secret is stored
1156        // inline, so this transfers the bytes rather than handing over an allocation;
1157        // what the sentinel guarantees is that no second copy is left behind here.
1158        core::mem::replace(&mut self.inner, crate::SentinelValue::sentinel_value())
1159    }
1160}
1161
1162/// Length is only meaningful for array-shaped secrets.
1163impl<const N: usize, T: zeroize::Zeroize> crate::SecretLen for Fixed<[T; N]> {
1164    #[inline(always)]
1165    fn len(&self) -> usize {
1166        N
1167    }
1168
1169    #[inline(always)]
1170    fn byte_len(&self) -> usize {
1171        N * core::mem::size_of::<T>()
1172    }
1173}
1174
1175/// Explicit access to mutable [`Fixed<[T; N]>`] contents.
1176impl<T: zeroize::Zeroize> RevealSecretMut for Fixed<T> {
1177    #[inline(always)]
1178    fn with_secret_mut<F, R>(&mut self, f: F) -> R
1179    where
1180        F: FnOnce(&mut T) -> R,
1181    {
1182        f(&mut self.inner)
1183    }
1184
1185    #[inline(always)]
1186    fn expose_secret_mut(&mut self) -> &mut T {
1187        &mut self.inner
1188    }
1189}
1190
1191#[cfg(feature = "rand")]
1192impl<const N: usize> Fixed<[u8; N]> {
1193    /// Fills a new `[u8; N]` with cryptographically secure random bytes and wraps it.
1194    ///
1195    /// Uses the system RNG ([`OsRng`](rand::rngs::OsRng)) via [`TryRngCore::try_fill_bytes`](rand::TryRngCore::try_fill_bytes).
1196    /// In `rand` 0.9, `OsRng` is a zero-sized handle to the OS generator (not user-seedable). Requires the `rand`
1197    /// feature. Heap-free and works in `no_std` / `no_alloc` builds.
1198    ///
1199    /// # Panics
1200    ///
1201    /// Panics if the system RNG fails to provide bytes ([`TryRngCore::try_fill_bytes`](rand::TryRngCore::try_fill_bytes)
1202    /// returns `Err`). This is treated as a fatal environment error.
1203    ///
1204    /// # Examples
1205    ///
1206    /// ```rust
1207    /// # #[cfg(feature = "rand")]
1208    /// use secure_gate::{Fixed, RevealSecret, SecretLen};
1209    ///
1210    /// # #[cfg(feature = "rand")]
1211    /// # {
1212    /// let key: Fixed<[u8; 32]> = Fixed::from_random();
1213    /// assert_eq!(key.len(), 32);
1214    /// # }
1215    /// ```
1216    #[inline]
1217    pub fn from_random() -> Self {
1218        Self::new_with(|arr| {
1219            OsRng
1220                .try_fill_bytes(arr)
1221                .expect("OsRng failure is a program error");
1222        })
1223    }
1224
1225    /// Fills a new `[u8; N]` from `rng` and wraps it.
1226    ///
1227    /// Accepts any [`TryCryptoRng`](rand::TryCryptoRng) + [`TryRngCore`](rand::TryRngCore) — for example,
1228    /// a seeded `StdRng` for deterministic tests. Requires the `rand`
1229    /// feature. Heap-free.
1230    ///
1231    /// # Errors
1232    ///
1233    /// Returns `R::Error` if [`try_fill_bytes`](rand::TryRngCore::try_fill_bytes) fails.
1234    ///
1235    /// # Examples
1236    ///
1237    /// ```rust
1238    /// # #[cfg(feature = "rand")]
1239    /// # {
1240    /// use rand::rngs::StdRng;
1241    /// use rand::SeedableRng;
1242    /// use secure_gate::Fixed;
1243    ///
1244    /// let mut rng = StdRng::from_seed([1u8; 32]);
1245    /// let key: Fixed<[u8; 16]> = Fixed::from_rng(&mut rng).expect("rng fill");
1246    /// # }
1247    /// ```
1248    #[inline]
1249    pub fn from_rng<R: TryRngCore + TryCryptoRng>(rng: &mut R) -> Result<Self, R::Error> {
1250        // This body was the captured-local workaround `try_new_with` exists to retire:
1251        // `new_with` could not report failure, so the error was written to a local and
1252        // checked afterwards. Same behaviour, one expression.
1253        Self::try_new_with(|arr| rng.try_fill_bytes(arr))
1254    }
1255}
1256
1257/// Constant-time equality for `Fixed<T>` — routes through [`expose_secret()`](crate::RevealSecret::expose_secret).
1258///
1259/// `==` is **deliberately not implemented** on `Fixed`. Always use `ct_eq`.
1260///
1261/// ```rust
1262/// # #[cfg(feature = "ct-eq")]
1263/// # {
1264/// use secure_gate::{Fixed, ConstantTimeEq};
1265///
1266/// let a = Fixed::new([1u8; 4]);
1267/// let b = Fixed::new([1u8; 4]);
1268/// let c = Fixed::new([2u8; 4]);
1269/// assert!(a.ct_eq(&b));
1270/// assert!(!a.ct_eq(&c));
1271/// # }
1272/// ```
1273#[cfg(feature = "ct-eq")]
1274impl<T: zeroize::Zeroize> crate::ConstantTimeEq for Fixed<T>
1275where
1276    T: crate::ConstantTimeEq,
1277    Self: crate::RevealSecret<Inner = T>,
1278{
1279    fn ct_eq(&self, other: &Self) -> bool {
1280        self.expose_secret().ct_eq(other.expose_secret())
1281    }
1282}
1283
1284/// Always prints `[REDACTED]` — secrets never appear in debug output.
1285///
1286/// ```rust
1287/// use secure_gate::Fixed;
1288///
1289/// let key = Fixed::new([0xABu8; 32]);
1290/// assert_eq!(format!("{:?}", key), "[REDACTED]");
1291/// ```
1292impl<T: zeroize::Zeroize> core::fmt::Debug for Fixed<T> {
1293    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1294        f.write_str("[REDACTED]")
1295    }
1296}
1297
1298/// Opt-in cloning — requires `cloneable` feature and [`CloneableSecret`](crate::CloneableSecret)
1299/// marker on the inner type. Each clone is independently zeroized on drop, but cloning
1300/// increases the in-memory exposure surface. Use sparingly.
1301#[cfg(feature = "cloneable")]
1302impl<T: zeroize::Zeroize + crate::CloneableSecret + crate::FixedStorage> Clone for Fixed<T> {
1303    fn clone(&self) -> Self {
1304        Self::new(self.inner.clone())
1305    }
1306}
1307
1308/// Opt-in serialization — requires `serde-serialize` feature and
1309/// [`SerializableSecret`](crate::SerializableSecret) marker on the inner type.
1310/// Serialization exposes the full secret — audit every impl.
1311#[cfg(feature = "serde-serialize")]
1312impl<T: zeroize::Zeroize + crate::SerializableSecret> serde::Serialize for Fixed<T> {
1313    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1314    where
1315        S: serde::Serializer,
1316    {
1317        self.inner.serialize(serializer)
1318    }
1319}
1320
1321/// Deserialization uses `Zeroizing`-wrapped temporary buffers — zeroized even on rejection.
1322///
1323/// The sequence buffer is pre-allocated to exactly `N` bytes and **never grows**: an
1324/// input sequence longer than `N` is rejected before the element that would trigger a
1325/// reallocation is stored. This matters because a `Vec` reallocation frees the old
1326/// buffer — which would already hold `N` secret bytes — without zeroizing it.
1327///
1328/// The entry point is `deserialize_seq` (unchanged wire format for non-self-describing
1329/// formats such as postcard), but the visitor also accepts byte strings via
1330/// `visit_bytes` / `visit_byte_buf`, so self-describing formats that encode byte
1331/// arrays as byte strings (e.g. CBOR) round-trip too. Owned buffers handed over
1332/// through `visit_byte_buf` are zeroized after the copy.
1333#[cfg(feature = "serde-deserialize")]
1334impl<'de, const N: usize> serde::Deserialize<'de> for Fixed<[u8; N]> {
1335    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1336    where
1337        D: serde::Deserializer<'de>,
1338    {
1339        use core::fmt;
1340        use serde::de::Visitor;
1341        struct FixedVisitor<const M: usize>;
1342        impl<'de, const M: usize> Visitor<'de> for FixedVisitor<M> {
1343            type Value = Fixed<[u8; M]>;
1344            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
1345                write!(formatter, "a byte array of length {}", M)
1346            }
1347            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
1348            where
1349                A: serde::de::SeqAccess<'de>,
1350            {
1351                let mut vec: zeroize::Zeroizing<alloc::vec::Vec<u8>> =
1352                    zeroize::Zeroizing::new(alloc::vec::Vec::with_capacity(M));
1353                while let Some(value) = seq.next_element()? {
1354                    // Reject over-length input *before* pushing past the reserved
1355                    // capacity: growing would realloc and free the old buffer
1356                    // (already holding M secret bytes) without zeroization.
1357                    if vec.len() == M {
1358                        return Err(serde::de::Error::invalid_length(M + 1, &self));
1359                    }
1360                    vec.push(value);
1361                }
1362                if vec.len() != M {
1363                    return Err(serde::de::Error::invalid_length(vec.len(), &self));
1364                }
1365                Ok(Fixed::<[u8; M]>::new_with(|arr| arr.copy_from_slice(&vec)))
1366            }
1367            fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
1368            where
1369                E: serde::de::Error,
1370            {
1371                if v.len() != M {
1372                    return Err(serde::de::Error::invalid_length(v.len(), &self));
1373                }
1374                Ok(Fixed::<[u8; M]>::new_with(|arr| arr.copy_from_slice(v)))
1375            }
1376            fn visit_byte_buf<E>(self, v: alloc::vec::Vec<u8>) -> Result<Self::Value, E>
1377            where
1378                E: serde::de::Error,
1379            {
1380                // Take ownership under Zeroizing so the deserializer-provided
1381                // buffer is wiped after the copy — the default forwarding impl
1382                // would drop it unzeroized.
1383                let v = zeroize::Zeroizing::new(v);
1384                self.visit_bytes(&v)
1385            }
1386        }
1387        deserializer.deserialize_seq(FixedVisitor::<N>)
1388    }
1389}
1390
1391/// Zeroizes the inner value. Called automatically by [`Drop`].
1392///
1393/// **Warning:** zeroization does not run for `static` items or under `panic = "abort"`.
1394impl<T: zeroize::Zeroize> zeroize::Zeroize for Fixed<T> {
1395    fn zeroize(&mut self) {
1396        self.inner.zeroize();
1397    }
1398}
1399
1400/// Unconditionally zeroizes the inner value when the wrapper is dropped.
1401///
1402/// **Warning:** `Drop` does not run for `static` items or under `panic = "abort"`.
1403impl<T: zeroize::Zeroize> Drop for Fixed<T> {
1404    fn drop(&mut self) {
1405        self.zeroize();
1406    }
1407}
1408
1409/// Marker confirming that `Fixed<T>` always zeroizes on drop.
1410impl<T: zeroize::Zeroize> zeroize::ZeroizeOnDrop for Fixed<T> {}