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> {}