aboutsummaryrefslogtreecommitdiff
path: root/test/storage/StorageSupport.mc
blob: 5f2ead39cc501f2e77ba6e50462e5b89cb1714b7 (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
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
// Author:  cirakg & claude ai
// Summary: Test doubles for storage: an in-memory store with a size limit, a hand-set clock, and fake entries.
//
// MemoryBackend copies values on the way in and out, as Application.Storage
// does, so a test can't pass by keeping a reference to stored data. raw()
// gives tests the stored object itself, for tampering.

import Toybox.Application;
import Toybox.Lang;
import Toybox.Test;

(:test)
class MemoryBackend extends Persist.Backend {

    // Total size limit in (approximate) bytes; setValue throws above it.
    var capacity as Number = 1000000;
    private var _data as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType> = {} as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>;

    function initialize() {
        Backend.initialize();
    }

    function getValue(key as String) as Application.Storage.ValueType {
        return MemoryBackend.copyOf(_data[key]);
    }

    function setValue(key as String, value as Application.Storage.ValueType) as Void {
        var hadKey = _data.hasKey(key);
        var old = _data[key];
        _data[key] = MemoryBackend.copyOf(value);
        if (usedBytes() > capacity) {
            if (hadKey) {
                _data[key] = old;
            } else {
                _data.remove(key);
            }
            throw new Lang.StorageFullException("Storage full");
        }
    }

    function deleteValue(key as String) as Void {
        _data.remove(key);
    }

    // The stored object itself (not a copy), for tampering in tests.
    function raw(key as String) as Application.Storage.ValueType {
        return _data[key];
    }

    function keys() as Array<String> {
        return _data.keys() as Array<String>;
    }

    function usedBytes() as Number {
        var total = 0;
        var keys = _data.keys();
        for (var i = 0; i < keys.size(); i++) {
            var key = keys[i] as String;
            total += key.length() + MemoryBackend.sizeOf(_data[key]);
        }
        return total;
    }

    // Rough serialized size, enough to make "store full" reachable.
    static function sizeOf(value as Application.Storage.ValueType) as Number {
        if (value instanceof ByteArray) {
            return value.size();
        }
        if (value instanceof String) {
            return value.length();
        }
        if (value instanceof Array) {
            var total = 0;
            for (var i = 0; i < value.size(); i++) {
                total += sizeOf(value[i] as Application.Storage.ValueType);
            }
            return total;
        }
        if (value instanceof Dictionary) {
            var total = 0;
            var keys = value.keys();
            for (var i = 0; i < keys.size(); i++) {
                total += sizeOf(keys[i] as Application.Storage.ValueType) + sizeOf(value[keys[i]] as Application.Storage.ValueType);
            }
            return total;
        }
        return 8;
    }

    // Deep copy of arrays, dictionaries and byte arrays.
    static function copyOf(value as Application.Storage.ValueType) as Application.Storage.ValueType {
        if (value instanceof ByteArray) {
            return Bytes.copy(value);
        }
        if (value instanceof Array) {
            var out = [] as Array<Application.Storage.ValueType>;
            for (var i = 0; i < value.size(); i++) {
                out.add(copyOf(value[i] as Application.Storage.ValueType));
            }
            return out;
        }
        if (value instanceof Dictionary) {
            var out = {} as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>;
            var keys = value.keys();
            for (var i = 0; i < keys.size(); i++) {
                out[keys[i] as Application.Storage.KeyType] = copyOf(value[keys[i]] as Application.Storage.ValueType);
            }
            return out;
        }
        return value;
    }
}

// A clock the test sets by hand.
(:test)
class FakeTime extends TimeSource {

    var trusted as Long? = 1800000000l;
    var uptime as Number = 1000;

    function initialize() {
        TimeSource.initialize();
    }

    function trustedUnix() as Long? {
        return trusted;
    }

    function uptimeMs() as Number {
        return uptime;
    }

    // Advance both clocks (the trusted one only when it is synced).
    function advance(seconds as Number) as Void {
        uptime += seconds * 1000;
        var t = trusted;
        if (t != null) {
            trusted = t + seconds;
        }
    }
}

(:test)
module StorageFixtures {

    const PIN = "2468"; // KeyManager.PIN_DIGITS digits, like every new PIN
    const OTHER_PIN = "1357";
    const LEGACY_PIN = "246810"; // a 6-digit PIN from before the 4-digit rule
    const ITERATIONS = 2000; // Kdf.MIN_ITERATIONS: the cheapest allowed

    // A fake TOTP entry (the secret is the RFC 4226 test key, not a real one).
    function totp(name as String) as Entry {
        var e = new Entry();
        e.kind = Entry.KIND_OTP;
        e.type = Entry.OTP_TOTP;
        e.name = name;
        e.account = "alice@example.com";
        e.group = "Work";
        e.algorithm = Hashes.SHA256;
        e.digits = 8;
        e.period = 60;
        e.secret = Bytes.fromUtf8("12345678901234567890");
        e.created = 1700000000l;
        return e;
    }

    function hotp(name as String, counter as Long) as Entry {
        var e = totp(name);
        e.type = Entry.OTP_HOTP;
        e.algorithm = Hashes.SHA1;
        e.digits = 6;
        e.period = 30;
        e.counter = counter;
        return e;
    }

    function steam(name as String) as Entry {
        var e = totp(name);
        e.type = Entry.OTP_STEAM;
        e.algorithm = Hashes.SHA1;
        e.digits = 6;
        e.period = 30;
        return e;
    }

    function code(name as String) as Entry {
        var e = new Entry();
        e.kind = Entry.KIND_CODE;
        e.type = 3;
        e.name = name;
        e.payload = Bytes.fromUtf8("CARD-0000-1111-2222 éß");
        e.options = 0x102;
        e.source = Entry.SOURCE_IMPORT;
        e.favorite = true;
        return e;
    }

    function pin(text as String) as ByteArray {
        return Bytes.fromUtf8(text);
    }

    // Set the first PIN (leaves the session unlocked).
    function setPin(vault as Vault, text as String) as Void {
        var job = vault.beginSetPin(pin(text), ITERATIONS);
        Jobs.runToCompletion(job, Kdf.ITERATIONS_PER_STEP);
        vault.finishSetPin(job);
    }

    // Store a wrap the way versions before the 4-digit rule did: any 4 to
    // 10 digits and no PIN-length marker. The session stays locked.
    function setLegacyPin(store as MemoryBackend, text as String) as Void {
        var keyManager = new KeyManager(store);
        var job = new PinJob(pin(text), new [KeyManager.SALT_BYTES]b, ITERATIONS);
        Jobs.runToCompletion(job, Kdf.ITERATIONS_PER_STEP);
        keyManager.storeWrap(job, keyManager.newMasterKey());
        store.deleteValue(KeyManager.PIN_LENGTH_KEY);
    }

    // True when the store holds the 4-digit PIN marker.
    function pinMarked(store as MemoryBackend) as Boolean {
        var marker = store.getValue(KeyManager.PIN_LENGTH_KEY);
        return marker instanceof Number && marker == KeyManager.PIN_DIGITS;
    }

    // One unlock attempt; true when the PIN was right.
    function unlock(vault as Vault, text as String) as Boolean {
        var job = vault.beginUnlock(pin(text));
        Jobs.runToCompletion(job, Kdf.ITERATIONS_PER_STEP);
        return vault.finishUnlock(job);
    }

    // True when the two entries hold the same fields (secrets included).
    function same(logger as Test.Logger, a as Entry, b as Entry) as Boolean {
        var problems = [] as Array<String>;
        if (!a.id.equals(b.id)) { problems.add("id"); }
        if (a.kind != b.kind || a.type != b.type) { problems.add("kind/type"); }
        if (!a.name.equals(b.name) || !a.account.equals(b.account) || !a.group.equals(b.group)) { problems.add("names"); }
        if (a.locked != b.locked || a.source != b.source || a.favorite != b.favorite) { problems.add("flags"); }
        if (a.counter != b.counter || a.created != b.created) { problems.add("counter/created"); }
        if (a.namesEncrypted != b.namesEncrypted) { problems.add("namesEncrypted"); }
        if (a.algorithm != b.algorithm || a.digits != b.digits || a.period != b.period || a.options != b.options) {
            problems.add("parameters");
        }
        if (!sameOptional(a.secret, b.secret) || !sameOptional(a.payload, b.payload)) { problems.add("secret/payload"); }
        if (problems.size() > 0) {
            logger.error("entries differ in " + problems.toString());
            return false;
        }
        return true;
    }

    function sameOptional(a as ByteArray?, b as ByteArray?) as Boolean {
        if (a == null || b == null) {
            return a == null && b == null;
        }
        return Bytes.equal(a, b);
    }

    function allZero(data as ByteArray) as Boolean {
        for (var i = 0; i < data.size(); i++) {
            if (data[i] != 0) {
                return false;
            }
        }
        return true;
    }
}