webauthn_authenticator_rs/cable/
base10.rs

1//! caBLE Base10 encoder.
2//!
3//! QR codes store arbitrary binary data very inefficiently, but it has
4//! alternate modes (such as numeric and alphanumeric) which can store it more
5//! efficiently.
6//!
7//! While [RFC 9285] presents an encoding for efficiently encoding binary data
8//! in QR's alphanumeric mode, there are additional issues:
9//!
10//! * caBLE pairing codes must be valid URLs (for mobile intent handling)
11//!
12//! * QR's alphanumeric mode does not allow all [URL-safe characters][url-chars],
13//!   reducing efficiency
14//!
15//! * QR's alphanumeric mode allows [non-URL-safe characters][url-chars],
16//!   reducing efficiency
17//!
18//! As a result, caBLE uses a novel Base10 encoding for the payload, which
19//! achieves comparable density (in QR code bits), though with longer URLs.
20//!
21//! In absence of a publicly-published caBLE specification, this is a port of
22//! [Chromium's `BytesToDigits` and `DigitsToBytes` functions][crbase10].
23//!
24//! [crbase10]: https://source.chromium.org/chromium/chromium/src/+/main:device/fido/cable/v2_handshake.cc;l=471-568;drc=6767131b3528fefd866f604b32ebbb278c35d395
25//! [RFC 9285]: https://www.rfc-editor.org/rfc/rfc9285.html
26//! [url-chars]: https://www.rfc-editor.org/rfc/rfc3986.html#section-2.3
27
28use std::fmt::Write;
29/// Size of a chunk of data in its original form
30const CHUNK_SIZE: usize = 7;
31
32/// Size of a chunk of data in its encoded form
33const CHUNK_DIGITS: usize = 17;
34
35/// Encodes binary data into Base10 format.
36///
37/// See Chromium's `BytesToDigits`.
38pub fn encode(i: &[u8]) -> String {
39    i.chunks(CHUNK_SIZE).fold(String::new(), |mut out, c| {
40        let chunk_len = c.len();
41        let w = match chunk_len {
42            CHUNK_SIZE => CHUNK_DIGITS,
43            6 => 15,
44            5 => 13,
45            4 => 10,
46            3 => 8,
47            2 => 5,
48            1 => 3,
49            // This should never happen
50            _ => 0,
51        };
52
53        let mut chunk: [u8; 8] = [0; 8];
54        chunk[0..chunk_len].copy_from_slice(c);
55        let v = u64::from_le_bytes(chunk);
56        let _ = write!(out, "{:0width$}", v, width = w);
57        out
58    })
59}
60
61#[derive(Debug, PartialEq, Eq)]
62pub enum DecodeError {
63    /// The input value contained non-ASCII-digit characters.
64    ContainsNonDigitChars,
65    /// The input value was not a valid length.
66    InvalidLength,
67    /// The input value contained a value which was out of range.
68    OutOfRange,
69}
70
71/// Decodes Base10 formatted data into binary form.
72///
73/// See Chromium's `DigitsToBytes`.
74pub fn decode(i: &str) -> Result<Vec<u8>, DecodeError> {
75    // Check that i only contains ASCII digits
76    if i.chars().any(|c| !c.is_ascii_digit()) {
77        return Err(DecodeError::ContainsNonDigitChars);
78    }
79
80    // It's safe to operate on the string in bytes now because:
81    //
82    // - we've previously thrown an error for anything containing non-ASCII digits.
83    // - each ASCII digit is exactly 1 byte in UTF-8.
84    // - &str is always valid UTF-8.
85    let mut o = Vec::with_capacity(((i.len() + CHUNK_DIGITS - 1) / CHUNK_DIGITS) * CHUNK_SIZE);
86
87    i.as_bytes()
88        .chunks(CHUNK_DIGITS)
89        .map(|b| unsafe { std::str::from_utf8_unchecked(b) })
90        .try_for_each(|s| {
91            let d = s
92                .parse::<u64>()
93                .map_err(|_| DecodeError::ContainsNonDigitChars)?;
94            let w = match s.len() {
95                CHUNK_DIGITS => CHUNK_SIZE,
96                15 => 6,
97                13 => 5,
98                10 => 4,
99                8 => 3,
100                5 => 2,
101                3 => 1,
102                _ => return Err(DecodeError::InvalidLength),
103            };
104
105            if d >> (w * 8) != 0 {
106                return Err(DecodeError::OutOfRange);
107            }
108
109            o.extend_from_slice(&d.to_le_bytes()[..w]);
110            Ok(())
111        })?;
112
113    Ok(o)
114}
115
116#[cfg(test)]
117mod test {
118    use super::*;
119
120    fn decoder_err_test(i: &str, e: DecodeError) {
121        assert_eq!(Err(e), decode(i), "decode({:?})", i);
122    }
123
124    #[test]
125    fn invalid_decode() {
126        use DecodeError::*;
127        // Non-digit characters
128        decoder_err_test("abc", ContainsNonDigitChars);
129        decoder_err_test("abc1234", ContainsNonDigitChars);
130
131        // Full-width romaji digits
132        decoder_err_test("\u{ff11}\u{ff12}\u{ff13}", ContainsNonDigitChars);
133
134        // Digits with umlauts (decomposed combining diacriticals on digits)
135        decoder_err_test("1\u{308}2\u{308}3\u{308}", ContainsNonDigitChars);
136
137        // Incorrect lengths
138        decoder_err_test("1", InvalidLength);
139        decoder_err_test("12", InvalidLength);
140        decoder_err_test("1234", InvalidLength);
141        decoder_err_test("123456789012345678", InvalidLength);
142
143        // Valid length, but results in bytes > 0xff
144        decoder_err_test("999", OutOfRange);
145        decoder_err_test("99999999999999999", OutOfRange);
146    }
147
148    #[test]
149    fn decoding_zero() {
150        let lengths = [
151            (0, 0),
152            (1, 3),
153            (2, 5),
154            (3, 8),
155            (4, 10),
156            (5, 13),
157            (6, 15),
158            (7, 17),
159            (8, 20),
160        ];
161        for (bl, dl) in lengths {
162            let bytes = vec![0; bl];
163            let digits = "0".repeat(dl);
164
165            assert_eq!(encode(bytes.as_slice()), digits);
166            assert_eq!(decode(&digits), Ok(bytes));
167        }
168    }
169
170    #[test]
171    fn encoding_survives_roundtrips() {
172        let i: Vec<u8> = (0..255).collect();
173
174        for len in 0..i.len() {
175            let i = &i[0..len];
176            assert_eq!(decode(&encode(i)), Ok(i.to_vec()), "length = {}", len);
177        }
178    }
179
180    #[test]
181    fn encoding_should_not_change() {
182        let i: [u8; 3] = [0x61, 0x62, 0xff];
183        assert_eq!(encode(&i), "16736865");
184        assert_eq!(decode("16736865").expect("unexpected error"), i);
185    }
186}