summaryrefslogtreecommitdiff
path: root/source/otp/Otp.mc
blob: c6cfee844630b573289f9a8a40c13fee58d63a09 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// 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;

// (:glance): also loaded by the glance, which shows the favourite code (GlanceCode).
(:glance)
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;
    }
}