aboutsummaryrefslogtreecommitdiff
path: root/source/storage/KeyManager.mc
blob: 9b2b3c6a5cd92a839c1acca6601e487dc0e25066 (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
// Author:  cirakg & claude ai
// Summary: The device key and the PIN-wrapped locked master key: creation, PIN derivation jobs, wrap and unwrap.
//
// Keys (README 7.1, 7.2):
//   * Device key "dk": 32 random bytes, created on first use, stored as is.
//     It seals unlocked entries, pending items and the import buffer.
//   * Locked master key (LMK): 32 random bytes, stored only wrapped under
//     the PIN key, in "w".
//   * PIN key = PBKDF2-HMAC-SHA256(PIN as ASCII digits, salt, n, 32 bytes).
//
// Wrap format, version 1 (one ByteArray under "w"):
//     salt (16 bytes) | n (4 bytes, big-endian) | SealedBox(Keys(PIN key), aad, LMK)
//     aad = "OTPW-WRAP-1" || salt || n
// A correct PIN is exactly "the box verifies"; no PIN hash exists. The salt
// and iteration count are in the aad, so changing either fails the MAC.
// Changing the PIN re-wraps the same LMK; entries aren't re-encrypted.
//
// PBKDF2 runs as a PinJob (a Jobs.Job) because one derivation is calibrated
// to about a second, far beyond one event's watchdog budget.

import Toybox.Cryptography;
import Toybox.Lang;

class KeyManager {

    static const DEVICE_KEY = "dk";
    static const WRAP_KEY = "w";
    static const KEY_BYTES = 32;
    static const SALT_BYTES = 16;
    static const WRAP_LABEL = "OTPW-WRAP-1";
    static const MIN_PIN_DIGITS = 4;
    static const MAX_PIN_DIGITS = 10;

    private var _store as Persist.Backend;
    private var _deviceKeys as SealedBox.Keys? = null;
    private var _fingerprintKey as ByteArray? = null;

    static const FINGERPRINT_BYTES = 8;

    function initialize(store as Persist.Backend) {
        _store = store;
    }

    // The device key's derived keys, creating the device key on first use.
    function deviceKeys() as SealedBox.Keys {
        var keys = _deviceKeys;
        if (keys != null) {
            return keys;
        }
        var stored = _store.getValue(DEVICE_KEY);
        var raw = null;
        if (stored instanceof ByteArray && stored.size() == KEY_BYTES) {
            raw = stored;
        } else {
            // First start (or a lost key, which makes unlocked records
            // unreadable; they then show as damaged).
            raw = Cryptography.randomBytes(KEY_BYTES);
            _store.setValue(DEVICE_KEY, raw);
        }
        keys = new SealedBox.Keys(raw);
        _fingerprintKey = Hkdf.derive(raw, []b, Bytes.fromUtf8("OTPW fp v1"), KEY_BYTES);
        Bytes.wipe(raw);
        _deviceKeys = keys;
        return keys;
    }

    // An 8-byte keyed fingerprint of an entry's kind, type and secret (or
    // payload): equal for duplicates, meaningless without the device key.
    // Stored in the readable header so imports can find duplicates of locked
    // entries while the watch is locked (docs/SECURITY.md).
    function fingerprint(entry as Entry) as ByteArray {
        deviceKeys();
        var input = Bytes.fromUtf8("OTPW-FP-1");
        input.add(entry.kind);
        input.add(entry.type);
        var material = entry.kind == Entry.KIND_OTP ? entry.secret : entry.payload;
        if (material != null) {
            input.addAll(material);
        }
        var mac = Hmac.nativeSha256(_fingerprintKey as ByteArray, input);
        Bytes.wipe(input);
        var out = mac.slice(0, FINGERPRINT_BYTES);
        Bytes.wipe(mac);
        return out;
    }

    // A keyed hash (16 hex characters) of a settings form's content, for
    // the applied-forms log (README 10.3). Keyed like the fingerprints, with
    // its own label, because the content includes secrets. The parts are
    // fed to the native HMAC one by one, each preceded by `separator`, so a
    // large import form costs no byte loop here (the watchdog).
    function formHash(parts as Array<ByteArray>, separator as Number) as String {
        deviceKeys();
        var mac = new Cryptography.HashBasedMessageAuthenticationCode({
            :algorithm => Cryptography.HASH_SHA256,
            :key => _fingerprintKey as ByteArray
        });
        mac.update(Bytes.fromUtf8("OTPW-FH-1"));
        for (var i = 0; i < parts.size(); i++) {
            mac.update([separator]b);
            if (parts[i].size() > 0) {
                mac.update(parts[i]);
            }
        }
        var digest = mac.digest();
        var out = Bytes.toHex(digest.slice(0, FINGERPRINT_BYTES));
        Bytes.wipe(digest);
        return out;
    }

    // True when a PIN has been set.
    function hasPin() as Boolean {
        return _store.getValue(WRAP_KEY) != null;
    }

    // A fresh random master key (when the first PIN is created).
    function newMasterKey() as ByteArray {
        return Cryptography.randomBytes(KEY_BYTES);
    }

    // True for 4 to 10 ASCII digits.
    static function isValidPin(pin as ByteArray) as Boolean {
        if (pin.size() < MIN_PIN_DIGITS || pin.size() > MAX_PIN_DIGITS) {
            return false;
        }
        for (var i = 0; i < pin.size(); i++) {
            if (pin[i] < 0x30 || pin[i] > 0x39) {
                return false;
            }
        }
        return true;
    }

    // The derivation for checking `pin` against the stored wrap. Throws
    // NO_PIN when no PIN is set, DAMAGED when the wrap is malformed.
    function unlockJob(pin as ByteArray) as PinJob {
        var wrap = _store.getValue(WRAP_KEY);
        if (wrap == null) {
            throw new VaultError(VaultError.NO_PIN, "no PIN set");
        }
        if (!(wrap instanceof ByteArray) || wrap.size() < SALT_BYTES + 4 + SealedBox.MIN_SIZE) {
            throw new VaultError(VaultError.DAMAGED, "PIN data damaged");
        }
        var iterations = wrap.decodeNumber(Lang.NUMBER_FORMAT_UINT32, {:offset => SALT_BYTES, :endianness => Lang.ENDIAN_BIG}) as Number;
        if (iterations < Kdf.MIN_ITERATIONS || iterations > Kdf.MAX_ITERATIONS) {
            // Also stops a tampered count from making the derivation endless.
            throw new VaultError(VaultError.DAMAGED, "PIN data damaged");
        }
        return new PinJob(pin, wrap.slice(0, SALT_BYTES), iterations);
    }

    // The derivation for a new PIN, with a fresh salt.
    function newPinJob(pin as ByteArray, iterations as Number) as PinJob {
        if (iterations < Kdf.MIN_ITERATIONS || iterations > Kdf.MAX_ITERATIONS) {
            throw new Lang.InvalidValueException("PIN: iteration count out of range");
        }
        return new PinJob(pin, Cryptography.randomBytes(SALT_BYTES), iterations);
    }

    // After a finished unlockJob: the master key, or null for a wrong PIN.
    // Wipes the PIN key.
    function unwrap(job as PinJob) as ByteArray? {
        var wrap = _store.getValue(WRAP_KEY);
        if (!(wrap instanceof ByteArray) || wrap.size() < SALT_BYTES + 4 + SealedBox.MIN_SIZE) {
            job.wipe();
            return null;
        }
        var keys = job.keys();
        var masterKey = SealedBox.open(keys, wrapAad(job), wrap.slice(SALT_BYTES + 4, wrap.size()));
        keys.wipe();
        job.wipe();
        if (masterKey != null && masterKey.size() != KEY_BYTES) {
            Bytes.wipe(masterKey);
            return null;
        }
        return masterKey;
    }

    // After a finished newPinJob: wrap masterKey under the new PIN and store
    // it, replacing any previous wrap. Wipes the PIN key, not masterKey.
    function storeWrap(job as PinJob, masterKey as ByteArray) as Void {
        var keys = job.keys();
        var wrap = Bytes.copy(job.salt);
        wrap.addAll(Bytes.uint32BigEndian(job.iterations));
        wrap.addAll(SealedBox.seal(keys, wrapAad(job), masterKey));
        keys.wipe();
        job.wipe();
        _store.setValue(WRAP_KEY, wrap);
    }

    // Forget the PIN (reset or erase). The master key is then gone for good.
    function deleteWrap() as Void {
        _store.deleteValue(WRAP_KEY);
    }

    private function wrapAad(job as PinJob) as ByteArray {
        var aad = Bytes.fromUtf8(WRAP_LABEL);
        aad.addAll(job.salt);
        aad.addAll(Bytes.uint32BigEndian(job.iterations));
        return aad;
    }
}

// PBKDF2 of a PIN as a resumable job, remembering its salt and count.
class PinJob extends Jobs.Job {

    var salt as ByteArray;
    var iterations as Number;
    private var _pbkdf2 as Kdf.Pbkdf2;

    function initialize(pin as ByteArray, salt as ByteArray, iterations as Number) {
        Job.initialize();
        if (!KeyManager.isValidPin(pin)) {
            throw new Lang.InvalidValueException("PIN: 4 to 10 digits");
        }
        self.salt = salt;
        self.iterations = iterations;
        _pbkdf2 = new Kdf.Pbkdf2(pin, salt, iterations, KeyManager.KEY_BYTES);
    }

    function step(budget as Number) as Boolean {
        return _pbkdf2.step(budget);
    }

    // The wrap keys derived from the PIN key; only after step() returned true.
    function keys() as SealedBox.Keys {
        var pinKey = _pbkdf2.result();
        var keys = new SealedBox.Keys(pinKey);
        Bytes.wipe(pinKey);
        return keys;
    }

    function wipe() as Void {
        _pbkdf2.wipe();
    }
}