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