// 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); } }