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::SysRng, TryRng};
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, recommended).
46/// `Debug` always prints `[REDACTED]`. Performance indistinguishable from raw arrays.
47pub struct Fixed<T: zeroize::Zeroize> {
48    inner: T,
49}
50
51impl<T: zeroize::Zeroize> Fixed<T> {
52    /// Creates a new [`Fixed<T>`] by wrapping a value.
53    #[inline(always)]
54    pub const fn new(value: T) -> Self {
55        Fixed { inner: value }
56    }
57}
58
59impl<const N: usize> From<[u8; N]> for Fixed<[u8; N]> {
60    #[inline(always)]
61    fn from(arr: [u8; N]) -> Self {
62        Self::new(arr)
63    }
64}
65
66impl<const N: usize> core::convert::TryFrom<&[u8]> for Fixed<[u8; N]> {
67    type Error = crate::error::FromSliceError;
68
69    fn try_from(slice: &[u8]) -> Result<Self, Self::Error> {
70        if slice.len() != N {
71            #[cfg(debug_assertions)]
72            return Err(crate::error::FromSliceError::InvalidLength {
73                actual: slice.len(),
74                expected: N,
75            });
76            #[cfg(not(debug_assertions))]
77            return Err(crate::error::FromSliceError::InvalidLength);
78        }
79        let mut arr = [0u8; N];
80        arr.copy_from_slice(slice);
81        Ok(Self::new(arr))
82    }
83}
84
85/// Ergonomic encoding helpers for `Fixed<[u8; N]>`.
86impl<const N: usize> Fixed<[u8; N]> {
87    /// Encodes the secret bytes as a lowercase hex string.
88    ///
89    /// Delegates to [`ToHex::to_hex`](crate::ToHex::to_hex) on the inner `[u8; N]`.
90    /// Requires the `encoding-hex` feature.
91    #[cfg(feature = "encoding-hex")]
92    #[inline]
93    pub fn to_hex(&self) -> alloc::string::String {
94        self.with_secret(|s: &[u8; N]| s.to_hex())
95    }
96
97    /// Encodes the secret bytes as an uppercase hex string.
98    ///
99    /// Delegates to [`ToHex::to_hex_upper`](crate::ToHex::to_hex_upper) on the inner `[u8; N]`.
100    /// Requires the `encoding-hex` feature.
101    #[cfg(feature = "encoding-hex")]
102    #[inline]
103    pub fn to_hex_upper(&self) -> alloc::string::String {
104        self.with_secret(|s: &[u8; N]| s.to_hex_upper())
105    }
106
107    /// Encodes the secret bytes as an unpadded Base64url string.
108    ///
109    /// Delegates to [`ToBase64Url::to_base64url`](crate::ToBase64Url::to_base64url) on the inner `[u8; N]`.
110    /// Requires the `encoding-base64` feature.
111    #[cfg(feature = "encoding-base64")]
112    #[inline]
113    pub fn to_base64url(&self) -> alloc::string::String {
114        self.with_secret(|s: &[u8; N]| s.to_base64url())
115    }
116}
117
118/// Explicit access to immutable [`Fixed<[T; N]>`] contents.
119impl<const N: usize, T: zeroize::Zeroize> RevealSecret for Fixed<[T; N]> {
120    type Inner = [T; N];
121
122    #[inline(always)]
123    fn with_secret<F, R>(&self, f: F) -> R
124    where
125        F: FnOnce(&[T; N]) -> R,
126    {
127        f(&self.inner)
128    }
129
130    #[inline(always)]
131    fn expose_secret(&self) -> &[T; N] {
132        &self.inner
133    }
134
135    #[inline(always)]
136    fn len(&self) -> usize {
137        N * core::mem::size_of::<T>()
138    }
139}
140
141/// Explicit access to mutable [`Fixed<[T; N]>`] contents.
142impl<const N: usize, T: zeroize::Zeroize> RevealSecretMut for Fixed<[T; N]> {
143    #[inline(always)]
144    fn with_secret_mut<F, R>(&mut self, f: F) -> R
145    where
146        F: FnOnce(&mut [T; N]) -> R,
147    {
148        f(&mut self.inner)
149    }
150
151    #[inline(always)]
152    fn expose_secret_mut(&mut self) -> &mut [T; N] {
153        &mut self.inner
154    }
155}
156
157#[cfg(feature = "rand")]
158impl<const N: usize> Fixed<[u8; N]> {
159    /// Fills a new `[u8; N]` with cryptographically secure random bytes and wraps it.
160    ///
161    /// Uses the system RNG ([`SysRng`](rand::rngs::SysRng)). Requires the `rand` feature.
162    /// Heap-free and works in `no_std` / `no_alloc` builds.
163    ///
164    /// # Panics
165    ///
166    /// Panics if the system RNG fails to provide bytes ([`TryRng::try_fill_bytes`](rand::TryRng::try_fill_bytes)
167    /// returns `Err`). This is treated as a fatal environment error.
168    ///
169    /// # Examples
170    ///
171    /// ```rust
172    /// # #[cfg(feature = "rand")]
173    /// use secure_gate::{Fixed, RevealSecret};
174    ///
175    /// # #[cfg(feature = "rand")]
176    /// # {
177    /// let key: Fixed<[u8; 32]> = Fixed::from_random();
178    /// assert_eq!(key.len(), 32);
179    /// # }
180    /// ```
181    #[inline]
182    pub fn from_random() -> Self {
183        let mut bytes = [0u8; N];
184        SysRng
185            .try_fill_bytes(&mut bytes)
186            .expect("SysRng failure is a program error");
187        Self::from(bytes)
188    }
189}
190
191#[cfg(feature = "encoding-hex")]
192impl<const N: usize> Fixed<[u8; N]> {
193    /// Decodes a lowercase hex string into `Fixed<[u8; N]>`.
194    ///
195    /// The decoded bytes are held in a `Zeroizing<Vec<u8>>` until copied onto
196    /// the stack array, so the temporary heap buffer is zeroed even if a panic
197    /// occurs mid-flight.
198    ///
199    /// # Errors
200    ///
201    /// Returns `HexError::InvalidLength` if the decoded length does not equal `N`,
202    /// or a parse error if the input is not valid hex.
203    ///
204    /// # Note
205    ///
206    /// Unlike [`Dynamic::try_from_hex`](crate::Dynamic::try_from_hex), the secret
207    /// lives on the stack inside a `[u8; N]`. Stack residue behaviour after the
208    /// `Fixed` is dropped and zeroized is discussed in `SECURITY.md`.
209    pub fn try_from_hex(hex: &str) -> Result<Self, crate::error::HexError> {
210        let bytes = zeroize::Zeroizing::new(hex.try_from_hex()?);
211        if bytes.len() != N {
212            #[cfg(debug_assertions)]
213            return Err(crate::error::HexError::InvalidLength {
214                expected: N,
215                got: bytes.len(),
216            });
217            #[cfg(not(debug_assertions))]
218            return Err(crate::error::HexError::InvalidLength);
219        }
220        let mut arr = [0u8; N];
221        arr.copy_from_slice(&bytes);
222        Ok(Self::new(arr))
223    }
224}
225
226#[cfg(feature = "encoding-base64")]
227impl<const N: usize> Fixed<[u8; N]> {
228    /// Decodes an unpadded Base64url string into `Fixed<[u8; N]>`.
229    ///
230    /// The decoded bytes are held in a `Zeroizing<Vec<u8>>` until copied onto
231    /// the stack array, so the temporary heap buffer is zeroed even if a panic
232    /// occurs mid-flight.
233    ///
234    /// # Errors
235    ///
236    /// Returns `Base64Error::InvalidLength` if the decoded length does not equal `N`,
237    /// or a parse error if the input is not valid Base64url.
238    ///
239    /// # Note
240    ///
241    /// Unlike [`Dynamic::try_from_base64url`](crate::Dynamic::try_from_base64url), the
242    /// secret lives on the stack inside a `[u8; N]`. Stack residue behaviour after the
243    /// `Fixed` is dropped and zeroized is discussed in `SECURITY.md`.
244    pub fn try_from_base64url(s: &str) -> Result<Self, crate::error::Base64Error> {
245        let bytes = zeroize::Zeroizing::new(s.try_from_base64url()?);
246        if bytes.len() != N {
247            #[cfg(debug_assertions)]
248            return Err(crate::error::Base64Error::InvalidLength {
249                expected: N,
250                got: bytes.len(),
251            });
252            #[cfg(not(debug_assertions))]
253            return Err(crate::error::Base64Error::InvalidLength);
254        }
255        let mut arr = [0u8; N];
256        arr.copy_from_slice(&bytes);
257        Ok(Self::new(arr))
258    }
259}
260
261#[cfg(feature = "encoding-bech32")]
262impl<const N: usize> Fixed<[u8; N]> {
263    /// Decodes a Bech32 (BIP-173) string into `Fixed<[u8; N]>`.
264    ///
265    /// # Warning
266    ///
267    /// The HRP is **not validated** — any HRP will be accepted as long as the checksum
268    /// is valid and the payload length equals `N`. For security-critical code where
269    /// cross-protocol confusion must be prevented, use [`try_from_bech32`](Self::try_from_bech32).
270    pub fn try_from_bech32_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
271        let (_hrp, bytes_raw) = s.try_from_bech32_unchecked()?;
272        let bytes = zeroize::Zeroizing::new(bytes_raw);
273        if bytes.len() != N {
274            #[cfg(debug_assertions)]
275            return Err(crate::error::Bech32Error::InvalidLength {
276                expected: N,
277                got: bytes.len(),
278            });
279            #[cfg(not(debug_assertions))]
280            return Err(crate::error::Bech32Error::InvalidLength);
281        }
282        let mut arr = [0u8; N];
283        arr.copy_from_slice(&bytes);
284        Ok(Self::new(arr))
285    }
286
287    /// Decodes a Bech32 (BIP-173) string into `Fixed<[u8; N]>`, validating that the HRP
288    /// matches `expected_hrp` (case-insensitive).
289    ///
290    /// Prefer this over [`try_from_bech32_unchecked`](Self::try_from_bech32_unchecked) in
291    /// security-critical code to prevent cross-protocol confusion attacks.
292    pub fn try_from_bech32(s: &str, expected_hrp: &str) -> Result<Self, crate::error::Bech32Error> {
293        let bytes_raw = s.try_from_bech32(expected_hrp)?;
294        let bytes = zeroize::Zeroizing::new(bytes_raw);
295        if bytes.len() != N {
296            #[cfg(debug_assertions)]
297            return Err(crate::error::Bech32Error::InvalidLength {
298                expected: N,
299                got: bytes.len(),
300            });
301            #[cfg(not(debug_assertions))]
302            return Err(crate::error::Bech32Error::InvalidLength);
303        }
304        let mut arr = [0u8; N];
305        arr.copy_from_slice(&bytes);
306        Ok(Self::new(arr))
307    }
308}
309
310#[cfg(feature = "encoding-bech32m")]
311impl<const N: usize> Fixed<[u8; N]> {
312    /// Decodes a Bech32m (BIP-350) string into `Fixed<[u8; N]>`.
313    ///
314    /// # Warning
315    ///
316    /// The HRP is **not validated** — any HRP will be accepted as long as the checksum
317    /// is valid and the payload length equals `N`. For security-critical code where
318    /// cross-protocol confusion must be prevented, use [`try_from_bech32m`](Self::try_from_bech32m).
319    pub fn try_from_bech32m_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
320        let (_hrp, bytes_raw) = s.try_from_bech32m_unchecked()?;
321        let bytes = zeroize::Zeroizing::new(bytes_raw);
322        if bytes.len() != N {
323            #[cfg(debug_assertions)]
324            return Err(crate::error::Bech32Error::InvalidLength {
325                expected: N,
326                got: bytes.len(),
327            });
328            #[cfg(not(debug_assertions))]
329            return Err(crate::error::Bech32Error::InvalidLength);
330        }
331        let mut arr = [0u8; N];
332        arr.copy_from_slice(&bytes);
333        Ok(Self::new(arr))
334    }
335
336    /// Decodes a Bech32m (BIP-350) string into `Fixed<[u8; N]>`, validating that the HRP
337    /// matches `expected_hrp` (case-insensitive).
338    ///
339    /// Prefer this over [`try_from_bech32m_unchecked`](Self::try_from_bech32m_unchecked) in
340    /// security-critical code to prevent cross-protocol confusion attacks.
341    pub fn try_from_bech32m(s: &str, expected_hrp: &str) -> Result<Self, crate::error::Bech32Error> {
342        let bytes_raw = s.try_from_bech32m(expected_hrp)?;
343        let bytes = zeroize::Zeroizing::new(bytes_raw);
344        if bytes.len() != N {
345            #[cfg(debug_assertions)]
346            return Err(crate::error::Bech32Error::InvalidLength {
347                expected: N,
348                got: bytes.len(),
349            });
350            #[cfg(not(debug_assertions))]
351            return Err(crate::error::Bech32Error::InvalidLength);
352        }
353        let mut arr = [0u8; N];
354        arr.copy_from_slice(&bytes);
355        Ok(Self::new(arr))
356    }
357}
358
359#[cfg(feature = "ct-eq")]
360impl<T: zeroize::Zeroize> crate::ConstantTimeEq for Fixed<T>
361where
362    T: crate::ConstantTimeEq,
363{
364    fn ct_eq(&self, other: &Self) -> bool {
365        self.inner.ct_eq(&other.inner)
366    }
367}
368
369impl<T: zeroize::Zeroize> core::fmt::Debug for Fixed<T> {
370    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
371        f.write_str("[REDACTED]")
372    }
373}
374
375#[cfg(feature = "cloneable")]
376impl<T: zeroize::Zeroize + crate::CloneableSecret> Clone for Fixed<T> {
377    fn clone(&self) -> Self {
378        Self::new(self.inner.clone())
379    }
380}
381
382#[cfg(feature = "serde-serialize")]
383impl<T: zeroize::Zeroize + crate::SerializableSecret> serde::Serialize for Fixed<T> {
384    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
385    where
386        S: serde::Serializer,
387    {
388        self.inner.serialize(serializer)
389    }
390}
391
392#[cfg(feature = "serde-deserialize")]
393impl<'de, const N: usize> serde::Deserialize<'de> for Fixed<[u8; N]> {
394    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
395    where
396        D: serde::Deserializer<'de>,
397    {
398        use core::fmt;
399        use serde::de::Visitor;
400        struct FixedVisitor<const M: usize>;
401        impl<'de, const M: usize> Visitor<'de> for FixedVisitor<M> {
402            type Value = Fixed<[u8; M]>;
403            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
404                write!(formatter, "a byte array of length {}", M)
405            }
406            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
407            where
408                A: serde::de::SeqAccess<'de>,
409            {
410                let mut vec: zeroize::Zeroizing<alloc::vec::Vec<u8>> =
411                    zeroize::Zeroizing::new(alloc::vec::Vec::with_capacity(M));
412                while let Some(value) = seq.next_element()? {
413                    vec.push(value);
414                }
415                if vec.len() != M {
416                    #[cfg(debug_assertions)]
417                    return Err(serde::de::Error::invalid_length(
418                        vec.len(),
419                        &M.to_string().as_str(),
420                    ));
421                    #[cfg(not(debug_assertions))]
422                    return Err(serde::de::Error::custom("decoded length mismatch"));
423                }
424                let mut arr = [0u8; M];
425                arr.copy_from_slice(&vec);
426                Ok(Fixed::new(arr))
427            }
428        }
429        deserializer.deserialize_seq(FixedVisitor::<N>)
430    }
431}
432
433// Zeroize integration — now always present
434impl<T: zeroize::Zeroize> zeroize::Zeroize for Fixed<T> {
435    fn zeroize(&mut self) {
436        self.inner.zeroize();
437    }
438}
439
440impl<T: zeroize::Zeroize> Drop for Fixed<T> {
441    fn drop(&mut self) {
442        self.zeroize();
443    }
444}
445
446impl<T: zeroize::Zeroize> zeroize::ZeroizeOnDrop for Fixed<T> {}