diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-18 23:44:18 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-18 23:44:18 +0200 |
| commit | 5646ae7f40dc53c9ebed0b3b6e907cc85fcd5993 (patch) | |
| tree | 3ae736c894970569644c5d5136ad7ecb7b87a3df | |
| parent | e47ae684101981a7c20f3270994a61944059c825 (diff) | |
feat(otp): add Base32 decoding, HOTP, TOTP and Steam Guard codes
Base32 accepts any case, spaces, hyphens and missing padding, and reports
the exact position of a bad character. TOTP uses 64-bit time, so codes stay
correct after 2038. Steam Guard is a separate, optional code type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | source/otp/Base32.mc | 116 | ||||
| -rw-r--r-- | source/otp/Otp.mc | 82 |
2 files changed, 198 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; + } +} diff --git a/source/otp/Otp.mc b/source/otp/Otp.mc new file mode 100644 index 0000000..8a2992b --- /dev/null +++ b/source/otp/Otp.mc @@ -0,0 +1,82 @@ +// Author: cirakg & claude ai +// Summary: HOTP (RFC 4226), TOTP (RFC 6238) and Steam Guard code generation. +// +// All three share HOTP's dynamic truncation of an HMAC over the 8-byte +// big-endian counter: +// * HOTP: the counter is stored with the entry. +// * TOTP: counter = floor(unix time / period). Time is a 64-bit Long, so +// the maths keeps working after 2038 (Clock.mc widens the watch's 32-bit +// clock value) and the RFC 6238 vector for the year 2603 passes. +// * Steam: TOTP with SHA-1 and a 30-second period, written as 5 characters +// from Steam's 26-letter alphabet instead of decimal digits. Optional entry +// type, kept separate here. +// +// Security note: callers decrypt the secret just for the call and wipe it +// afterwards (README 7.8); nothing here keeps a copy. +// Verified with RFC 4226 Appendix D, RFC 6238 Appendix B and seeded vectors +// from an independent Python implementation (test/otp/OtpTest.mc). + +import Toybox.Lang; + +module Otp { + + const MIN_DIGITS = 6; + const MAX_DIGITS = 8; + const MIN_PERIOD = 1; + const MAX_PERIOD = 300; + const STEAM_PERIOD = 30; + const STEAM_LENGTH = 5; + const STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY"; // lint: allow-literal + const POWERS_OF_TEN = [1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000]; + + // RFC 4226 dynamic truncation: a 31-bit number from HMAC(secret, counter). + function truncate(alg as Hashes.Algorithm, secret as ByteArray, counter as Long) as Number { + var mac = Hmac.compute(alg, secret, Bytes.uint64BigEndian(counter)); + var offset = mac[mac.size() - 1] & 0x0F; + var value = ((mac[offset] & 0x7F) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3]; + Bytes.wipe(mac); + return value; + } + + // HOTP code with `digits` digits (6 to 8), zero-padded. + function hotp(alg as Hashes.Algorithm, secret as ByteArray, counter as Long, digits as Number) as String { + if (digits < MIN_DIGITS || digits > MAX_DIGITS) { + throw new Lang.InvalidValueException("OTP: digits must be 6 to 8"); + } + var code = truncate(alg, secret, counter) % (POWERS_OF_TEN[digits] as Number); + return code.format("%0" + digits + "d"); + } + + // The TOTP counter (time step) for a unix time; times before 1970 count as 0. + function totpCounter(unixTime as Long, period as Number) as Long { + if (period < MIN_PERIOD || period > MAX_PERIOD) { + throw new Lang.InvalidValueException("OTP: period must be 1 to 300 seconds"); + } + return unixTime < 0 ? 0l : unixTime / period; + } + + // TOTP code at a unix time. + function totp(alg as Hashes.Algorithm, secret as ByteArray, unixTime as Long, period as Number, digits as Number) as String { + return hotp(alg, secret, totpCounter(unixTime, period), digits); + } + + // Seconds until the code changes (1 to period). + function secondsRemaining(unixTime as Long, period as Number) as Number { + if (unixTime < 0) { + return period; + } + return period - (unixTime % period).toNumber(); + } + + // Steam Guard code at a unix time. + function steam(secret as ByteArray, unixTime as Long) as String { + var value = truncate(Hashes.SHA1, secret, totpCounter(unixTime, STEAM_PERIOD)); + var code = ""; + for (var i = 0; i < STEAM_LENGTH; i++) { + var index = value % 26; + code += STEAM_ALPHABET.substring(index, index + 1); + value = value / 26; + } + return code; + } +} |
