diff options
Diffstat (limited to 'source/otp/Base32.mc')
| -rw-r--r-- | source/otp/Base32.mc | 116 |
1 files changed, 116 insertions, 0 deletions
diff --git a/source/otp/Base32.mc b/source/otp/Base32.mc new file mode 100644 index 0000000..9df2e7c --- /dev/null +++ b/source/otp/Base32.mc @@ -0,0 +1,116 @@ +// Author: cirakg & claude ai +// Summary: Base32 (RFC 4648) decoding of OTP secrets with precise error positions, and encoding. +// +// Secrets arrive typed by hand or pasted, so decode() is lenient where it is +// safe: any letter case, spaces, tabs and hyphens are ignored, and "=" +// padding is optional. Everything else fails with the 1-based position of +// the offending character in the original text, for the status messages in +// README 10.4. Leftover bits after the last full byte are ignored (many +// services emit secrets whose length isn't a multiple of 8 characters). +// +// Verified with RFC 4648 section 10 and seeded round trips +// (test/otp/Base32Test.mc). + +import Toybox.Lang; + +module Base32 { + + const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; // lint: allow-literal + const PAD = 61; // '=' + + // Why a decode failed: a message, and the 1-based character position (0 + // when the problem is the length rather than one character). + class DecodeError { + var position as Number; + var message as String; + + function initialize(pos as Number, msg as String) { + position = pos; + message = msg; + } + } + + // Value 0-31 of one Base32 character code, -1 if it isn't one. + function valueOf(code as Number) as Number { + if (code >= 65 && code <= 90) { // A-Z + return code - 65; + } + if (code >= 97 && code <= 122) { // a-z + return code - 97; + } + if (code >= 50 && code <= 55) { // 2-7 + return code - 24; + } + return -1; + } + + // True for the separators people type into secrets: space, tab, hyphen. + function isIgnorable(code as Number) as Boolean { + return code == 32 || code == 9 || code == 45; + } + + // Bytes of a Base32 text, or a DecodeError. + function decode(text as String) as ByteArray or DecodeError { + var chars = text.toCharArray(); + var out = new [0]b; + var buffer = 0; + var bits = 0; + var symbols = 0; + var pads = 0; + for (var i = 0; i < chars.size(); i++) { + var code = chars[i].toNumber(); + if (isIgnorable(code)) { + continue; + } + if (code == PAD) { + pads++; + continue; + } + var value = valueOf(code); + if (value < 0) { + return new DecodeError(i + 1, "'" + chars[i].toString() + "' is not a Base32 character"); + } + if (pads > 0) { + return new DecodeError(i + 1, "characters after '=' padding"); + } + buffer = ((buffer << 5) | value) & 0xFFF; + bits += 5; + symbols++; + if (bits >= 8) { + bits -= 8; + out.add((buffer >> bits) & 0xFF); + } + } + var remainder = symbols % 8; + if (remainder == 1 || remainder == 3 || remainder == 6) { + return new DecodeError(0, "incomplete Base32 group: " + symbols + " characters can't be a whole number of bytes"); + } + if (pads > 0 && (symbols + pads) % 8 != 0) { + return new DecodeError(0, "wrong amount of '=' padding"); + } + return out; + } + + // RFC 4648 Base32 text with "=" padding. + function encode(data as ByteArray) as String { + var result = ""; + var buffer = 0; + var bits = 0; + for (var i = 0; i < data.size(); i++) { + buffer = ((buffer << 8) | data[i]) & 0xFFFF; + bits += 8; + while (bits >= 5) { + bits -= 5; + result += ALPHABET.substring((buffer >> bits) & 31, ((buffer >> bits) & 31) + 1); + } + } + if (bits > 0) { + var index = (buffer << (5 - bits)) & 31; + result += ALPHABET.substring(index, index + 1); + } + while (result.length() % 8 != 0) { + result += "="; + } + return result; + } +} |
