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