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. `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    /// Allocates a `Vec<u8>` of length `len`, fills it with cryptographically secure random bytes,
231    /// and wraps it.
232    ///
233    /// Uses the system RNG ([`OsRng`](rand::rngs::OsRng)). Requires the `rand` feature and `alloc`
234    /// (implicit — [`Dynamic<T>`](crate::Dynamic) itself requires `alloc`).
235    ///
236    /// # Panics
237    ///
238    /// Panics if the RNG fails ([`TryRngCore::try_fill_bytes`](rand::TryRngCore::try_fill_bytes)
239    /// returns `Err`). This is treated as a fatal environment error.
240    #[inline]
241    pub fn from_random(len: usize) -> Self {
242        let mut bytes = vec![0u8; len];
243        OsRng
244            .try_fill_bytes(&mut bytes)
245            .expect("OsRng failure is a program error");
246        Self::from(bytes)
247    }
248
249    /// Allocates a `Vec<u8>` of length `len`, fills it from `rng`, and wraps it.
250    ///
251    /// Accepts any [`TryCryptoRng`](rand::TryCryptoRng) + [`TryRngCore`](rand::TryRngCore) (e.g. a
252    /// seeded generator for deterministic tests). Requires the `rand` feature and `alloc`.
253    ///
254    /// # Errors
255    ///
256    /// Returns `R::Error` if [`try_fill_bytes`](rand::TryRngCore::try_fill_bytes) fails.
257    #[inline]
258    pub fn from_rng<R: TryRngCore + TryCryptoRng>(
259        len: usize,
260        rng: &mut R,
261    ) -> Result<Self, R::Error> {
262        let mut bytes = vec![0u8; len];
263        rng.try_fill_bytes(&mut bytes)?;
264        Ok(Self::from(bytes))
265    }
266}
267
268// Decoding constructors
269#[cfg(feature = "encoding-hex")]
270impl Dynamic<alloc::vec::Vec<u8>> {
271    /// Decodes a lowercase hex string into `Dynamic<Vec<u8>>`.
272    ///
273    /// The decoded buffer is kept inside a `Zeroizing` wrapper until after the
274    /// `Box` allocation completes, guaranteeing zeroization even on OOM panic.
275    pub fn try_from_hex(s: &str) -> Result<Self, crate::error::HexError> {
276        Ok(Self::from_protected_bytes(zeroize::Zeroizing::new(
277            s.try_from_hex()?,
278        )))
279    }
280}
281
282#[cfg(feature = "encoding-base64")]
283impl Dynamic<alloc::vec::Vec<u8>> {
284    /// Decodes a Base64url (unpadded) string into `Dynamic<Vec<u8>>`.
285    ///
286    /// The decoded buffer is kept inside a `Zeroizing` wrapper until after the
287    /// `Box` allocation completes, guaranteeing zeroization even on OOM panic.
288    pub fn try_from_base64url(s: &str) -> Result<Self, crate::error::Base64Error> {
289        Ok(Self::from_protected_bytes(zeroize::Zeroizing::new(
290            s.try_from_base64url()?,
291        )))
292    }
293}
294
295#[cfg(feature = "encoding-bech32")]
296impl Dynamic<alloc::vec::Vec<u8>> {
297    /// Decodes a Bech32 (BIP-173) string into `Dynamic<Vec<u8>>`.
298    ///
299    /// The decoded buffer is kept inside a `Zeroizing` wrapper until after the
300    /// `Box` allocation completes, guaranteeing zeroization even on OOM panic.
301    ///
302    /// # Warning
303    ///
304    /// The HRP is **not validated** — any HRP will be accepted as long as the checksum
305    /// is valid. For security-critical code where cross-protocol confusion must be
306    /// prevented, use [`try_from_bech32`](Self::try_from_bech32).
307    pub fn try_from_bech32_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
308        let (_hrp, bytes) = s.try_from_bech32_unchecked()?;
309        Ok(Self::from_protected_bytes(zeroize::Zeroizing::new(bytes)))
310    }
311
312    /// Decodes a Bech32 (BIP-173) string into `Dynamic<Vec<u8>>`, validating that the HRP
313    /// matches `expected_hrp` (case-insensitive).
314    ///
315    /// The decoded buffer is kept inside a `Zeroizing` wrapper until after the
316    /// `Box` allocation completes, guaranteeing zeroization even on OOM panic.
317    ///
318    /// Prefer this over [`try_from_bech32_unchecked`](Self::try_from_bech32_unchecked) in
319    /// security-critical code to prevent cross-protocol confusion attacks.
320    pub fn try_from_bech32(s: &str, expected_hrp: &str) -> Result<Self, crate::error::Bech32Error> {
321        Ok(Self::from_protected_bytes(zeroize::Zeroizing::new(
322            s.try_from_bech32(expected_hrp)?,
323        )))
324    }
325}
326
327#[cfg(feature = "encoding-bech32m")]
328impl Dynamic<alloc::vec::Vec<u8>> {
329    /// Decodes a Bech32m (BIP-350) string into `Dynamic<Vec<u8>>`.
330    ///
331    /// The decoded buffer is kept inside a `Zeroizing` wrapper until after the
332    /// `Box` allocation completes, guaranteeing zeroization even on OOM panic.
333    ///
334    /// # Warning
335    ///
336    /// The HRP is **not validated** — any HRP will be accepted as long as the checksum
337    /// is valid. For security-critical code where cross-protocol confusion must be
338    /// prevented, use [`try_from_bech32m`](Self::try_from_bech32m).
339    pub fn try_from_bech32m_unchecked(s: &str) -> Result<Self, crate::error::Bech32Error> {
340        let (_hrp, bytes) = s.try_from_bech32m_unchecked()?;
341        Ok(Self::from_protected_bytes(zeroize::Zeroizing::new(bytes)))
342    }
343
344    /// Decodes a Bech32m (BIP-350) string into `Dynamic<Vec<u8>>`, validating that the HRP
345    /// matches `expected_hrp` (case-insensitive).
346    ///
347    /// The decoded buffer is kept inside a `Zeroizing` wrapper until after the
348    /// `Box` allocation completes, guaranteeing zeroization even on OOM panic.
349    ///
350    /// Prefer this over [`try_from_bech32m_unchecked`](Self::try_from_bech32m_unchecked) in
351    /// security-critical code to prevent cross-protocol confusion attacks.
352    pub fn try_from_bech32m(
353        s: &str,
354        expected_hrp: &str,
355    ) -> Result<Self, crate::error::Bech32Error> {
356        Ok(Self::from_protected_bytes(zeroize::Zeroizing::new(
357            s.try_from_bech32m(expected_hrp)?,
358        )))
359    }
360}
361
362// ConstantTimeEq
363#[cfg(feature = "ct-eq")]
364impl<T: ?Sized + zeroize::Zeroize> crate::ConstantTimeEq for Dynamic<T>
365where
366    T: crate::ConstantTimeEq,
367{
368    fn ct_eq(&self, other: &Self) -> bool {
369        self.inner.ct_eq(&other.inner)
370    }
371}
372
373// Debug
374impl<T: ?Sized + zeroize::Zeroize> core::fmt::Debug for Dynamic<T> {
375    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
376        f.write_str("[REDACTED]")
377    }
378}
379
380// Clone
381#[cfg(feature = "cloneable")]
382impl<T: zeroize::Zeroize + crate::CloneableSecret> Clone for Dynamic<T> {
383    fn clone(&self) -> Self {
384        Self::new(self.inner.clone())
385    }
386}
387
388// Serialize
389#[cfg(feature = "serde-serialize")]
390impl<T: zeroize::Zeroize + crate::SerializableSecret> serde::Serialize for Dynamic<T> {
391    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
392    where
393        S: serde::Serializer,
394    {
395        self.inner.serialize(serializer)
396    }
397}
398
399// Deserialize
400
401/// Default maximum byte length accepted when deserializing `Dynamic<Vec<u8>>` or
402/// `Dynamic<String>` via the standard `serde::Deserialize` impl (1 MiB).
403///
404/// Pass a custom value to [`Dynamic::deserialize_with_limit`] when a different
405/// ceiling is required.
406///
407/// **Important:** this limit is enforced *after* the upstream deserializer has fully
408/// materialized the payload. It is a **result-length acceptance bound**, not a
409/// pre-allocation DoS guard. For untrusted input, enforce size limits at the
410/// transport or parser layer upstream.
411#[cfg(feature = "serde-deserialize")]
412pub const MAX_DESERIALIZE_BYTES: usize = 1_048_576;
413
414#[cfg(feature = "serde-deserialize")]
415impl Dynamic<alloc::vec::Vec<u8>> {
416    /// Deserializes into `Dynamic<Vec<u8>>`, rejecting payloads larger than `limit` bytes.
417    ///
418    /// The standard [`serde::Deserialize`] impl calls this with [`MAX_DESERIALIZE_BYTES`].
419    /// Use this method directly when you need a tighter or looser ceiling.
420    ///
421    /// The intermediate buffer is kept inside a `Zeroizing` wrapper until after the `Box`
422    /// allocation completes, guaranteeing zeroization even on OOM panic. Oversized buffers
423    /// are also zeroized before the error is returned.
424    ///
425    /// **Important:** this limit is enforced *after* the upstream deserializer has fully
426    /// materialized the payload. It is a **result-length acceptance bound**, not a
427    /// pre-allocation DoS guard. For untrusted input, enforce size limits at the
428    /// transport or parser layer upstream.
429    pub fn deserialize_with_limit<'de, D>(deserializer: D, limit: usize) -> Result<Self, D::Error>
430    where
431        D: serde::Deserializer<'de>,
432    {
433        let mut buf: zeroize::Zeroizing<alloc::vec::Vec<u8>> =
434            zeroize::Zeroizing::new(serde::Deserialize::deserialize(deserializer)?);
435        if buf.len() > limit {
436            // buf drops here → Zeroizing zeros the oversized buffer before deallocation
437            return Err(serde::de::Error::custom(
438                "deserialized secret exceeds maximum size",
439            ));
440        }
441        // Only fallible allocation; protected stays live across it for panic-safety
442        let mut boxed = Box::new(alloc::vec::Vec::new());
443        core::mem::swap(&mut *boxed, &mut *buf);
444        Ok(Self::from(boxed))
445    }
446}
447
448#[cfg(feature = "serde-deserialize")]
449impl Dynamic<String> {
450    /// Deserializes into `Dynamic<String>`, rejecting payloads larger than `limit` bytes.
451    ///
452    /// The standard [`serde::Deserialize`] impl calls this with [`MAX_DESERIALIZE_BYTES`].
453    /// Use this method directly when you need a tighter or looser ceiling.
454    ///
455    /// The intermediate buffer is kept inside a `Zeroizing` wrapper until after the `Box`
456    /// allocation completes, guaranteeing zeroization even on OOM panic. Oversized buffers
457    /// are also zeroized before the error is returned.
458    ///
459    /// **Important:** this limit is enforced *after* the upstream deserializer has fully
460    /// materialized the payload. It is a **result-length acceptance bound**, not a
461    /// pre-allocation DoS guard. For untrusted input, enforce size limits at the
462    /// transport or parser layer upstream.
463    pub fn deserialize_with_limit<'de, D>(deserializer: D, limit: usize) -> Result<Self, D::Error>
464    where
465        D: serde::Deserializer<'de>,
466    {
467        let mut buf: zeroize::Zeroizing<alloc::string::String> =
468            zeroize::Zeroizing::new(serde::Deserialize::deserialize(deserializer)?);
469        if buf.len() > limit {
470            // buf drops here → Zeroizing zeros the oversized buffer before deallocation
471            return Err(serde::de::Error::custom(
472                "deserialized secret exceeds maximum size",
473            ));
474        }
475        // Only fallible allocation; protected stays live across it for panic-safety
476        let mut boxed = Box::new(alloc::string::String::new());
477        core::mem::swap(&mut *boxed, &mut *buf);
478        Ok(Self::from(boxed))
479    }
480}
481
482#[cfg(feature = "serde-deserialize")]
483impl<'de> serde::Deserialize<'de> for Dynamic<alloc::vec::Vec<u8>> {
484    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
485    where
486        D: serde::Deserializer<'de>,
487    {
488        Self::deserialize_with_limit(deserializer, MAX_DESERIALIZE_BYTES)
489    }
490}
491
492#[cfg(feature = "serde-deserialize")]
493impl<'de> serde::Deserialize<'de> for Dynamic<String> {
494    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
495    where
496        D: serde::Deserializer<'de>,
497    {
498        Self::deserialize_with_limit(deserializer, MAX_DESERIALIZE_BYTES)
499    }
500}
501
502// Zeroize + Drop (now always present with bound)
503impl<T: ?Sized + zeroize::Zeroize> zeroize::Zeroize for Dynamic<T> {
504    fn zeroize(&mut self) {
505        self.inner.zeroize();
506    }
507}
508
509impl<T: ?Sized + zeroize::Zeroize> Drop for Dynamic<T> {
510    fn drop(&mut self) {
511        self.zeroize();
512    }
513}
514
515impl<T: ?Sized + zeroize::Zeroize> zeroize::ZeroizeOnDrop for Dynamic<T> {}