summaryrefslogtreecommitdiff
path: root/source/app
diff options
context:
space:
mode:
Diffstat (limited to 'source/app')
-rw-r--r--source/app/AppState.mc1
-rw-r--r--source/app/Fixtures.mc12
-rw-r--r--source/app/GlanceCode.mc177
3 files changed, 187 insertions, 3 deletions
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);
+ }
+}