qualia2fa-core 0.4.1

Qualia2FA core library: TOTP/HOTP primitives, encrypted vault and device sync
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
// Copyright (C) 2026 Qualia2FA contributors
//
// This library is free software; you can redistribute it and/or modify it under
// the terms of the GNU Lesser General Public License as published by the Free
// Software Foundation; either version 3 of the License, or (at your option) any
// later version.
//
// This library is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
// details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with this library; if not, see <https://www.gnu.org/licenses/>.

//! Base32 encoding and decoding (RFC 4648 §6).
//!
//! This is an **attacker-controlled input surface** (QR code contents, seeds typed in by
//! hand), so the implementation is biased towards "reject rather than guess": any
//! ambiguous input returns an error and is never silently corrected.
//!
//! # Deliberate leniency
//!
//! Real-world `otpauth://` seeds are not all strictly canonical, and the leniency below
//! is required, otherwise users run into "scanning fails here but works in another app":
//!
//! - **Case-insensitive**: RFC 4648 specifies uppercase for encoding, but lowercase is
//!   accepted as well.
//! - **Missing `=` padding tolerated**: many generators omit the padding.
//! - **ASCII whitespace tolerated**: seeds copied from a web page often carry spaces or
//!   line breaks.
//!
//! # Deliberately rejected input
//!
//! - **Characters outside the alphabet** (including digits such as `0`, `1`, `8`, `9`
//!   that are easily confused with letters) — rejected rather than guessed, because a
//!   wrong guess yields a "wrong key that appears to work", and the user only notices
//!   when no code arrives.
//! - **Invalid length**: a valid Base32 length is `0, 2, 4, 5, 7` modulo 8. Other lengths
//!   (1, 3, 6) mean the input was truncated or damaged.
//! - **Non-zero padding bits** (rejected only by [`decode_strict`]; [`decode`] accepts
//!   them) — see below.
//!
//! # About padding bits: lenient mode and strict mode
//!
//! RFC 4648 allows the last character to carry **unused** padding bits. One point needs
//! to be clear: **all official RFC test vectors happen to have zero padding bits**, but
//! that is a coincidence of the chosen cases, not a guarantee. Rejecting non-zero
//! padding bits would therefore falsely reject many perfectly valid encodings.
//!
//! This module consequently has two entry points:
//!
//! | Function | Non-zero padding bits | Purpose |
//! |---|---|---|
//! | [`decode`] | **accepted** (matching authenticator apps) | import seeds from users / QR codes |
//! | [`decode_strict`] | **rejected** | verify that our own [`encode`] output is canonical |
//!
//! The leniency is not a security weakness: the decoded result is uniquely determined by
//! the **valid** bits, and padding bits cannot change it, so a non-canonical encoding
//! never decodes to "another key".
//!
//! The module is about 100 lines and is covered by the RFC 4648 §10 vectors and by
//! fuzzing.

use crate::{Error, Result, SecretBytes};

/// The Base32 alphabet (RFC 4648 §6, standard uppercase form).
const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

/// A single Base32 character carries 5 bits.
const BITS_PER_CHAR: usize = 5;

/// Maps a single ASCII character to its 5-bit value.
///
/// Uppercase and lowercase are equivalent; whitespace is stripped by the caller
/// beforehand, so whitespace returns `None` here.
const fn decode_char(byte: u8) -> Option<u8> {
    match byte {
        b'A'..=b'Z' => Some(byte - b'A'),
        b'a'..=b'z' => Some(byte - b'a'),
        b'2'..=b'7' => Some(byte - b'2' + 26),
        _ => None,
    }
}

/// Returns the number of output bytes needed for `count` significant characters, after
/// ASCII whitespace and `=` padding have been stripped.
///
/// Integer arithmetic is used instead of floating point to avoid precision issues.
const fn output_len_for(count: usize) -> Option<usize> {
    match count % 8 {
        0 => Some(count / 8 * 5),
        2 => Some(count / 8 * 5 + 1),
        4 => Some(count / 8 * 5 + 2),
        5 => Some(count / 8 * 5 + 3),
        7 => Some(count / 8 * 5 + 4),
        _ => None,
    }
}

