secure-gate 0.9.0-rc.13

Secure wrappers for secrets with explicit access and mandatory zeroization — no_std-compatible, zero-overhead library with audit-friendly access patterns.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! Bech32 encoding trait.
//!
//! > **Import path:** `use secure_gate::ToBech32;`
//!
//! This trait provides secure, explicit encoding of byte data to Bech32 strings
//! (BIP-173 checksum) with a specified Human-Readable Part (HRP). Designed for
//! intentional export (addresses, QR codes, audited logs).
//!
//! Requires the `encoding-bech32` feature.
//!
//! # Security Notes
//!
//! - **Full secret exposure**: The resulting string contains the **entire** secret.
//!   Always treat output as sensitive.
//! - **Always wiped**: `try_to_bech32` returns
//!   [`EncodedSecret`](crate::EncodedSecret), which wraps `Zeroizing<String>`, redacts
//!   its `Debug`, and wipes on drop.
//!   Addresses and other public values come back in the same wrapper. `.into_inner()` is the named point where that
//!   protection ends.
//! - **Audit visibility**: Direct wrapper calls (`key.try_to_bech32(..., Case::Lower)`) do **not** appear in
//!   `grep expose_secret` / `grep with_secret` audit sweeps. For audit-first teams or
//!   multi-step operations, prefer `with_secret(|b| b.try_to_bech32(..., Case::Lower))` — the borrow
//!   checker enforces the reference cannot escape the closure.
//! - **HRP**: pass the intended human-readable part to `try_to_bech32`; test empty and
//!   invalid HRP inputs in security-critical code.
//! - **Caller-chosen code length**: the plain methods use [`BECH32_CODE_LENGTH`]
//!   (1023), the length at which the BCH checksum retains its error-detection
//!   guarantee. For larger payloads — age-style recipients, KEM ciphertexts,
//!   arbitrary binary blobs — use the `_sized::<N>` methods with an explicit `N`
//!   and read the guarantee note on [`Bech32Sized`] first.
//!   For Bitcoin address formats, use [`ToBech32m`](crate::ToBech32m) (BIP-350).
//! - **Treat all input as untrusted**: validate data upstream before wrapping.
//!
//! # Example
//!
//! ```rust
//! # #[cfg(feature = "encoding-bech32")]
//! use secure_gate::{Case, Fixed, ToBech32, RevealSecret};
//! # #[cfg(feature = "encoding-bech32")]
//! {
//! let secret = Fixed::new([0x42u8; 4]);
//!
//! // Use try_to_bech32 — the sole encoding API:
//! let encoded = secret.with_secret(|s| s.try_to_bech32("test", Case::Lower)).unwrap();
//! assert!(encoded.starts_with("test1"));
//! }
//! ```
#[cfg(all(feature = "encoding-bech32", feature = "alloc"))]
use super::Case;
#[cfg(all(feature = "encoding-bech32", feature = "alloc"))]
use bech32::Hrp;
#[cfg(all(feature = "encoding-bech32", feature = "alloc"))]
use bech32::primitives::iter::{ByteIterExt, Fe32IterExt};

#[cfg(feature = "encoding-bech32")]
use bech32::primitives::checksum::Checksum;

/// Bech32/Bech32m generator coefficients (BIP-173 § Checksum, Bitcoin Core `bech32.cpp`).
/// Identical for both checksums; only `TARGET_RESIDUE` differs.
#[cfg(feature = "encoding-bech32")]
pub(crate) const GENERATOR_SH: [u32; 5] = [
    0x3b6a_57b2,
    0x2650_8e6d,
    0x1ea1_19fa,
    0x3d42_33dd,
    0x2a14_62b3,
];

