summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-18 23:44:18 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-18 23:44:18 +0200
commit5646ae7f40dc53c9ebed0b3b6e907cc85fcd5993 (patch)
tree3ae736c894970569644c5d5136ad7ecb7b87a3df
parente47ae684101981a7c20f3270994a61944059c825 (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.mc116
-rw-r--r--source/otp/Otp.mc82
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;
+ }
+}