summaryrefslogtreecommitdiff
path: root/source/app/GlanceCode.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-21 03:55:10 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-21 03:55:10 +0200
commit5e8ac1e2f5a660e55ea132996e1d5730cfa4fa64 (patch)
tree9bb99bdca1ef8f33c93c5100816448681329d810 /source/app/GlanceCode.mc
parent88f1cac4fa530fc30fd6ef01970af11c7a959b8e (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'source/app/GlanceCode.mc')
-rw-r--r--source/app/GlanceCode.mc177
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);
+ }
+}