Skip to main content

md_codec/
codex32.rs

1//! v0.11 ↔ codex32 BCH layer adapter, symbol-aligned per spec §3.1 / D7.
2//!
3//! Bypasses v0.x's byte-oriented `encode_string` / `decode_string` to avoid
4//! adding an extra codex32 char per encoding due to byte-padding. Uses v0.x's
5//! lower-level BCH primitives (`bch_create_checksum_regular`,
6//! `bch_verify_regular`) which operate on `&[u8]` slices of 5-bit symbols.
7
8use crate::bitstream::{BitReader, BitWriter};
9use crate::error::Error;
10
11/// Codex32 alphabet (BIP 173 lowercase). Each char = one 5-bit symbol.
12const CODEX32_ALPHABET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
13
14/// HRP for v0.11 (matches v0.x).
15const HRP: &str = "md";
16
17/// Regular-BCH checksum length, in 5-bit symbols.
18pub(crate) const REGULAR_CHECKSUM_SYMBOLS: usize = 13;
19
20/// Maximum data-symbol count for a single codex32 regular-code string.
21/// The codex32 regular code is BCH(93, 80, 8): `REGULAR_DATA_SYMBOLS_MAX +
22/// REGULAR_CHECKSUM_SYMBOLS == 93` (80 data + 13 checksum). Payloads exceeding
23/// this cap MUST be chunked (`split()` / `--force-chunked`); a single string
24/// cannot carry them. Enforced at the top of [`wrap_payload`] (cycle-4 H6).
25pub(crate) const REGULAR_DATA_SYMBOLS_MAX: usize = 80;
26
27/// Maximum total codeword length (data + checksum) for a single codex32
28/// regular-code string: `REGULAR_DATA_SYMBOLS_MAX + REGULAR_CHECKSUM_SYMBOLS
29/// == 93`. The generator `β` has order 93, so a word longer than this aliases
30/// under the BCH decoder. Enforced on the decode boundaries (cycle-4 M4 in
31/// `chunk::decode_with_correction`; cycle-4 I1 in [`unwrap_string`]).
32pub(crate) const REGULAR_CODE_SYMBOLS_MAX: usize =
33    REGULAR_DATA_SYMBOLS_MAX + REGULAR_CHECKSUM_SYMBOLS;
34
35/// Pack `bit_count` bits from `payload_bytes` into 5-bit symbols. Pads the
36/// final symbol with zeros if `bit_count` is not a multiple of 5. Returns
37/// `ceil(bit_count / 5)` symbols. Each output u8 contains a 5-bit value.
38fn bits_to_symbols(payload_bytes: &[u8], bit_count: usize) -> Result<Vec<u8>, Error> {
39    let symbol_count = bit_count.div_ceil(5);
40    let mut r = BitReader::with_bit_limit(payload_bytes, bit_count);
41    let mut symbols = Vec::with_capacity(symbol_count);
42    for _ in 0..symbol_count {
43        let take = r.remaining_bits().min(5);
44        let val = if take == 0 {
45            0
46        } else {
47            r.read_bits(take)? as u8
48        };
49        // Left-justify within 5 bits if final symbol is short. (For decoder
50        // round-trip purposes the spec defines bit-packing MSB-first into
51        // 5-bit symbols, so zero-padding the LOW bits of the final symbol is
52        // the canonical form.)
53        let symbol = (val << (5 - take as u32)) & 0x1F;
54        symbols.push(symbol);
55    }
56    Ok(symbols)
57}
58
59/// Convert a stream of 5-bit symbols back into byte-padded bytes (MSB-first).
60fn symbols_to_bytes(symbols: &[u8]) -> Vec<u8> {
61    let mut w = BitWriter::new();
62    for &s in symbols {
63        w.write_bits((s & 0x1F) as u64, 5);
64    }
65    w.into_bytes()
66}
67
68fn symbol_to_char(s: u8) -> char {
69    CODEX32_ALPHABET[(s & 0x1F) as usize] as char
70}
71
72fn char_to_symbol(c: char) -> Option<u8> {
73    let lc = c.to_ascii_lowercase() as u8;
74    CODEX32_ALPHABET
75        .iter()
76        .position(|&b| b == lc)
77        .map(|i| i as u8)
78}
79
80/// Wrap a v0.11 payload bit stream (byte-padded with exact `bit_count`)
81/// into a complete codex32 md1 string with HRP and BCH checksum, symbol-aligned.
82pub fn wrap_payload(payload_bytes: &[u8], bit_count: usize) -> Result<String, Error> {
83    let data_symbols = bits_to_symbols(payload_bytes, bit_count)?;
84    // cycle-4 H6: enforce the regular-code 80-data-symbol cap at the lowest
85    // shared chokepoint (every `wrap_payload` caller inherits it, including
86    // `encode_md1_string`). An over-length single string is un-decodable under
87    // the BCH(93, 80, 8) regular code, so fail closed and direct the caller to
88    // chunked encoding rather than emit an aliasing-prone card.
89    if data_symbols.len() > REGULAR_DATA_SYMBOLS_MAX {
90        return Err(Error::PayloadTooLongForSingleString {
91            data_symbols: data_symbols.len(),
92            max: REGULAR_DATA_SYMBOLS_MAX,
93        });
94    }
95    // v0.x exposes `bch_create_checksum_regular(hrp: &str, data: &[u8]) -> [u8; 13]`.
96    let checksum: [u8; 13] = crate::bch::bch_create_checksum_regular(HRP, &data_symbols);
97
98    let mut s =
99        String::with_capacity(HRP.len() + 1 + data_symbols.len() + REGULAR_CHECKSUM_SYMBOLS);
100    s.push_str(HRP);
101    s.push('1'); // BIP 173-style HRP separator
102    for sym in &data_symbols {
103        s.push(symbol_to_char(*sym));
104    }
105    for sym in checksum.iter() {
106        s.push(symbol_to_char(*sym));
107    }
108    Ok(s)
109}
110
111/// BIP-173: a bech32/codex32 string must NOT mix upper and lower case. Returns
112/// true iff `s` (ignoring `-`/whitespace separators + digits, which are
113/// case-neutral) contains BOTH an ASCII-uppercase AND an ASCII-lowercase letter.
114/// All-upper, all-lower, and no-letters are fine. Mirrors mk-codec's
115/// `case_check` (`string_layer/bch.rs`); shared with `chunk::parse_chunk_symbols`.
116pub(crate) fn is_mixed_case(s: &str) -> bool {
117    let mut has_upper = false;
118    let mut has_lower = false;
119    for c in s.chars() {
120        if c.is_ascii_uppercase() {
121            has_upper = true;
122        } else if c.is_ascii_lowercase() {
123            has_lower = true;
124        }
125        if has_upper && has_lower {
126            return true;
127        }
128    }
129    false
130}
131
132/// Unwrap a v0.11 md1 string into (byte-padded payload bytes, symbol-aligned bit count).
133///
134/// The returned `symbol_aligned_bit_count = 5 × data_symbol_count`. This is
135/// the EXACT bit length carried by the codex32 BCH layer (rounded up to the
136/// next 5-bit boundary from the actual payload). The caller uses this as
137/// `decode_payload`'s `bit_len` so the v11 decoder's TLV-rollback only sees
138/// ≤4 bits of trailing zero-padding (well under the 7-bit threshold).
139pub fn unwrap_string(s: &str) -> Result<(Vec<u8>, usize), Error> {
140    // BIP-173: reject mixed-case input (all-upper / all-lower both OK, the
141    // latter canonicalized below). md-codec was the one constellation codec
142    // that leniently accepted mixed case; mk-codec + ms-codec reject it.
143    if is_mixed_case(s) {
144        return Err(Error::Codex32DecodeError(
145            "string mixes upper and lower case (BIP-173 forbids mixed case)".to_string(),
146        ));
147    }
148    // 1. Strip HRP + separator.
149    let prefix = format!("{}1", HRP);
150    if !s.to_ascii_lowercase().starts_with(&prefix) {
151        return Err(Error::Codex32DecodeError(format!(
152            "string does not start with HRP {prefix}"
153        )));
154    }
155    let symbols_str = &s[prefix.len()..];
156
157    // 2. Char-to-symbol decode (tolerate visual separators per D11).
158    let mut symbols = Vec::with_capacity(symbols_str.len());
159    for c in symbols_str.chars() {
160        if c.is_whitespace() || c == '-' {
161            continue;
162        }
163        let sym = char_to_symbol(c).ok_or_else(|| {
164            Error::Codex32DecodeError(format!("character {c:?} not in codex32 alphabet"))
165        })?;
166        symbols.push(sym);
167    }
168
169    // cycle-4 I1 (§5.2.3): reject an over-93-symbol codeword BEFORE the
170    // length-agnostic BCH verify. A clean (residue==0) over-length word is
171    // BCH-verifiable but structurally out-of-domain for the regular code
172    // (β has order 93). Symmetric with the too-short floor below; fail-closed
173    // so a non-correcting `decode` cannot accept an out-of-domain payload.
174    if symbols.len() > REGULAR_CODE_SYMBOLS_MAX {
175        return Err(Error::StringSymbolCountOutOfRange {
176            symbols: symbols.len(),
177            max: REGULAR_CODE_SYMBOLS_MAX,
178        });
179    }
180
181    // 3. BCH-verify.
182    if !crate::bch::bch_verify_regular(HRP, &symbols) {
183        return Err(Error::Codex32DecodeError(
184            "BCH checksum verification failed".into(),
185        ));
186    }
187
188    // 4. Strip the 13-symbol checksum.
189    if symbols.len() < REGULAR_CHECKSUM_SYMBOLS {
190        return Err(Error::Codex32DecodeError(
191            "string too short for BCH checksum".into(),
192        ));
193    }
194    let data_symbols = &symbols[..symbols.len() - REGULAR_CHECKSUM_SYMBOLS];
195    let bit_count = 5 * data_symbols.len();
196
197    // 5. Convert symbols → byte-padded bytes.
198    Ok((symbols_to_bytes(data_symbols), bit_count))
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn wrap_unwrap_round_trip_57_bits() {
207        // Synthetic 57-bit payload (mimics BIP 84 single-sig length).
208        let mut w = BitWriter::new();
209        w.write_bits(0xDEAD_BEEF_CAFE_BABE_u64 >> 7, 57);
210        let bytes = w.into_bytes();
211        let s = wrap_payload(&bytes, 57).unwrap();
212        // HRP "md1" (3 chars) + 12 data symbols + 13 checksum = 28 chars.
213        assert_eq!(s.len(), 28);
214        assert!(s.starts_with("md1"));
215        let (out_bytes, out_bits) = unwrap_string(&s).unwrap();
216        // Symbol-aligned bit count = 5 * 12 = 60 (≥ 57 by ≤4 padding bits).
217        assert_eq!(out_bits, 60);
218        // First 7 bytes match exactly; last byte's high bits match (low bits = padding).
219        assert_eq!(&out_bytes[..7], &bytes[..7]);
220        assert_eq!(out_bytes[7] & 0x80, bytes[7] & 0x80);
221    }
222
223    /// Critical: covers an N-byte chunk whose round-trip would mismatch under
224    /// byte-aligned `bytes.len() * 8` accounting. N=3 is the smallest such case
225    /// (encoder writes 8 bytes; symbol-aligned packing produces 13 symbols which
226    /// unpack to 9 bytes — but symbol_aligned_bit_count = 65 stays the right
227    /// reference).
228    #[test]
229    fn wrap_unwrap_n3_chunk_byte_count_recovers_correctly() {
230        // Chunk-format wire: 37-bit header + 8*3 = 24-bit payload = 61 bits.
231        let bit_count = 37 + 24;
232        let mut w = BitWriter::new();
233        w.write_bits(0x1FFF_FFFF_FFFF_u64, 37); // arbitrary header bits
234        w.write_bits(0x00AA_BBCC_u64, 24);
235        let bytes = w.into_bytes();
236        assert_eq!(bytes.len(), 8); // ceil(61/8)
237        let s = wrap_payload(&bytes, bit_count).unwrap();
238        let (_out_bytes, out_bits) = unwrap_string(&s).unwrap();
239        // Symbol-aligned bit count = 5 * ceil(61/5) = 5 * 13 = 65.
240        assert_eq!(out_bits, 65);
241        // (out_bits - 37) / 8 = (65 - 37) / 8 = 3 → 3 chunk-payload bytes recovered.
242        let recovered_payload_byte_count = (out_bits - 37) / 8;
243        assert_eq!(recovered_payload_byte_count, 3);
244    }
245
246    #[test]
247    fn unwrap_rejects_non_md_string() {
248        assert!(unwrap_string("xx1qpz9r4cy7").is_err());
249    }
250
251    #[test]
252    fn unwrap_tolerates_visual_separators() {
253        let mut w = BitWriter::new();
254        w.write_bits(0b1010, 4);
255        let bytes = w.into_bytes();
256        let s = wrap_payload(&bytes, 4).unwrap();
257        let mut grouped = String::new();
258        for (i, c) in s.chars().enumerate() {
259            grouped.push(c);
260            if i == 3 {
261                grouped.push('-');
262            }
263            if i == 8 {
264                grouped.push(' ');
265            }
266        }
267        let _ = unwrap_string(&grouped).unwrap();
268    }
269
270    // ── H6 (cycle-4): encode-side 80-data-symbol cap ─────────────────────────
271    // The codex32 regular code is BCH(93, 80, 8): a single string carries at
272    // most 80 data symbols + 13 checksum = 93. `wrap_payload` is the lowest
273    // shared chokepoint; it MUST reject an over-80-data-symbol payload rather
274    // than emit an un-decodable / aliasing-prone single string.
275
276    #[test]
277    fn wrap_payload_rejects_over_80_data_symbols() {
278        // 405 bits → ceil(405/5) = 81 data symbols (one past the cap).
279        let bit_count = 81 * 5;
280        let mut w = BitWriter::new();
281        // Fill with arbitrary non-zero bits, 32 at a time.
282        let mut remaining = bit_count;
283        while remaining > 0 {
284            let take = remaining.min(32);
285            w.write_bits(0xDEAD_BEEF_u64 & ((1u64 << take) - 1), take);
286            remaining -= take;
287        }
288        let bytes = w.into_bytes();
289        let got = wrap_payload(&bytes, bit_count);
290        assert_eq!(
291            got,
292            Err(Error::PayloadTooLongForSingleString {
293                data_symbols: 81,
294                max: 80,
295            }),
296            "81 data symbols must be rejected with the typed cap error"
297        );
298    }
299
300    #[test]
301    fn wrap_payload_accepts_exactly_80_data_symbols() {
302        // 400 bits → ceil(400/5) = 80 data symbols (the maximal LEGAL value).
303        let bit_count = 80 * 5;
304        let mut w = BitWriter::new();
305        let mut remaining = bit_count;
306        while remaining > 0 {
307            let take = remaining.min(32);
308            w.write_bits(0x1234_5678_u64 & ((1u64 << take) - 1), take);
309            remaining -= take;
310        }
311        let bytes = w.into_bytes();
312        let s = wrap_payload(&bytes, bit_count).expect("80 data symbols is in-domain");
313        // HRP "md1" (3) + 80 data + 13 checksum = 96 chars (93-symbol codeword).
314        assert_eq!(s.chars().count(), 3 + 80 + REGULAR_CHECKSUM_SYMBOLS);
315    }
316
317    // ── I1 (cycle-4, §5.2.3): non-correcting decode 93-symbol-codeword cap ────
318    // `unwrap_string` (the `decode_md1_string` primitive) BCH-verifies via the
319    // length-agnostic `bch_verify_regular` and only had a too-SHORT floor. A
320    // CLEAN (residue==0, BCH-valid) over-93-symbol md1 must fail closed, not
321    // decode an out-of-domain payload.
322
323    /// Build a CLEAN (BCH-valid, residue==0) md1 string with `data_symbols`
324    /// arbitrary data symbols, bypassing `wrap_payload`'s H6 cap by calling the
325    /// raw BCH primitive directly. Used to forge over-93-codeword strings.
326    fn clean_md1_with_data_symbols(data_symbols: usize) -> String {
327        let data: Vec<u8> = (0..data_symbols).map(|i| (i as u8) & 0x1F).collect();
328        let checksum = crate::bch::bch_create_checksum_regular(HRP, &data);
329        let mut s = String::new();
330        s.push_str(HRP);
331        s.push('1');
332        for &sym in data.iter().chain(checksum.iter()) {
333            s.push(symbol_to_char(sym));
334        }
335        s
336    }
337
338    #[test]
339    fn unwrap_string_rejects_clean_over_93_symbol_string() {
340        // 90 data + 13 checksum = 103 codeword symbols (> 93), residue == 0.
341        let s = clean_md1_with_data_symbols(90);
342        let codeword_symbols = 90 + REGULAR_CHECKSUM_SYMBOLS;
343        assert_eq!(codeword_symbols, 103);
344        match crate::decode::decode_md1_string(&s) {
345            Err(Error::StringSymbolCountOutOfRange { symbols, max }) => {
346                assert_eq!(symbols, codeword_symbols);
347                assert_eq!(max, 93);
348            }
349            other => panic!(
350                "clean over-93-symbol string must be rejected with StringSymbolCountOutOfRange, got {other:?}"
351            ),
352        }
353    }
354
355    #[test]
356    fn unwrap_string_accepts_exactly_93_symbol_codeword() {
357        // 80 data + 13 checksum = 93 codeword symbols (the maximal legal value).
358        let s = clean_md1_with_data_symbols(80);
359        assert_eq!(s.chars().count(), 3 + 80 + REGULAR_CHECKSUM_SYMBOLS);
360        let (_bytes, bit_count) =
361            unwrap_string(&s).expect("a 93-symbol legal codeword must still decode");
362        assert_eq!(bit_count, 5 * 80);
363    }
364}