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`/`Deserialize`** — requires marker traits 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-base64", feature = "alloc"))]
95use crate::traits::encoding::base64_url::ToBase64Url;
96#[cfg(all(feature = "encoding-bech32", feature = "alloc"))]
97use crate::traits::encoding::bech32::ToBech32;
98#[cfg(all(feature = "encoding-bech32m", feature = "alloc"))]
99use crate::traits::encoding::bech32m::ToBech32m;
100#[cfg(all(feature = "encoding-hex", feature = "alloc"))]
101use crate::traits::encoding::hex::ToHex;
102
103#[cfg(feature = "rand")]
104use rand::{TryCryptoRng, TryRng, rngs::SysRng};
105use zeroize::Zeroize;
106
107/// Drains a validated Bech32/Bech32m payload into a stack buffer.
108///
109/// Shared by the four `try_from_bech32*` constructors. Copies exactly `N`
110/// payload bytes into a `Zeroizing<[u8; N]>`; keeps counting (without storing)
111/// past `N` so the length-mismatch error reports the exact decoded length.
112/// The iteration count is bounded by the checksum-validated input string, so
113/// oversized inputs cost at most one bounded pass. Works without `alloc`.
114#[cfg(any(feature = "encoding-bech32", feature = "encoding-bech32m"))]
115fn drain_bech32_payload<const N: usize>(
116    checked: &bech32::primitives::decode::CheckedHrpstring<'_>,
117) -> Result<zeroize::Zeroizing<[u8; N]>, crate::error::Bech32Error> {
118    let mut buf = zeroize::Zeroizing::new([0u8; N]);
119    let mut count = 0usize;
120    for byte in checked.byte_iter() {
121        if count < N {
122            buf[count] = byte;
123        }
124        count += 1;
125    }
126    if count != N {
127        return Err(crate::error::Bech32Error::InvalidLength {
128            expected: N,
129            got: count,
130        });
131    }
132    Ok(buf)
133    // On the error path, buf is zeroized on drop.
134}
135
136/// Zero-cost stack-allocated wrapper for fixed-size secrets.
137///
138/// `Fixed<T>` stores a `T: Zeroize` value inline and unconditionally zeroizes it
139/// on drop. There is no `Deref`, `AsRef`, or `Copy` — every access is explicit
140/// through [`RevealSecret`] or [`RevealSecretMut`].
141///
142/// # Examples
143///
144/// ```rust
145/// use secure_gate::{Fixed, RevealSecret};
146///
147/// // Create a secret key.
148/// let key = Fixed::new([0xABu8; 32]);
149///
150/// // Scoped access — the borrow cannot escape the closure.
151/// let first = key.with_secret(|k| k[0]);
152/// assert_eq!(first, 0xAB);
153///
154/// // Debug is always redacted.
155/// assert_eq!(format!("{:?}", key), "[REDACTED]");
156/// ```
157///
158/// # Constructors
159///
160/// | Constructor | Feature | Notes |
161/// |---|---|---|
162/// | [`new(value)`](Self::new) | — | `const fn`, ergonomic default |
163/// | [`new_with(f)`](Self::new_with) | — | Scoped; preferred for stack-residue minimization |
164/// | [`From<[u8; N]>`](#impl-From<%5Bu8;+N%5D>-for-Fixed<%5Bu8;+N%5D>) | — | Equivalent to `new` |
165/// | [`TryFrom<&[u8]>`](#impl-TryFrom<%26%5Bu8%5D>-for-Fixed<%5Bu8;+N%5D>) | — | Length-checked slice conversion |
166/// | [`try_from_hex`](Self::try_from_hex) | `encoding-hex` | Constant-time hex decoding |
167/// | [`try_from_base64url`](Self::try_from_base64url) | `encoding-base64` | Constant-time Base64url decoding |
168/// | [`try_from_bech32`](Self::try_from_bech32) | `encoding-bech32` | HRP-validated Bech32 decoding |
169/// | [`try_from_bech32_unchecked`](Self::try_from_bech32_unchecked) | `encoding-bech32` | Bech32 without HRP check |
170/// | [`try_from_bech32m`](Self::try_from_bech32m) | `encoding-bech32m` | HRP-validated Bech32m decoding |
171/// | [`try_from_bech32m_unchecked`](Self::try_from_bech32m_unchecked) | `encoding-bech32m` | Bech32m without HRP check |
172/// | [`from_random()`](Self::from_random) | `rand` | System RNG |
173/// | [`from_rng(rng)`](Self::from_rng) | `rand` | Custom RNG |
174///
175/// # See also
176///
177/// - [`RevealSecret`] / [`RevealSecretMut`] — the 3-tier access traits.
178/// - [`new_with`](Self::new_with) — scoped constructor preferred over [`new`](Self::new).
179///
180/// # Note
181///
182/// `const fn new` compiles in `static` position, but **must not** be used there
183/// because `Drop` does not run on statics, which means zeroization is skipped.
184#[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"]
185pub struct Fixed<T: zeroize::Zeroize> {
186    inner: T,
187}
188
189impl<T: zeroize::Zeroize> Fixed<T> {
190    /// Creates a new [`Fixed<T>`] by wrapping a value.
191    ///
192    /// This is a `const fn`, so it can be evaluated at compile time. However,
193    /// **do not** use it to initialize `static` items — `Drop` does not run on
194    /// statics, so zeroization would be skipped.
195    ///
196    /// For `Fixed<[u8; N]>`, prefer [`new_with`](Fixed::new_with) when minimizing
197    /// stack residue matters, as `new` may leave an intermediate copy of `value`
198    /// on the caller's stack frame.
199    ///
200    /// # Examples
201    ///
202    /// ```rust
203    /// use secure_gate::{Fixed, RevealSecret};
204    ///
205    /// let secret = Fixed::new([0u8; 32]);
206    /// assert_eq!(secret.len(), 32);
207    /// ```
208    #[inline(always)]
209    pub const fn new(value: T) -> Self {
210        Fixed { inner: value }
211    }
212}
213
214/// Converts a byte array into a [`Fixed`] wrapper (equivalent to [`Fixed::new`]).
215///
216/// # Examples
217///
218/// ```rust
219/// use secure_gate::Fixed;
220///
221/// let secret: Fixed<[u8; 4]> = [1u8, 2, 3, 4].into();
222/// ```
223impl<const N: usize> From<[u8; N]> for Fixed<[u8; N]> {
224    #[inline(always)]
225    fn from(arr: [u8; N]) -> Self {
226        Self::new(arr)
227    }
228}
229
230/// Converts a byte slice into `Fixed<[u8; N]>`, failing if the length does not
231/// match `N`.
232///
233/// Internally uses [`Fixed::new_with`] so the secret is written directly into
234/// the wrapper's storage.
235///
236/// # Errors
237///
238/// Returns [`FromSliceError::InvalidLength`](crate::error::FromSliceError) when
239/// `slice.len() != N`.
240///
241/// # Examples
242///
243/// ```rust
244/// use secure_gate::{Fixed, RevealSecret};
245///
246/// // Success — exact length.
247/// let data = [0xFFu8; 4];
248/// let secret = Fixed::<[u8; 4]>::try_from(data.as_slice()).unwrap();
249/// assert_eq!(secret.expose_secret()[0], 0xFF);
250///
251/// // Failure — wrong length.
252/// let short = [0u8; 2];
253/// assert!(Fixed::<[u8; 4]>::try_from(short.as_slice()).is_err());
254/// ```
255impl<const N: usize> core::convert::TryFrom<&[u8]> for Fixed<[u8; N]> {
256    type Error = crate::error::FromSliceError;
257
258    fn try_from(slice: &[u8]) -> Result<Self, Self::Error> {
259        if slice.len() != N {
260            return Err(crate::error::FromSliceError::InvalidLength {
261                expected: N,
262                got: slice.len(),
263            });
264        }
265        Ok(Self::new_with(|arr| arr.copy_from_slice(slice)))
266    }
267}
268
269/// Construction and ergonomic encoding helpers for `Fixed<[u8; N]>`.
270impl<const N: usize> Fixed<[u8; N]> {
271    /// Writes directly into the wrapper's storage via a user-supplied closure,
272    /// eliminating the intermediate stack copy that [`new`](Self::new) may produce.
273    ///
274    /// The array is zero-initialized before the closure runs. Prefer this over
275    /// [`new(value)`](Self::new) when minimizing stack residue matters
276    /// (long-lived keys, high-assurance environments).
277    ///
278    /// # Security rationale
279    ///
280    /// With [`Fixed::new(value)`](Self::new), the caller first builds `value` on
281    /// its own stack frame, then moves it into the wrapper. The compiler *may*
282    /// elide the copy, but this is not guaranteed — leaving a plaintext residue
283    /// on the stack. `new_with` avoids this by giving the closure a mutable
284    /// reference to the wrapper's *own* storage, so the secret is never placed
285    /// anywhere else.
286    ///
287    /// # Examples
288    ///
289    /// ```rust
290    /// use secure_gate::{Fixed, RevealSecret};
291    ///
292    /// // Fill from a closure — no intermediate stack copy.
293    /// let secret = Fixed::<[u8; 4]>::new_with(|arr| arr.fill(0xAB));
294    /// assert_eq!(secret.expose_secret(), &[0xAB; 4]);
295    ///
296    /// // Copy from an existing slice.
297    /// let src = [1u8, 2, 3, 4];
298    /// let secret = Fixed::<[u8; 4]>::new_with(|arr| arr.copy_from_slice(&src));
299    /// ```
300    ///
301    /// # See also
302    ///
303    /// - [`Dynamic::new_with`](crate::Dynamic::new_with) — the heap-allocated
304    ///   equivalent (requires `alloc`).
305    ///
306    /// If the secret will outlive the current function, prefer
307    /// [`Dynamic<T>`](crate::Dynamic) over moving `Fixed<T>` around — each
308    /// move-by-value leaves residue in the previous stack slot.
309    #[inline(always)]
310    pub fn new_with<F>(f: F) -> Self
311    where
312        F: FnOnce(&mut [u8; N]),
313    {
314        let mut this = Self { inner: [0u8; N] };
315        f(&mut this.inner);
316        this
317    }
318}
319
320/// Hex encoding and decoding for `Fixed<[u8; N]>`.
321///
322/// Encoding uses a constant-time backend (`base16ct`). Decoding works with or without
323/// the `alloc` feature — on no-alloc targets the bytes are decoded directly into a
324/// `Zeroizing<[u8; N]>` stack buffer.
325#[cfg(feature = "encoding-hex")]
326impl<const N: usize> Fixed<[u8; N]> {
327    /// Encodes the secret bytes as a lowercase hex string.
328    ///
329    /// Requires the `encoding-hex` and `alloc` features.
330    ///
331    /// # Examples
332    ///
333    /// ```rust
334    /// # #[cfg(all(feature = "encoding-hex", feature = "alloc"))]
335    /// # {
336    /// use secure_gate::Fixed;
337    ///
338    /// let secret = Fixed::new([0xDE, 0xAD]);
339    /// assert_eq!(secret.to_hex(), "dead");
340    /// # }
341    /// ```
342    #[cfg(feature = "alloc")]
343    #[inline]
344    pub fn to_hex(&self) -> alloc::string::String {
345        self.with_secret(|s: &[u8; N]| s.to_hex())
346    }
347
348    /// Encodes the secret bytes as an uppercase hex string.
349    ///
350    /// Requires the `encoding-hex` and `alloc` features.
351    ///
352    /// # Examples
353    ///
354    /// ```rust
355    /// # #[cfg(all(feature = "encoding-hex", feature = "alloc"))]
356    /// # {
357    /// use secure_gate::Fixed;
358    ///
359    /// let secret = Fixed::new([0xDE, 0xAD]);
360    /// assert_eq!(secret.to_hex_upper(), "DEAD");
361    /// # }
362    /// ```
363    #[cfg(feature = "alloc")]
364    #[inline]
365    pub fn to_hex_upper(&self) -> alloc::string::String {
366        self.with_secret(|s: &[u8; N]| s.to_hex_upper())
367    }
368
369    /// Encodes the secret bytes as a lowercase hex string, returning
370    /// [`EncodedSecret`](crate::EncodedSecret) to preserve zeroization.
371    ///
372    /// Prefer this over [`to_hex`](Self::to_hex) when the encoded form should
373    /// still be treated as sensitive (e.g. private keys). The returned
374    /// [`EncodedSecret`](crate::EncodedSecret) is zeroized on drop.
375    ///
376    /// Requires the `encoding-hex` and `alloc` features.
377    ///
378    /// # Examples
379    ///
380    /// ```rust
381    /// # #[cfg(all(feature = "encoding-hex", feature = "alloc"))]
382    /// # {
383    /// use secure_gate::{Fixed, RevealSecret};
384    ///
385    /// let secret = Fixed::new([0xCA, 0xFE]);
386    /// let encoded = secret.to_hex_zeroizing();
387    /// assert_eq!(&*encoded, "cafe");
388    /// // `encoded` is zeroized when it goes out of scope.
389    /// # }
390    /// ```
391    #[cfg(feature = "alloc")]
392    #[inline]
393    pub fn to_hex_zeroizing(&self) -> crate::EncodedSecret {
394        self.with_secret(|s: &[u8; N]| s.to_hex_zeroizing())
395    }
396
397    /// Encodes the secret bytes as an uppercase hex string, returning
398    /// [`EncodedSecret`](crate::EncodedSecret) to preserve zeroization.
399    ///
400    /// Requires the `encoding-hex` and `alloc` features.
401    ///
402    /// # Examples
403    ///
404    /// ```rust
405    /// # #[cfg(all(feature = "encoding-hex", feature = "alloc"))]
406    /// # {
407    /// use secure_gate::{Fixed, RevealSecret};
408    ///
409    /// let secret = Fixed::new([0xCA, 0xFE]);
410    /// let encoded = secret.to_hex_upper_zeroizing();
411    /// assert_eq!(&*encoded, "CAFE");
412    /// # }
413    /// ```
414    #[cfg(feature = "alloc")]
415    #[inline]
416    pub fn to_hex_upper_zeroizing(&self) -> crate::EncodedSecret {
417        self.with_secret(|s: &[u8; N]| s.to_hex_upper_zeroizing())
418    }
419
420    /// Decodes a hex string (lowercase, uppercase, or mixed) into `Fixed<[u8; N]>`.
421    ///
422    /// Uses a constant-time backend (`base16ct`) for both paths.
423    ///
424    /// - **With `alloc`**: decodes into a `Zeroizing<Vec<u8>>` then copies onto the stack.
425    ///   The temporary heap buffer is zeroed on drop even if an error occurs.
426    /// - **Without `alloc`**: decodes directly into a `Zeroizing<[u8; N]>` stack buffer.
427    ///   No heap allocation occurs.
428    ///
429    /// # Errors
430    ///
431    /// - [`HexError::InvalidHex`] — non-hex characters or odd-length input.
432    /// - [`HexError::InvalidLength`] — decoded byte count does not equal `N`.
433    ///
434    /// # Examples
435    ///
436    /// ```rust
437    /// # #[cfg(feature = "encoding-hex")]
438    /// # {
439    /// use secure_gate::{Fixed, RevealSecret};
440    ///
441    /// // Round-trip: encode then decode.
442    /// let original = Fixed::new([0xDE, 0xAD, 0xBE, 0xEF]);
443    /// # #[cfg(feature = "alloc")]
444    /// # {
445    /// let hex_str = original.to_hex();
446    /// let decoded = Fixed::<[u8; 4]>::try_from_hex(&hex_str).unwrap();
447    /// assert_eq!(decoded.expose_secret(), &[0xDE, 0xAD, 0xBE, 0xEF]);
448    /// # }
449    ///
450    /// // Wrong length fails.
451    /// assert!(Fixed::<[u8; 2]>::try_from_hex("deadbeef").is_err());
452    /// # }
453    /// ```
454    pub fn try_from_hex(hex: &str) -> Result<Self, crate::error::HexError> {
455        #[cfg(feature = "alloc")]
456        {
457            use zeroize::Zeroizing;
458            let bytes = Zeroizing::new(
459                base16ct::mixed::decode_vec(hex.as_bytes())
460                    .map_err(|_| crate::error::HexError::InvalidHex)?,
461            );
462            if bytes.len() != N {
463                return Err(crate::error::HexError::InvalidLength {
464                    expected: N,
465                    got: bytes.len(),
466                });
467            }
468            Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
469        }
470        #[cfg(not(feature = "alloc"))]
471        {
472            use zeroize::Zeroizing;
473            // no-alloc path: decode directly into a stack buffer; no heap allocation
474            // base16ct::mixed accepts upper, lower, and mixed-case hex
475            let mut buf = Zeroizing::new([0u8; N]);
476            let decoded = base16ct::mixed::decode(hex.as_bytes(), &mut *buf)
477                .map_err(|_| crate::error::HexError::InvalidHex)?;
478            if decoded.len() != N {
479                return Err(crate::error::HexError::InvalidLength {
480                    expected: N,
481                    got: decoded.len(),
482                });
483            }
484            Ok(Self::new_with(|arr| arr.copy_from_slice(decoded)))
485            // buf is zeroized on drop (both success and error paths)
486        }
487    }
488}
489
490/// Base64url encoding and decoding for `Fixed<[u8; N]>`.
491///
492/// Encoding uses a constant-time backend (`base64ct`). Decoding works with or without
493/// the `alloc` feature — on no-alloc targets the bytes are decoded directly into a
494/// `Zeroizing<[u8; N]>` stack buffer.
495#[cfg(feature = "encoding-base64")]
496impl<const N: usize> Fixed<[u8; N]> {
497    /// Encodes the secret bytes as an unpadded Base64url string (RFC 4648, URL-safe alphabet).
498    ///
499    /// Requires the `encoding-base64` and `alloc` features.
500    ///
501    /// # Examples
502    ///
503    /// ```rust
504    /// # #[cfg(all(feature = "encoding-base64", feature = "alloc"))]
505    /// # {
506    /// use secure_gate::Fixed;
507    ///
508    /// let secret = Fixed::new([0xDE, 0xAD, 0xBE, 0xEF]);
509    /// let encoded = secret.to_base64url();
510    /// assert_eq!(encoded, "3q2-7w");
511    /// # }
512    /// ```
513    #[cfg(feature = "alloc")]
514    #[inline]
515    pub fn to_base64url(&self) -> alloc::string::String {
516        self.with_secret(|s: &[u8; N]| s.to_base64url())
517    }
518
519    /// Encodes the secret bytes as an unpadded Base64url string, returning
520    /// [`EncodedSecret`](crate::EncodedSecret) to preserve zeroization.
521    ///
522    /// Prefer this over [`to_base64url`](Self::to_base64url) when the encoded
523    /// form should still be treated as sensitive. The returned
524    /// [`EncodedSecret`](crate::EncodedSecret) is zeroized on drop.
525    ///
526    /// Requires the `encoding-base64` and `alloc` features.
527    ///
528    /// # Examples
529    ///
530    /// ```rust
531    /// # #[cfg(all(feature = "encoding-base64", feature = "alloc"))]
532    /// # {
533    /// use secure_gate::{Fixed, RevealSecret};
534    ///
535    /// let secret = Fixed::new([0xDE, 0xAD, 0xBE, 0xEF]);
536    /// let encoded = secret.to_base64url_zeroizing();
537    /// assert_eq!(&*encoded, "3q2-7w");
538    /// // `encoded` is zeroized when it goes out of scope.
539    /// # }
540    /// ```
541    #[cfg(feature = "alloc")]
542    #[inline]
543    pub fn to_base64url_zeroizing(&self) -> crate::EncodedSecret {
544        self.with_secret(|s: &[u8; N]| s.to_base64url_zeroizing())
545    }
546
547    /// Decodes an unpadded Base64url string (RFC 4648, URL-safe alphabet) into
548    /// `Fixed<[u8; N]>`.
549    ///
550    /// Uses a constant-time backend (`base64ct`) on both paths.
551    ///
552    /// - **With `alloc`**: decodes into a `Zeroizing<Vec<u8>>` then copies onto the stack.
553    /// - **Without `alloc`**: decodes directly into a `Zeroizing<[u8; N]>` stack buffer.
554    ///
555    /// # Errors
556    ///
557    /// - [`Base64Error::InvalidBase64`] — non-base64 characters or invalid padding.
558    /// - [`Base64Error::InvalidLength`] — decoded byte count does not equal `N`.
559    ///
560    /// # Examples
561    ///
562    /// ```rust
563    /// # #[cfg(feature = "encoding-base64")]
564    /// # {
565    /// use secure_gate::{Fixed, RevealSecret};
566    ///
567    /// # #[cfg(feature = "alloc")]
568    /// # {
569    /// // Round-trip.
570    /// let original = Fixed::new([0xDE, 0xAD, 0xBE, 0xEF]);
571    /// let encoded = original.to_base64url();
572    /// let decoded = Fixed::<[u8; 4]>::try_from_base64url(&encoded).unwrap();
573    /// assert_eq!(decoded.expose_secret(), &[0xDE, 0xAD, 0xBE, 0xEF]);
574    /// # }
575    /// # }
576    /// ```
577    pub fn try_from_base64url(s: &str) -> Result<Self, crate::error::Base64Error> {
578        #[cfg(feature = "alloc")]
579        {
580            use base64ct::{Base64UrlUnpadded, Encoding};
581            use zeroize::Zeroizing;
582            let bytes = Zeroizing::new(
583                Base64UrlUnpadded::decode_vec(s)
584                    .map_err(|_| crate::error::Base64Error::InvalidBase64)?,
585            );
586            if bytes.len() != N {
587                return Err(crate::error::Base64Error::InvalidLength {
588                    expected: N,
589                    got: bytes.len(),
590                });
591            }
592            Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
593        }
594        #[cfg(not(feature = "alloc"))]
595        {
596            use base64ct::{Base64UrlUnpadded, Encoding};
597            use zeroize::Zeroizing;
598            let mut buf = Zeroizing::new([0u8; N]);
599            let decoded = Base64UrlUnpadded::decode(s, &mut *buf)
600                .map_err(|_| crate::error::Base64Error::InvalidBase64)?;
601            if decoded.len() != N {
602                return Err(crate::error::Base64Error::InvalidLength {
603                    expected: N,
604                    got: decoded.len(),
605                });
606            }
607            Ok(Self::new_with(|arr| arr.copy_from_slice(decoded)))
608            // buf is zeroized on drop (both success and error paths)
609        }
610    }
611}
612
613/// Bech32 (BIP-173) encoding and decoding for `Fixed<[u8; N]>`.
614///
615/// Uses the extended `Bech32Large` checksum variant (~5 KB payload limit) rather than
616/// the 90-character standard limit. For Bitcoin address formats use `ToBech32m`.
617#[cfg(feature = "encoding-bech32")]
618impl<const N: usize> Fixed<[u8; N]> {
619    /// Encodes the secret bytes as a Bech32 (BIP-173) string with the given HRP.
620    ///
621    /// Requires the `encoding-bech32` and `alloc` features.
622    #[cfg(feature = "alloc")]
623    #[inline]
624    pub fn try_to_bech32(
625        &self,
626        hrp: &str,
627    ) -> Result<alloc::string::String, crate::error::Bech32Error> {
628        self.with_secret(|s: &[u8; N]| s.try_to_bech32(hrp))
629    }
630
631    /// Encodes the secret bytes as a Bech32 string, returning
632    /// [`EncodedSecret`](crate::EncodedSecret) to preserve zeroization.
633    ///
634    /// Requires the `encoding-bech32` and `alloc` features.
635    #[cfg(feature = "alloc")]
636    #[inline]
637    pub fn try_to_bech32_zeroizing(
638        &self,
639        hrp: &str,
640    ) -> Result<crate::EncodedSecret, crate::error::Bech32Error> {
641        self.with_secret(|s: &[u8; N]| s.try_to_bech32_zeroizing(hrp))
642    }
643
644    /// Decodes a Bech32 (BIP-173) string into `Fixed<[u8; N]>`, validating that the HRP
645    /// matches `expected_hrp` (case-insensitive).
646    ///
647    /// HRP comparison is non-constant-time — this is intentional, as the HRP is public
648    /// metadata, not secret material. Timing leaks on HRP mismatch are acceptable because
649    /// the HRP is not secret. Prefer this over
650    /// [`try_from_bech32_unchecked`](Self::try_from_bech32_unchecked) to prevent
651    /// cross-protocol confusion attacks.
652    ///
653    /// Works without `alloc` — decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
654    pub fn try_from_bech32(s: &str, expected_hrp: &str) -> Result<Self, crate::error::Bech32Error> {
655        use crate::traits::encoding::bech32::Bech32Large;
656        use bech32::primitives::decode::CheckedHrpstring;
657        let checked = CheckedHrpstring::new::<Bech32Large>(s)
658            .map_err(|_| crate::error::Bech32Error::OperationFailed)?;
659        // HRP check before any payload byte is materialized (case-insensitive
660        // comparison — timing leak is acceptable since HRP is public metadata)
661        if !checked.hrp().as_str().eq_ignore_ascii_case(expected_hrp) {
662            return Err(crate::error::Bech32Error::UnexpectedHrp);
663        }
664        let buf = drain_bech32_payload::<N>(&checked)?;
665        Ok(Self::new_with(|arr| arr.copy_from_slice(&*buf)))
666        // buf is zeroized on drop
667    }
668
669    /// Decodes a Bech32 (BIP-173) string into `Fixed<[u8; N]>` without validating the HRP.
670    ///
671    /// Any valid HRP is accepted as long as the checksum is valid and the payload length
672    /// equals `N`. Use [`try_from_bech32`](Self::try_from_bech32) in security-critical code
673    /// to prevent cross-protocol confusion attacks.
674    ///
675    /// Works without `alloc` — decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
676    pub fn try_from_bech32_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
677        use crate::traits::encoding::bech32::Bech32Large;
678        use bech32::primitives::decode::CheckedHrpstring;
679        let checked = CheckedHrpstring::new::<Bech32Large>(s)
680            .map_err(|_| crate::error::Bech32Error::OperationFailed)?;
681        let buf = drain_bech32_payload::<N>(&checked)?;
682        Ok(Self::new_with(|arr| arr.copy_from_slice(&*buf)))
683        // buf is zeroized on drop
684    }
685}
686
687/// Bech32m (BIP-350) encoding and decoding for `Fixed<[u8; N]>`.
688///
689/// Uses the standard BIP-350 payload limit (~90 bytes). For large secrets
690/// (ciphertexts, recipients) use `ToBech32` / `Bech32Large` instead.
691#[cfg(feature = "encoding-bech32m")]
692impl<const N: usize> Fixed<[u8; N]> {
693    /// Encodes the secret bytes as a Bech32m (BIP-350) string with the given HRP.
694    ///
695    /// Requires the `encoding-bech32m` and `alloc` features.
696    #[cfg(feature = "alloc")]
697    #[inline]
698    pub fn try_to_bech32m(
699        &self,
700        hrp: &str,
701    ) -> Result<alloc::string::String, crate::error::Bech32Error> {
702        self.with_secret(|s: &[u8; N]| s.try_to_bech32m(hrp))
703    }
704
705    /// Encodes the secret bytes as a Bech32m string, returning
706    /// [`EncodedSecret`](crate::EncodedSecret) to preserve zeroization.
707    ///
708    /// Requires the `encoding-bech32m` and `alloc` features.
709    #[cfg(feature = "alloc")]
710    #[inline]
711    pub fn try_to_bech32m_zeroizing(
712        &self,
713        hrp: &str,
714    ) -> Result<crate::EncodedSecret, crate::error::Bech32Error> {
715        self.with_secret(|s: &[u8; N]| s.try_to_bech32m_zeroizing(hrp))
716    }
717
718    /// Decodes a Bech32m (BIP-350) string into `Fixed<[u8; N]>`, validating that the HRP
719    /// matches `expected_hrp` (case-insensitive).
720    ///
721    /// HRP comparison is non-constant-time — this is intentional, as the HRP is public
722    /// metadata, not secret material. Timing leaks on HRP mismatch are acceptable because
723    /// the HRP is not secret. Prefer this over
724    /// [`try_from_bech32m_unchecked`](Self::try_from_bech32m_unchecked) to prevent
725    /// cross-protocol confusion attacks.
726    ///
727    /// Works without `alloc` — decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
728    pub fn try_from_bech32m(
729        s: &str,
730        expected_hrp: &str,
731    ) -> Result<Self, crate::error::Bech32Error> {
732        use bech32::{Bech32m, primitives::decode::CheckedHrpstring};
733        let checked = CheckedHrpstring::new::<Bech32m>(s)
734            .map_err(|_| crate::error::Bech32Error::OperationFailed)?;
735        // HRP check before any payload byte is materialized (case-insensitive
736        // comparison — timing leak is acceptable since HRP is public metadata)
737        if !checked.hrp().as_str().eq_ignore_ascii_case(expected_hrp) {
738            return Err(crate::error::Bech32Error::UnexpectedHrp);
739        }
740        let buf = drain_bech32_payload::<N>(&checked)?;
741        Ok(Self::new_with(|arr| arr.copy_from_slice(&*buf)))
742        // buf is zeroized on drop
743    }
744
745    /// Decodes a Bech32m (BIP-350) string into `Fixed<[u8; N]>` without validating the HRP.
746    ///
747    /// Any valid HRP is accepted as long as the checksum is valid and the payload length
748    /// equals `N`. Use [`try_from_bech32m`](Self::try_from_bech32m) in security-critical
749    /// code to prevent cross-protocol confusion attacks.
750    ///
751    /// Works without `alloc` — decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
752    pub fn try_from_bech32m_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
753        use bech32::{Bech32m, primitives::decode::CheckedHrpstring};
754        let checked = CheckedHrpstring::new::<Bech32m>(s)
755            .map_err(|_| crate::error::Bech32Error::OperationFailed)?;
756        let buf = drain_bech32_payload::<N>(&checked)?;
757        Ok(Self::new_with(|arr| arr.copy_from_slice(&*buf)))
758        // buf is zeroized on drop
759    }
760}
761
762/// Explicit access to immutable [`Fixed<[T; N]>`] contents.
763impl<const N: usize, T: zeroize::Zeroize> RevealSecret for Fixed<[T; N]> {
764    type Inner = [T; N];
765
766    #[inline(always)]
767    fn with_secret<F, R>(&self, f: F) -> R
768    where
769        F: FnOnce(&[T; N]) -> R,
770    {
771        f(&self.inner)
772    }
773
774    #[inline(always)]
775    fn expose_secret(&self) -> &[T; N] {
776        &self.inner
777    }
778
779    #[inline(always)]
780    fn len(&self) -> usize {
781        N
782    }
783
784    #[inline(always)]
785    fn byte_len(&self) -> usize {
786        N * core::mem::size_of::<T>()
787    }
788
789    /// Consumes `self` and returns the inner `[T; N]` wrapped in [`crate::InnerSecret`].
790    ///
791    /// Zero cost — no allocation. The sentinel placed in `self.inner` is
792    /// `[T::default(); N]` via [`crate::SentinelValue`] (already zeroed for `u8`),
793    /// so `Fixed::drop` zeroizes an already-zero array — a harmless no-op.
794    /// Works for **any** array length `N` (not limited to 32 like `Default`).
795    ///
796    /// See [`RevealSecret::into_inner`] for full documentation including the
797    /// `SentinelValue` bound rationale and redacted `Debug` behavior.
798    #[inline(always)]
799    fn into_inner(mut self) -> crate::InnerSecret<[T; N]>
800    where
801        Self: Sized,
802        Self::Inner: Sized + crate::SentinelValue + zeroize::Zeroize,
803    {
804        // Replace inner with the sentinel so Fixed::drop zeroizes a harmless
805        // placeholder while the caller receives the real secret.
806        let inner = core::mem::replace(&mut self.inner, crate::SentinelValue::sentinel_value());
807        crate::InnerSecret::new(inner)
808    }
809}
810
811/// Explicit access to mutable [`Fixed<[T; N]>`] contents.
812impl<const N: usize, T: zeroize::Zeroize> RevealSecretMut for Fixed<[T; N]> {
813    #[inline(always)]
814    fn with_secret_mut<F, R>(&mut self, f: F) -> R
815    where
816        F: FnOnce(&mut [T; N]) -> R,
817    {
818        f(&mut self.inner)
819    }
820
821    #[inline(always)]
822    fn expose_secret_mut(&mut self) -> &mut [T; N] {
823        &mut self.inner
824    }
825}
826
827#[cfg(feature = "rand")]
828impl<const N: usize> Fixed<[u8; N]> {
829    /// Fills a new `[u8; N]` with cryptographically secure random bytes and wraps it.
830    ///
831    /// Uses the system RNG ([`SysRng`](rand::rngs::SysRng)). Requires the `rand` feature.
832    /// Heap-free and works in `no_std` / `no_alloc` builds.
833    ///
834    /// # Panics
835    ///
836    /// Panics if the system RNG fails to provide bytes ([`TryRng::try_fill_bytes`](rand::TryRng::try_fill_bytes)
837    /// returns `Err`). This is treated as a fatal environment error.
838    ///
839    /// # Examples
840    ///
841    /// ```rust
842    /// # #[cfg(feature = "rand")]
843    /// use secure_gate::{Fixed, RevealSecret};
844    ///
845    /// # #[cfg(feature = "rand")]
846    /// # {
847    /// let key: Fixed<[u8; 32]> = Fixed::from_random();
848    /// assert_eq!(key.len(), 32);
849    /// # }
850    /// ```
851    #[inline]
852    pub fn from_random() -> Self {
853        Self::new_with(|arr| {
854            SysRng
855                .try_fill_bytes(arr)
856                .expect("SysRng failure is a program error");
857        })
858    }
859
860    /// Fills a new `[u8; N]` from `rng` and wraps it.
861    ///
862    /// Accepts any [`TryCryptoRng`](rand::TryCryptoRng) + [`TryRng`](rand::TryRng) — for example,
863    /// a seeded [`StdRng`](rand::rngs::StdRng) for deterministic tests. Requires the `rand`
864    /// feature. Heap-free.
865    ///
866    /// # Errors
867    ///
868    /// Returns `R::Error` if [`try_fill_bytes`](rand::TryRng::try_fill_bytes) fails.
869    ///
870    /// # Examples
871    ///
872    /// ```rust
873    /// # #[cfg(feature = "rand")]
874    /// # {
875    /// use rand::rngs::StdRng;
876    /// use rand::SeedableRng;
877    /// use secure_gate::Fixed;
878    ///
879    /// let mut rng = StdRng::from_seed([1u8; 32]);
880    /// let key: Fixed<[u8; 16]> = Fixed::from_rng(&mut rng).expect("rng fill");
881    /// # }
882    /// ```
883    #[inline]
884    pub fn from_rng<R: TryRng + TryCryptoRng>(rng: &mut R) -> Result<Self, R::Error> {
885        let mut result = Ok(());
886        let this = Self::new_with(|arr| {
887            result = rng.try_fill_bytes(arr);
888        });
889        result.map(|_| this) // on Err, `this` drops → zeroizes any partial fill
890    }
891}
892
893/// Constant-time equality for `Fixed<T>` — routes through [`expose_secret()`](crate::RevealSecret::expose_secret).
894///
895/// `==` is **deliberately not implemented** on `Fixed`. Always use `ct_eq`.
896///
897/// ```rust
898/// # #[cfg(feature = "ct-eq")]
899/// # {
900/// use secure_gate::{Fixed, ConstantTimeEq};
901///
902/// let a = Fixed::new([1u8; 4]);
903/// let b = Fixed::new([1u8; 4]);
904/// let c = Fixed::new([2u8; 4]);
905/// assert!(a.ct_eq(&b));
906/// assert!(!a.ct_eq(&c));
907/// # }
908/// ```
909#[cfg(feature = "ct-eq")]
910impl<T: zeroize::Zeroize> crate::ConstantTimeEq for Fixed<T>
911where
912    T: crate::ConstantTimeEq,
913    Self: crate::RevealSecret<Inner = T>,
914{
915    fn ct_eq(&self, other: &Self) -> bool {
916        self.expose_secret().ct_eq(other.expose_secret())
917    }
918}
919
920/// Always prints `[REDACTED]` — secrets never appear in debug output.
921///
922/// ```rust
923/// use secure_gate::Fixed;
924///
925/// let key = Fixed::new([0xABu8; 32]);
926/// assert_eq!(format!("{:?}", key), "[REDACTED]");
927/// ```
928impl<T: zeroize::Zeroize> core::fmt::Debug for Fixed<T> {
929    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
930        f.write_str("[REDACTED]")
931    }
932}
933
934/// Opt-in cloning — requires `cloneable` feature and [`CloneableSecret`](crate::CloneableSecret)
935/// marker on the inner type. Each clone is independently zeroized on drop, but cloning
936/// increases the in-memory exposure surface. Use sparingly.
937#[cfg(feature = "cloneable")]
938impl<T: zeroize::Zeroize + crate::CloneableSecret> Clone for Fixed<T> {
939    fn clone(&self) -> Self {
940        Self::new(self.inner.clone())
941    }
942}
943
944/// Opt-in serialization — requires `serde-serialize` feature and
945/// [`SerializableSecret`](crate::SerializableSecret) marker on the inner type.
946/// Serialization exposes the full secret — audit every impl.
947#[cfg(feature = "serde-serialize")]
948impl<T: zeroize::Zeroize + crate::SerializableSecret> serde::Serialize for Fixed<T> {
949    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
950    where
951        S: serde::Serializer,
952    {
953        self.inner.serialize(serializer)
954    }
955}
956
957/// Deserialization uses `Zeroizing`-wrapped temporary buffers — zeroized even on rejection.
958///
959/// The sequence buffer is pre-allocated to exactly `N` bytes and **never grows**: an
960/// input sequence longer than `N` is rejected before the element that would trigger a
961/// reallocation is stored. This matters because a `Vec` reallocation frees the old
962/// buffer — which would already hold `N` secret bytes — without zeroizing it.
963///
964/// The entry point is `deserialize_seq` (unchanged wire format for non-self-describing
965/// formats such as bincode), but the visitor also accepts byte strings via
966/// `visit_bytes` / `visit_byte_buf`, so self-describing formats that encode byte
967/// arrays as byte strings (e.g. CBOR) round-trip too. Owned buffers handed over
968/// through `visit_byte_buf` are zeroized after the copy.
969#[cfg(feature = "serde-deserialize")]
970impl<'de, const N: usize> serde::Deserialize<'de> for Fixed<[u8; N]> {
971    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
972    where
973        D: serde::Deserializer<'de>,
974    {
975        use core::fmt;
976        use serde::de::Visitor;
977        struct FixedVisitor<const M: usize>;
978        impl<'de, const M: usize> Visitor<'de> for FixedVisitor<M> {
979            type Value = Fixed<[u8; M]>;
980            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
981                write!(formatter, "a byte array of length {}", M)
982            }
983            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
984            where
985                A: serde::de::SeqAccess<'de>,
986            {
987                let mut vec: zeroize::Zeroizing<alloc::vec::Vec<u8>> =
988                    zeroize::Zeroizing::new(alloc::vec::Vec::with_capacity(M));
989                while let Some(value) = seq.next_element()? {
990                    // Reject over-length input *before* pushing past the reserved
991                    // capacity: growing would realloc and free the old buffer
992                    // (already holding M secret bytes) without zeroization.
993                    if vec.len() == M {
994                        return Err(serde::de::Error::invalid_length(M + 1, &self));
995                    }
996                    vec.push(value);
997                }
998                if vec.len() != M {
999                    return Err(serde::de::Error::invalid_length(vec.len(), &self));
1000                }
1001                Ok(Fixed::new_with(|arr| arr.copy_from_slice(&vec)))
1002            }
1003            fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
1004            where
1005                E: serde::de::Error,
1006            {
1007                if v.len() != M {
1008                    return Err(serde::de::Error::invalid_length(v.len(), &self));
1009                }
1010                Ok(Fixed::new_with(|arr| arr.copy_from_slice(v)))
1011            }
1012            fn visit_byte_buf<E>(self, v: alloc::vec::Vec<u8>) -> Result<Self::Value, E>
1013            where
1014                E: serde::de::Error,
1015            {
1016                // Take ownership under Zeroizing so the deserializer-provided
1017                // buffer is wiped after the copy — the default forwarding impl
1018                // would drop it unzeroized.
1019                let v = zeroize::Zeroizing::new(v);
1020                self.visit_bytes(&v)
1021            }
1022        }
1023        deserializer.deserialize_seq(FixedVisitor::<N>)
1024    }
1025}
1026
1027/// Zeroizes the inner value. Called automatically by [`Drop`].
1028///
1029/// **Warning:** zeroization does not run for `static` items or under `panic = "abort"`.
1030impl<T: zeroize::Zeroize> zeroize::Zeroize for Fixed<T> {
1031    fn zeroize(&mut self) {
1032        self.inner.zeroize();
1033    }
1034}
1035
1036/// Unconditionally zeroizes the inner value when the wrapper is dropped.
1037///
1038/// **Warning:** `Drop` does not run for `static` items or under `panic = "abort"`.
1039impl<T: zeroize::Zeroize> Drop for Fixed<T> {
1040    fn drop(&mut self) {
1041        self.zeroize();
1042    }
1043}
1044
1045/// Marker confirming that `Fixed<T>` always zeroizes on drop.
1046impl<T: zeroize::Zeroize> zeroize::ZeroizeOnDrop for Fixed<T> {}