summaryrefslogtreecommitdiff
path: root/test/settings/OtpFormTest.mc
blob: 9e6249e7ae7a5b100636655aeb9245a64d12d71e (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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
// Author:  cirakg & claude ai
// Summary: Tests for the Add OTP form: every type, every validation message, clearing, warnings, idempotency, pending locked entries.

import Toybox.Lang;
import Toybox.Test;

// A TOTP form is added with all its fields; the form is cleared (secret
// included), the switch turned off and the status names the new ID.
(:test)
function settingsAddsTotpAndClearsTheForm(logger as Test.Logger) as Boolean {
    var vault = SettingsFixtures.vault();
    var s = SettingsFixtures.store();
    SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_1);
    s.set("otpAlgorithm", Hashes.SHA256);
    s.set("otpDigits", 8);
    s.set("otpPeriod", 60);
    s.set("otpGroup", "Work");
    var job = SettingsFixtures.run(vault, s);
    if (vault.ids().size() != 1) {
        logger.error("status: " + SettingsFixtures.text(s, OtpForm.STATUS));
        return false;
    }
    var id = vault.ids()[0];
    var e = vault.open(id);
    var fields = e.name.equals("GitHub") && e.account.equals("alice") && e.group.equals("Work") && e.algorithm == Hashes.SHA256
        && e.digits == 8 && e.period == 60 && e.source == Entry.SOURCE_MANUAL && !e.locked;
    e.wipeSecrets();
    return fields && SettingsFixtures.secretOf(vault, id).equals("12345678901234567890")
        && SettingsFixtures.text(s, OtpForm.STATUS).equals("Added " + id + " GitHub (alice)")
        && SettingsFixtures.text(s, "otpSecret").equals("") && SettingsFixtures.text(s, "otpIssuer").equals("")
        && !Forms.flag(s, OtpForm.SWITCH) && Forms.number(s, "otpDigits") == 6 && job.forms == 1 && job.changed
        && SettingsFixtures.hasLine(job.lines, "Add OTP: Added " + id);
}

// HOTP keeps its counter; Steam ignores algorithm, digits and period.
(:test)
function settingsAddsHotpAndSteam(logger as Test.Logger) as Boolean {
    var vault = SettingsFixtures.vault();
    var s = SettingsFixtures.store();
    SettingsFixtures.otp(s, Entry.OTP_HOTP, "VPN", "", AegisFixtures.SECRET_1);
    s.set("otpCounter", 42l);
    SettingsFixtures.run(vault, s);
    SettingsFixtures.otp(s, Entry.OTP_STEAM, "Steam", "erin", AegisFixtures.SECRET_3);
    s.set("otpAlgorithm", Hashes.SHA512);
    s.set("otpDigits", 8);
    s.set("otpPeriod", 10);
    SettingsFixtures.run(vault, s);
    if (vault.ids().size() != 2) {
        logger.error("status: " + SettingsFixtures.text(s, OtpForm.STATUS));
        return false;
    }
    var hotp = vault.open(vault.ids()[0]);
    var steam = vault.open(vault.ids()[1]);
    var ok = hotp.type == Entry.OTP_HOTP && hotp.counter == 42l && steam.type == Entry.OTP_STEAM
        && steam.algorithm == Hashes.SHA1 && steam.period == Otp.STEAM_PERIOD;
    hotp.wipeSecrets();
    steam.wipeSecrets();
    return ok;
}

// Each invalid field gives its message; nothing is added, only the secret
// is cleared (so the rest can be corrected) and the switch goes off.
(:test)
function settingsRejectsInvalidOtpForms(logger as Test.Logger) as Boolean {
    // [field, value, expected message part]
    var cases = [
        ["otpSecret", "JBSW1Y3D","Not added: Secret: '1' is not a Base32 character (position 5)"],
        ["otpSecret", "  ", "Secret: missing"],
        ["otpSecret", "JBSWY3DP=A", "Secret: characters after '=' padding (position 10)"],
        ["otpDigits", 9, "Digits: choose 6, 7 or 8"],
        ["otpDigits", "eight", "Digits: choose 6, 7 or 8"],
        ["otpPeriod", 0, "Period: 1 to 300 seconds"],
        ["otpPeriod", 301, "Period: 1 to 300 seconds"],
        ["otpAlgorithm", 5, "Algorithm: choose SHA1, SHA256 or SHA512"],
        ["otpType", 7, "Type: choose TOTP, HOTP or Steam"],
        ["otpIssuer", "Git\nHub", "Issuer: control character at position 4"],
        ["otpGroup", SettingsFixtures.repeat("g", 33), "Group: too long (33 bytes, at most 32)"],
        ["otpAccount", SettingsFixtures.repeat("é", 33), "Account: too long (66 bytes, at most 64)"]
    ];
    var ok = true;
    for (var i = 0; i < cases.size(); i++) {
        var c = cases[i] as Array;
        var vault = SettingsFixtures.vault();
        var s = SettingsFixtures.store();
        SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_2);
        s.set("otpLocked", true); // a field no case changes: it must be kept
        s.set(c[0] as String, c[1] as String or Number);
        SettingsFixtures.run(vault, s);
        var status = SettingsFixtures.text(s, OtpForm.STATUS);
        if (status.find(c[2] as String) == null || vault.ids().size() != 0 || vault.pendingIds().size() != 0
                || Forms.flag(s, OtpForm.SWITCH) || !SettingsFixtures.text(s, "otpSecret").equals("")
                || !Forms.flag(s, "otpLocked")) {
            logger.error("case " + i + ": status '" + status + "'");
            ok = false;
        }
    }
    return ok;
}

