secure_gate/fixed.rs
1//! Stack-allocated wrapper for fixed-size secrets.
2//!
3//! [`Fixed<T>`] is a zero-cost wrapper that enforces explicit, auditable access to
4//! sensitive data stored inline on the stack. It is the primary secret type for
5//! fixed-length material such as cryptographic keys, nonces, and seeds.
6//!
7//! # Security invariants
8//!
9//! - **No `Deref`, `AsRef`, or `Copy`** — the inner value cannot leak through
10//! implicit conversions.
11//! - **`Debug` always prints `[REDACTED]`** — secrets never appear in logs or
12//! panic messages.
13//! - **Unconditional zeroization on drop** — the inner `T` is overwritten with
14//! zeroes when the wrapper is dropped, even on error paths.
15//! - **Opt-in `Clone`** — requires `T: CloneableSecret` and the `cloneable` feature.
16//! - **Opt-in `Serialize`/`Deserialize`** — requires marker traits and the
17//! `serde-serialize`/`serde-deserialize` features.
18//!
19//! # Construction
20//!
21//! | Constructor | Notes |
22//! |---|---|
23//! | [`Fixed::new(value)`](Fixed::new) | Ergonomic default; `const fn`. |
24//! | [`Fixed::new_with(f)`](Fixed::new_with) | Scoped — preferred for stack-residue minimization. |
25//!
26//! Prefer [`new_with`](Fixed::new_with) in high-assurance code: it writes directly
27//! into the wrapper's storage, avoiding the intermediate stack copy that `new` may
28//! produce.
29//!
30//! # 3-tier access model
31//!
32//! ```rust
33//! use secure_gate::{Fixed, RevealSecret, RevealSecretMut};
34//!
35//! let mut secret = Fixed::new([1u8, 2, 3, 4]);
36//!
37//! // Tier 1 — scoped (preferred): borrow is confined to the closure.
38//! let sum = secret.with_secret(|arr| arr.iter().sum::<u8>());
39//! assert_eq!(sum, 10);
40//!
41//! // Tier 2 — direct: returns a reference. Use as an escape hatch.
42//! let first: u8 = secret.expose_secret()[0];
43//! assert_eq!(first, 1);
44//!
45//! // Tier 1 mutable — scoped mutation (preferred over Tier 2 mutable).
46//! secret.with_secret_mut(|arr| arr[0] = 0xFF);
47//!
48//! // Tier 3 — owned: consumes the wrapper for final use.
49//! let owned = secret.into_inner();
50//! ```
51//!
52//! # Warning: no `static` secrets
53//!
54//! `Drop` does not run on `static` items. Placing a `Fixed` in a `static` or
55//! `lazy_static!` will **skip zeroization**. Always use stack or heap allocation.
56//!
57//! Also ensure your profile sets `panic = "unwind"` — `panic = "abort"` skips
58//! destructors and therefore skips zeroization.
59//!
60//! # Import path
61//!
62//! All public items are re-exported at the crate root. Use:
63//!
64//! ```rust
65//! use secure_gate::Fixed;
66//! ```
67//!
68//! Not `secure_gate::fixed::Fixed`.
69//!
70//! # See also
71//!
72//! - [`Dynamic<T>`](crate::Dynamic) — heap-allocated alternative for variable-length
73//! secrets (passwords, API keys, ciphertexts). Requires the `alloc` feature.
74//!
75//! # Examples
76//!
77//! ```rust
78//! use secure_gate::{Fixed, RevealSecret};
79//!
80//! let secret = Fixed::new([1u8, 2, 3, 4]);
81//! let sum = secret.with_secret(|arr| arr.iter().sum::<u8>());
82//! assert_eq!(sum, 10);
83//! ```
84
85use crate::RevealSecret;
86use crate::RevealSecretMut;
87
88#[cfg(all(feature = "encoding-base64", feature = "alloc"))]
89use crate::traits::encoding::base64_url::ToBase64Url;
90#[cfg(all(feature = "encoding-bech32", feature = "alloc"))]
91use crate::traits::encoding::bech32::ToBech32;
92#[cfg(all(feature = "encoding-bech32m", feature = "alloc"))]
93use crate::traits::encoding::bech32m::ToBech32m;
94#[cfg(all(feature = "encoding-hex", feature = "alloc"))]
95use crate::traits::encoding::hex::ToHex;
96
97#[cfg(feature = "rand")]
98use rand::{rngs::OsRng, TryCryptoRng, TryRngCore};
99use zeroize::Zeroize;
100
101/// Zero-cost stack-allocated wrapper for fixed-size secrets.
102///
103/// `Fixed<T>` stores a `T: Zeroize` value inline and unconditionally zeroizes it
104/// on drop. There is no `Deref`, `AsRef`, or `Copy` — every access is explicit
105/// through [`RevealSecret`] or [`RevealSecretMut`].
106///
107/// # Examples
108///
109/// ```rust
110/// use secure_gate::{Fixed, RevealSecret};
111///
112/// // Create a secret key.
113/// let key = Fixed::new([0xABu8; 32]);
114///
115/// // Scoped access — the borrow cannot escape the closure.
116/// let first = key.with_secret(|k| k[0]);
117/// assert_eq!(first, 0xAB);
118///
119/// // Debug is always redacted.
120/// assert_eq!(format!("{:?}", key), "[REDACTED]");
121/// ```
122///
123/// # Constructors
124///
125/// | Constructor | Feature | Notes |
126/// |---|---|---|
127/// | [`new(value)`](Self::new) | — | `const fn`, ergonomic default |
128/// | [`new_with(f)`](Self::new_with) | — | Scoped; preferred for stack-residue minimization |
129/// | [`From<[u8; N]>`](#impl-From<%5Bu8;+N%5D>-for-Fixed<%5Bu8;+N%5D>) | — | Equivalent to `new` |
130/// | [`TryFrom<&[u8]>`](#impl-TryFrom<%26%5Bu8%5D>-for-Fixed<%5Bu8;+N%5D>) | — | Length-checked slice conversion |
131/// | [`try_from_hex`](Self::try_from_hex) | `encoding-hex` | Constant-time hex decoding |
132/// | [`try_from_base64url`](Self::try_from_base64url) | `encoding-base64` | Constant-time Base64url decoding |
133/// | [`try_from_bech32`](Self::try_from_bech32) | `encoding-bech32` | HRP-validated Bech32 decoding |
134/// | [`try_from_bech32_unchecked`](Self::try_from_bech32_unchecked) | `encoding-bech32` | Bech32 without HRP check |
135/// | [`try_from_bech32m`](Self::try_from_bech32m) | `encoding-bech32m` | HRP-validated Bech32m decoding |
136/// | [`try_from_bech32m_unchecked`](Self::try_from_bech32m_unchecked) | `encoding-bech32m` | Bech32m without HRP check |
137/// | [`from_random()`](Self::from_random) | `rand` | System RNG |
138/// | [`from_rng(rng)`](Self::from_rng) | `rand` | Custom RNG |
139///
140/// # See also
141///
142/// - [`RevealSecret`] / [`RevealSecretMut`] — the 3-tier access traits.
143/// - [`new_with`](Self::new_with) — scoped constructor preferred over [`new`](Self::new).
144///
145/// # Note
146///
147/// `const fn new` compiles in `static` position, but **must not** be used there
148/// because `Drop` does not run on statics, which means zeroization is skipped.
149pub struct Fixed<T: zeroize::Zeroize> {
150 inner: T,
151}
152
153impl<T: zeroize::Zeroize> Fixed<T> {
154 /// Creates a new [`Fixed<T>`] by wrapping a value.
155 ///
156 /// This is a `const fn`, so it can be evaluated at compile time. However,
157 /// **do not** use it to initialize `static` items — `Drop` does not run on
158 /// statics, so zeroization would be skipped.
159 ///
160 /// For `Fixed<[u8; N]>`, prefer [`new_with`](Fixed::new_with) when minimizing
161 /// stack residue matters, as `new` may leave an intermediate copy of `value`
162 /// on the caller's stack frame.
163 ///
164 /// # Examples
165 ///
166 /// ```rust
167 /// use secure_gate::{Fixed, RevealSecret};
168 ///
169 /// let secret = Fixed::new([0u8; 32]);
170 /// assert_eq!(secret.len(), 32);
171 /// ```
172 #[inline(always)]
173 pub const fn new(value: T) -> Self {
174 Fixed { inner: value }
175 }
176}
177
178/// Converts a byte array into a [`Fixed`] wrapper (equivalent to [`Fixed::new`]).
179///
180/// # Examples
181///
182/// ```rust
183/// use secure_gate::Fixed;
184///
185/// let secret: Fixed<[u8; 4]> = [1u8, 2, 3, 4].into();
186/// ```
187impl<const N: usize> From<[u8; N]> for Fixed<[u8; N]> {
188 #[inline(always)]
189 fn from(arr: [u8; N]) -> Self {
190 Self::new(arr)
191 }
192}
193
194/// Converts a byte slice into `Fixed<[u8; N]>`, failing if the length does not
195/// match `N`.
196///
197/// Internally uses [`Fixed::new_with`] so the secret is written directly into
198/// the wrapper's storage.
199///
200/// # Errors
201///
202/// Returns [`FromSliceError::InvalidLength`](crate::error::FromSliceError) when
203/// `slice.len() != N`.
204///
205/// # Examples
206///
207/// ```rust
208/// use secure_gate::{Fixed, RevealSecret};
209///
210/// // Success — exact length.
211/// let data = [0xFFu8; 4];
212/// let secret = Fixed::<[u8; 4]>::try_from(data.as_slice()).unwrap();
213/// assert_eq!(secret.expose_secret()[0], 0xFF);
214///
215/// // Failure — wrong length.
216/// let short = [0u8; 2];
217/// assert!(Fixed::<[u8; 4]>::try_from(short.as_slice()).is_err());
218/// ```
219impl<const N: usize> core::convert::TryFrom<&[u8]> for Fixed<[u8; N]> {
220 type Error = crate::error::FromSliceError;
221
222 fn try_from(slice: &[u8]) -> Result<Self, Self::Error> {
223 if slice.len() != N {
224 #[cfg(debug_assertions)]
225 return Err(crate::error::FromSliceError::InvalidLength {
226 actual: slice.len(),
227 expected: N,
228 });
229 #[cfg(not(debug_assertions))]
230 return Err(crate::error::FromSliceError::InvalidLength);
231 }
232 Ok(Self::new_with(|arr| arr.copy_from_slice(slice)))
233 }
234}
235
236/// Construction and ergonomic encoding helpers for `Fixed<[u8; N]>`.
237impl<const N: usize> Fixed<[u8; N]> {
238 /// Writes directly into the wrapper's storage via a user-supplied closure,
239 /// eliminating the intermediate stack copy that [`new`](Self::new) may produce.
240 ///
241 /// The array is zero-initialized before the closure runs. Prefer this over
242 /// [`new(value)`](Self::new) when minimizing stack residue matters
243 /// (long-lived keys, high-assurance environments).
244 ///
245 /// # Security rationale
246 ///
247 /// With [`Fixed::new(value)`](Self::new), the caller first builds `value` on
248 /// its own stack frame, then moves it into the wrapper. The compiler *may*
249 /// elide the copy, but this is not guaranteed — leaving a plaintext residue
250 /// on the stack. `new_with` avoids this by giving the closure a mutable
251 /// reference to the wrapper's *own* storage, so the secret is never placed
252 /// anywhere else.
253 ///
254 /// # Examples
255 ///
256 /// ```rust
257 /// use secure_gate::{Fixed, RevealSecret};
258 ///
259 /// // Fill from a closure — no intermediate stack copy.
260 /// let secret = Fixed::<[u8; 4]>::new_with(|arr| arr.fill(0xAB));
261 /// assert_eq!(secret.expose_secret(), &[0xAB; 4]);
262 ///
263 /// // Copy from an existing slice.
264 /// let src = [1u8, 2, 3, 4];
265 /// let secret = Fixed::<[u8; 4]>::new_with(|arr| arr.copy_from_slice(&src));
266 /// ```
267 ///
268 /// # See also
269 ///
270 /// - [`Dynamic::new_with`](crate::Dynamic::new_with) — the heap-allocated
271 /// equivalent (requires `alloc`).
272 #[inline(always)]
273 pub fn new_with<F>(f: F) -> Self
274 where
275 F: FnOnce(&mut [u8; N]),
276 {
277 let mut this = Self { inner: [0u8; N] };
278 f(&mut this.inner);
279 this
280 }
281}
282
283/// Hex encoding and decoding for `Fixed<[u8; N]>`.
284///
285/// Encoding uses a constant-time backend (`base16ct`). Decoding works with or without
286/// the `alloc` feature — on no-alloc targets the bytes are decoded directly into a
287/// `Zeroizing<[u8; N]>` stack buffer.
288#[cfg(feature = "encoding-hex")]
289impl<const N: usize> Fixed<[u8; N]> {
290 /// Encodes the secret bytes as a lowercase hex string.
291 ///
292 /// Requires the `encoding-hex` and `alloc` features.
293 ///
294 /// # Examples
295 ///
296 /// ```rust
297 /// # #[cfg(all(feature = "encoding-hex", feature = "alloc"))]
298 /// # {
299 /// use secure_gate::Fixed;
300 ///
301 /// let secret = Fixed::new([0xDE, 0xAD]);
302 /// assert_eq!(secret.to_hex(), "dead");
303 /// # }
304 /// ```
305 #[cfg(feature = "alloc")]
306 #[inline]
307 pub fn to_hex(&self) -> alloc::string::String {
308 self.with_secret(|s: &[u8; N]| s.to_hex())
309 }
310
311 /// Encodes the secret bytes as an uppercase hex string.
312 ///
313 /// Requires the `encoding-hex` and `alloc` features.
314 ///
315 /// # Examples
316 ///
317 /// ```rust
318 /// # #[cfg(all(feature = "encoding-hex", feature = "alloc"))]
319 /// # {
320 /// use secure_gate::Fixed;
321 ///
322 /// let secret = Fixed::new([0xDE, 0xAD]);
323 /// assert_eq!(secret.to_hex_upper(), "DEAD");
324 /// # }
325 /// ```
326 #[cfg(feature = "alloc")]
327 #[inline]
328 pub fn to_hex_upper(&self) -> alloc::string::String {
329 self.with_secret(|s: &[u8; N]| s.to_hex_upper())
330 }
331
332 /// Encodes the secret bytes as a lowercase hex string, returning
333 /// [`EncodedSecret`](crate::EncodedSecret) to preserve zeroization.
334 ///
335 /// Prefer this over [`to_hex`](Self::to_hex) when the encoded form should
336 /// still be treated as sensitive (e.g. private keys). The returned
337 /// [`EncodedSecret`](crate::EncodedSecret) is zeroized on drop.
338 ///
339 /// Requires the `encoding-hex` and `alloc` features.
340 ///
341 /// # Examples
342 ///
343 /// ```rust
344 /// # #[cfg(all(feature = "encoding-hex", feature = "alloc"))]
345 /// # {
346 /// use secure_gate::{Fixed, RevealSecret};
347 ///
348 /// let secret = Fixed::new([0xCA, 0xFE]);
349 /// let encoded = secret.to_hex_zeroizing();
350 /// assert_eq!(&*encoded, "cafe");
351 /// // `encoded` is zeroized when it goes out of scope.
352 /// # }
353 /// ```
354 #[cfg(feature = "alloc")]
355 #[inline]
356 pub fn to_hex_zeroizing(&self) -> crate::EncodedSecret {
357 self.with_secret(|s: &[u8; N]| s.to_hex_zeroizing())
358 }
359
360 /// Encodes the secret bytes as an uppercase hex string, returning
361 /// [`EncodedSecret`](crate::EncodedSecret) to preserve zeroization.
362 ///
363 /// Requires the `encoding-hex` and `alloc` features.
364 ///
365 /// # Examples
366 ///
367 /// ```rust
368 /// # #[cfg(all(feature = "encoding-hex", feature = "alloc"))]
369 /// # {
370 /// use secure_gate::{Fixed, RevealSecret};
371 ///
372 /// let secret = Fixed::new([0xCA, 0xFE]);
373 /// let encoded = secret.to_hex_upper_zeroizing();
374 /// assert_eq!(&*encoded, "CAFE");
375 /// # }
376 /// ```
377 #[cfg(feature = "alloc")]
378 #[inline]
379 pub fn to_hex_upper_zeroizing(&self) -> crate::EncodedSecret {
380 self.with_secret(|s: &[u8; N]| s.to_hex_upper_zeroizing())
381 }
382
383 /// Decodes a hex string (lowercase, uppercase, or mixed) into `Fixed<[u8; N]>`.
384 ///
385 /// Uses a constant-time backend (`base16ct`) for both paths.
386 ///
387 /// - **With `alloc`**: decodes into a `Zeroizing<Vec<u8>>` then copies onto the stack.
388 /// The temporary heap buffer is zeroed on drop even if an error occurs.
389 /// - **Without `alloc`**: decodes directly into a `Zeroizing<[u8; N]>` stack buffer.
390 /// No heap allocation occurs.
391 ///
392 /// # Errors
393 ///
394 /// - [`HexError::InvalidHex`] — non-hex characters or odd-length input.
395 /// - [`HexError::InvalidLength`] — decoded byte count does not equal `N`.
396 ///
397 /// # Examples
398 ///
399 /// ```rust
400 /// # #[cfg(feature = "encoding-hex")]
401 /// # {
402 /// use secure_gate::{Fixed, RevealSecret};
403 ///
404 /// // Round-trip: encode then decode.
405 /// let original = Fixed::new([0xDE, 0xAD, 0xBE, 0xEF]);
406 /// # #[cfg(feature = "alloc")]
407 /// # {
408 /// let hex_str = original.to_hex();
409 /// let decoded = Fixed::<[u8; 4]>::try_from_hex(&hex_str).unwrap();
410 /// assert_eq!(decoded.expose_secret(), &[0xDE, 0xAD, 0xBE, 0xEF]);
411 /// # }
412 ///
413 /// // Wrong length fails.
414 /// assert!(Fixed::<[u8; 2]>::try_from_hex("deadbeef").is_err());
415 /// # }
416 /// ```
417 pub fn try_from_hex(hex: &str) -> Result<Self, crate::error::HexError> {
418 #[cfg(feature = "alloc")]
419 {
420 use zeroize::Zeroizing;
421 let bytes = Zeroizing::new(
422 base16ct::mixed::decode_vec(hex.as_bytes())
423 .map_err(|_| crate::error::HexError::InvalidHex)?,
424 );
425 if bytes.len() != N {
426 #[cfg(debug_assertions)]
427 return Err(crate::error::HexError::InvalidLength {
428 expected: N,
429 got: bytes.len(),
430 });
431 #[cfg(not(debug_assertions))]
432 return Err(crate::error::HexError::InvalidLength);
433 }
434 Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
435 }
436 #[cfg(not(feature = "alloc"))]
437 {
438 use zeroize::Zeroizing;
439 // no-alloc path: decode directly into a stack buffer; no heap allocation
440 // base16ct::mixed accepts upper, lower, and mixed-case hex
441 let mut buf = Zeroizing::new([0u8; N]);
442 let decoded = base16ct::mixed::decode(hex.as_bytes(), &mut *buf)
443 .map_err(|_| crate::error::HexError::InvalidHex)?;
444 if decoded.len() != N {
445 #[cfg(debug_assertions)]
446 return Err(crate::error::HexError::InvalidLength {
447 expected: N,
448 got: decoded.len(),
449 });
450 #[cfg(not(debug_assertions))]
451 return Err(crate::error::HexError::InvalidLength);
452 }
453 Ok(Self::new_with(|arr| arr.copy_from_slice(decoded)))
454 // buf is zeroized on drop (both success and error paths)
455 }
456 }
457}
458
459/// Base64url encoding and decoding for `Fixed<[u8; N]>`.
460///
461/// Encoding uses a constant-time backend (`base64ct`). Decoding works with or without
462/// the `alloc` feature — on no-alloc targets the bytes are decoded directly into a
463/// `Zeroizing<[u8; N]>` stack buffer.
464#[cfg(feature = "encoding-base64")]
465impl<const N: usize> Fixed<[u8; N]> {
466 /// Encodes the secret bytes as an unpadded Base64url string (RFC 4648, URL-safe alphabet).
467 ///
468 /// Requires the `encoding-base64` and `alloc` features.
469 ///
470 /// # Examples
471 ///
472 /// ```rust
473 /// # #[cfg(all(feature = "encoding-base64", feature = "alloc"))]
474 /// # {
475 /// use secure_gate::Fixed;
476 ///
477 /// let secret = Fixed::new([0xDE, 0xAD, 0xBE, 0xEF]);
478 /// let encoded = secret.to_base64url();
479 /// assert_eq!(encoded, "3q2-7w");
480 /// # }
481 /// ```
482 #[cfg(feature = "alloc")]
483 #[inline]
484 pub fn to_base64url(&self) -> alloc::string::String {
485 self.with_secret(|s: &[u8; N]| s.to_base64url())
486 }
487
488 /// Encodes the secret bytes as an unpadded Base64url string, returning
489 /// [`EncodedSecret`](crate::EncodedSecret) to preserve zeroization.
490 ///
491 /// Prefer this over [`to_base64url`](Self::to_base64url) when the encoded
492 /// form should still be treated as sensitive. The returned
493 /// [`EncodedSecret`](crate::EncodedSecret) is zeroized on drop.
494 ///
495 /// Requires the `encoding-base64` and `alloc` features.
496 ///
497 /// # Examples
498 ///
499 /// ```rust
500 /// # #[cfg(all(feature = "encoding-base64", feature = "alloc"))]
501 /// # {
502 /// use secure_gate::{Fixed, RevealSecret};
503 ///
504 /// let secret = Fixed::new([0xDE, 0xAD, 0xBE, 0xEF]);
505 /// let encoded = secret.to_base64url_zeroizing();
506 /// assert_eq!(&*encoded, "3q2-7w");
507 /// // `encoded` is zeroized when it goes out of scope.
508 /// # }
509 /// ```
510 #[cfg(feature = "alloc")]
511 #[inline]
512 pub fn to_base64url_zeroizing(&self) -> crate::EncodedSecret {
513 self.with_secret(|s: &[u8; N]| s.to_base64url_zeroizing())
514 }
515
516 /// Decodes an unpadded Base64url string (RFC 4648, URL-safe alphabet) into
517 /// `Fixed<[u8; N]>`.
518 ///
519 /// Uses a constant-time backend (`base64ct`) on both paths.
520 ///
521 /// - **With `alloc`**: decodes into a `Zeroizing<Vec<u8>>` then copies onto the stack.
522 /// - **Without `alloc`**: decodes directly into a `Zeroizing<[u8; N]>` stack buffer.
523 ///
524 /// # Errors
525 ///
526 /// - [`Base64Error::InvalidBase64`] — non-base64 characters or invalid padding.
527 /// - [`Base64Error::InvalidLength`] — decoded byte count does not equal `N`.
528 ///
529 /// # Examples
530 ///
531 /// ```rust
532 /// # #[cfg(feature = "encoding-base64")]
533 /// # {
534 /// use secure_gate::{Fixed, RevealSecret};
535 ///
536 /// # #[cfg(feature = "alloc")]
537 /// # {
538 /// // Round-trip.
539 /// let original = Fixed::new([0xDE, 0xAD, 0xBE, 0xEF]);
540 /// let encoded = original.to_base64url();
541 /// let decoded = Fixed::<[u8; 4]>::try_from_base64url(&encoded).unwrap();
542 /// assert_eq!(decoded.expose_secret(), &[0xDE, 0xAD, 0xBE, 0xEF]);
543 /// # }
544 /// # }
545 /// ```
546 pub fn try_from_base64url(s: &str) -> Result<Self, crate::error::Base64Error> {
547 #[cfg(feature = "alloc")]
548 {
549 use base64ct::{Base64UrlUnpadded, Encoding};
550 use zeroize::Zeroizing;
551 let bytes = Zeroizing::new(
552 Base64UrlUnpadded::decode_vec(s)
553 .map_err(|_| crate::error::Base64Error::InvalidBase64)?,
554 );
555 if bytes.len() != N {
556 #[cfg(debug_assertions)]
557 return Err(crate::error::Base64Error::InvalidLength {
558 expected: N,
559 got: bytes.len(),
560 });
561 #[cfg(not(debug_assertions))]
562 return Err(crate::error::Base64Error::InvalidLength);
563 }
564 Ok(Self::new_with(|arr| arr.copy_from_slice(&bytes)))
565 }
566 #[cfg(not(feature = "alloc"))]
567 {
568 use base64ct::{Base64UrlUnpadded, Encoding};
569 use zeroize::Zeroizing;
570 let mut buf = Zeroizing::new([0u8; N]);
571 let decoded = Base64UrlUnpadded::decode(s, &mut *buf)
572 .map_err(|_| crate::error::Base64Error::InvalidBase64)?;
573 if decoded.len() != N {
574 #[cfg(debug_assertions)]
575 return Err(crate::error::Base64Error::InvalidLength {
576 expected: N,
577 got: decoded.len(),
578 });
579 #[cfg(not(debug_assertions))]
580 return Err(crate::error::Base64Error::InvalidLength);
581 }
582 Ok(Self::new_with(|arr| arr.copy_from_slice(decoded)))
583 // buf is zeroized on drop (both success and error paths)
584 }
585 }
586}
587
588/// Bech32 (BIP-173) encoding and decoding for `Fixed<[u8; N]>`.
589///
590/// Uses the extended `Bech32Large` checksum variant (~5 KB payload limit) rather than
591/// the 90-character standard limit. For Bitcoin address formats use `ToBech32m`.
592#[cfg(feature = "encoding-bech32")]
593impl<const N: usize> Fixed<[u8; N]> {
594 /// Encodes the secret bytes as a Bech32 (BIP-173) string with the given HRP.
595 ///
596 /// Requires the `encoding-bech32` and `alloc` features.
597 #[cfg(feature = "alloc")]
598 #[inline]
599 pub fn try_to_bech32(
600 &self,
601 hrp: &str,
602 ) -> Result<alloc::string::String, crate::error::Bech32Error> {
603 self.with_secret(|s: &[u8; N]| s.try_to_bech32(hrp))
604 }
605
606 /// Encodes the secret bytes as a Bech32 string, returning
607 /// [`EncodedSecret`](crate::EncodedSecret) to preserve zeroization.
608 ///
609 /// Requires the `encoding-bech32` and `alloc` features.
610 #[cfg(feature = "alloc")]
611 #[inline]
612 pub fn try_to_bech32_zeroizing(
613 &self,
614 hrp: &str,
615 ) -> Result<crate::EncodedSecret, crate::error::Bech32Error> {
616 self.with_secret(|s: &[u8; N]| s.try_to_bech32_zeroizing(hrp))
617 }
618
619 /// Decodes a Bech32 (BIP-173) string into `Fixed<[u8; N]>`, validating that the HRP
620 /// matches `expected_hrp` (case-insensitive).
621 ///
622 /// HRP comparison is non-constant-time — this is intentional, as the HRP is public
623 /// metadata, not secret material. Timing leaks on HRP mismatch are acceptable because
624 /// the HRP is not secret. Prefer this over
625 /// [`try_from_bech32_unchecked`](Self::try_from_bech32_unchecked) to prevent
626 /// cross-protocol confusion attacks.
627 ///
628 /// Works without `alloc` — decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
629 pub fn try_from_bech32(s: &str, expected_hrp: &str) -> Result<Self, crate::error::Bech32Error> {
630 use crate::traits::encoding::bech32::Bech32Large;
631 use bech32::primitives::decode::CheckedHrpstring;
632 let checked = CheckedHrpstring::new::<Bech32Large>(s)
633 .map_err(|_| crate::error::Bech32Error::OperationFailed)?;
634 // HRP check (case-insensitive comparison follows — timing leak is acceptable since HRP is public metadata)
635 if !checked.hrp().as_str().eq_ignore_ascii_case(expected_hrp) {
636 #[cfg(debug_assertions)]
637 return Err(crate::error::Bech32Error::UnexpectedHrp {
638 expected: expected_hrp.to_string(),
639 got: checked.hrp().as_str().to_string(),
640 });
641 #[cfg(not(debug_assertions))]
642 return Err(crate::error::Bech32Error::UnexpectedHrp);
643 }
644 let mut buf = zeroize::Zeroizing::new([0u8; N]);
645 let mut count = 0usize;
646 for byte in checked.byte_iter() {
647 if count >= N {
648 #[cfg(debug_assertions)]
649 return Err(crate::error::Bech32Error::InvalidLength {
650 expected: N,
651 got: count + 1,
652 });
653 #[cfg(not(debug_assertions))]
654 return Err(crate::error::Bech32Error::InvalidLength);
655 }
656 buf[count] = byte;
657 count += 1;
658 }
659 if count != N {
660 #[cfg(debug_assertions)]
661 return Err(crate::error::Bech32Error::InvalidLength {
662 expected: N,
663 got: count,
664 });
665 #[cfg(not(debug_assertions))]
666 return Err(crate::error::Bech32Error::InvalidLength);
667 }
668 Ok(Self::new_with(|arr| arr.copy_from_slice(&*buf)))
669 // buf is zeroized on drop
670 }
671
672 /// Decodes a Bech32 (BIP-173) string into `Fixed<[u8; N]>` without validating the HRP.
673 ///
674 /// Any valid HRP is accepted as long as the checksum is valid and the payload length
675 /// equals `N`. Use [`try_from_bech32`](Self::try_from_bech32) in security-critical code
676 /// to prevent cross-protocol confusion attacks.
677 ///
678 /// Works without `alloc` — decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
679 pub fn try_from_bech32_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
680 use crate::traits::encoding::bech32::Bech32Large;
681 use bech32::primitives::decode::CheckedHrpstring;
682 let checked = CheckedHrpstring::new::<Bech32Large>(s)
683 .map_err(|_| crate::error::Bech32Error::OperationFailed)?;
684 let mut buf = zeroize::Zeroizing::new([0u8; N]);
685 let mut count = 0usize;
686 for byte in checked.byte_iter() {
687 if count >= N {
688 #[cfg(debug_assertions)]
689 return Err(crate::error::Bech32Error::InvalidLength {
690 expected: N,
691 got: count + 1,
692 });
693 #[cfg(not(debug_assertions))]
694 return Err(crate::error::Bech32Error::InvalidLength);
695 }
696 buf[count] = byte;
697 count += 1;
698 }
699 if count != N {
700 #[cfg(debug_assertions)]
701 return Err(crate::error::Bech32Error::InvalidLength {
702 expected: N,
703 got: count,
704 });
705 #[cfg(not(debug_assertions))]
706 return Err(crate::error::Bech32Error::InvalidLength);
707 }
708 Ok(Self::new_with(|arr| arr.copy_from_slice(&*buf)))
709 // buf is zeroized on drop
710 }
711}
712
713/// Bech32m (BIP-350) encoding and decoding for `Fixed<[u8; N]>`.
714///
715/// Uses the standard BIP-350 payload limit (~90 bytes). For large secrets
716/// (ciphertexts, recipients) use `ToBech32` / `Bech32Large` instead.
717#[cfg(feature = "encoding-bech32m")]
718impl<const N: usize> Fixed<[u8; N]> {
719 /// Encodes the secret bytes as a Bech32m (BIP-350) string with the given HRP.
720 ///
721 /// Requires the `encoding-bech32m` and `alloc` features.
722 #[cfg(feature = "alloc")]
723 #[inline]
724 pub fn try_to_bech32m(
725 &self,
726 hrp: &str,
727 ) -> Result<alloc::string::String, crate::error::Bech32Error> {
728 self.with_secret(|s: &[u8; N]| s.try_to_bech32m(hrp))
729 }
730
731 /// Encodes the secret bytes as a Bech32m string, returning
732 /// [`EncodedSecret`](crate::EncodedSecret) to preserve zeroization.
733 ///
734 /// Requires the `encoding-bech32m` and `alloc` features.
735 #[cfg(feature = "alloc")]
736 #[inline]
737 pub fn try_to_bech32m_zeroizing(
738 &self,
739 hrp: &str,
740 ) -> Result<crate::EncodedSecret, crate::error::Bech32Error> {
741 self.with_secret(|s: &[u8; N]| s.try_to_bech32m_zeroizing(hrp))
742 }
743
744 /// Decodes a Bech32m (BIP-350) string into `Fixed<[u8; N]>`, validating that the HRP
745 /// matches `expected_hrp` (case-insensitive).
746 ///
747 /// HRP comparison is non-constant-time — this is intentional, as the HRP is public
748 /// metadata, not secret material. Timing leaks on HRP mismatch are acceptable because
749 /// the HRP is not secret. Prefer this over
750 /// [`try_from_bech32m_unchecked`](Self::try_from_bech32m_unchecked) to prevent
751 /// cross-protocol confusion attacks.
752 ///
753 /// Works without `alloc` — decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
754 pub fn try_from_bech32m(
755 s: &str,
756 expected_hrp: &str,
757 ) -> Result<Self, crate::error::Bech32Error> {
758 use bech32::{primitives::decode::CheckedHrpstring, Bech32m};
759 let checked = CheckedHrpstring::new::<Bech32m>(s)
760 .map_err(|_| crate::error::Bech32Error::OperationFailed)?;
761 // HRP check (case-insensitive comparison follows — timing leak is acceptable since HRP is public metadata)
762 if !checked.hrp().as_str().eq_ignore_ascii_case(expected_hrp) {
763 #[cfg(debug_assertions)]
764 return Err(crate::error::Bech32Error::UnexpectedHrp {
765 expected: expected_hrp.to_string(),
766 got: checked.hrp().as_str().to_string(),
767 });
768 #[cfg(not(debug_assertions))]
769 return Err(crate::error::Bech32Error::UnexpectedHrp);
770 }
771 let mut buf = zeroize::Zeroizing::new([0u8; N]);
772 let mut count = 0usize;
773 for byte in checked.byte_iter() {
774 if count >= N {
775 #[cfg(debug_assertions)]
776 return Err(crate::error::Bech32Error::InvalidLength {
777 expected: N,
778 got: count + 1,
779 });
780 #[cfg(not(debug_assertions))]
781 return Err(crate::error::Bech32Error::InvalidLength);
782 }
783 buf[count] = byte;
784 count += 1;
785 }
786 if count != N {
787 #[cfg(debug_assertions)]
788 return Err(crate::error::Bech32Error::InvalidLength {
789 expected: N,
790 got: count,
791 });
792 #[cfg(not(debug_assertions))]
793 return Err(crate::error::Bech32Error::InvalidLength);
794 }
795 Ok(Self::new_with(|arr| arr.copy_from_slice(&*buf)))
796 // buf is zeroized on drop
797 }
798
799 /// Decodes a Bech32m (BIP-350) string into `Fixed<[u8; N]>` without validating the HRP.
800 ///
801 /// Any valid HRP is accepted as long as the checksum is valid and the payload length
802 /// equals `N`. Use [`try_from_bech32m`](Self::try_from_bech32m) in security-critical
803 /// code to prevent cross-protocol confusion attacks.
804 ///
805 /// Works without `alloc` — decodes into a stack-allocated `Zeroizing<[u8; N]>` buffer.
806 pub fn try_from_bech32m_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
807 use bech32::{primitives::decode::CheckedHrpstring, Bech32m};
808 let checked = CheckedHrpstring::new::<Bech32m>(s)
809 .map_err(|_| crate::error::Bech32Error::OperationFailed)?;
810 let mut buf = zeroize::Zeroizing::new([0u8; N]);
811 let mut count = 0usize;
812 for byte in checked.byte_iter() {
813 if count >= N {
814 #[cfg(debug_assertions)]
815 return Err(crate::error::Bech32Error::InvalidLength {
816 expected: N,
817 got: count + 1,
818 });
819 #[cfg(not(debug_assertions))]
820 return Err(crate::error::Bech32Error::InvalidLength);
821 }
822 buf[count] = byte;
823 count += 1;
824 }
825 if count != N {
826 #[cfg(debug_assertions)]
827 return Err(crate::error::Bech32Error::InvalidLength {
828 expected: N,
829 got: count,
830 });
831 #[cfg(not(debug_assertions))]
832 return Err(crate::error::Bech32Error::InvalidLength);
833 }
834 Ok(Self::new_with(|arr| arr.copy_from_slice(&*buf)))
835 // buf is zeroized on drop
836 }
837}
838
839/// Explicit access to immutable [`Fixed<[T; N]>`] contents.
840impl<const N: usize, T: zeroize::Zeroize> RevealSecret for Fixed<[T; N]> {
841 type Inner = [T; N];
842
843 #[inline(always)]
844 fn with_secret<F, R>(&self, f: F) -> R
845 where
846 F: FnOnce(&[T; N]) -> R,
847 {
848 f(&self.inner)
849 }
850
851 #[inline(always)]
852 fn expose_secret(&self) -> &[T; N] {
853 &self.inner
854 }
855
856 #[inline(always)]
857 fn len(&self) -> usize {
858 N * core::mem::size_of::<T>()
859 }
860
861 /// Consumes `self` and returns the inner `[T; N]` wrapped in [`crate::InnerSecret`].
862 ///
863 /// Zero cost — no allocation. The sentinel placed in `self.inner` is
864 /// `[T::default(); N]` (already zeroed for `u8`), so `Fixed::drop` zeroizes
865 /// an already-zero array — a harmless no-op.
866 ///
867 /// See [`RevealSecret::into_inner`] for full documentation including the
868 /// `Default` bound rationale and redacted `Debug` behavior.
869 #[inline(always)]
870 fn into_inner(mut self) -> crate::InnerSecret<[T; N]>
871 where
872 Self: Sized,
873 Self::Inner: Sized + Default + zeroize::Zeroize,
874 {
875 // Replace inner with a zero-sentinel so Fixed::drop zeroizes a harmless
876 // default value while the caller receives the real secret.
877 // Default::default() is inferred as [T; N] from context; [T; N]: Default
878 // is guaranteed by the where clause above.
879 let inner = core::mem::take(&mut self.inner);
880 crate::InnerSecret::new(inner)
881 }
882}
883
884/// Explicit access to mutable [`Fixed<[T; N]>`] contents.
885impl<const N: usize, T: zeroize::Zeroize> RevealSecretMut for Fixed<[T; N]> {
886 #[inline(always)]
887 fn with_secret_mut<F, R>(&mut self, f: F) -> R
888 where
889 F: FnOnce(&mut [T; N]) -> R,
890 {
891 f(&mut self.inner)
892 }
893
894 #[inline(always)]
895 fn expose_secret_mut(&mut self) -> &mut [T; N] {
896 &mut self.inner
897 }
898}
899
900#[cfg(feature = "rand")]
901impl<const N: usize> Fixed<[u8; N]> {
902 /// Fills a new `[u8; N]` with cryptographically secure random bytes and wraps it.
903 ///
904 /// Uses the system RNG ([`OsRng`](rand::rngs::OsRng)) via [`TryRngCore::try_fill_bytes`](rand::TryRngCore::try_fill_bytes).
905 /// In `rand` 0.9, `OsRng` is a zero-sized handle to the OS generator (not user-seedable). Requires the `rand`
906 /// feature. Heap-free and works in `no_std` / `no_alloc` builds.
907 ///
908 /// # Panics
909 ///
910 /// Panics if the system RNG fails to provide bytes ([`TryRngCore::try_fill_bytes`](rand::TryRngCore::try_fill_bytes)
911 /// returns `Err`). This is treated as a fatal environment error.
912 ///
913 /// # Examples
914 ///
915 /// ```rust
916 /// # #[cfg(feature = "rand")]
917 /// use secure_gate::{Fixed, RevealSecret};
918 ///
919 /// # #[cfg(feature = "rand")]
920 /// # {
921 /// let key: Fixed<[u8; 32]> = Fixed::from_random();
922 /// assert_eq!(key.len(), 32);
923 /// # }
924 /// ```
925 #[inline]
926 pub fn from_random() -> Self {
927 Self::new_with(|arr| {
928 OsRng
929 .try_fill_bytes(arr)
930 .expect("OsRng failure is a program error");
931 })
932 }
933
934 /// Fills a new `[u8; N]` from `rng` and wraps it.
935 ///
936 /// Accepts any [`TryCryptoRng`](rand::TryCryptoRng) + [`TryRngCore`](rand::TryRngCore) — for example,
937 /// a seeded [`StdRng`](rand::rngs::StdRng) for deterministic tests. Requires the `rand`
938 /// feature. Heap-free.
939 ///
940 /// # Errors
941 ///
942 /// Returns `R::Error` if [`try_fill_bytes`](rand::TryRngCore::try_fill_bytes) fails.
943 ///
944 /// # Examples
945 ///
946 /// ```rust
947 /// # #[cfg(feature = "rand")]
948 /// # {
949 /// use rand::rngs::StdRng;
950 /// use rand::SeedableRng;
951 /// use secure_gate::Fixed;
952 ///
953 /// let mut rng = StdRng::from_seed([1u8; 32]);
954 /// let key: Fixed<[u8; 16]> = Fixed::from_rng(&mut rng).expect("rng fill");
955 /// # }
956 /// ```
957 #[inline]
958 pub fn from_rng<R: TryRngCore + TryCryptoRng>(rng: &mut R) -> Result<Self, R::Error> {
959 let mut result = Ok(());
960 let this = Self::new_with(|arr| {
961 result = rng.try_fill_bytes(arr);
962 });
963 result.map(|_| this) // on Err, `this` drops → zeroizes any partial fill
964 }
965}
966
967/// Constant-time equality for `Fixed<T>` — routes through [`expose_secret()`](crate::RevealSecret::expose_secret).
968///
969/// `==` is **deliberately not implemented** on `Fixed`. Always use `ct_eq`.
970///
971/// ```rust
972/// # #[cfg(feature = "ct-eq")]
973/// # {
974/// use secure_gate::{Fixed, ConstantTimeEq};
975///
976/// let a = Fixed::new([1u8; 4]);
977/// let b = Fixed::new([1u8; 4]);
978/// let c = Fixed::new([2u8; 4]);
979/// assert!(a.ct_eq(&b));
980/// assert!(!a.ct_eq(&c));
981/// # }
982/// ```
983#[cfg(feature = "ct-eq")]
984impl<T: zeroize::Zeroize> crate::ConstantTimeEq for Fixed<T>
985where
986 T: crate::ConstantTimeEq,
987 Self: crate::RevealSecret<Inner = T>,
988{
989 fn ct_eq(&self, other: &Self) -> bool {
990 self.expose_secret().ct_eq(other.expose_secret())
991 }
992}
993
994/// Always prints `[REDACTED]` — secrets never appear in debug output.
995///
996/// ```rust
997/// use secure_gate::Fixed;
998///
999/// let key = Fixed::new([0xABu8; 32]);
1000/// assert_eq!(format!("{:?}", key), "[REDACTED]");
1001/// ```
1002impl<T: zeroize::Zeroize> core::fmt::Debug for Fixed<T> {
1003 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1004 f.write_str("[REDACTED]")
1005 }
1006}
1007
1008/// Opt-in cloning — requires `cloneable` feature and [`CloneableSecret`](crate::CloneableSecret)
1009/// marker on the inner type. Each clone is independently zeroized on drop, but cloning
1010/// increases the in-memory exposure surface. Use sparingly.
1011#[cfg(feature = "cloneable")]
1012impl<T: zeroize::Zeroize + crate::CloneableSecret> Clone for Fixed<T> {
1013 fn clone(&self) -> Self {
1014 Self::new(self.inner.clone())
1015 }
1016}
1017
1018/// Opt-in serialization — requires `serde-serialize` feature and
1019/// [`SerializableSecret`](crate::SerializableSecret) marker on the inner type.
1020/// Serialization exposes the full secret — audit every impl.
1021#[cfg(feature = "serde-serialize")]
1022impl<T: zeroize::Zeroize + crate::SerializableSecret> serde::Serialize for Fixed<T> {
1023 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1024 where
1025 S: serde::Serializer,
1026 {
1027 self.inner.serialize(serializer)
1028 }
1029}
1030
1031/// Deserialization uses `Zeroizing`-wrapped temporary buffers — zeroized even on rejection.
1032#[cfg(feature = "serde-deserialize")]
1033impl<'de, const N: usize> serde::Deserialize<'de> for Fixed<[u8; N]> {
1034 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1035 where
1036 D: serde::Deserializer<'de>,
1037 {
1038 use core::fmt;
1039 use serde::de::Visitor;
1040 struct FixedVisitor<const M: usize>;
1041 impl<'de, const M: usize> Visitor<'de> for FixedVisitor<M> {
1042 type Value = Fixed<[u8; M]>;
1043 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
1044 write!(formatter, "a byte array of length {}", M)
1045 }
1046 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
1047 where
1048 A: serde::de::SeqAccess<'de>,
1049 {
1050 let mut vec: zeroize::Zeroizing<alloc::vec::Vec<u8>> =
1051 zeroize::Zeroizing::new(alloc::vec::Vec::with_capacity(M));
1052 while let Some(value) = seq.next_element()? {
1053 vec.push(value);
1054 }
1055 if vec.len() != M {
1056 #[cfg(debug_assertions)]
1057 return Err(serde::de::Error::invalid_length(
1058 vec.len(),
1059 &M.to_string().as_str(),
1060 ));
1061 #[cfg(not(debug_assertions))]
1062 return Err(serde::de::Error::custom("decoded length mismatch"));
1063 }
1064 Ok(Fixed::new_with(|arr| arr.copy_from_slice(&vec)))
1065 }
1066 }
1067 deserializer.deserialize_seq(FixedVisitor::<N>)
1068 }
1069}
1070
1071/// Zeroizes the inner value. Called automatically by [`Drop`].
1072///
1073/// **Warning:** zeroization does not run for `static` items or under `panic = "abort"`.
1074impl<T: zeroize::Zeroize> zeroize::Zeroize for Fixed<T> {
1075 fn zeroize(&mut self) {
1076 self.inner.zeroize();
1077 }
1078}
1079
1080/// Unconditionally zeroizes the inner value when the wrapper is dropped.
1081///
1082/// **Warning:** `Drop` does not run for `static` items or under `panic = "abort"`.
1083impl<T: zeroize::Zeroize> Drop for Fixed<T> {
1084 fn drop(&mut self) {
1085 self.zeroize();
1086 }
1087}
1088
1089/// Marker confirming that `Fixed<T>` always zeroizes on drop.
1090impl<T: zeroize::Zeroize> zeroize::ZeroizeOnDrop for Fixed<T> {}