summaryrefslogtreecommitdiff
path: root/source/app/GlanceCode.mc
blob: 0d1e51d798da470c8a4365fcc10bd156d53f2e86 (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
// Author:  cirakg & claude ai
// Summary: The opt-in favourite code in the glance: one entry sealed by the app (GlanceStore), opened by the glance (GlanceCode).
//
// README 13.1: with "Show the favourite code in the glance" on, the glance
// shows the code of the first favourite TOTP or Steam entry that doesn't
// require the PIN (favourites come from Aegis, or the Manage form's "Mark
// as favourite"). The glance has 64 KB and can't open the vault, so the app
// stores that one entry under "g", after every About scan (which runs when
// the entries or the option change):
//     SealedBox(device keys, aad "OTPW-GL-1", plaintext)
//     plaintext = FORMAT (1) | type | algorithm | digits | period (2 bytes,
//                 big-endian) | label length | label (UTF-8) | secret
// and deletes it when the option is off or no entry qualifies. That is the
// same protection the entry itself has (device key, docs/SECURITY.md). The
// glance also checks the option itself, so turning it off on the phone
// hides the code at once, before the app has run again.

import Toybox.Application;
import Toybox.Lang;

(:glance)
module GlanceCode {

    const KEY = "g";
    const AAD = "OTPW-GL-1";
    const FORMAT = 1;
    const OPTION = "optGlanceCode";
    // KeyManager.DEVICE_KEY (that class isn't available to the glance).
    const DEVICE_KEY = "dk";
    // Entry.OTP_STEAM (checked by test/ui/GlanceCodeTest.mc).
    const TYPE_STEAM = 2;
    const HEADER_BYTES = 7;

    // What the glance needs to show a code.
    class Item {
        var label as String;
        var type as Number;
        var algorithm as Hashes.Algorithm;
        var digits as Number;
        var period as Number;
        var secret as ByteArray;

        function initialize(name as String, otpType as Number, alg as Hashes.Algorithm, length as Number, seconds as Number,
                key as ByteArray) {
            label = name;
            type = otpType;
            algorithm = alg;
            digits = length;
            period = seconds;
            secret = key;
        }

        function counter(now as Long) as Long {
            return Otp.totpCounter(now, type == TYPE_STEAM ? Otp.STEAM_PERIOD : period);
        }

        function secondsLeft(now as Long) as Number {
            return Otp.secondsRemaining(now, type == TYPE_STEAM ? Otp.STEAM_PERIOD : period);
        }

        function periodSeconds() as Number {
            return type == TYPE_STEAM ? Otp.STEAM_PERIOD : period;
        }

        // The code at unix time `now`.
        function code(now as Long) as String {
            return type == TYPE_STEAM ? Otp.steam(secret, now) : Otp.totp(algorithm, secret, now, period, digits);
        }

        function wipe() as Void {
            Bytes.wipe(secret);
        }
    }

    // The plaintext of an item (the app seals it).
    function encode(label as String, type as Number, algorithm as Number, digits as Number, period as Number,
            secret as ByteArray) as ByteArray {
        var name = Bytes.fromUtf8(label);
        if (name.size() > 255) {
            name = name.slice(0, 255);
        }
        var plain = [FORMAT, type, algorithm, digits, (period >> 8) & 0xFF, period & 0xFF, name.size()]b;
        plain.addAll(name);
        plain.addAll(secret);
        return plain;
    }

    // The sealed item, or null when anything is missing, damaged or sealed
    // with another key.
    function open(deviceKey as ByteArray?, sealed as ByteArray?) as Item? {
        if (deviceKey == null || sealed == null || deviceKey.size() != 32) {
            return null;
        }
        var keys = new SealedBox.Keys(deviceKey);
        var plain = SealedBox.open(keys, Bytes.fromUtf8(AAD), sealed);
        keys.wipe();
        if (plain == null) {
            return null;
        }
        var item = decode(plain);
        Bytes.wipe(plain);
        return item;
    }

    function decode(plain as ByteArray) as Item? {
        if (plain.size() < HEADER_BYTES || plain[0] != FORMAT || !Hashes.isValid(plain[2])) {
            return null;
        }
        var nameEnd = HEADER_BYTES + plain[6];
        if (plain.size() <= nameEnd || plain[3] < Otp.MIN_DIGITS || plain[3] > Otp.MAX_DIGITS) {
            return null;
        }
        var period = (plain[4] << 8) | plain[5];
        if (period < Otp.MIN_PERIOD || period > Otp.MAX_PERIOD) {
            return null;
        }
        var label = Bytes.toUtf8(plain.slice(HEADER_BYTES, nameEnd));
        return new Item(label != null ? label : "", plain[1], plain[2] as Hashes.Algorithm, plain[3], period,
            plain.slice(nameEnd, plain.size()));
    }

    // The glance: the option is on and the app stored an item.
    function load() as Item? {
        if (!enabled()) {
            return null;
        }
        var dk = Application.Storage.getValue(DEVICE_KEY);
        var sealed = Application.Storage.getValue(KEY);
        return open(dk instanceof ByteArray ? dk : null, sealed instanceof ByteArray ? sealed : null);
    }

    function enabled() as Boolean {
        try {
            var v = Application.Properties.getValue(OPTION);
            return v instanceof Boolean && v;
        } catch (e instanceof Lang.Exception) {
            return false;
        }
    }

    // "123 456", "1234 5678", "1234 567"; Steam's five letters as they are.
    function grouped(code as String) as String {
        var n = code.length();
        if (n < 6) {
            return code;
        }
        var split = n == 6 ? 3 : 4;
        return code.substring(0, split) + " " + code.substring(split, n);
    }
}

// The app side: choose and seal the glance entry (AboutScan calls update).
module GlanceStore {

    // True when an entry header can be the glance code: a favourite TOTP or
    // Steam entry without the PIN.
    function qualifies(header as Entry) as Boolean {
        return header.favorite && !header.locked && header.kind == Entry.KIND_OTP && header.type != Entry.OTP_HOTP;
    }

    // Store entry `id` as the glance item, or delete the item when id is
    // null or the option is off.
    function update(vault as Vault, id as String?) as Void {
        var store = vault.storage();
        if (id == null || !vault.glanceCode) {
            store.deleteValue(GlanceCode.KEY);
            return;
        }
        var entry = vault.open(id);
        var plain = GlanceCode.encode(entry.name.length() > 0 ? entry.name : entry.account, entry.type, entry.algorithm,
            entry.digits, entry.period, entry.secret as ByteArray);
        entry.wipeSecrets();
        var sealed = SealedBox.seal(vault.keyManager.deviceKeys(), Bytes.fromUtf8(GlanceCode.AAD), plain);
        Bytes.wipe(plain);
        store.setValue(GlanceCode.KEY, sealed);
    }
}