Skip to main content

secure_gate/traits/encoding/
bech32.rs

1//! Bech32 encoding trait.
2//!
3//! This trait provides secure, explicit encoding of byte data to Bech32 strings
4//! (BIP-173 checksum) with a specified Human-Readable Part (HRP). Designed for
5//! intentional export (addresses, QR codes, audited logs).
6//!
7//! Requires the `encoding-bech32` feature.
8//!
9//! # Security Notes
10//!
11//! - **Full secret exposure**: The resulting string contains the **entire** secret.
12//!   Always treat output as sensitive.
13//! - **Audit visibility**: Direct wrapper calls (`key.try_to_bech32(...)`) do **not** appear in
14//!   `grep expose_secret` / `grep with_secret` audit sweeps. For audit-first teams or
15//!   multi-step operations, prefer `with_secret(|b| b.try_to_bech32(...))` — the borrow
16//!   checker enforces the reference cannot escape the closure.
17//! - **HRP**: pass the intended human-readable part to `try_to_bech32`; test empty and
18//!   invalid HRP inputs in security-critical code.
19//! - **Extended limit**: Uses [`Bech32Large`] (8191 Fe32 values, ~5 KB (5,115 bytes maximum payload)) instead
20//!   of the 90-character standard limit — suitable for large secrets such as
21//!   age-style encryption recipients, ciphertexts, and arbitrary binary payloads.
22//!   For Bitcoin address formats, use [`ToBech32m`](crate::ToBech32m) (BIP-350).
23//! - **Treat all input as untrusted**: validate data upstream before wrapping.
24//!
25//! # Example
26//!
27//! ```rust
28//! # #[cfg(feature = "encoding-bech32")]
29//! use secure_gate::{Fixed, ToBech32, RevealSecret};
30//! # #[cfg(feature = "encoding-bech32")]
31//! {
32//! let secret = Fixed::new([0x42u8; 4]);
33//!
34//! // Use try_to_bech32 — the sole encoding API:
35//! let encoded = secret.with_secret(|s| s.try_to_bech32("test")).unwrap();
36//! assert!(encoded.starts_with("test1"));
37//! }
38//! ```
39#[cfg(feature = "encoding-bech32")]
40use bech32::{encode_lower, Hrp};
41
42#[cfg(feature = "encoding-bech32")]
43use bech32::primitives::checksum::Checksum;
44
45/// Custom Bech32 (BIP-173) checksum variant with an extended payload capacity.
46///
47/// Matches classic Bech32 checksum behavior but raises the `CODE_LENGTH` limit to
48/// 8191 Fe32 values (~5 KB (5,115 bytes maximum payload)), well above the standard 90-character limit.
49/// Used by the [`ToBech32`] trait to support large secrets while preserving full
50/// checksum validation.
51///
52/// Most users interact with this type indirectly via [`ToBech32`]. It is `pub`
53/// for use in `impl Checksum` and for advanced callers who construct their own
54/// `encode_lower::<Bech32Large>(...)` calls.
55#[cfg(feature = "encoding-bech32")]
56#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub enum Bech32Large {}
58
59#[cfg(feature = "encoding-bech32")]
60impl Checksum for Bech32Large {
61    type MidstateRepr = u32;
62
63    const CODE_LENGTH: usize = 8191;
64    const CHECKSUM_LENGTH: usize = 6;
65
66    const GENERATOR_SH: [u32; 5] = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
67    const TARGET_RESIDUE: u32 = 1;
68}
69
70#[cfg(feature = "encoding-bech32")]
71use crate::error::Bech32Error;
72
73/// Extension trait for encoding byte data as Bech32 (BIP-173) strings.
74///
75/// *Requires feature `encoding-bech32`.*
76///
77/// Blanket-implemented for all `AsRef<[u8]>` types. Use [`try_to_bech32`](Self::try_to_bech32)
78/// with the protocol's HRP. Test empty and invalid HRP inputs in security-critical code.
79#[cfg(feature = "encoding-bech32")]
80pub trait ToBech32 {
81    /// Fallibly encodes bytes as a Bech32 (BIP-173) string with the given HRP.
82    ///
83    /// # Errors
84    ///
85    /// - [`Bech32Error::InvalidHrp`] — `hrp` contains invalid characters.
86    /// - [`Bech32Error::OperationFailed`] — encoding failure.
87    ///
88    /// # Examples
89    ///
90    /// ```rust
91    /// use secure_gate::ToBech32;
92    ///
93    /// let encoded = b"hello".try_to_bech32("test")?;
94    /// assert!(encoded.starts_with("test1"));
95    /// # Ok::<(), secure_gate::Bech32Error>(())
96    /// ```
97    fn try_to_bech32(&self, hrp: &str) -> Result<alloc::string::String, Bech32Error>;
98}
99
100// Blanket impl to cover any AsRef<[u8]> (e.g., &[u8], Vec<u8>, [u8; N], etc.)
101#[cfg(feature = "encoding-bech32")]
102impl<T: AsRef<[u8]> + ?Sized> ToBech32 for T {
103    #[inline(always)]
104    fn try_to_bech32(&self, hrp: &str) -> Result<alloc::string::String, Bech32Error> {
105        let hrp_parsed = Hrp::parse(hrp).map_err(|_| Bech32Error::InvalidHrp)?;
106        encode_lower::<Bech32Large>(hrp_parsed, self.as_ref())
107            .map_err(|_| Bech32Error::OperationFailed)
108    }
109}
110
111#[cfg(feature = "encoding-bech32")]
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use bech32::primitives::iter::ByteIterExt;
116    use bech32::{decode, encode_lower, Bech32, Fe32, Fe32IterExt, NoChecksum};
117
118    #[test]
119    fn test_bech32_large_with_checksum() {
120        let large_data = vec![0u8; 1000];
121        let hrp = Hrp::parse("test").unwrap();
122        let encoded = encode_lower::<Bech32Large>(hrp, &large_data).unwrap();
123
124        let pos = encoded.rfind('1').unwrap();
125        let hrp_str = &encoded[..pos];
126        let data_str = &encoded[pos + 1..];
127        let decoded_hrp = Hrp::parse(hrp_str).unwrap();
128        let data_part = &data_str[..data_str.len() - 6];
129        let mut fe32s = Vec::new();
130        for c in data_part.chars() {
131            fe32s.push(Fe32::from_char(c).unwrap());
132        }
133        let decoded_data: Vec<u8> = fe32s.iter().copied().fes_to_bytes().collect();
134
135        assert_eq!(decoded_hrp, hrp);
136        assert_eq!(decoded_data, large_data);
137
138        let re_encoded = encode_lower::<Bech32Large>(decoded_hrp, &decoded_data).unwrap();
139        assert_eq!(re_encoded, encoded);
140    }
141
142    #[test]
143    fn test_bit_conversion_large_uncapped() {
144        let large_data = vec![0u8; 4096];
145        let fes: Vec<Fe32> = large_data.iter().copied().bytes_to_fes().collect();
146        assert_eq!(fes.len(), (large_data.len() * 8).div_ceil(5));
147
148        let bytes_back: Vec<u8> = fes.iter().copied().fes_to_bytes().collect();
149        assert_eq!(bytes_back, large_data);
150    }
151
152    #[test]
153    fn test_full_encode_decode_uncapped() {
154        let large_data = vec![0u8; 1000];
155        let hrp = Hrp::parse("test").unwrap();
156        let encoded = encode_lower::<NoChecksum>(hrp, &large_data).unwrap();
157        assert!(encoded.len() > 1000 * 8 / 5);
158
159        let s = &encoded;
160        let pos = s.rfind('1').unwrap();
161        let hrp_str = &s[..pos];
162        let data_str = &s[pos + 1..];
163        let decoded_hrp = Hrp::parse(hrp_str).unwrap();
164        let mut fe32s = Vec::new();
165        for c in data_str.chars() {
166            fe32s.push(Fe32::from_char(c).unwrap());
167        }
168        let decoded_data: Vec<u8> = fe32s.iter().copied().fes_to_bytes().collect();
169        assert_eq!(decoded_hrp, hrp);
170        assert_eq!(decoded_data, large_data);
171    }
172
173    #[test]
174    fn test_bip173_roundtrip() {
175        let data = b"bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"[4..].to_vec();
176        let hrp = Hrp::parse("bc").unwrap();
177        let encoded = encode_lower::<Bech32>(hrp, &data).unwrap();
178        let (decoded_hrp, decoded_data) = decode(&encoded).unwrap();
179        assert_eq!(decoded_hrp, hrp);
180        assert_eq!(decoded_data, data);
181    }
182}