Skip to main content

secure_gate/
fixed.rs

1//! Stack-allocated wrapper for fixed-size secrets.
2//!
3//! Provides [`Fixed<T>`], a zero-cost wrapper enforcing explicit access to sensitive data.
4//! Treat secrets as radioactive — minimize exposure surface.
5//!
6//! Inner type **must implement `Zeroize`** for automatic zeroization on drop.
7//!
8//! # Examples
9//!
10//! ```rust
11//! use secure_gate::{Fixed, RevealSecret};
12//!
13//! let secret = Fixed::new([1u8, 2, 3, 4]);
14//! let sum = secret.with_secret(|arr| arr.iter().sum::<u8>());
15//! assert_eq!(sum, 10);
16//! ```
17
18use crate::RevealSecret;
19use crate::RevealSecretMut;
20
21#[cfg(feature = "encoding-base64")]
22use crate::traits::encoding::base64_url::ToBase64Url;
23#[cfg(feature = "encoding-hex")]
24use crate::traits::encoding::hex::ToHex;
25
26#[cfg(feature = "rand")]
27use rand::{rngs::OsRng, TryCryptoRng, TryRngCore};
28use zeroize::Zeroize;
29
30#[cfg(feature = "encoding-base64")]
31use crate::traits::decoding::base64_url::FromBase64UrlStr;
32#[cfg(feature = "encoding-bech32")]
33use crate::traits::decoding::bech32::FromBech32Str;
34#[cfg(feature = "encoding-bech32m")]
35use crate::traits::decoding::bech32m::FromBech32mStr;
36#[cfg(feature = "encoding-hex")]
37use crate::traits::decoding::hex::FromHexStr;
38
39/// Zero-cost stack-allocated wrapper for fixed-size secrets.
40///
41/// Always available. Inner type **must implement `Zeroize`** for automatic zeroization on drop.
42///
43/// No `Deref`, `AsRef`, or `Copy` by default — all access requires
44/// [`expose_secret()`](RevealSecret::expose_secret) or
45/// [`with_secret()`](RevealSecret::with_secret) (scoped, preferred).
46/// For construction of `Fixed<[u8; N]>`, [`new_with`](Fixed::new_with) is the
47/// matching scoped constructor — it writes directly into the wrapper's storage
48/// and avoids any intermediate stack copy. [`new(value)`](Fixed::new) remains
49/// available as the ergonomic default.
50/// `Debug` always prints `[REDACTED]`. Performance indistinguishable from raw arrays.
51pub struct Fixed<T: zeroize::Zeroize> {
52    inner: T,
53}
54
55impl<T: zeroize::Zeroize> Fixed<T> {
56    /// Creates a new [`Fixed<T>`] by wrapping a value.
57    #[inline(always)]
58    pub const fn new(value: T) -> Self {
59        Fixed { inner: value }
60    }
61}
62
63impl<const N: usize> From<[u8; N]> for Fixed<[u8; N]> {
64    #[inline(always)]
65    fn from(arr: [u8; N]) -> Self {
66        Self::new(arr)
67    }
68}
69
70impl<const N: usize> core::convert::TryFrom<&[u8]> for Fixed<[u8; N]> {
71    type Error = crate::error::FromSliceError;
72
73    fn try_from(slice: &[u8]) -> Result<Self, Self::Error> {
74        if slice.len() != N {
75            #[cfg(debug_assertions)]
76            return Err(crate::error::FromSliceError::InvalidLength {
77                actual: slice.len(),
78                expected: N,
79            });
80            #[cfg(not(debug_assertions))]
81            return Err(crate::error::FromSliceError::InvalidLength);
82        }
83        Ok(Self::new_with(|arr| arr.copy_from_slice(slice)))
84    }
85}
86
87/// Construction and ergonomic encoding helpers for `Fixed<[u8; N]>`.
88impl<const N: usize> Fixed<[u8; N]> {
89    /// Writes directly into the wrapper's storage via a user-supplied closure,
90    /// eliminating the intermediate stack copy that [`new`](Self::new) may produce.
91    ///
92    /// The array is zero-initialized before the closure runs. Prefer this over
93    /// [`new(value)`](Self::new) when minimizing stack residue matters
94    /// (long-lived keys, high-assurance environments).
95    ///
96    /// # Examples
97    ///
98    /// ```rust
99    /// use secure_gate::Fixed;
100    ///
101    /// let secret = Fixed::<[u8; 4]>::new_with(|arr| arr.fill(0xAB));
102    /// ```
103    #[inline(always)]
104    pub fn new_with<F>(f: F) -> Self
105    where
106        F: FnOnce(&mut [u8; N]),
107    {
108        let mut this = Self { inner: [0u8; N] };
109        f(&mut this.inner);
110        this
111    }
112
113    /// Encodes the secret bytes as a lowercase hex string.
114    ///
115    /// Delegates to [`ToHex::to_hex`](crate::ToHex::to_hex) on the inner `[u8; N]`.
116    /// Requires the `encoding-hex` feature.
117    #[cfg(feature = "encoding-hex")]
118    #[inline]
119    pub fn to_hex(&self) -> alloc::string::String {
120        self.with_secret(|s: &[u8; N]| s.to_hex())
121    }
122
123    /// Encodes the secret bytes as an uppercase hex string.
124    ///
125    /// Delegates to [`ToHex::to_hex_upper`](crate::ToHex::to_hex_upper) on the inner `[u8; N]`.
126    /// Requires the `encoding-hex` feature.
127    #[cfg(feature = "encoding-hex")]
128    #[inline]
129    pub fn to_hex_upper(&self) -> alloc::string::String {
130        self.with_secret(|s: &[u8; N]| s.to_hex_upper())
131    }
132
133    /// Encodes the secret bytes as an unpadded Base64url string.
134    ///
135    /// Delegates to [`ToBase64Url::to_base64url`](crate::ToBase64Url::to_base64url) on the inner `[u8; N]`.
136    /// Requires the `encoding-base64` feature.
137    #[cfg(feature = "encoding-base64")]
138    #[inline]
139    pub fn to_base64url(&self) -> alloc::string::String {
140        self.with_secret(|s: &[u8; N]| s.to_base64url())
141    }
142}
143
144/// Explicit access to immutable [`Fixed<[T; N]>`] contents.
145impl<const N: usize, T: zeroize::Zeroize> RevealSecret for Fixed<[T; N]> {
146    type Inner = [T; N];
147
148    #[inline(always)]
149    fn with_secret<F, R>(&self, f: F) -> R
150    where
151        F: FnOnce(&[T; N]) -> R,
152    {
153        f(&self.inner)
154    }
155
156    #[inline(always)]
157    fn expose_secret(&self) -> &[T; N] {
158        &self.inner
159    }
160
161    #[inline(always)]
162    fn len(&self) -> usize {
163        N * core::mem::size_of::<T>()
164    }
165
166    /// Consumes `self` and returns the inner `[T; N]` wrapped in [`crate::InnerSecret`].
167    ///
168    /// Zero cost — no allocation. The sentinel placed in `self.inner` is
169    /// `[T::default(); N]` (already zeroed for `u8`), so `Fixed::drop` zeroizes
170    /// an already-zero array — a harmless no-op.
171    ///
172    /// See [`RevealSecret::into_inner`] for full documentation including the
173    /// `Default` bound rationale and redacted `Debug` behavior.
174    #[inline(always)]
175    fn into_inner(mut self) -> crate::InnerSecret<[T; N]>
176    where
177        Self: Sized,
178        Self::Inner: Sized + Default + zeroize::Zeroize,
179    {
180        // Take inner and leave a zero-sentinel so Fixed::drop zeroizes a harmless
181        // default value while the caller receives the real secret.
182        // `take` uses Default; [T; N]: Default is guaranteed by the where clause above.
183        let inner = core::mem::take(&mut self.inner);
184        crate::InnerSecret::new(inner)
185    }
186}
187
188/// Explicit access to mutable [`Fixed<[T; N]>`] contents.
189impl<const N: usize, T: zeroize::Zeroize> RevealSecretMut for Fixed<[T; N]> {
190    #[inline(always)]
191    fn with_secret_mut<F, R>(&mut self, f: F) -> R
192    where
193        F: FnOnce(&mut [T; N]) -> R,
194    {
195        f(&mut self.inner)
196    }
197
198    #[inline(always)]
199    fn expose_secret_mut(&mut self) -> &mut [T; N] {
200        &mut self.inner
201    }
202}
203
204#[cfg(feature = "rand")]
205impl<const N: usize> Fixed<[u8; N]> {
206    /// Fills a new `[u8; N]` with cryptographically secure random bytes and wraps it.
207    ///
208    /// Uses the system RNG ([`OsRng`](rand::rngs::OsRng)) via [`TryRngCore::try_fill_bytes`](rand::TryRngCore::try_fill_bytes).
209    /// In `rand` 0.9, `OsRng` is a zero-sized handle to the OS generator (not user-seedable). Requires the `rand`
210    /// feature. Heap-free and works in `no_std` / `no_alloc` builds.
211    ///
212    /// # Panics
213    ///
214    /// Panics if the system RNG fails to provide bytes ([`TryRngCore::try_fill_bytes`](rand::TryRngCore::try_fill_bytes)
215    /// returns `Err`). This is treated as a fatal environment error.
216    ///
217    /// # Examples
218    ///
219    /// ```rust
220    /// # #[cfg(feature = "rand")]
221    /// use secure_gate::{Fixed, RevealSecret};
222    ///
223    /// # #[cfg(feature = "rand")]
224    /// # {
225    /// let key: Fixed<[u8; 32]> = Fixed::from_random();
226    /// assert_eq!(key.len(), 32);
227    /// # }
228    /// ```
229    #[inline]
230    pub fn from_random() -> Self {
231        Self::new_with(|arr| {
232            OsRng
233                .try_fill_bytes(arr)
234                .expect("OsRng failure is a program error");
235        })
236    }
237
238    /// Fills a new `[u8; N]` from `rng` and wraps it.
239    ///
240    /// Accepts any [`TryCryptoRng`](rand::TryCryptoRng) + [`TryRngCore`](rand::TryRngCore).
241    /// Pass [`OsRng`](rand::rngs::OsRng) for the same system entropy as [`from_random`](Self::from_random)
242    /// with a fallible interface. **Do not use `OsRng` for deterministic tests** — in `rand` 0.9 it is a
243    /// unit struct backed by the OS and is **not** seedable; use a seedable PRNG such as
244    /// [`StdRng`](rand::rngs::StdRng) with [`SeedableRng`](rand::SeedableRng) instead. Requires the `rand`
245    /// feature. Heap-free.
246    ///
247    /// # Errors
248    ///
249    /// Returns `R::Error` if [`try_fill_bytes`](rand::TryRngCore::try_fill_bytes) fails.
250    ///
251    /// # Examples
252    ///
253    /// System RNG (same source as `from_random`, `Result`-based):
254    ///
255    /// ```rust
256    /// # #[cfg(feature = "rand")]
257    /// # {
258    /// use rand::rngs::OsRng;
259    /// use secure_gate::Fixed;
260    ///
261    /// let key: Fixed<[u8; 16]> = Fixed::from_rng(&mut OsRng).expect("rng fill");
262    /// # }
263    /// ```
264    ///
265    /// Deterministic fill (tests) with a seedable generator:
266    ///
267    /// ```rust
268    /// # #[cfg(feature = "rand")]
269    /// # {
270    /// use rand::rngs::StdRng;
271    /// use rand::SeedableRng;
272    /// use secure_gate::Fixed;
273    ///
274    /// let mut rng = StdRng::from_seed([1u8; 32]);
275    /// let key: Fixed<[u8; 16]> = Fixed::from_rng(&mut rng).expect("rng fill");
276    /// # }
277    /// ```
278    #[inline]
279    pub fn from_rng<R: TryRngCore + TryCryptoRng>(rng: &mut R) -> Result<Self, R::Error> {
280        let mut result = Ok(());
281        let this = Self::new_with(|arr| {
282            result = rng.try_fill_bytes(arr);
283        });
284        result.map(|_| this) // on Err, `this` drops → zeroizes any partial fill
285    }
286}
287
288#[cfg(feature = "encoding-hex")]
289impl<const N: usize> Fixed<[u8; N]> {
290    /// Decodes a lowercase hex string into `Fixed<[u8; N]>`.
291    ///
292    /// The decoded bytes are held in a `Zeroizing<Vec<u8>>` until copied onto
293    /// the stack array, so the temporary heap buffer is zeroed even if a panic
294    /// occurs mid-flight.
295    ///
296    /// # Errors
297    ///
298    /// Returns `HexError::InvalidLength` if the decoded length does not equal `N`,
299    /// or a parse error if the input is not valid hex.
300    ///
301    /// # Note
302    ///
303    /// Unlike [`Dynamic::try_from_hex`](crate::Dynamic::try_from_hex), the secret
304    /// lives on the stack inside a `[u8; N]`. Stack residue behaviour after the
305    /// `Fixed` is dropped and zeroized is discussed in `SECURITY.md`.
306    pub fn try_from_hex(hex: &str) -> Result<Self, crate::error::HexError> {
307        let bytes = zeroize::Zeroizing::new(hex.try_from_hex()?);
308        if bytes.len() != N {
309            #[cfg(debug_assertions)]
310            return Err(crate::error::HexError::InvalidLength {
311                expected: N,
312                got: bytes.len(),
313            });
314            #[cfg(not(debug_assertions))]
315            return Err(crate::error::HexError::InvalidLength);
316        }
317        Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
318    }
319}
320
321#[cfg(feature = "encoding-base64")]
322impl<const N: usize> Fixed<[u8; N]> {
323    /// Decodes an unpadded Base64url string into `Fixed<[u8; N]>`.
324    ///
325    /// The decoded bytes are held in a `Zeroizing<Vec<u8>>` until copied onto
326    /// the stack array, so the temporary heap buffer is zeroed even if a panic
327    /// occurs mid-flight.
328    ///
329    /// # Errors
330    ///
331    /// Returns `Base64Error::InvalidLength` if the decoded length does not equal `N`,
332    /// or a parse error if the input is not valid Base64url.
333    ///
334    /// # Note
335    ///
336    /// Unlike [`Dynamic::try_from_base64url`](crate::Dynamic::try_from_base64url), the
337    /// secret lives on the stack inside a `[u8; N]`. Stack residue behaviour after the
338    /// `Fixed` is dropped and zeroized is discussed in `SECURITY.md`.
339    pub fn try_from_base64url(s: &str) -> Result<Self, crate::error::Base64Error> {
340        let bytes = zeroize::Zeroizing::new(s.try_from_base64url()?);
341        if bytes.len() != N {
342            #[cfg(debug_assertions)]
343            return Err(crate::error::Base64Error::InvalidLength {
344                expected: N,
345                got: bytes.len(),
346            });
347            #[cfg(not(debug_assertions))]
348            return Err(crate::error::Base64Error::InvalidLength);
349        }
350        Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
351    }
352}
353
354#[cfg(feature = "encoding-bech32")]
355impl<const N: usize> Fixed<[u8; N]> {
356    /// Decodes a Bech32 (BIP-173) string into `Fixed<[u8; N]>`.
357    ///
358    /// # Warning
359    ///
360    /// The HRP is **not validated** — any HRP will be accepted as long as the checksum
361    /// is valid and the payload length equals `N`. For security-critical code where
362    /// cross-protocol confusion must be prevented, use [`try_from_bech32`](Self::try_from_bech32).
363    pub fn try_from_bech32_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
364        let (_hrp, bytes_raw) = s.try_from_bech32_unchecked()?;
365        let bytes = zeroize::Zeroizing::new(bytes_raw);
366        if bytes.len() != N {
367            #[cfg(debug_assertions)]
368            return Err(crate::error::Bech32Error::InvalidLength {
369                expected: N,
370                got: bytes.len(),
371            });
372            #[cfg(not(debug_assertions))]
373            return Err(crate::error::Bech32Error::InvalidLength);
374        }
375        Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
376    }
377
378    /// Decodes a Bech32 (BIP-173) string into `Fixed<[u8; N]>`, validating that the HRP
379    /// matches `expected_hrp` (case-insensitive).
380    ///
381    /// Prefer this over [`try_from_bech32_unchecked`](Self::try_from_bech32_unchecked) in
382    /// security-critical code to prevent cross-protocol confusion attacks.
383    pub fn try_from_bech32(s: &str, expected_hrp: &str) -> Result<Self, crate::error::Bech32Error> {
384        let bytes_raw = s.try_from_bech32(expected_hrp)?;
385        let bytes = zeroize::Zeroizing::new(bytes_raw);
386        if bytes.len() != N {
387            #[cfg(debug_assertions)]
388            return Err(crate::error::Bech32Error::InvalidLength {
389                expected: N,
390                got: bytes.len(),
391            });
392            #[cfg(not(debug_assertions))]
393            return Err(crate::error::Bech32Error::InvalidLength);
394        }
395        Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
396    }
397}
398
399#[cfg(feature = "encoding-bech32m")]
400impl<const N: usize> Fixed<[u8; N]> {
401    /// Decodes a Bech32m (BIP-350) string into `Fixed<[u8; N]>`.
402    ///
403    /// # Warning
404    ///
405    /// The HRP is **not validated** — any HRP will be accepted as long as the checksum
406    /// is valid and the payload length equals `N`. For security-critical code where
407    /// cross-protocol confusion must be prevented, use [`try_from_bech32m`](Self::try_from_bech32m).
408    pub fn try_from_bech32m_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
409        let (_hrp, bytes_raw) = s.try_from_bech32m_unchecked()?;
410        let bytes = zeroize::Zeroizing::new(bytes_raw);
411        if bytes.len() != N {
412            #[cfg(debug_assertions)]
413            return Err(crate::error::Bech32Error::InvalidLength {
414                expected: N,
415                got: bytes.len(),
416            });
417            #[cfg(not(debug_assertions))]
418            return Err(crate::error::Bech32Error::InvalidLength);
419        }
420        Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
421    }
422
423    /// Decodes a Bech32m (BIP-350) string into `Fixed<[u8; N]>`, validating that the HRP
424    /// matches `expected_hrp` (case-insensitive).
425    ///
426    /// Prefer this over [`try_from_bech32m_unchecked`](Self::try_from_bech32m_unchecked) in
427    /// security-critical code to prevent cross-protocol confusion attacks.
428    pub fn try_from_bech32m(
429        s: &str,
430        expected_hrp: &str,
431    ) -> Result<Self, crate::error::Bech32Error> {
432        let bytes_raw = s.try_from_bech32m(expected_hrp)?;
433        let bytes = zeroize::Zeroizing::new(bytes_raw);
434        if bytes.len() != N {
435            #[cfg(debug_assertions)]
436            return Err(crate::error::Bech32Error::InvalidLength {
437                expected: N,
438                got: bytes.len(),
439            });
440            #[cfg(not(debug_assertions))]
441            return Err(crate::error::Bech32Error::InvalidLength);
442        }
443        Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
444    }
445}
446
447#[cfg(feature = "ct-eq")]
448impl<T: zeroize::Zeroize> crate::ConstantTimeEq for Fixed<T>
449where
450    T: crate::ConstantTimeEq,
451{
452    fn ct_eq(&self, other: &Self) -> bool {
453        self.inner.ct_eq(&other.inner)
454    }
455}
456
457impl<T: zeroize::Zeroize> core::fmt::Debug for Fixed<T> {
458    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
459        f.write_str("[REDACTED]")
460    }
461}
462
463#[cfg(feature = "cloneable")]
464impl<T: zeroize::Zeroize + crate::CloneableSecret> Clone for Fixed<T> {
465    fn clone(&self) -> Self {
466        Self::new(self.inner.clone())
467    }
468}
469
470#[cfg(feature = "serde-serialize")]
471impl<T: zeroize::Zeroize + crate::SerializableSecret> serde::Serialize for Fixed<T> {
472    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
473    where
474        S: serde::Serializer,
475    {
476        self.inner.serialize(serializer)
477    }
478}
479
480#[cfg(feature = "serde-deserialize")]
481impl<'de, const N: usize> serde::Deserialize<'de> for Fixed<[u8; N]> {
482    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
483    where
484        D: serde::Deserializer<'de>,
485    {
486        use core::fmt;
487        use serde::de::Visitor;
488        struct FixedVisitor<const M: usize>;
489        impl<'de, const M: usize> Visitor<'de> for FixedVisitor<M> {
490            type Value = Fixed<[u8; M]>;
491            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
492                write!(formatter, "a byte array of length {}", M)
493            }
494            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
495            where
496                A: serde::de::SeqAccess<'de>,
497            {
498                let mut vec: zeroize::Zeroizing<alloc::vec::Vec<u8>> =
499                    zeroize::Zeroizing::new(alloc::vec::Vec::with_capacity(M));
500                while let Some(value) = seq.next_element()? {
501                    vec.push(value);
502                }
503                if vec.len() != M {
504                    #[cfg(debug_assertions)]
505                    return Err(serde::de::Error::invalid_length(
506                        vec.len(),
507                        &M.to_string().as_str(),
508                    ));
509                    #[cfg(not(debug_assertions))]
510                    return Err(serde::de::Error::custom("decoded length mismatch"));
511                }
512                Ok(Fixed::new_with(|arr| arr.copy_from_slice(&vec)))
513            }
514        }
515        deserializer.deserialize_seq(FixedVisitor::<N>)
516    }
517}
518
519// Zeroize integration — now always present
520impl<T: zeroize::Zeroize> zeroize::Zeroize for Fixed<T> {
521    fn zeroize(&mut self) {
522        self.inner.zeroize();
523    }
524}
525
526impl<T: zeroize::Zeroize> Drop for Fixed<T> {
527    fn drop(&mut self) {
528        self.zeroize();
529    }
530}
531
532impl<T: zeroize::Zeroize> zeroize::ZeroizeOnDrop for Fixed<T> {}