/// The code length of the bech32 BCH code: **1023**.
///
/// This is the value the `bech32` crate itself uses for both [`bech32::Bech32`] and
/// [`bech32::Bech32m`], and it is the length up to which the checksum's error-detection
/// guarantee holds. Every non-`_sized` method in this crate uses it.
///
/// It is *not* BIP-173's 90-character limit: that is a convention for Bitcoin addresses
/// which the `bech32` crate deliberately does not enforce. 1023 characters is roughly
/// 630 payload bytes, depending on HRP length — see [`bech32_code_length`].
#[cfg(feature = "encoding-bech32")]
pub const BECH32_CODE_LENGTH: usize = 1023;

/// The code length required to encode `payload_bytes` under an HRP of `hrp_len` characters.
///
/// Accounts for every component of the string, which the payload figure alone does not:
/// HRP + the `1` separator + the base32 payload (8 bits in, 5 bits out, rounded up) +
/// the 6-character checksum. Use it to pick an `N` for the `_sized` methods:
///
/// ```rust
/// use secure_gate::{Case, ToBech32, bech32_code_length};
///
/// const N: usize = bech32_code_length(3, 4096); // hrp "age", 4 KiB payload
/// let blob = [0xA5u8; 4096];
/// let encoded = blob.try_to_bech32_sized::<N>("age", Case::Lower)?;
/// assert!(encoded.starts_with("age1"));
/// # Ok::<(), secure_gate::Bech32Error>(())
/// ```
///
/// Values above [`BECH32_CODE_LENGTH`] forfeit the checksum's error-detection guarantee
/// — see [`Bech32Sized`].
#[cfg(feature = "encoding-bech32")]
#[must_use]
pub const fn bech32_code_length(hrp_len: usize, payload_bytes: usize) -> usize {
    // ceil(payload_bytes * 8 / 5) base32 characters, plus HRP, separator, checksum.
    //
    // Saturating rather than wrapping: for a payload whose encoding cannot fit in a
    // `usize` at all the result is `usize::MAX`, which every encoder then refuses. It
    // never panics and never returns a value smaller than the truth. (Adversarial
    // review: the plain `payload_bytes * 8` panicked in debug and wrapped in release.)
    // ceil(8b / 5) computed as 8·(b/5) + ceil(8·(b%5) / 5), which cannot overflow
    // until the true answer itself no longer fits in a usize.
    let payload_chars = (payload_bytes / 5)
        .saturating_mul(8)
        .saturating_add(((payload_bytes % 5) * 8).div_ceil(5));
    hrp_len
        .saturating_add(1)
        .saturating_add(payload_chars)
        .saturating_add(6)
}

/// Bech32 (BIP-173) checksum with a caller-chosen code length.
///
/// `N` is the maximum length of the whole encoded string — HRP, separator, payload and
/// checksum together. It is a **length gate only**: `N` never enters the checksum
/// computation, so the same bytes and HRP produce byte-identical output at every `N`,
/// and a string is decodable by any `N` at least as large as the string.
///
/// # The guarantee, and where it ends
///
/// The bech32 checksum is a BCH code of length [`BECH32_CODE_LENGTH`] (1023). Within
/// that length it is guaranteed to detect up to 4 character errors. **Above it, that
/// guarantee does not hold** — the same 30-bit checksum is stretched over a longer
/// message, and it degrades to an integrity check with no proven detection bound. It is
/// still computed and verified; it simply stops promising what BIP-173 proves.
///
/// Choosing `N > 1023` is therefore a deliberate trade, which is why it is spelled at
/// the call site rather than baked into a default. For payloads that fit, prefer
/// [`Bech32Standard`].
///
/// ```rust
/// use secure_gate::{Case, ToBech32};
///
/// // A 2 KiB KEM ciphertext needs a code length well past the BCH bound.
/// let ct = [0x5Au8; 2048];
/// let encoded = ct.try_to_bech32_sized::<4096>("kyber", Case::Lower)?;
/// assert!(encoded.starts_with("kyber1"));
/// # Ok::<(), secure_gate::Bech32Error>(())
/// ```
#[cfg(feature = "encoding-bech32")]
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Bech32Sized<const CODE_LENGTH: usize> {}

