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> {}