aboutsummaryrefslogtreecommitdiff
path: root/source/otp/Base32.mc
diff options
context:
space:
mode:
Diffstat (limited to 'source/otp/Base32.mc')
-rw-r--r--source/otp/Base32.mc116
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;
+ }
+}