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