From 5e8ac1e2f5a660e55ea132996e1d5730cfa4fa64 Mon Sep 17 00:00:00 2001 From: cirakg Date: Wed, 21 Oct 2026 03:55:10 +0200 Subject: feat(glance): show the favourite code in the glance (opt-in) With "Show the favourite code in the glance" on, the glance shows the code of the first favourite TOTP or Steam entry without the PIN, with a countdown bar. The glance can't open the vault, so the About scan seals that entry under "g" (device key, aad OTPW-GL-1) after every change, and removes it when the option is off or no entry qualifies; the glance also reads the option itself. HKDF moves to its own module so the glance loads only the crypto it needs (37 % of its 64 KB). Manage entry gains "Mark as favourite (glance)" and "Remove favourite", queued for locked entries like the other changes. Lines are inset on round screens so the Instinct 3's curved glance corner doesn't cut them. Co-Authored-By: Claude Opus 5.5 --- source/app/AppState.mc | 1 + source/app/Fixtures.mc | 12 +++- source/app/GlanceCode.mc | 177 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 source/app/GlanceCode.mc (limited to 'source/app') diff --git a/source/app/AppState.mc b/source/app/AppState.mc index fb56689..d7db6dc 100644 --- a/source/app/AppState.mc +++ b/source/app/AppState.mc @@ -99,6 +99,7 @@ module AppState { function applyOptions(v as Vault) as Void { v.eraseAfterFailures = options.eraseAfterFailures; v.encryptNames = options.encryptNames; + v.glanceCode = options.glanceCode; v.session.autoLockMs = TestMode.ON ? TestMode.AUTO_LOCK_MS : options.autoLockMs; } diff --git a/source/app/Fixtures.mc b/source/app/Fixtures.mc index 463780c..7c21d9a 100644 --- a/source/app/Fixtures.mc +++ b/source/app/Fixtures.mc @@ -36,7 +36,9 @@ module Fixtures { vault.session.unlock(Bytes.fromHex(FIXTURE_MASTER_KEY)); } var rfcKey = "12345678901234567890"; // lint: allow-literal (RFC 4226 test key) - vault.add(otp("GitHub", "alice@example.com", "Work", Entry.OTP_TOTP, Hashes.SHA1, 6, 30, rfcKey, false)); + var github = otp("GitHub", "alice@example.com", "Work", Entry.OTP_TOTP, Hashes.SHA1, 6, 30, rfcKey, false); + github.favorite = true; // the glance code (UI test: optGlanceCode is on) + vault.add(github); vault.add(otp("Google", "bob@example.com", "Personal", Entry.OTP_TOTP, Hashes.SHA256, 8, 30, "12345678901234567890123456789012", false)); // lint: allow-literal (RFC 6238 test key) vault.add(otp("Bank", "carol", "Finance", Entry.OTP_TOTP, Hashes.SHA512, 6, 30, @@ -71,14 +73,18 @@ module Fixtures { } } - // UI-test build: the other forms, filled in as the phone would (README - // 16, M7 end-to-end): Add OTP (a TOTP whose code the screenshot shows), + // UI-test build: the glance option, and the other forms, filled in as + // the phone would (README 16, M7 end-to-end): Add OTP (a TOTP whose code the screenshot shows), // Add code (a Code 128 card whose screenshot must decode) and Manage // entry (Bank's group renamed: Bank is locked, so the change waits for // the PIN, which the UI flow confirms from the menu). They are applied // after the import, in README 10.3 order, so the new entries end the // list. function sendForms(vault as Vault) as Void { + // The glance shows GitHub's code after the flow (the About scan + // seals it for the glance). + Application.Properties.setValue("optGlanceCode", true); + vault.glanceCode = true; Application.Properties.setValue("otpType", Entry.OTP_TOTP); Application.Properties.setValue("otpIssuer", "Mailbox"); Application.Properties.setValue("otpAccount", "fixture@example.com"); 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); + } +} -- cgit v1.2.3