aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--resources/settings/settings.xml2
-rw-r--r--resources/strings/strings.xml2
-rw-r--r--source/app/AppState.mc1
-rw-r--r--source/app/Fixtures.mc12
-rw-r--r--source/app/GlanceCode.mc177
-rw-r--r--source/crypto/Aes.mc2
-rw-r--r--source/crypto/Hashes.mc2
-rw-r--r--source/crypto/Hkdf.mc39
-rw-r--r--source/crypto/Hmac.mc2
-rw-r--r--source/crypto/Kdf.mc33
-rw-r--r--source/crypto/SealedBox.mc6
-rw-r--r--source/crypto/SelfTest.mc2
-rw-r--r--source/crypto/Sha512.mc2
-rw-r--r--source/otp/Otp.mc2
-rw-r--r--source/settings/AboutScan.mc20
-rw-r--r--source/settings/ManageForm.mc25
-rw-r--r--source/storage/KeyManager.mc2
-rw-r--r--source/storage/Vault.mc3
-rw-r--r--source/ui/GlanceView.mc128
-rw-r--r--source/util/Bytes.mc2
-rw-r--r--source/util/Clock.mc2
-rw-r--r--source/util/Diag.mc12
-rw-r--r--source/util/TestMode.mc4
-rw-r--r--test/crypto/KdfAesTest.mc2
-rw-r--r--test/ui/GlanceCodeTest.mc151
25 files changed, 581 insertions, 54 deletions
diff --git a/resources/settings/settings.xml b/resources/settings/settings.xml
index d6ae008..d6a9710 100644
--- a/resources/settings/settings.xml
+++ b/resources/settings/settings.xml
@@ -126,6 +126,8 @@
<listEntry value="4">@Strings.ActReplace</listEntry>
<listEntry value="5">@Strings.ActCounter</listEntry>
<listEntry value="6">@Strings.ActDelete</listEntry>
+ <listEntry value="7">@Strings.ActFavorite</listEntry>
+ <listEntry value="8">@Strings.ActUnfavorite</listEntry>
</settingConfig>
</setting>
<setting propertyKey="@Properties.mgName" title="@Strings.MgName">
diff --git a/resources/strings/strings.xml b/resources/strings/strings.xml
index 32f6cc6..858f8eb 100644
--- a/resources/strings/strings.xml
+++ b/resources/strings/strings.xml
@@ -79,6 +79,8 @@
<string id="ActReplace">Replace secret or contents</string>
<string id="ActCounter">Set HOTP counter</string>
<string id="ActDelete">Delete</string>
+ <string id="ActFavorite">Mark as favourite (glance)</string>
+ <string id="ActUnfavorite">Remove favourite</string>
<string id="MgName">New name (blank: unchanged)</string>
<string id="MgAccount">New account (blank: unchanged, - clears)</string>
<string id="MgGroup">New group (blank: unchanged, - clears)</string>
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);
+ }
+}
diff --git a/source/crypto/Aes.mc b/source/crypto/Aes.mc
index e77cb86..242448b 100644
--- a/source/crypto/Aes.mc
+++ b/source/crypto/Aes.mc
@@ -15,6 +15,8 @@
import Toybox.Cryptography;
import Toybox.Lang;
+// (:glance): also loaded by the glance, which shows the favourite code (GlanceCode).
+(:glance)
module Aes {
const BLOCK_BYTES = 16;
diff --git a/source/crypto/Hashes.mc b/source/crypto/Hashes.mc
index 14e2cf2..321ea2d 100644
--- a/source/crypto/Hashes.mc
+++ b/source/crypto/Hashes.mc
@@ -9,6 +9,8 @@
import Toybox.Cryptography;
import Toybox.Lang;
+// (:glance): also loaded by the glance, which shows the favourite code (GlanceCode).
+(:glance)
module Hashes {
// OTP and HMAC hash algorithms. The values are stored in entry records, so
diff --git a/source/crypto/Hkdf.mc b/source/crypto/Hkdf.mc
new file mode 100644
index 0000000..97814b9
--- /dev/null
+++ b/source/crypto/Hkdf.mc
@@ -0,0 +1,39 @@
+// Author: cirakg & claude ai
+// Summary: HKDF-SHA256 (RFC 5869) on the native HMAC, small enough for the glance.
+//
+// The key derivation SealedBox and KeyManager use for every sub-key. It
+// lives apart from Kdf (PBKDF2, which needs the job system) so the glance
+// can open the sealed favourite code (GlanceCode) with only the crypto it
+// needs. Verified with RFC 5869 test cases 1 to 3 (test/crypto/KdfAesTest.mc)
+// and in the start-up self-test.
+
+import Toybox.Lang;
+
+(:glance)
+module Hkdf {
+
+ const HASH_BYTES = 32;
+
+ // Extract with salt (empty means 32 zero bytes), then expand to
+ // `length` bytes with info. length <= 255 * 32.
+ function derive(ikm as ByteArray, salt as ByteArray, info as ByteArray, length as Number) as ByteArray {
+ if (length < 1 || length > 255 * HASH_BYTES) {
+ throw new Lang.InvalidValueException("HKDF: bad key length");
+ }
+ var prk = Hmac.nativeSha256(salt.size() > 0 ? salt : new [HASH_BYTES]b, ikm);
+ var okm = new [0]b;
+ var block = new [0]b;
+ for (var counter = 1; okm.size() < length; counter++) {
+ var input = Bytes.concat(block, info);
+ input.add(counter);
+ Bytes.wipe(block);
+ block = Hmac.nativeSha256(prk, input);
+ okm.addAll(block);
+ }
+ Bytes.wipe(prk);
+ Bytes.wipe(block);
+ var key = okm.slice(0, length);
+ Bytes.wipe(okm);
+ return key;
+ }
+}
diff --git a/source/crypto/Hmac.mc b/source/crypto/Hmac.mc
index ff620a4..736d2d4 100644
--- a/source/crypto/Hmac.mc
+++ b/source/crypto/Hmac.mc
@@ -16,6 +16,8 @@
import Toybox.Cryptography;
import Toybox.Lang;
+// (:glance): also loaded by the glance, which shows the favourite code (GlanceCode).
+(:glance)
module Hmac {
const IPAD = 0x36;
diff --git a/source/crypto/Kdf.mc b/source/crypto/Kdf.mc
index 5bace1c..e64c8ff 100644
--- a/source/crypto/Kdf.mc
+++ b/source/crypto/Kdf.mc
@@ -1,5 +1,5 @@
// Author: cirakg & claude ai
-// Summary: Key derivation: resumable PBKDF2-HMAC-SHA256 (RFC 8018) with calibration, and HKDF-SHA256 (RFC 5869).
+// Summary: PIN key derivation: resumable PBKDF2-HMAC-SHA256 (RFC 8018) with calibration.
//
// PBKDF2 turns the PIN into a key (README 7.2). It runs as a Jobs.Job, a
// bounded number of iterations per step, because the iteration count is
@@ -7,11 +7,11 @@
// watchdog budget. It uses the native HMAC-SHA256: the object keeps the key
// and digest() resets it, so each iteration is one native call.
//
-// HKDF splits one master key into separate encryption and MAC keys
-// (README 7.3).
+// HKDF, which splits one master key into separate encryption and MAC keys
+// (README 7.3), is in Hkdf.mc (the glance needs it, but not PBKDF2).
//
-// Verified with RFC 7914 section 11 (PBKDF2) and RFC 5869 A.1-A.3 (HKDF),
-// plus seeded vectors from Python's hashlib (test/crypto/KdfTest.mc).
+// Verified with RFC 7914 section 11 (PBKDF2) plus seeded vectors from
+// Python's hashlib (test/crypto/KdfAesTest.mc).
import Toybox.Cryptography;
import Toybox.Lang;
@@ -191,27 +191,4 @@ module Kdf {
}
return estimate > MAX_ITERATIONS ? MAX_ITERATIONS : estimate.toNumber();
}
-
- // HKDF-SHA256 (RFC 5869): extract with salt (empty means 32 zero bytes),
- // then expand to `length` bytes with info. length <= 255 * 32.
- function hkdf(ikm as ByteArray, salt as ByteArray, info as ByteArray, length as Number) as ByteArray {
- if (length < 1 || length > 255 * SHA256_BYTES) {
- throw new Lang.InvalidValueException("HKDF: bad key length");
- }
- var prk = Hmac.nativeSha256(salt.size() > 0 ? salt : new [SHA256_BYTES]b, ikm);
- var okm = new [0]b;
- var block = new [0]b;
- for (var counter = 1; okm.size() < length; counter++) {
- var input = Bytes.concat(block, info);
- input.add(counter);
- Bytes.wipe(block);
- block = Hmac.nativeSha256(prk, input);
- okm.addAll(block);
- }
- Bytes.wipe(prk);
- Bytes.wipe(block);
- var key = okm.slice(0, length);
- Bytes.wipe(okm);
- return key;
- }
}
diff --git a/source/crypto/SealedBox.mc b/source/crypto/SealedBox.mc
index 0804b96..765a1d8 100644
--- a/source/crypto/SealedBox.mc
+++ b/source/crypto/SealedBox.mc
@@ -20,6 +20,8 @@
import Toybox.Cryptography;
import Toybox.Lang;
+// (:glance): also loaded by the glance, which shows the favourite code (GlanceCode).
+(:glance)
module SealedBox {
const VERSION = 1;
@@ -34,8 +36,8 @@ module SealedBox {
var macKey as ByteArray;
function initialize(masterKey as ByteArray) {
- encKey = Kdf.hkdf(masterKey, []b, Bytes.fromUtf8("OTPW enc v1"), KEY_BYTES);
- macKey = Kdf.hkdf(masterKey, []b, Bytes.fromUtf8("OTPW mac v1"), KEY_BYTES);
+ encKey = Hkdf.derive(masterKey, []b, Bytes.fromUtf8("OTPW enc v1"), KEY_BYTES);
+ macKey = Hkdf.derive(masterKey, []b, Bytes.fromUtf8("OTPW mac v1"), KEY_BYTES);
}
// Wipe both keys (when the session locks).
diff --git a/source/crypto/SelfTest.mc b/source/crypto/SelfTest.mc
index 0d7eae4..87515b6 100644
--- a/source/crypto/SelfTest.mc
+++ b/source/crypto/SelfTest.mc
@@ -54,7 +54,7 @@ module SelfTest {
for (var i = 0; i < 22; i++) {
ikm[i] = 0x0b;
}
- if (!check(Kdf.hkdf(ikm, []b, []b, 42), "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8")) { // lint: allow-literal
+ if (!check(Hkdf.derive(ikm, []b, []b, 42), "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8")) { // lint: allow-literal
return "HKDF";
}
// RFC 4226 Appendix D, counter 0.
diff --git a/source/crypto/Sha512.mc b/source/crypto/Sha512.mc
index 4248462..37e6607 100644
--- a/source/crypto/Sha512.mc
+++ b/source/crypto/Sha512.mc
@@ -16,6 +16,8 @@
import Toybox.Lang;
+// (:glance): also loaded by the glance, which shows the favourite code (GlanceCode).
+(:glance)
module Sha512 {
// Round constants: the first 64 bits of the fractional parts of the cube
diff --git a/source/otp/Otp.mc b/source/otp/Otp.mc
index 8a2992b..c6cfee8 100644
--- a/source/otp/Otp.mc
+++ b/source/otp/Otp.mc
@@ -18,6 +18,8 @@
import Toybox.Lang;
+// (:glance): also loaded by the glance, which shows the favourite code (GlanceCode).
+(:glance)
module Otp {
const MIN_DIGITS = 6;
diff --git a/source/settings/AboutScan.mc b/source/settings/AboutScan.mc
index 8fc898a..559cce9 100644
--- a/source/settings/AboutScan.mc
+++ b/source/settings/AboutScan.mc
@@ -1,5 +1,5 @@
// Author: cirakg & claude ai
-// Summary: Writes the read-only About fields (catalog, version, storage use) and applies "Encrypt names" to existing entries.
+// Summary: Writes the read-only About fields (catalog, version, storage use), applies "Encrypt names" to existing entries and updates the glance code.
//
// One pass over every entry header, a few per step:
// * catalog: "K7F2 GitHub (alice) · 9QXR Gym card · ...", so the Manage
@@ -11,8 +11,11 @@
// "Encrypt names" option are re-sealed, one per step. Locked ones need
// the session; if one can't be done now, the next scan retries (after
// the next PIN entry, which runs the processor again).
+// * The glance code (GlanceCode.mc): the first favourite TOTP or Steam
+// entry without the PIN is sealed for the glance, or the glance item
+// removed when there is none or the option is off.
// The scan only runs when something it reports changed: a form was
-// applied, or the signature of the entry list (IDs, pending, option,
+// applied, or the signature of the entry list (IDs, pending, options,
// version) differs from the last complete scan.
import Toybox.Lang;
@@ -35,6 +38,7 @@ class AboutScan extends Jobs.Job {
private var _left as Number = 0;
private var _bytes as Number = 0;
private var _incomplete as Boolean = false;
+ private var _glanceId as String? = null;
function initialize(vault as Vault, store as Forms.Store) {
Job.initialize();
@@ -55,6 +59,7 @@ class AboutScan extends Jobs.Job {
content.add(vault.ids().toString());
content.add(vault.pendingIds().toString());
content.add(vault.encryptNames);
+ content.add(vault.glanceCode);
content.add(ChangeQueue.count(vault));
content.add(AppInfo.VERSION);
return Forms.hash(vault, content);
@@ -102,6 +107,9 @@ class AboutScan extends Jobs.Job {
}
}
addItem(id + " " + describe(header) + (header.locked ? " (PIN)" : ""));
+ if (_glanceId == null && GlanceStore.qualifies(header)) {
+ _glanceId = id;
+ }
return converted;
}
@@ -156,6 +164,14 @@ class AboutScan extends Jobs.Job {
setIfChanged("catalog", catalog);
setIfChanged("storageUse", use);
setIfChanged("version", AppInfo.VERSION);
+ try {
+ GlanceStore.update(_vault, _glanceId);
+ } catch (e instanceof Lang.Exception) {
+ // Damaged entry or a full store: no glance code, and the scan
+ // runs again next time.
+ _vault.storage().deleteValue(GlanceCode.KEY);
+ _incomplete = true;
+ }
if (!_incomplete) {
try {
_vault.storage().setValue(SIGNATURE_KEY, signature(_vault));
diff --git a/source/settings/ManageForm.mc b/source/settings/ManageForm.mc
index b92c40d..a075880 100644
--- a/source/settings/ManageForm.mc
+++ b/source/settings/ManageForm.mc
@@ -6,8 +6,8 @@
// * ManageForm.read(): the mg* fields to a ManageAction or a problem.
// * ManageRun: a job that checks the action against the entry and then
// applies it, or queues it (ChangeQueue) when it touches a locked
-// entry: edit, replace, counter, "Don't require PIN" and delete wait
-// until the PIN is entered on the watch. "Require PIN" waits too while
+// entry: edit, replace, counter, "Don't require PIN", the favourite
+// mark and delete wait until the PIN is entered on the watch. "Require PIN" waits too while
// the watch is locked (it needs the master key). Moving never waits:
// the order isn't protected and a position is absolute, so applying a
// move twice can't hit another entry.
@@ -28,8 +28,11 @@ class ManageAction {
static const REPLACE = 4;
static const COUNTER = 5;
static const DELETE = 6;
+ static const FAVORITE = 7;
+ static const UNFAVORITE = 8;
- static const NAMES = ["Edit details", "Move", "Require PIN", "Don't require PIN", "Replace contents", "Set counter", "Delete"];
+ static const NAMES = ["Edit details", "Move", "Require PIN", "Don't require PIN", "Replace contents", "Set counter", "Delete",
+ "Mark as favourite", "Remove favourite"];
// Codec tags of the queued form.
static const T_ID = 1;
@@ -114,7 +117,7 @@ class ManageAction {
if (!(id instanceof String) || !(action instanceof Long) || !(position instanceof Long) || !(counter instanceof Long)) {
return null;
}
- if (action < EDIT || action > DELETE || !Entry.isValidId(id)) {
+ if (action < EDIT || action > UNFAVORITE || !Entry.isValidId(id)) {
return null;
}
a.id = id;
@@ -173,7 +176,7 @@ module ManageForm {
return "Entry ID: '" + Text.clip(a.id, 8) + "' is not an ID. IDs have 4 characters, like K7F2";
}
var action = Forms.number(store, "mgAction");
- if (action == null || action < ManageAction.EDIT || action > ManageAction.DELETE) {
+ if (action == null || action < ManageAction.EDIT || action > ManageAction.UNFAVORITE) {
return "Action: choose one from the list";
}
a.action = action;
@@ -334,6 +337,12 @@ class ManageRun extends Jobs.Job {
if (a.action == ManageAction.UNLOCK && !header.locked) {
return a.id + " doesn't require the PIN";
}
+ if (a.action == ManageAction.FAVORITE && header.favorite) {
+ return a.id + " is already a favourite";
+ }
+ if (a.action == ManageAction.UNFAVORITE && !header.favorite) {
+ return a.id + " is not a favourite";
+ }
if (a.action == ManageAction.REPLACE && isOtp && !a.decoded) {
var raw = a.material as ByteArray;
var text = Bytes.toUtf8(raw);
@@ -435,7 +444,8 @@ class ManageRun extends Jobs.Job {
return true;
}
- // Edit, lock, unlock or replace: open, change, re-seal. Null or the problem.
+ // Edit, lock, unlock, (un)favourite or replace: open, change, re-seal.
+ // Null or the problem.
private function changeEntry() as String? {
var a = _a;
var target = a.id + (a.label.length() > 0 ? " " + a.label : "");
@@ -453,6 +463,9 @@ class ManageRun extends Jobs.Job {
} else if (a.action == ManageAction.LOCK || a.action == ManageAction.UNLOCK) {
entry.locked = a.action == ManageAction.LOCK;
message = target + (entry.locked ? " now requires the PIN" : " no longer requires the PIN");
+ } else if (a.action == ManageAction.FAVORITE || a.action == ManageAction.UNFAVORITE) {
+ entry.favorite = a.action == ManageAction.FAVORITE;
+ message = target + (entry.favorite ? " is now a favourite" : " is no longer a favourite");
} else {
var material = a.material as ByteArray;
if (entry.kind == Entry.KIND_OTP) {
diff --git a/source/storage/KeyManager.mc b/source/storage/KeyManager.mc
index 47dbd59..9b2b3c6 100644
--- a/source/storage/KeyManager.mc
+++ b/source/storage/KeyManager.mc
@@ -58,7 +58,7 @@ class KeyManager {
_store.setValue(DEVICE_KEY, raw);
}
keys = new SealedBox.Keys(raw);
- _fingerprintKey = Kdf.hkdf(raw, []b, Bytes.fromUtf8("OTPW fp v1"), KEY_BYTES);
+ _fingerprintKey = Hkdf.derive(raw, []b, Bytes.fromUtf8("OTPW fp v1"), KEY_BYTES);
Bytes.wipe(raw);
_deviceKeys = keys;
return keys;
diff --git a/source/storage/Vault.mc b/source/storage/Vault.mc
index 88054e0..fcd973f 100644
--- a/source/storage/Vault.mc
+++ b/source/storage/Vault.mc
@@ -34,6 +34,9 @@ class Vault {
// The "Encrypt names" setting: new entries keep their names in the
// encrypted part (existing ones are converted by AboutScan).
var encryptNames as Boolean = false;
+ // The "Show the favourite code in the glance" option (AppState); the
+ // About scan stores or removes the glance item by it (GlanceStore).
+ var glanceCode as Boolean = false;
private var _store as Persist.Backend;
private var _meta as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>;
diff --git a/source/ui/GlanceView.mc b/source/ui/GlanceView.mc
index a7207ce..4540f2a 100644
--- a/source/ui/GlanceView.mc
+++ b/source/ui/GlanceView.mc
@@ -1,23 +1,83 @@
// Author: cirakg & claude ai
-// Summary: The glance (widget-list preview): app name and number of entries.
+// Summary: The glance (widget-list preview): the app name and number of entries, or the opt-in favourite code with its countdown.
//
-// Glances run with very little memory, so this reads only the entry index
-// from storage and opens no vault and no keys. The opt-in favourite code
-// (README 13.1) comes with M8.
+// Glances run with very little memory (64 KB), so this opens no vault: it
+// reads the entry index, and with "Show the favourite code in the glance"
+// on, the one entry the app sealed for it (GlanceCode). The code is
+// computed once per period (one HMAC; SHA-512 once per period too) and
+// redrawn every second for the countdown bar while the glance is visible.
+// The secret is wiped when the glance is hidden.
import Toybox.Application;
import Toybox.Graphics;
import Toybox.Lang;
+import Toybox.Math;
+import Toybox.System;
+import Toybox.Timer;
import Toybox.WatchUi;
(:glance)
class OtpGlanceView extends WatchUi.GlanceView {
+ private var _item as GlanceCode.Item? = null;
+ private var _timer as Timer.Timer? = null;
+ private var _counter as Long = -1l;
+ private var _code as String = "";
+ private var _measured as Boolean = false;
+
function initialize() {
GlanceView.initialize();
}
+ function onShow() as Void {
+ var item = GlanceCode.load();
+ _item = item;
+ _counter = -1l;
+ if (item != null) {
+ try {
+ var timer = new Timer.Timer();
+ timer.start(method(:onTick), 1000, true);
+ _timer = timer;
+ } catch (e instanceof Lang.Exception) {
+ // No timers here: the code still updates whenever the system redraws.
+ }
+ }
+ }
+
+ function onHide() as Void {
+ var timer = _timer;
+ if (timer != null) {
+ timer.stop();
+ }
+ _timer = null;
+ var item = _item;
+ if (item != null) {
+ item.wipe();
+ }
+ _item = null;
+ _code = "";
+ }
+
+ function onTick() as Void {
+ WatchUi.requestUpdate();
+ }
+
function onUpdate(dc as Graphics.Dc) as Void {
+ var item = _item;
+ if (item != null) {
+ drawCode(dc, item);
+ } else {
+ drawCount(dc);
+ }
+ // The simulator starts in the glance; launch scripts open the app from it.
+ if (!_measured) {
+ _measured = true;
+ Diag.glanceMemory();
+ }
+ Diag.shown(item != null ? "glance_code" : "glance");
+ }
+
+ private function drawCount(dc as Graphics.Dc) as Void {
var count = 0;
var meta = Application.Storage.getValue("m");
if (meta instanceof Dictionary) {
@@ -27,11 +87,63 @@ class OtpGlanceView extends WatchUi.GlanceView {
}
}
var h = dc.getHeight();
+ var top = h / 2 - dc.getFontHeight(Graphics.FONT_GLANCE);
+ var x = inset(dc, top);
dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
- dc.drawText(0, h / 2 - dc.getFontHeight(Graphics.FONT_GLANCE), Graphics.FONT_GLANCE, "OTP WALLET", Graphics.TEXT_JUSTIFY_LEFT);
+ dc.drawText(x, top, Graphics.FONT_GLANCE, "OTP WALLET", Graphics.TEXT_JUSTIFY_LEFT);
dc.setColor(Graphics.COLOR_LT_GRAY, Graphics.COLOR_TRANSPARENT);
- dc.drawText(0, h / 2, Graphics.FONT_GLANCE, count == 1 ? "1 entry" : count + " entries", Graphics.TEXT_JUSTIFY_LEFT);
- // The simulator starts in the glance; launch scripts open the app from it.
- Diag.shown("glance");
+ dc.drawText(x, h / 2, Graphics.FONT_GLANCE, count == 1 ? "1 entry" : count + " entries",
+ Graphics.TEXT_JUSTIFY_LEFT);
+ }
+
+ // How far right to start text whose top is at y, so a round screen's
+ // edge doesn't cut it. A glance doesn't know where it sits, so this
+ // assumes it is centred across the screen and starts at the top, which
+ // overestimates the cut (the Instinct 3's glance reaches into the
+ // screen's top-left curve; on most round watches this is 0 or a few
+ // pixels).
+ private function inset(dc as Graphics.Dc, y as Number) as Number {
+ var screen = System.getDeviceSettings();
+ if (screen.screenShape != System.SCREEN_SHAPE_ROUND) {
+ return 0;
+ }
+ var r = screen.screenWidth / 2;
+ var dy = r - y;
+ var edge = r - Math.sqrt(r * r - dy * dy).toNumber();
+ var left = (screen.screenWidth - dc.getWidth()) / 2;
+ return edge > left ? edge - left : 0;
+ }
+
+ // The label, the code and a bar for the seconds left in the period.
+ private function drawCode(dc as Graphics.Dc, item as GlanceCode.Item) as Void {
+ var now = Clock.nowUnix(0);
+ var counter = item.counter(now);
+ if (counter != _counter) {
+ _code = GlanceCode.grouped(item.code(now));
+ _counter = counter;
+ }
+ var w = dc.getWidth();
+ var h = dc.getHeight();
+ // The label ends at mid-height, like the entry count's first line:
+ // on some watches (Instinct 3) the glance's top corners are cut by
+ // the round screen, and a glance can't know where it sits.
+ var labelH = dc.getFontHeight(Graphics.FONT_GLANCE);
+ var codeFont = Graphics.FONT_GLANCE_NUMBER;
+ if (h / 2 + dc.getFontHeight(codeFont) + 5 > h) {
+ codeFont = Graphics.FONT_GLANCE;
+ }
+ var codeH = dc.getFontHeight(codeFont);
+ var top = h / 2 - labelH;
+ dc.setColor(Graphics.COLOR_LT_GRAY, Graphics.COLOR_TRANSPARENT);
+ // One inset for the block (the top line's), so the lines align.
+ var barX = inset(dc, top);
+ dc.drawText(barX, top, Graphics.FONT_GLANCE, item.label.toUpper(), Graphics.TEXT_JUSTIFY_LEFT);
+ dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
+ dc.drawText(barX, h / 2, codeFont, _code, Graphics.TEXT_JUSTIFY_LEFT);
+ var barY = h / 2 + codeH + 2;
+ dc.setColor(Graphics.COLOR_DK_GRAY, Graphics.COLOR_TRANSPARENT);
+ dc.fillRectangle(barX, barY, w - barX, 3);
+ dc.setColor(Graphics.COLOR_BLUE, Graphics.COLOR_TRANSPARENT);
+ dc.fillRectangle(barX, barY, (w - barX) * item.secondsLeft(now) / item.periodSeconds(), 3);
}
}
diff --git a/source/util/Bytes.mc b/source/util/Bytes.mc
index a32475a..aa5a28b 100644
--- a/source/util/Bytes.mc
+++ b/source/util/Bytes.mc
@@ -11,6 +11,8 @@
import Toybox.Lang;
import Toybox.StringUtil;
+// (:glance): also loaded by the glance, which shows the favourite code (GlanceCode).
+(:glance)
module Bytes {
// Hex text (any case, even length) to bytes. Empty text gives an empty array.
diff --git a/source/util/Clock.mc b/source/util/Clock.mc
index 44a0e37..6657224 100644
--- a/source/util/Clock.mc
+++ b/source/util/Clock.mc
@@ -10,6 +10,8 @@
import Toybox.Lang;
import Toybox.Time;
+// (:glance): also loaded by the glance, which shows the favourite code (GlanceCode).
+(:glance)
module Clock {
const TWO_POW_32 = 4294967296l;
diff --git a/source/util/Diag.mc b/source/util/Diag.mc
index 0ab121f..8fba19d 100644
--- a/source/util/Diag.mc
+++ b/source/util/Diag.mc
@@ -56,6 +56,18 @@ module Diag {
function firstFrame() as Void {
}
+ // The glance's memory after its first draw (README 18: at most 70 % of
+ // the glance's 64 KB; `make memtest` checks it).
+ (:debug)
+ function glanceMemory() as Void {
+ var stats = System.getSystemStats();
+ System.println("DIAG glance mem_used=" + stats.usedMemory + " mem_total=" + stats.totalMemory);
+ }
+
+ (:release)
+ function glanceMemory() as Void {
+ }
+
// A screen finished its first draw since it was shown.
(:debug)
function shown(screen as String) as Void {
diff --git a/source/util/TestMode.mc b/source/util/TestMode.mc
index 9b5c36f..e583ecf 100644
--- a/source/util/TestMode.mc
+++ b/source/util/TestMode.mc
@@ -13,7 +13,7 @@
import Toybox.Lang;
-(:uitest)
+(:uitest, :glance)
module TestMode {
const ON = true;
// 2027-01-15 08:00:26 UTC: 4 s left in a 30 s period, so the detail
@@ -22,7 +22,7 @@ module TestMode {
const AUTO_LOCK_MS = 10000;
}
-(:no_uitest)
+(:no_uitest, :glance)
module TestMode {
const ON = false;
const NOW = 0l;
diff --git a/test/crypto/KdfAesTest.mc b/test/crypto/KdfAesTest.mc
index 6587bc9..176158d 100644
--- a/test/crypto/KdfAesTest.mc
+++ b/test/crypto/KdfAesTest.mc
@@ -56,7 +56,7 @@ function hkdfMatchesRfcVectors(logger as Test.Logger) as Boolean {
var ok = true;
for (var i = 0; i < TestVectors.HKDF.size(); i++) {
var r = TestSupport.row(TestVectors.HKDF, i);
- var okm = Kdf.hkdf(TestSupport.bytes(r, 0), TestSupport.bytes(r, 1), TestSupport.bytes(r, 2), TestSupport.num(r, 3));
+ var okm = Hkdf.derive(TestSupport.bytes(r, 0), TestSupport.bytes(r, 1), TestSupport.bytes(r, 2), TestSupport.num(r, 3));
ok = TestSupport.sameBytes(logger, "HKDF vector " + i, okm, TestSupport.bytes(r, 4)) && ok;
}
return ok;
diff --git a/test/ui/GlanceCodeTest.mc b/test/ui/GlanceCodeTest.mc
new file mode 100644
index 0000000..25c7dd7
--- /dev/null
+++ b/test/ui/GlanceCodeTest.mc
@@ -0,0 +1,151 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the glance code: the sealed item round trip and its codes, tampering, the scan's choice of entry, the option, and the Manage favourite actions.
+
+import Toybox.Lang;
+import Toybox.Test;
+
+(:test)
+module GlanceFixtures {
+
+ const NOW = 1800000026l;
+
+ // The item the glance would read from the store, or null.
+ function item(v as Vault) as GlanceCode.Item? {
+ var store = v.storage();
+ var dk = store.getValue(GlanceCode.DEVICE_KEY);
+ var sealed = store.getValue(GlanceCode.KEY);
+ return GlanceCode.open(dk instanceof ByteArray ? dk : null, sealed instanceof ByteArray ? sealed : null);
+ }
+
+ function favorite(e as Entry) as Entry {
+ e.favorite = true;
+ return e;
+ }
+}
+
+// The glance item opens to the entry's label, and its codes are the
+// entry's codes (TOTP with SHA-256, 8 digits, 60 s; Steam).
+(:test)
+function glanceItemRoundTripsWithTheSameCodes(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ vault.glanceCode = true;
+ var github = vault.add(StorageFixtures.totp("GitHub"));
+ var steam = vault.add(StorageFixtures.steam("Steam"));
+ GlanceStore.update(vault, github);
+ var item = GlanceFixtures.item(vault);
+ var secret = Bytes.fromUtf8("12345678901234567890");
+ var now = GlanceFixtures.NOW;
+ if (item == null) {
+ logger.error("no item");
+ return false;
+ }
+ var totpOk = item.label.equals("GitHub") && item.code(now).equals(Otp.totp(Hashes.SHA256, secret, now, 60, 8))
+ && item.code(now).length() == 8 && item.counter(now) == now / 60 && item.secondsLeft(now) == 60 - (now % 60).toNumber();
+ GlanceStore.update(vault, steam);
+ var s = GlanceFixtures.item(vault);
+ var steamOk = s != null && s.code(now).equals(Otp.steam(secret, now)) && s.periodSeconds() == 30;
+ return totpOk && steamOk && GlanceCode.TYPE_STEAM == Entry.OTP_STEAM && GlanceCode.DEVICE_KEY.equals(KeyManager.DEVICE_KEY);
+}
+
+// A changed byte, another device key or another format gives nothing.
+(:test)
+function glanceItemRefusesTamperingAndOtherKeys(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ vault.glanceCode = true;
+ GlanceStore.update(vault, vault.add(StorageFixtures.totp("GitHub")));
+ var store = vault.storage();
+ var sealed = store.getValue(GlanceCode.KEY) as ByteArray;
+ var dk = store.getValue(GlanceCode.DEVICE_KEY) as ByteArray;
+ var tampered = Bytes.copy(sealed);
+ tampered[20] = tampered[20] ^ 1;
+ var otherKey = Bytes.copy(dk);
+ otherKey[0] = otherKey[0] ^ 1;
+ var bad = GlanceCode.encode("x", 0, 9, 6, 30, [1]b); // algorithm 9 doesn't exist
+ return GlanceCode.open(dk, sealed) != null && GlanceCode.open(dk, tampered) == null
+ && GlanceCode.open(otherKey, sealed) == null && GlanceCode.open(null, sealed) == null
+ && GlanceCode.open(dk, null) == null && GlanceCode.decode(bad) == null
+ && GlanceCode.decode(GlanceCode.encode("x", 0, 0, 6, 0, [1]b)) == null;
+}
+
+// The About scan picks the first favourite TOTP or Steam entry without the
+// PIN (not a locked, HOTP or barcode favourite), and removes the item when
+// the option goes off or the entry is no longer a favourite.
+(:test)
+function glanceScanChoosesTheFirstQualifyingFavourite(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ StorageFixtures.setPin(vault, StorageFixtures.PIN);
+ vault.glanceCode = true;
+ var locked = GlanceFixtures.favorite(StorageFixtures.totp("Bank"));
+ locked.locked = true;
+ vault.add(locked);
+ vault.add(GlanceFixtures.favorite(StorageFixtures.hotp("VPN", 3l)));
+ vault.add(StorageFixtures.code("Gym")); // a favourite barcode
+ vault.add(StorageFixtures.totp("Plain"));
+ var mail = vault.add(GlanceFixtures.favorite(StorageFixtures.totp("Mail")));
+ var s = SettingsFixtures.store();
+ SettingsFixtures.run(vault, s);
+ var chosen = GlanceFixtures.item(vault);
+ var first = chosen != null && chosen.label.equals("Mail");
+ // "Remove favourite" from the phone: the item goes.
+ SettingsFixtures.manage(s, mail, ManageAction.UNFAVORITE);
+ var job = SettingsFixtures.run(vault, s);
+ var gone = vault.storage().getValue(GlanceCode.KEY) == null && SettingsFixtures.text(s, ManageForm.STATUS).find("no longer a favourite") != null;
+ // Marked again, then the option off: the item goes too.
+ SettingsFixtures.manage(s, mail.toLower(), ManageAction.FAVORITE);
+ SettingsFixtures.run(vault, s);
+ var back = GlanceFixtures.item(vault) != null;
+ vault.glanceCode = false;
+ var needed = AboutScan.needed(vault);
+ SettingsFixtures.run(vault, s);
+ var off = vault.storage().getValue(GlanceCode.KEY) == null;
+ if (!(first && gone && back && needed && off)) {
+ logger.error("first " + first + " gone " + gone + " (" + job.lines + ") back " + back + " needed " + needed + " off " + off);
+ return false;
+ }
+ return true;
+}
+
+// Favourite actions check the entry's current mark, and wait for the PIN
+// on a locked entry like the other changes.
+(:test)
+function manageFavouriteActions(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var github = vault.ids()[0];
+ var s = ManageFixtures.send(vault, github, ManageAction.UNFAVORITE);
+ SettingsFixtures.run(vault, s);
+ var notYet = SettingsFixtures.text(s, ManageForm.STATUS).find("is not a favourite") != null;
+ s = ManageFixtures.send(vault, github, ManageAction.FAVORITE);
+ SettingsFixtures.run(vault, s);
+ var marked = (vault.header(github) as Entry).favorite;
+ // (The same form again would be a repeat; mark another entry directly.)
+ var vpn = vault.ids()[1];
+ var e = vault.open(vpn);
+ e.favorite = true;
+ vault.update(e);
+ e.wipeSecrets();
+ s = ManageFixtures.send(vault, vpn, ManageAction.FAVORITE);
+ SettingsFixtures.run(vault, s);
+ var already = SettingsFixtures.text(s, ManageForm.STATUS).find("already a favourite") != null;
+ var bank = ManageFixtures.addLocked(vault, "Bank");
+ vault.lock();
+ s = ManageFixtures.send(vault, bank, ManageAction.FAVORITE);
+ SettingsFixtures.run(vault, s);
+ var waits = ChangeQueue.count(vault) == 1 && !(vault.anyHeader(bank) as Entry).favorite;
+ var a = new ManageAction();
+ a.id = bank;
+ a.action = ManageAction.UNFAVORITE;
+ var parsed = ManageAction.fromBytes(a.toBytes());
+ var ok = notYet && marked && already && waits && parsed != null && parsed.action == ManageAction.UNFAVORITE
+ && a.describe().equals("Remove favourite: " + bank) && ManageAction.NAMES.size() == ManageAction.UNFAVORITE + 1;
+ if (!ok) {
+ logger.error("notYet " + notYet + " marked " + marked + " already " + already + " waits " + waits + " queue "
+ + ChangeQueue.count(vault) + " describe '" + a.describe() + "'");
+ }
+ return ok;
+}
+
+(:test)
+function glanceGroupsDigits(logger as Test.Logger) as Boolean {
+ return GlanceCode.grouped("123456").equals("123 456") && GlanceCode.grouped("1234567").equals("1234 567")
+ && GlanceCode.grouped("12345678").equals("1234 5678") && GlanceCode.grouped("VK4T9").equals("VK4T9");
+}