(:test)
function settingsNeedsANameAndACounter(logger as Test.Logger) as Boolean {
    var vault = SettingsFixtures.vault();
    var s = SettingsFixtures.store();
    SettingsFixtures.otp(s, Entry.OTP_TOTP, "  ", "", AegisFixtures.SECRET_2);
    SettingsFixtures.run(vault, s);
    var names = SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Fill in the issuer or the account");
    SettingsFixtures.otp(s, Entry.OTP_HOTP, "VPN", "", AegisFixtures.SECRET_2);
    s.set("otpCounter", -1l);
    SettingsFixtures.run(vault, s);
    return names && SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Counter: a whole number, 0 or more") && vault.ids().size() == 0;
}

// A secret below 80 bits is accepted with a warning (README 10.4).
(:test)
function settingsWarnsAboutShortSecrets(logger as Test.Logger) as Boolean {
    var vault = SettingsFixtures.vault();
    var s = SettingsFixtures.store();
    SettingsFixtures.otp(s, Entry.OTP_TOTP, "Old", "x", "JBSW Y3DP"); // 5 bytes; the space is ignored
    var job = SettingsFixtures.run(vault, s);
    return vault.ids().size() == 1 && SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Warning: the secret is only 40 bits")
        && SettingsFixtures.hasLine(job.lines, "Warning: the secret is only 40 bits");
}

// The same form sent twice is applied once (README 10.3, rule 3); any
// change makes it a new request.
(:test)
function settingsAppliesTheSameOtpFormOnce(logger as Test.Logger) as Boolean {
    var vault = SettingsFixtures.vault();
    var s = SettingsFixtures.store();
    SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_1);
    SettingsFixtures.run(vault, s);
    SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_1);
    SettingsFixtures.run(vault, s);
    var repeated = SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Already added") && vault.ids().size() == 1
        && SettingsFixtures.text(s, "otpSecret").equals("");
    SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice (work)", AegisFixtures.SECRET_1);
    SettingsFixtures.run(vault, s);
    return repeated && vault.ids().size() == 2;
}

// A locked entry sent while the watch is locked waits as pending (README 7.7).
(:test)
function settingsLockedOtpWaitsForThePin(logger as Test.Logger) as Boolean {
    var vault = SettingsFixtures.vault();
    StorageFixtures.setPin(vault, StorageFixtures.PIN);
    vault.lock();
    var s = SettingsFixtures.store();
    SettingsFixtures.otp(s, Entry.OTP_TOTP, "Bank", "carol", AegisFixtures.SECRET_1);
    s.set("otpLocked", true);
    SettingsFixtures.run(vault, s);
    return vault.pendingIds().size() == 1 && vault.ids().size() == 0
        && SettingsFixtures.contains(logger, s, OtpForm.STATUS, "It waits for your PIN on the watch");
}

// Nothing switched on: no form is looked at, nothing is written.
(:test)
function settingsIgnoresFormsThatAreOff(logger as Test.Logger) as Boolean {
    var vault = SettingsFixtures.vault();
    var s = SettingsFixtures.store();
    s.set("otpIssuer", "GitHub");
    s.set("otpSecret", AegisFixtures.SECRET_1);
    var job = SettingsFixtures.run(vault, s);
    return job.forms == 0 && vault.ids().size() == 0 && SettingsFixtures.text(s, "otpSecret").equals(AegisFixtures.SECRET_1)
        && job.lines.size() == 0;
}

// Field values of unexpected types are read safely (the phone promises
// nothing): numbers as text, longs, whole floats.
(:test)
function settingsReadsLooselyTypedNumbers(logger as Test.Logger) as Boolean {
    var s = new Forms.MemoryStore();
    s.set("a", "42");
    s.set("b", 7l);
    s.set("c", 3.0f);
    s.set("d", 3.5f);
    s.set("e", "-12");
    s.set("f", "x1");
    s.set("g", true);
    return Forms.number(s, "a") == 42 && Forms.number(s, "b") == 7 && Forms.number(s, "c") == 3 && Forms.number(s, "d") == null
        && Forms.long(s, "e") == -12l && Forms.number(s, "f") == null && Forms.number(s, "g") == null
        && Forms.number(s, "missing") == null && Forms.text(s, "g").equals("") && !Forms.flag(s, "a");
}