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