#[cfg(feature = "encoding-bech32")]
impl<const N: usize> Checksum for Bech32Sized<N> {
    type MidstateRepr = u32;

    const CODE_LENGTH: usize = N;
    const CHECKSUM_LENGTH: usize = 6;

    const GENERATOR_SH: [u32; 5] = GENERATOR_SH;
    const TARGET_RESIDUE: u32 = 1;
}

/// Bech32 (BIP-173) at the BCH code length, [`BECH32_CODE_LENGTH`].
///
/// The checksum's error-detection guarantee holds throughout. This is what every
/// non-`_sized` bech32 method uses.
#[cfg(feature = "encoding-bech32")]
pub type Bech32Standard = Bech32Sized<BECH32_CODE_LENGTH>;

#[cfg(all(feature = "encoding-bech32", feature = "alloc"))]
use crate::error::Bech32Error;

/// Extension trait for encoding byte data as Bech32 (BIP-173) strings.
///
/// *Requires feature `encoding-bech32`.*
///
/// Blanket-implemented for `AsRef<[u8]>` + [`EncodableBytes`](super::EncodableBytes). Use [`try_to_bech32`](Self::try_to_bech32)
/// with the protocol's HRP. Test empty and invalid HRP inputs in security-critical code.
#[cfg(all(feature = "encoding-bech32", feature = "alloc"))]
pub trait ToBech32 {
    /// Fallibly encodes bytes as a Bech32 (BIP-173) string with the given HRP.
    ///
    /// # Errors
    ///
    /// - [`Bech32Error::InvalidHrp`] — `hrp` contains invalid characters.
    /// - [`Bech32Error::OperationFailed`] — encoding failure.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use secure_gate::{Case, ToBech32};
    ///
    /// let encoded = b"hello".try_to_bech32("test", Case::Lower)?;
    /// assert!(encoded.starts_with("test1"));
    /// # Ok::<(), secure_gate::Bech32Error>(())
    /// ```
    fn try_to_bech32(&self, hrp: &str, case: Case) -> Result<crate::EncodedSecret, Bech32Error>;

    /// Like [`try_to_bech32`](Self::try_to_bech32), with a caller-chosen code length `N`.
    ///
    /// `N` caps the length of the whole encoded string, not the payload; size it with
    /// [`bech32_code_length`]. Output is byte-identical to `try_to_bech32` for any input
    /// that fits both, because `N` is a length gate and never enters the checksum.
    ///
    /// Passing `N` greater than [`BECH32_CODE_LENGTH`] forfeits the checksum's
    /// error-detection guarantee — see [`Bech32Sized`].
    ///
    /// # Errors
    ///
    /// - [`Bech32Error::InvalidHrp`] — `hrp` contains invalid characters.
    /// - [`Bech32Error::OperationFailed`] — encoding failure, including a string longer
    ///   than `N`.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use secure_gate::{Case, ToBech32};
    ///
    /// let recipient = [0x11u8; 900];
    /// // 900 bytes does not fit the default 1023-character code length.
    /// assert!(recipient.try_to_bech32("age", Case::Lower).is_err());
    /// let encoded = recipient.try_to_bech32_sized::<2048>("age", Case::Lower)?;
    /// assert!(encoded.starts_with("age1"));
    /// # Ok::<(), secure_gate::Bech32Error>(())
    /// ```
    fn try_to_bech32_sized<const N: usize>(
        &self,
        hrp: &str,
        case: Case,
    ) -> Result<crate::EncodedSecret, Bech32Error>;
}

