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