diff options
Diffstat (limited to 'source/app/GlanceCode.mc')
| -rw-r--r-- | source/app/GlanceCode.mc | 177 |
1 files changed, 177 insertions, 0 deletions
diff --git a/source/app/GlanceCode.mc b/source/app/GlanceCode.mc new file mode 100644 index 0000000..0d1e51d --- /dev/null +++ b/source/app/GlanceCode.mc @@ -0,0 +1,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); + } +} |
