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