/// Decodes a Base32 string into secret bytes (**lenient** mode, for accepting external
/// input).
///
/// The return value is [`SecretBytes`], because in the 2FA setting the decoded result is
/// almost always an OTP seed.
///
/// # Errors
///
/// - [`Error::Base32InvalidCharacter`]: a character outside the alphabet appears, or data
///   appears after `=` padding. `index` is its position in the character sequence
///   **after whitespace has been stripped**, which makes it easy to locate.
/// - [`Error::Base32InvalidLength`]: the number of significant characters modulo 8 is not
///   `0/2/4/5/7`.
/// - [`Error::SecretTooShort`]: the decoded result is empty.
///
/// RFC 4648 allows the last character to carry **unused** padding bits whose value does not
/// participate in the decoded result, and this function ignores them, as mature implementations
/// (including authenticator apps) do. A non-canonical encoding never decodes to "another key": the
/// result is uniquely determined by the **valid** bits. To confirm that an encoding is itself
/// canonical (for example to check our own `encode` output), use [`decode_strict`].
///
/// # Example
///
/// ```
/// use qualia2fa_core::{base32, ExposeSecret};
///
/// // RFC 4648 §10 test vector for "foobar"
/// let decoded = base32::decode("MZXW6YTBOI======").expect("the vector must decode");
/// assert_eq!(decoded.expose_secret(), b"foobar");
/// ```
pub fn decode(input: &str) -> Result<SecretBytes> {
    decode_inner(input, false)
}

/// Decodes a Base32 string and **additionally** requires the trailing padding bits to be
/// zero (canonical form).
///
/// Use it only to confirm that an encoding is in RFC 4648 canonical form, for example to
/// verify the output of this module's [`encode`] or to check recovery material we
/// generated ourselves.
///
/// Do **not** use it to validate seeds imported by users — many legitimate generators
/// produce non-canonical but perfectly valid encodings, which this function would falsely
/// reject (see [`decode`]).
///
/// # Errors
///
/// All the errors of [`decode`], plus [`Error::Base32InvalidPadding`]: the unused bits of
/// the last character are non-zero.
pub fn decode_strict(input: &str) -> Result<SecretBytes> {
    decode_inner(input, true)
}

/// Shared implementation of [`decode`] and [`decode_strict`].
///
/// When `require_canonical` is true, the trailing padding bits are checked as well.
fn decode_inner(input: &str, require_canonical: bool) -> Result<SecretBytes> {
    let mut valid = 0_usize;
    let mut seen_padding = false;
    for (index, &byte) in input.as_bytes().iter().enumerate() {
        if byte.is_ascii_whitespace() {
            continue;
        }
        if byte == b'=' {
            seen_padding = true;
            continue;
        }
        if seen_padding {
            return Err(Error::Base32InvalidCharacter { index });
        }
        if decode_char(byte).is_none() {
            return Err(Error::Base32InvalidCharacter { index });
        }
        valid += 1;
    }

    let Some(byte_len) = output_len_for(valid) else {
        return Err(Error::Base32InvalidLength { length: valid });
    };
    if byte_len == 0 {
        return Err(Error::SecretTooShort {
            required: 1,
            actual: 0,
        });
    }

    let mut out = Vec::with_capacity(byte_len);
    let mut buffer: u64 = 0;
    let mut buffered_bits = 0_u32;

    for &byte in input.as_bytes() {
        if byte.is_ascii_whitespace() || byte == b'=' {
            continue;
        }
        let Some(value) = decode_char(byte) else {
            return Err(Error::Base32InvalidCharacter { index: 0 });
        };
        buffer = (buffer << BITS_PER_CHAR) | u64::from(value);
        buffered_bits += BITS_PER_CHAR as u32;
        if buffered_bits >= 8 {
            buffered_bits -= 8;
            out.push((buffer >> buffered_bits) as u8);
        }
    }

    if require_canonical && buffered_bits > 0 {
        let residual_mask = (1_u64 << buffered_bits) - 1;
        if buffer & residual_mask != 0 {
            return Err(Error::Base32InvalidPadding);
        }
    }

    debug_assert_eq!(
        out.len(),
        byte_len,
        "the capacity calculation disagrees with the bytes actually written"
    );
    Ok(out.into())
}