// Blanket impl over AsRef<[u8]> + EncodableBytes (e.g. &[u8], Vec<u8>, [u8; N]).
// encode_lower returns String — requires alloc.
#[cfg(all(feature = "encoding-bech32", feature = "alloc"))]
impl<T: AsRef<[u8]> + super::EncodableBytes + ?Sized> ToBech32 for T {
    #[inline(always)]
    fn try_to_bech32(&self, hrp: &str, case: Case) -> Result<crate::EncodedSecret, Bech32Error> {
        self.try_to_bech32_sized::<BECH32_CODE_LENGTH>(hrp, case)
    }

    #[inline(always)]
    fn try_to_bech32_sized<const N: usize>(
        &self,
        hrp: &str,
        case: Case,
    ) -> Result<crate::EncodedSecret, Bech32Error> {
        let hrp_parsed = Hrp::parse(hrp).map_err(|_| Bech32Error::InvalidHrp)?;
        let data = self.as_ref();
        let len = bech32_code_length(hrp.len(), data.len());
        if len > N {
            return Err(Bech32Error::OperationFailed);
        }
        // Drive `bech32`'s iterator primitives straight into a buffer reserved to the
        // exact final length. This is what `bech32::encode_lower_to_fmt` does too,
        // except that it stages every output character through a 1 KiB stack array
        // (`[0u8; BUF_LENGTH]`) it never wipes, so the encoded secret survived in that
        // frame after the call returned. Adversarial review found it; the crate's own
        // guarantee is about the heap, but "wiped on drop" should not have a stack
        // footnote. The chain below holds one pending byte, a bit offset and a `u32`
        // checksum midstate -- a unit test pins its size -- and each char goes into
        // `out` as it is produced. One allocation, no intermediate copy anywhere.
        //
        // The CODE_LENGTH gate that `encode_lower_to_fmt` applied via `encoded_length`
        // is replicated exactly: refuse when the whole string would exceed N.
        let mut out = alloc::string::String::with_capacity(len);
        let chain = data
            .iter()
            .copied()
            .bytes_to_fes()
            .with_checksum::<Bech32Sized<N>>(&hrp_parsed)
            .chars();
        for c in chain {
            out.push(c);
        }
        debug_assert_eq!(
            out.len(),
            len,
            "bech32_code_length disagreed with the encoder"
        );
        // BIP-173 defines the checksum over the lowercase form and accepts either pure
        // case, so uppercasing the finished string -- HRP, separator, payload and
        // checksum together -- stays valid and decodable. Done here, on a buffer we
        // still own at exact capacity: ASCII case conversion is length-preserving, so
        // it cannot reallocate and the secret is never copied.
        if matches!(case, Case::Upper) {
            out.make_ascii_uppercase();
        }
        Ok(crate::EncodedSecret::new(out))
    }
}

#[cfg(all(feature = "encoding-bech32", feature = "alloc"))]
#[cfg(test)]
mod tests {
    use super::*;
    use alloc::vec;
    use alloc::vec::Vec;
    use bech32::primitives::iter::ByteIterExt;
    use bech32::{Bech32, Fe32, Fe32IterExt, NoChecksum, decode, encode_lower};

    /// The encoder chain holds no payload-sized state. `encode_lower_to_fmt` staged
    /// output through a 1 KiB stack array; this crate now drives the chain directly,
    /// and the chain itself is a pending byte, a bit offset, a borrowed HRP, and a
    /// `u32` checksum midstate — 72 bytes on x86_64. If someone reintroduces a staging
    /// buffer inside the chain, this number grows and the test says so. The bound is
    /// 96 rather than 72 so that pointer-width and padding differences across targets
    /// do not fail it; anything staging a payload would be orders of magnitude over.
    #[test]
    fn encoder_chain_carries_no_staging_buffer() {
        let data = [0xABu8; 1568];
        let hrp = Hrp::parse("age").unwrap();
        let chain = data
            .iter()
            .copied()
            .bytes_to_fes()
            .with_checksum::<Bech32Sized<4096>>(&hrp)
            .chars();
        let size = core::mem::size_of_val(&chain);
        assert!(
            size <= 96,
            "encoder chain is {size} bytes -- something payload-sized is being staged"
        );
    }

