// 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; } }