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) + 4) / 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}