    /// Byte-for-byte equivalence with upstream's encoder, so bypassing its staging
    /// buffer changed nothing observable except the stack.
    #[test]
    fn direct_chain_matches_upstream_encode_lower() {
        use bech32::encode_lower;
        for (hrp, len) in [
            ("a", 0usize),
            ("age", 1),
            ("age", 4),
            ("age", 32),
            ("kem", 633),
            ("age", 634),
            ("kem", 1568),
            ("x", 4096),
        ] {
            let data: Vec<u8> = (0..len).map(|i| (i * 131 + 7) as u8).collect();
            let ours = data
                .try_to_bech32_sized::<65535>(hrp, Case::Lower)
                .expect("ours");
            let theirs = encode_lower::<Bech32Sized<65535>>(Hrp::parse(hrp).unwrap(), &data)
                .expect("upstream");
            assert_eq!(&*ours, &*theirs, "hrp={hrp} len={len}");
        }
    }

    #[test]
    fn test_bech32_sized_with_checksum() {
        let large_data = vec![0u8; 1000];
        let hrp = Hrp::parse("test").unwrap();
        let encoded = encode_lower::<Bech32Sized<2048>>(hrp, &large_data).unwrap();

        let pos = encoded.rfind('1').unwrap();
        let hrp_str = &encoded[..pos];
        let data_str = &encoded[pos + 1..];
        let decoded_hrp = Hrp::parse(hrp_str).unwrap();
        let data_part = &data_str[..data_str.len() - 6];
        let mut fe32s = Vec::new();
        for c in data_part.chars() {
            fe32s.push(Fe32::from_char(c).unwrap());
        }
        let decoded_data: Vec<u8> = fe32s.iter().copied().fes_to_bytes().collect();

        assert_eq!(decoded_hrp, hrp);
        assert_eq!(decoded_data, large_data);

        let re_encoded = encode_lower::<Bech32Sized<2048>>(decoded_hrp, &decoded_data).unwrap();
        assert_eq!(re_encoded, encoded);
    }

    #[test]
    fn test_bit_conversion_large_uncapped() {
        let large_data = vec![0u8; 4096];
        let fes: Vec<Fe32> = large_data.iter().copied().bytes_to_fes().collect();
        assert_eq!(fes.len(), (large_data.len() * 8).div_ceil(5));

        let bytes_back: Vec<u8> = fes.iter().copied().fes_to_bytes().collect();
        assert_eq!(bytes_back, large_data);
    }

    #[test]
    fn test_full_encode_decode_uncapped() {
        let large_data = vec![0u8; 1000];
        let hrp = Hrp::parse("test").unwrap();
        let encoded = encode_lower::<NoChecksum>(hrp, &large_data).unwrap();
        assert!(encoded.len() > 1000 * 8 / 5);

        let s = &encoded;
        let pos = s.rfind('1').unwrap();
        let hrp_str = &s[..pos];
        let data_str = &s[pos + 1..];
        let decoded_hrp = Hrp::parse(hrp_str).unwrap();
        let mut fe32s = Vec::new();
        for c in data_str.chars() {
            fe32s.push(Fe32::from_char(c).unwrap());
        }
        let decoded_data: Vec<u8> = fe32s.iter().copied().fes_to_bytes().collect();
        assert_eq!(decoded_hrp, hrp);
        assert_eq!(decoded_data, large_data);
    }

    #[test]
    fn test_bip173_roundtrip() {
        let data = b"bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"[4..].to_vec();
        let hrp = Hrp::parse("bc").unwrap();
        let encoded = encode_lower::<Bech32>(hrp, &data).unwrap();
        let (decoded_hrp, decoded_data) = decode(&encoded).unwrap();
        assert_eq!(decoded_hrp, hrp);
        assert_eq!(decoded_data, data);
    }
}