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    #[cfg(feature = "encoding-hex")]
114    #[inline]
115    pub fn to_hex(&self) -> alloc::string::String {
116        self.with_secret(|s: &[u8; N]| s.to_hex())
117    }
118
119    #[cfg(feature = "encoding-hex")]
120    #[inline]
121    pub fn to_hex_upper(&self) -> alloc::string::String {
122        self.with_secret(|s: &[u8; N]| s.to_hex_upper())
123    }
124
125    #[cfg(feature = "encoding-base64")]
126    #[inline]
127    pub fn to_base64url(&self) -> alloc::string::String {
128        self.with_secret(|s: &[u8; N]| s.to_base64url())
129    }
130}
131
132/// Explicit access to immutable [`Fixed<[T; N]>`] contents.
133impl<const N: usize, T: zeroize::Zeroize> RevealSecret for Fixed<[T; N]> {
134    type Inner = [T; N];
135
136    #[inline(always)]
137    fn with_secret<F, R>(&self, f: F) -> R
138    where
139        F: FnOnce(&[T; N]) -> R,
140    {
141        f(&self.inner)
142    }
143
144    #[inline(always)]
145    fn expose_secret(&self) -> &[T; N] {
146        &self.inner
147    }
148
149    #[inline(always)]
150    fn len(&self) -> usize {
151        N * core::mem::size_of::<T>()
152    }
153}
154
155/// Explicit access to mutable [`Fixed<[T; N]>`] contents.
156impl<const N: usize, T: zeroize::Zeroize> RevealSecretMut for Fixed<[T; N]> {
157    #[inline(always)]
158    fn with_secret_mut<F, R>(&mut self, f: F) -> R
159    where
160        F: FnOnce(&mut [T; N]) -> R,
161    {
162        f(&mut self.inner)
163    }
164
165    #[inline(always)]
166    fn expose_secret_mut(&mut self) -> &mut [T; N] {
167        &mut self.inner
168    }
169}
170
171#[cfg(feature = "rand")]
172impl<const N: usize> Fixed<[u8; N]> {
173    /// Fills a new `[u8; N]` with cryptographically secure random bytes and wraps it.
174    ///
175    /// Uses the system RNG ([`OsRng`](rand::rngs::OsRng)). Requires the `rand` feature.
176    /// Heap-free and works in `no_std` / `no_alloc` builds.
177    ///
178    /// # Panics
179    ///
180    /// Panics if the RNG fails ([`TryRngCore::try_fill_bytes`](rand::TryRngCore::try_fill_bytes)
181    /// returns `Err`). This is treated as a fatal environment error.
182    #[inline]
183    pub fn from_random() -> Self {
184        Self::new_with(|arr| {
185            OsRng
186                .try_fill_bytes(arr)
187                .expect("OsRng failure is a program error");
188        })
189    }
190
191    /// Fills a new `[u8; N]` from `rng` and wraps it.
192    ///
193    /// Accepts any [`TryCryptoRng`](rand::TryCryptoRng) + [`TryRngCore`](rand::TryRngCore) (e.g. a
194    /// seeded generator for deterministic tests). Requires the `rand` feature. Heap-free.
195    ///
196    /// # Errors
197    ///
198    /// Returns `R::Error` if [`try_fill_bytes`](rand::TryRngCore::try_fill_bytes) fails.
199    #[inline]
200    pub fn from_rng<R: TryRngCore + TryCryptoRng>(rng: &mut R) -> Result<Self, R::Error> {
201        let mut result = Ok(());
202        let this = Self::new_with(|arr| {
203            result = rng.try_fill_bytes(arr);
204        });
205        result.map(|_| this) // on Err, `this` drops → zeroizes any partial fill
206    }
207}
208
209#[cfg(feature = "encoding-hex")]
210impl<const N: usize> Fixed<[u8; N]> {
211    pub fn try_from_hex(hex: &str) -> Result<Self, crate::error::HexError> {
212        let bytes = zeroize::Zeroizing::new(hex.try_from_hex()?);
213        if bytes.len() != N {
214            #[cfg(debug_assertions)]
215            return Err(crate::error::HexError::InvalidLength {
216                expected: N,
217                got: bytes.len(),
218            });
219            #[cfg(not(debug_assertions))]
220            return Err(crate::error::HexError::InvalidLength);
221        }
222        Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
223    }
224}
225
226#[cfg(feature = "encoding-base64")]
227impl<const N: usize> Fixed<[u8; N]> {
228    pub fn try_from_base64url(s: &str) -> Result<Self, crate::error::Base64Error> {
229        let bytes = zeroize::Zeroizing::new(s.try_from_base64url()?);
230        if bytes.len() != N {
231            #[cfg(debug_assertions)]
232            return Err(crate::error::Base64Error::InvalidLength {
233                expected: N,
234                got: bytes.len(),
235            });
236            #[cfg(not(debug_assertions))]
237            return Err(crate::error::Base64Error::InvalidLength);
238        }
239        Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
240    }
241}
242
243#[cfg(feature = "encoding-bech32")]
244impl<const N: usize> Fixed<[u8; N]> {
245    /// Decodes a Bech32 (BIP-173) string into `Fixed<[u8; N]>`.
246    ///
247    /// # Warning
248    ///
249    /// The HRP is **not validated** — any HRP will be accepted as long as the checksum
250    /// is valid and the payload length equals `N`. For security-critical code where
251    /// cross-protocol confusion must be prevented, use [`try_from_bech32`](Self::try_from_bech32).
252    pub fn try_from_bech32_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
253        let (_hrp, bytes_raw) = s.try_from_bech32_unchecked()?;
254        let bytes = zeroize::Zeroizing::new(bytes_raw);
255        if bytes.len() != N {
256            #[cfg(debug_assertions)]
257            return Err(crate::error::Bech32Error::InvalidLength {
258                expected: N,
259                got: bytes.len(),
260            });
261            #[cfg(not(debug_assertions))]
262            return Err(crate::error::Bech32Error::InvalidLength);
263        }
264        Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
265    }
266
267    /// Decodes a Bech32 (BIP-173) string into `Fixed<[u8; N]>`, validating that the HRP
268    /// matches `expected_hrp` (case-insensitive).
269    ///
270    /// Prefer this over [`try_from_bech32_unchecked`](Self::try_from_bech32_unchecked) in
271    /// security-critical code to prevent cross-protocol confusion attacks.
272    pub fn try_from_bech32(s: &str, expected_hrp: &str) -> Result<Self, crate::error::Bech32Error> {
273        let bytes_raw = s.try_from_bech32(expected_hrp)?;
274        let bytes = zeroize::Zeroizing::new(bytes_raw);
275        if bytes.len() != N {
276            #[cfg(debug_assertions)]
277            return Err(crate::error::Bech32Error::InvalidLength {
278                expected: N,
279                got: bytes.len(),
280            });
281            #[cfg(not(debug_assertions))]
282            return Err(crate::error::Bech32Error::InvalidLength);
283        }
284        Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
285    }
286}
287
288#[cfg(feature = "encoding-bech32m")]
289impl<const N: usize> Fixed<[u8; N]> {
290    /// Decodes a Bech32m (BIP-350) string into `Fixed<[u8; N]>`.
291    ///
292    /// # Warning
293    ///
294    /// The HRP is **not validated** — any HRP will be accepted as long as the checksum
295    /// is valid and the payload length equals `N`. For security-critical code where
296    /// cross-protocol confusion must be prevented, use [`try_from_bech32m`](Self::try_from_bech32m).
297    pub fn try_from_bech32m_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
298        let (_hrp, bytes_raw) = s.try_from_bech32m_unchecked()?;
299        let bytes = zeroize::Zeroizing::new(bytes_raw);
300        if bytes.len() != N {
301            #[cfg(debug_assertions)]
302            return Err(crate::error::Bech32Error::InvalidLength {
303                expected: N,
304                got: bytes.len(),
305            });
306            #[cfg(not(debug_assertions))]
307            return Err(crate::error::Bech32Error::InvalidLength);
308        }
309        Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
310    }
311
312    /// Decodes a Bech32m (BIP-350) string into `Fixed<[u8; N]>`, validating that the HRP
313    /// matches `expected_hrp` (case-insensitive).
314    ///
315    /// Prefer this over [`try_from_bech32m_unchecked`](Self::try_from_bech32m_unchecked) in
316    /// security-critical code to prevent cross-protocol confusion attacks.
317    pub fn try_from_bech32m(s: &str, expected_hrp: &str) -> Result<Self, crate::error::Bech32Error> {
318        let bytes_raw = s.try_from_bech32m(expected_hrp)?;
319        let bytes = zeroize::Zeroizing::new(bytes_raw);
320        if bytes.len() != N {
321            #[cfg(debug_assertions)]
322            return Err(crate::error::Bech32Error::InvalidLength {
323                expected: N,
324                got: bytes.len(),
325            });
326            #[cfg(not(debug_assertions))]
327            return Err(crate::error::Bech32Error::InvalidLength);
328        }
329        Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
330    }
331}
332
333#[cfg(feature = "ct-eq")]
334impl<T: zeroize::Zeroize> crate::ConstantTimeEq for Fixed<T>
335where
336    T: crate::ConstantTimeEq,
337{
338    fn ct_eq(&self, other: &Self) -> bool {
339        self.inner.ct_eq(&other.inner)
340    }
341}
342
343impl<T: zeroize::Zeroize> core::fmt::Debug for Fixed<T> {
344    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
345        f.write_str("[REDACTED]")
346    }
347}
348
349#[cfg(feature = "cloneable")]
350impl<T: zeroize::Zeroize + crate::CloneableSecret> Clone for Fixed<T> {
351    fn clone(&self) -> Self {
352        Self::new(self.inner.clone())
353    }
354}
355
356#[cfg(feature = "serde-serialize")]
357impl<T: zeroize::Zeroize + crate::SerializableSecret> serde::Serialize for Fixed<T> {
358    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
359    where
360        S: serde::Serializer,
361    {
362        self.inner.serialize(serializer)
363    }
364}
365
366#[cfg(feature = "serde-deserialize")]
367impl<'de, const N: usize> serde::Deserialize<'de> for Fixed<[u8; N]> {
368    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
369    where
370        D: serde::Deserializer<'de>,
371    {
372        use core::fmt;
373        use serde::de::Visitor;
374        struct FixedVisitor<const M: usize>;
375        impl<'de, const M: usize> Visitor<'de> for FixedVisitor<M> {
376            type Value = Fixed<[u8; M]>;
377            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
378                write!(formatter, "a byte array of length {}", M)
379            }
380            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
381            where
382                A: serde::de::SeqAccess<'de>,
383            {
384                let mut vec: zeroize::Zeroizing<alloc::vec::Vec<u8>> =
385                    zeroize::Zeroizing::new(alloc::vec::Vec::with_capacity(M));
386                while let Some(value) = seq.next_element()? {
387                    vec.push(value);
388                }
389                if vec.len() != M {
390                    #[cfg(debug_assertions)]
391                    return Err(serde::de::Error::invalid_length(
392                        vec.len(),
393                        &M.to_string().as_str(),
394                    ));
395                    #[cfg(not(debug_assertions))]
396                    return Err(serde::de::Error::custom("decoded length mismatch"));
397                }
398                Ok(Fixed::new_with(|arr| arr.copy_from_slice(&vec)))
399            }
400        }
401        deserializer.deserialize_seq(FixedVisitor::<N>)
402    }
403}
404
405// Zeroize integration — now always present
406impl<T: zeroize::Zeroize> zeroize::Zeroize for Fixed<T> {
407    fn zeroize(&mut self) {
408        self.inner.zeroize();
409    }
410}
411
412impl<T: zeroize::Zeroize> Drop for Fixed<T> {
413    fn drop(&mut self) {
414        self.zeroize();
415    }
416}
417
418impl<T: zeroize::Zeroize> zeroize::ZeroizeOnDrop for Fixed<T> {}