Skip to main content

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