/// Encodes secret bytes as a Base32 string (**with** `=` padding, standard form).
///
/// In this project it is mainly used to test round-trip properties and to generate
/// recovery material for users to write down; the parsing direction is the
/// attacker-controlled entry point.
#[must_use]
pub fn encode(data: &[u8]) -> String {
    let mut out = String::with_capacity(data.len().div_ceil(5) * 8);
    let mut buffer: u64 = 0;
    let mut buffered_bits = 0_u32;

    for &byte in data {
        buffer = (buffer << 8) | u64::from(byte);
        buffered_bits += 8;
        while buffered_bits >= BITS_PER_CHAR as u32 {
            buffered_bits -= BITS_PER_CHAR as u32;
            let index = ((buffer >> buffered_bits) & 0x1f) as usize;
            out.push(char::from(ALPHABET[index]));
        }
    }

    if buffered_bits > 0 {
        let index = ((buffer << (BITS_PER_CHAR as u32 - buffered_bits)) & 0x1f) as usize;
        out.push(char::from(ALPHABET[index]));
    }

    while !out.len().is_multiple_of(8) {
        out.push('=');
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ExposeSecret;

    /// The official test vectors of RFC 4648 §10.
    ///
    /// The RFC gives `""`, `"f"`, `"fo"`… growing step by step, covering all five valid
    /// remainders.
    #[test]
    fn rfc4648_test_vectors() {
        let vectors: &[(&str, &[u8])] = &[
            ("", b""),
            ("MY======", b"f"),
            ("MZXQ====", b"fo"),
            ("MZXW6===", b"foo"),
            ("MZXW6YQ=", b"foob"),
            ("MZXW6YTB", b"fooba"),
            ("MZXW6YTBOI======", b"foobar"),
        ];
        for (encoded, expected) in vectors {
            if expected.is_empty() {
                assert!(
                    matches!(decode(encoded), Err(Error::SecretTooShort { .. })),
                    "empty input must be rejected"
                );
                continue;
            }
            let decoded =
                decode(encoded).unwrap_or_else(|e| panic!("{encoded:?} failed to decode: {e}"));
            assert_eq!(
                decoded.expose_secret(),
                *expected,
                "{encoded:?} decoded to the wrong value"
            );
        }
    }

    /// The encoding direction must produce the RFC 4648 standard form (with padding).
    #[test]
    fn encode_matches_rfc4648_vectors() {
        assert_eq!(encode(b"f"), "MY======");
        assert_eq!(encode(b"fo"), "MZXQ====");
        assert_eq!(encode(b"foo"), "MZXW6===");
        assert_eq!(encode(b"foob"), "MZXW6YQ=");
        assert_eq!(encode(b"fooba"), "MZXW6YTB");
        assert_eq!(encode(b"foobar"), "MZXW6YTBOI======");
    }

    #[test]
    fn accepts_lowercase() {
        let upper = decode("MZXW6YTBOI======").expect("upper case must decode");
        let lower = decode("mzxw6ytboi======").expect("lower case must decode");
        assert_eq!(upper.expose_secret(), lower.expose_secret());
    }

    #[test]
    fn tolerates_missing_padding() {
        let padded = decode("MZXW6YTBOI======").expect("padded input must decode");
        let unpadded = decode("MZXW6YTBOI").expect("unpadded input must decode");
        assert_eq!(padded.expose_secret(), unpadded.expose_secret());
    }

    #[test]
    fn tolerates_ascii_whitespace() {
        let expected = decode("MZXW6YTBOI").expect("the baseline must decode");
        for variant in [
            " MZXW6YTBOI ",
            "MZXW 6YTBOI",
            "MZXW6YTB\nOI",
            "MZ\tXW6YTBOI",
            "MZ XW 6Y TB OI ",
        ] {
            let got = decode(variant).unwrap_or_else(|e| panic!("{variant:?} must decode: {e}"));
            assert_eq!(got.expose_secret(), expected.expose_secret(), "{variant:?}");
        }
    }

    #[test]
    fn rejects_characters_outside_alphabet() {
        for bad in [
            "MZXW6YTBO0======",
            "MZXW6YTBO1======",
            "MZXW6YTBO8======",
            "MZXW6YTBO9======",
            "MZXW6YTB-=",
            "MZXW6YTB!",
            "MZXW6YTBO\u{4e2d}",
        ] {
            let result = decode(bad);
            assert!(
                matches!(result, Err(Error::Base32InvalidCharacter { .. })),
                "{bad:?} must be rejected for an invalid character, got: {result:?}"
            );
        }
    }

    #[test]
    fn rejects_invalid_lengths() {
        for bad in ["M", "MZX", "MZXW6Y"] {
            let result = decode(bad);
            assert!(
                matches!(result, Err(Error::Base32InvalidLength { .. })),
                "{bad:?} must be rejected for an invalid length, got: {result:?}"
            );
        }
    }

    #[test]
    fn rejects_data_after_padding() {
        let result = decode("MZXW6YTB=OI");
        assert!(
            matches!(result, Err(Error::Base32InvalidCharacter { .. })),
            "data after padding must be rejected, got: {result:?}"
        );
    }

    /// Non-canonical padding bits: lenient mode accepts them, strict mode rejects them.
    ///
    /// `"MZ"` = 2 significant characters → 1 byte. Only the top 3 of `'Z'`'s 5 bits enter
    /// the result; the low 2 bits (`10`) are unused padding bits.
    ///
    /// The two assertions pin the lenient/strict split: changing either side fails
    /// immediately.
    #[test]
    fn padding_bits_are_accepted_leniently_but_rejected_strictly() {
        let lenient = decode("MZ").expect("lenient mode must accept non-canonical padding bits");
        assert_eq!(lenient.expose_secret(), &[0x66]);

        let strict = decode_strict("MZ");
        assert!(
            matches!(strict, Err(Error::Base32InvalidPadding)),
            "strict mode must reject non-zero padding bits, got: {strict:?}"
        );
    }

    /// Encodings with zero padding bits must succeed in both modes.
    ///
    /// 2 significant characters → 1 byte, leaving **2** residual bits, so the value of the
    /// last character must be congruent to 0 modulo 4 for zero padding. The alphabet
    /// members satisfying this are `A`(0), `E`(4), `I`(8), `M`(12), `Q`(16), `U`(20),
    /// `Y`(24), `4`(28).
    ///
    /// Note that `B`(1) does **not** satisfy it — its residual bits are `01`.
    #[test]
    fn zero_padding_bits_accepted_in_both_modes() {
        for input in ["MA", "ME", "MI", "MM", "MQ", "MU", "MY", "M4"] {
            assert!(
                decode(input).is_ok(),
                "{input:?} must decode in lenient mode"
            );
            assert!(
                decode_strict(input).is_ok(),
                "{input:?} must decode in strict mode (zero residual bits)"
            );
        }

        assert!(decode("ML").is_ok(), "lenient mode must still accept it");
        assert!(
            matches!(decode_strict("ML"), Err(Error::Base32InvalidPadding)),
            "strict mode must reject ML"
        );
    }

    /// The output of this module's `encode` must be canonical, that is, strict mode must
    /// accept it.
    ///
    /// This ties the encoder to the strict decoder: breaking canonical form on either side
    /// fails.
    #[test]
    fn our_own_encoding_is_canonical() {
        for len in 1_usize..=70 {
            let data: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(31)).collect();
            let encoded = encode(&data);
            let decoded = decode_strict(&encoded).unwrap_or_else(|e| {
                panic!("our own encoding of length {len} is not canonical: {e}")
            });
            assert_eq!(decoded.expose_secret(), data.as_slice(), "length {len}");
        }
    }

    #[test]
    fn rejects_empty_and_padding_only() {
        for bad in ["", "   ", "========", "="] {
            let result = decode(bad);
            assert!(
                matches!(result, Err(Error::SecretTooShort { .. })),
                "{bad:?} must be rejected as an empty secret, got: {result:?}"
            );
        }
    }

    /// Realistic case: a 32-byte (256-bit) TOTP seed.
    ///
    /// This is the most common seed length, and the round trip must be correct.
    #[test]
    fn roundtrip_realistic_seed_lengths() {
        for len in [10_usize, 16, 20, 32, 64] {
            let original: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(7)).collect();
            let encoded = encode(&original);
            let decoded = decode(&encoded)
                .unwrap_or_else(|e| panic!("the round trip of length {len} failed: {e}"));
            assert_eq!(decoded.expose_secret(), original.as_slice(), "length {len}");
        }
    }

    /// Error messages must **not** leak the input, which may contain a seed.
    #[test]
    fn errors_do_not_embed_input() {
        let secret_like = "JBSWY3DPEHPK3PXP0";
        let Err(error) = decode(secret_like) else {
            panic!("decoding must fail");
        };
        let text = format!("{error}");
        assert!(
            !text.contains("JBSWY3DPEHPK3PXP"),
            "the error message leaks the seed content: {text}"
        );
    }
}