aboutsummaryrefslogtreecommitdiff
path: root/source/app/GlanceCode.mc
diff options
context:
space:
mode:
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);
+ }
+}