Skip to main content

secure_gate/
dynamic.rs

1//! Heap-allocated wrapper for variable-length secrets.
2//!
3//! Provides [`Dynamic<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 (including spare capacity).
7//! Requires the `alloc` feature.
8//!
9//! # Examples
10//!
11//! ```rust
12//! # #[cfg(feature = "alloc")]
13//! use secure_gate::{Dynamic, RevealSecret};
14//!
15//! # #[cfg(feature = "alloc")]
16//! {
17//! let secret: Dynamic<Vec<u8>> = Dynamic::new(vec![1u8, 2, 3, 4]);
18//! let sum = secret.with_secret(|s| s.iter().sum::<u8>());
19//! assert_eq!(sum, 10);
20//! # }
21//! ```
22
23#[cfg(feature = "alloc")]
24extern crate alloc;
25use alloc::boxed::Box;
26use zeroize::Zeroize;
27
28#[cfg(any(feature = "encoding-hex", feature = "encoding-base64"))]
29use crate::RevealSecret;
30
31// Encoding traits
32#[cfg(feature = "encoding-base64")]
33use crate::traits::encoding::base64_url::ToBase64Url;
34#[cfg(feature = "encoding-hex")]
35use crate::traits::encoding::hex::ToHex;
36
37#[cfg(feature = "rand")]
38use rand::{TryRng, rngs::SysRng};
39
40#[cfg(feature = "encoding-base64")]
41use crate::traits::decoding::base64_url::FromBase64UrlStr;
42#[cfg(feature = "encoding-bech32")]
43use crate::traits::decoding::bech32::FromBech32Str;
44#[cfg(feature = "encoding-bech32m")]
45use crate::traits::decoding::bech32m::FromBech32mStr;
46#[cfg(feature = "encoding-hex")]
47use crate::traits::decoding::hex::FromHexStr;
48
49/// Zero-cost heap-allocated wrapper for variable-length secrets.
50///
51/// Requires `alloc`. **Inner type must implement `Zeroize`** for automatic zeroization on drop
52/// (including spare capacity in `Vec`/`String`).
53///
54/// No `Deref`, `AsRef`, or `Copy` by default. `Debug` always prints `[REDACTED]`.
55pub struct Dynamic<T: ?Sized + zeroize::Zeroize> {
56    inner: Box<T>,
57}
58
59impl<T: ?Sized + zeroize::Zeroize> Dynamic<T> {
60    /// Wraps `value` in a `Box<T>` and returns a `Dynamic<T>`.
61    ///
62    /// Accepts any type that implements `Into<Box<T>>` — including owned values,
63    /// `Box<T>`, `String`, `Vec<u8>`, `&str` (via the blanket `From<&str>` impl), etc.
64    ///
65    /// Equivalent to `Dynamic::from(value)` — `#[doc(alias = "from")]` is set so both
66    /// names appear in docs.rs search.
67    ///
68    /// Requires the `alloc` feature (which `Dynamic<T>` itself always requires).
69    #[doc(alias = "from")]
70    #[inline(always)]
71    pub fn new<U>(value: U) -> Self
72    where
73        U: Into<Box<T>>,
74    {
75        let inner = value.into();
76        Self { inner }
77    }
78}
79
80// From impls
81impl<T: ?Sized + zeroize::Zeroize> From<Box<T>> for Dynamic<T> {
82    #[inline(always)]
83    fn from(boxed: Box<T>) -> Self {
84        Self { inner: boxed }
85    }
86}
87
88impl From<&[u8]> for Dynamic<Vec<u8>> {
89    #[inline(always)]
90    fn from(slice: &[u8]) -> Self {
91        Self::new(slice.to_vec())
92    }
93}
94
95impl From<&str> for Dynamic<String> {
96    #[inline(always)]
97    fn from(input: &str) -> Self {
98        Self::new(input.to_string())
99    }
100}
101
102impl<T: 'static + zeroize::Zeroize> From<T> for Dynamic<T> {
103    #[inline(always)]
104    fn from(value: T) -> Self {
105        Self {
106            inner: Box::new(value),
107        }
108    }
109}
110
111// Encoding helpers for Dynamic<Vec<u8>>
112impl Dynamic<Vec<u8>> {
113    /// Encodes the secret bytes as a lowercase hex string.
114    ///
115    /// Delegates to [`ToHex::to_hex`](crate::ToHex::to_hex) on the inner `Vec<u8>`.
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: &Vec<u8>| 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 `Vec<u8>`.
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: &Vec<u8>| 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 `Vec<u8>`.
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: &Vec<u8>| s.to_base64url())
141    }
142
143    /// Transfers `protected` bytes into a freshly boxed `Vec`, keeping
144    /// [`zeroize::Zeroizing`] alive across the only allocation that can panic.
145    ///
146    /// # Panic safety
147    ///
148    /// `Box::new(Vec::new())` is the sole allocation point — just the 24-byte
149    /// `Vec` header, no data buffer. If it panics (OOM), `protected` is still
150    /// in scope and `Zeroizing::drop` zeroes the secret bytes during unwind.
151    /// After the swap, `protected` holds an empty `Vec` (no-op to zeroize) and
152    /// `Dynamic::from(boxed)` is an infallible struct-field assignment.
153    ///
154    /// Note: `Box::new(*protected)` would be cleaner but does not compile —
155    /// `Zeroizing` implements `Deref` (returning `&T`), not a move-out, so
156    /// `*protected` yields a reference rather than an owned value (E0507).
157    #[cfg(any(
158        feature = "encoding-hex",
159        feature = "encoding-base64",
160        feature = "encoding-bech32",
161        feature = "encoding-bech32m",
162    ))]
163    #[inline(always)]
164    fn from_protected_bytes(mut protected: zeroize::Zeroizing<alloc::vec::Vec<u8>>) -> Self {
165        // Only fallible allocation; protected stays live across it for panic-safety
166        let mut boxed = Box::new(alloc::vec::Vec::new());
167        core::mem::swap(&mut *boxed, &mut *protected);
168        Self::from(boxed)
169    }
170}
171
172// RevealSecret
173impl crate::RevealSecret for Dynamic<String> {
174    type Inner = String;
175
176    #[inline(always)]
177    fn with_secret<F, R>(&self, f: F) -> R
178    where
179        F: FnOnce(&String) -> R,
180    {
181        f(&self.inner)
182    }
183
184    #[inline(always)]
185    fn expose_secret(&self) -> &String {
186        &self.inner
187    }
188
189    #[inline(always)]
190    fn len(&self) -> usize {
191        self.inner.len()
192    }
193}
194
195impl<T: zeroize::Zeroize> crate::RevealSecret for Dynamic<Vec<T>> {
196    type Inner = Vec<T>;
197
198    #[inline(always)]
199    fn with_secret<F, R>(&self, f: F) -> R
200    where
201        F: FnOnce(&Vec<T>) -> R,
202    {
203        f(&self.inner)
204    }
205
206    #[inline(always)]
207    fn expose_secret(&self) -> &Vec<T> {
208        &self.inner
209    }
210
211    #[inline(always)]
212    fn len(&self) -> usize {
213        self.inner.len() * core::mem::size_of::<T>()
214    }
215}
216
217// RevealSecretMut
218impl crate::RevealSecretMut for Dynamic<String> {
219    #[inline(always)]
220    fn with_secret_mut<F, R>(&mut self, f: F) -> R
221    where
222        F: FnOnce(&mut String) -> R,
223    {
224        f(&mut self.inner)
225    }
226
227    #[inline(always)]
228    fn expose_secret_mut(&mut self) -> &mut String {
229        &mut self.inner
230    }
231}
232
233impl<T: zeroize::Zeroize> crate::RevealSecretMut for Dynamic<Vec<T>> {
234    #[inline(always)]
235    fn with_secret_mut<F, R>(&mut self, f: F) -> R
236    where
237        F: FnOnce(&mut Vec<T>) -> R,
238    {
239        f(&mut self.inner)
240    }
241
242    #[inline(always)]
243    fn expose_secret_mut(&mut self) -> &mut Vec<T> {
244        &mut self.inner
245    }
246}
247
248// Random generation
249#[cfg(feature = "rand")]
250impl Dynamic<alloc::vec::Vec<u8>> {
251    /// Fills a new `Vec<u8>` with `len` cryptographically secure random bytes and wraps it.
252    ///
253    /// Uses the system RNG ([`SysRng`](rand::rngs::SysRng)). Requires the `rand` feature (and
254    /// `alloc`, which `Dynamic<Vec<u8>>` always needs).
255    ///
256    /// # Panics
257    ///
258    /// Panics if the system RNG fails to provide bytes ([`TryRng::try_fill_bytes`](rand::TryRng::try_fill_bytes)
259    /// returns `Err`). This is treated as a fatal environment error.
260    ///
261    /// # Examples
262    ///
263    /// ```rust
264    /// # #[cfg(all(feature = "alloc", feature = "rand"))]
265    /// use secure_gate::{Dynamic, RevealSecret};
266    ///
267    /// # #[cfg(all(feature = "alloc", feature = "rand"))]
268    /// # {
269    /// let nonce: Dynamic<Vec<u8>> = Dynamic::from_random(24);
270    /// assert_eq!(nonce.len(), 24);
271    /// # }
272    /// ```
273    #[inline]
274    pub fn from_random(len: usize) -> Self {
275        let mut bytes = vec![0u8; len];
276        SysRng
277            .try_fill_bytes(&mut bytes)
278            .expect("SysRng failure is a program error");
279        Self::from(bytes)
280    }
281}
282
283// Decoding constructors
284#[cfg(feature = "encoding-hex")]
285impl Dynamic<alloc::vec::Vec<u8>> {
286    /// Decodes a lowercase hex string into `Dynamic<Vec<u8>>`.
287    ///
288    /// The decoded buffer is kept inside a `Zeroizing` wrapper until after the
289    /// `Box` allocation completes, guaranteeing zeroization even on OOM panic.
290    pub fn try_from_hex(s: &str) -> Result<Self, crate::error::HexError> {
291        Ok(Self::from_protected_bytes(zeroize::Zeroizing::new(
292            s.try_from_hex()?,
293        )))
294    }
295}
296
297#[cfg(feature = "encoding-base64")]
298impl Dynamic<alloc::vec::Vec<u8>> {
299    /// Decodes a Base64url (unpadded) string into `Dynamic<Vec<u8>>`.
300    ///
301    /// The decoded buffer is kept inside a `Zeroizing` wrapper until after the
302    /// `Box` allocation completes, guaranteeing zeroization even on OOM panic.
303    pub fn try_from_base64url(s: &str) -> Result<Self, crate::error::Base64Error> {
304        Ok(Self::from_protected_bytes(zeroize::Zeroizing::new(
305            s.try_from_base64url()?,
306        )))
307    }
308}
309
310#[cfg(feature = "encoding-bech32")]
311impl Dynamic<alloc::vec::Vec<u8>> {
312    /// Decodes a Bech32 (BIP-173) string into `Dynamic<Vec<u8>>`.
313    ///
314    /// The decoded buffer is kept inside a `Zeroizing` wrapper until after the
315    /// `Box` allocation completes, guaranteeing zeroization even on OOM panic.
316    ///
317    /// # Warning
318    ///
319    /// The HRP is **not validated** — any HRP will be accepted as long as the checksum
320    /// is valid. For security-critical code where cross-protocol confusion must be
321    /// prevented, use [`try_from_bech32`](Self::try_from_bech32).
322    pub fn try_from_bech32_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
323        let (_hrp, bytes) = s.try_from_bech32_unchecked()?;
324        Ok(Self::from_protected_bytes(zeroize::Zeroizing::new(bytes)))
325    }
326
327    /// Decodes a Bech32 (BIP-173) string into `Dynamic<Vec<u8>>`, validating that the HRP
328    /// matches `expected_hrp` (case-insensitive).
329    ///
330    /// The decoded buffer is kept inside a `Zeroizing` wrapper until after the
331    /// `Box` allocation completes, guaranteeing zeroization even on OOM panic.
332    ///
333    /// Prefer this over [`try_from_bech32_unchecked`](Self::try_from_bech32_unchecked) in
334    /// security-critical code to prevent cross-protocol confusion attacks.
335    pub fn try_from_bech32(s: &str, expected_hrp: &str) -> Result<Self, crate::error::Bech32Error> {
336        Ok(Self::from_protected_bytes(zeroize::Zeroizing::new(
337            s.try_from_bech32(expected_hrp)?,
338        )))
339    }
340}
341
342#[cfg(feature = "encoding-bech32m")]
343impl Dynamic<alloc::vec::Vec<u8>> {
344    /// Decodes a Bech32m (BIP-350) string into `Dynamic<Vec<u8>>`.
345    ///
346    /// The decoded buffer is kept inside a `Zeroizing` wrapper until after the
347    /// `Box` allocation completes, guaranteeing zeroization even on OOM panic.
348    ///
349    /// # Warning
350    ///
351    /// The HRP is **not validated** — any HRP will be accepted as long as the checksum
352    /// is valid. For security-critical code where cross-protocol confusion must be
353    /// prevented, use [`try_from_bech32m`](Self::try_from_bech32m).
354    pub fn try_from_bech32m_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
355        let (_hrp, bytes) = s.try_from_bech32m_unchecked()?;
356        Ok(Self::from_protected_bytes(zeroize::Zeroizing::new(bytes)))
357    }
358
359    /// Decodes a Bech32m (BIP-350) string into `Dynamic<Vec<u8>>`, validating that the HRP
360    /// matches `expected_hrp` (case-insensitive).
361    ///
362    /// The decoded buffer is kept inside a `Zeroizing` wrapper until after the
363    /// `Box` allocation completes, guaranteeing zeroization even on OOM panic.
364    ///
365    /// Prefer this over [`try_from_bech32m_unchecked`](Self::try_from_bech32m_unchecked) in
366    /// security-critical code to prevent cross-protocol confusion attacks.
367    pub fn try_from_bech32m(
368        s: &str,
369        expected_hrp: &str,
370    ) -> Result<Self, crate::error::Bech32Error> {
371        Ok(Self::from_protected_bytes(zeroize::Zeroizing::new(
372            s.try_from_bech32m(expected_hrp)?,
373        )))
374    }
375}
376
377// ConstantTimeEq
378#[cfg(feature = "ct-eq")]
379impl<T: ?Sized + zeroize::Zeroize> crate::ConstantTimeEq for Dynamic<T>
380where
381    T: crate::ConstantTimeEq,
382{
383    fn ct_eq(&self, other: &Self) -> bool {
384        self.inner.ct_eq(&other.inner)
385    }
386}
387
388// Debug
389impl<T: ?Sized + zeroize::Zeroize> core::fmt::Debug for Dynamic<T> {
390    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
391        f.write_str("[REDACTED]")
392    }
393}
394
395// Clone
396#[cfg(feature = "cloneable")]
397impl<T: zeroize::Zeroize + crate::CloneableSecret> Clone for Dynamic<T> {
398    fn clone(&self) -> Self {
399        Self::new(self.inner.clone())
400    }
401}
402
403// Serialize
404#[cfg(feature = "serde-serialize")]
405impl<T: zeroize::Zeroize + crate::SerializableSecret> serde::Serialize for Dynamic<T> {
406    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
407    where
408        S: serde::Serializer,
409    {
410        self.inner.serialize(serializer)
411    }
412}
413
414// Deserialize
415
416/// Default maximum byte length accepted when deserializing `Dynamic<Vec<u8>>` or
417/// `Dynamic<String>` via the standard `serde::Deserialize` impl (1 MiB).
418///
419/// Pass a custom value to [`Dynamic::deserialize_with_limit`] when a different
420/// ceiling is required.
421///
422/// **Important:** this limit is enforced *after* the upstream deserializer has fully
423/// materialized the payload. It is a **result-length acceptance bound**, not a
424/// pre-allocation DoS guard. For untrusted input, enforce size limits at the
425/// transport or parser layer upstream.
426#[cfg(feature = "serde-deserialize")]
427pub const MAX_DESERIALIZE_BYTES: usize = 1_048_576;
428
429#[cfg(feature = "serde-deserialize")]
430impl Dynamic<alloc::vec::Vec<u8>> {
431    /// Deserializes into `Dynamic<Vec<u8>>`, rejecting payloads larger than `limit` bytes.
432    ///
433    /// The standard [`serde::Deserialize`] impl calls this with [`MAX_DESERIALIZE_BYTES`].
434    /// Use this method directly when you need a tighter or looser ceiling.
435    ///
436    /// The intermediate buffer is kept inside a `Zeroizing` wrapper until after the `Box`
437    /// allocation completes, guaranteeing zeroization even on OOM panic. Oversized buffers
438    /// are also zeroized before the error is returned.
439    ///
440    /// **Important:** this limit is enforced *after* the upstream deserializer has fully
441    /// materialized the payload. It is a **result-length acceptance bound**, not a
442    /// pre-allocation DoS guard. For untrusted input, enforce size limits at the
443    /// transport or parser layer upstream.
444    pub fn deserialize_with_limit<'de, D>(deserializer: D, limit: usize) -> Result<Self, D::Error>
445    where
446        D: serde::Deserializer<'de>,
447    {
448        let mut buf: zeroize::Zeroizing<alloc::vec::Vec<u8>> =
449            zeroize::Zeroizing::new(serde::Deserialize::deserialize(deserializer)?);
450        if buf.len() > limit {
451            // buf drops here → Zeroizing zeros the oversized buffer before deallocation
452            return Err(serde::de::Error::custom(
453                "deserialized secret exceeds maximum size",
454            ));
455        }
456        // Only fallible allocation; protected stays live across it for panic-safety
457        let mut boxed = Box::new(alloc::vec::Vec::new());
458        core::mem::swap(&mut *boxed, &mut *buf);
459        Ok(Self::from(boxed))
460    }
461}
462
463#[cfg(feature = "serde-deserialize")]
464impl Dynamic<String> {
465    /// Deserializes into `Dynamic<String>`, rejecting payloads larger than `limit` bytes.
466    ///
467    /// The standard [`serde::Deserialize`] impl calls this with [`MAX_DESERIALIZE_BYTES`].
468    /// Use this method directly when you need a tighter or looser ceiling.
469    ///
470    /// The intermediate buffer is kept inside a `Zeroizing` wrapper until after the `Box`
471    /// allocation completes, guaranteeing zeroization even on OOM panic. Oversized buffers
472    /// are also zeroized before the error is returned.
473    ///
474    /// **Important:** this limit is enforced *after* the upstream deserializer has fully
475    /// materialized the payload. It is a **result-length acceptance bound**, not a
476    /// pre-allocation DoS guard. For untrusted input, enforce size limits at the
477    /// transport or parser layer upstream.
478    pub fn deserialize_with_limit<'de, D>(deserializer: D, limit: usize) -> Result<Self, D::Error>
479    where
480        D: serde::Deserializer<'de>,
481    {
482        let mut buf: zeroize::Zeroizing<alloc::string::String> =
483            zeroize::Zeroizing::new(serde::Deserialize::deserialize(deserializer)?);
484        if buf.len() > limit {
485            // buf drops here → Zeroizing zeros the oversized buffer before deallocation
486            return Err(serde::de::Error::custom(
487                "deserialized secret exceeds maximum size",
488            ));
489        }
490        // Only fallible allocation; protected stays live across it for panic-safety
491        let mut boxed = Box::new(alloc::string::String::new());
492        core::mem::swap(&mut *boxed, &mut *buf);
493        Ok(Self::from(boxed))
494    }
495}
496
497#[cfg(feature = "serde-deserialize")]
498impl<'de> serde::Deserialize<'de> for Dynamic<alloc::vec::Vec<u8>> {
499    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
500    where
501        D: serde::Deserializer<'de>,
502    {
503        Self::deserialize_with_limit(deserializer, MAX_DESERIALIZE_BYTES)
504    }
505}
506
507#[cfg(feature = "serde-deserialize")]
508impl<'de> serde::Deserialize<'de> for Dynamic<String> {
509    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
510    where
511        D: serde::Deserializer<'de>,
512    {
513        Self::deserialize_with_limit(deserializer, MAX_DESERIALIZE_BYTES)
514    }
515}
516
517// Zeroize + Drop (now always present with bound)
518impl<T: ?Sized + zeroize::Zeroize> zeroize::Zeroize for Dynamic<T> {
519    fn zeroize(&mut self) {
520        self.inner.zeroize();
521    }
522}
523
524impl<T: ?Sized + zeroize::Zeroize> Drop for Dynamic<T> {
525    fn drop(&mut self) {
526        self.zeroize();
527    }
528}
529
530impl<T: ?Sized + zeroize::Zeroize> zeroize::ZeroizeOnDrop for Dynamic<T> {}