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}