summaryrefslogtreecommitdiff
path: root/source/settings/OtpForm.mc
blob: 40c766f93e248d961b22a75045cea715c8939de6 (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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// Author:  cirakg & claude ai
// Summary: The Add OTP form (README 10.2): reads the otp* fields, validates them (README 10.4) and resets the form.
//
// read() turns the fields into a new Entry or the first problem, in the
// words of README 10.3 ("Secret: '1' is not a Base32 character (position
// 5)"). Messages never quote the secret. Steam ignores the algorithm, digits
// and period fields (always SHA-1, 5 characters, 30 s); TOTP ignores the
// counter and HOTP the period.

import Toybox.Lang;

module OtpForm {

    const SWITCH = "otpAdd";
    const STATUS = "otpStatus";
    const SECRET = "otpSecret";
    // Below this many bytes (80 bits) the secret gets a warning (RFC 4226 4).
    const WEAK_SECRET_BYTES = 10;

    // Every field and its value after a reset (the form's defaults in
    // resources/settings/properties.xml). The secret comes first: it is
    // cleared before anything else.
    function defaults() as Dictionary<String, String or Number or Long or Boolean> {
        return {
            SECRET => "",
            "otpType" => Entry.OTP_TOTP,
            "otpIssuer" => "",
            "otpAccount" => "",
            "otpAlgorithm" => Hashes.SHA1,
            "otpDigits" => 6,
            "otpPeriod" => 30,
            "otpCounter" => 0l,
            "otpGroup" => "",
            "otpLocked" => false
        } as Dictionary<String, String or Number or Long or Boolean>;
    }

    // The form's fields in their hash order.
    const FIELDS = ["otpType", "otpIssuer", "otpAccount", "otpSecret", "otpAlgorithm", "otpDigits", "otpPeriod", "otpCounter",
        "otpGroup", "otpLocked"];

    function read(store as Forms.Store) as Forms.Parsed {
        var parsed = new Forms.Parsed("otp");
        for (var i = 0; i < FIELDS.size(); i++) {
            parsed.content.add(store.get(FIELDS[i] as String));
        }
        var type = Forms.number(store, "otpType");
        if (type == null || type < Entry.OTP_TOTP || type > Entry.OTP_STEAM) {
            return parsed.fail("Type: choose TOTP, HOTP or Steam");
        }
        var e = new Entry();
        e.kind = Entry.KIND_OTP;
        e.type = type;
        e.source = Entry.SOURCE_MANUAL;
        e.name = Forms.text(store, "otpIssuer");
        e.account = Forms.text(store, "otpAccount");
        e.group = Forms.text(store, "otpGroup");
        e.locked = Forms.flag(store, "otpLocked");
        if (e.name.length() == 0 && e.account.length() == 0) {
            return parsed.fail("Fill in the issuer or the account");
        }
        var problem = Forms.checkName("Issuer", e.name, Entry.MAX_NAME_BYTES);
        if (problem == null) {
            problem = Forms.checkName("Account", e.account, Entry.MAX_NAME_BYTES);
        }
        if (problem == null) {
            problem = Forms.checkName("Group", e.group, Entry.MAX_GROUP_BYTES);
        }
        if (problem != null) {
            return parsed.fail(problem);
        }
        problem = readParameters(store, e);
        if (problem != null) {
            return parsed.fail(problem);
        }
        var secret = Forms.rawText(store, SECRET);
        var decoded = decodeSecret(secret);
        if (decoded instanceof String) {
            return parsed.fail(decoded);
        }
        e.secret = decoded;
        if (decoded.size() < WEAK_SECRET_BYTES) {
            parsed.notes.add("Warning: the secret is only " + decoded.size() * 8 + " bits (80 or more recommended)");
        }
        parsed.entry = e;
        return parsed;
    }

    // Algorithm, digits, period and counter by type; null or the problem.
    function readParameters(store as Forms.Store, e as Entry) as String? {
        if (e.type == Entry.OTP_STEAM) {
            e.algorithm = Hashes.SHA1;
            e.digits = Otp.MIN_DIGITS;
            e.period = Otp.STEAM_PERIOD;
            return null;
        }
        var algorithm = Forms.number(store, "otpAlgorithm");
        if (algorithm == null || !Hashes.isValid(algorithm)) {
            return "Algorithm: choose SHA1, SHA256 or SHA512";
        }
        e.algorithm = algorithm as Hashes.Algorithm;
        var digits = Forms.number(store, "otpDigits");
        if (digits == null || digits < Otp.MIN_DIGITS || digits > Otp.MAX_DIGITS) {
            return "Digits: choose 6, 7 or 8";
        }
        e.digits = digits;
        if (e.type == Entry.OTP_TOTP) {
            var period = Forms.number(store, "otpPeriod");
            if (period == null || period < Otp.MIN_PERIOD || period > Otp.MAX_PERIOD) {
                return "Period: 1 to " + Otp.MAX_PERIOD + " seconds";
            }
            e.period = period;
        } else {
            var counter = Forms.long(store, "otpCounter");
            if (counter == null || counter < 0) {
                return "Counter: a whole number, 0 or more";
            }
            e.counter = counter;
        }
        return null;
    }

    // The secret's bytes, or the message for the status field.
    function decodeSecret(text as String) as ByteArray or String {
        if (Text.trim(text).length() == 0) {
            return "Secret: missing";
        }
        var decoded = Base32.decode(text);
        if (decoded instanceof Base32.DecodeError) {
            return "Secret: " + decoded.message + (decoded.position > 0 ? " (position " + decoded.position + ")" : "");
        }
        if (decoded.size() == 0 || decoded.size() > Entry.MAX_SECRET_BYTES) {
            Bytes.wipe(decoded);
            return "Secret: must decode to 1 to " + Entry.MAX_SECRET_BYTES + " bytes";
        }
        return decoded;
    }

    // Turn the switch off and clear the form: everything after it was
    // applied, only the secret after a problem (so the rest can be fixed).
    function reset(store as Forms.Store, everything as Boolean) as Void {
        store.set(SECRET, "");
        if (everything) {
            var d = defaults();
            var keys = d.keys();
            for (var i = 0; i < keys.size(); i++) {
                store.set(keys[i], d[keys[i]] as String or Number or Long or Boolean);
            }
        }
        store.set(SWITCH, false);
    }
}