diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-21 03:55:10 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-21 03:55:10 +0200 |
| commit | 5e8ac1e2f5a660e55ea132996e1d5730cfa4fa64 (patch) | |
| tree | 9bb99bdca1ef8f33c93c5100816448681329d810 | |
| parent | 88f1cac4fa530fc30fd6ef01970af11c7a959b8e (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>
| -rw-r--r-- | resources/settings/settings.xml | 2 | ||||
| -rw-r--r-- | resources/strings/strings.xml | 2 | ||||
| -rw-r--r-- | source/app/AppState.mc | 1 | ||||
| -rw-r--r-- | source/app/Fixtures.mc | 12 | ||||
| -rw-r--r-- | source/app/GlanceCode.mc | 177 | ||||
| -rw-r--r-- | source/crypto/Aes.mc | 2 | ||||
| -rw-r--r-- | source/crypto/Hashes.mc | 2 | ||||
| -rw-r--r-- | source/crypto/Hkdf.mc | 39 | ||||
| -rw-r--r-- | source/crypto/Hmac.mc | 2 | ||||
| -rw-r--r-- | source/crypto/Kdf.mc | 33 | ||||
| -rw-r--r-- | source/crypto/SealedBox.mc | 6 | ||||
| -rw-r--r-- | source/crypto/SelfTest.mc | 2 | ||||
| -rw-r--r-- | source/crypto/Sha512.mc | 2 | ||||
| -rw-r--r-- | source/otp/Otp.mc | 2 | ||||
| -rw-r--r-- | source/settings/AboutScan.mc | 20 | ||||
| -rw-r--r-- | source/settings/ManageForm.mc | 25 | ||||
| -rw-r--r-- | source/storage/KeyManager.mc | 2 | ||||
| -rw-r--r-- | source/storage/Vault.mc | 3 | ||||
| -rw-r--r-- | source/ui/GlanceView.mc | 128 | ||||
| -rw-r--r-- | source/util/Bytes.mc | 2 | ||||
| -rw-r--r-- | source/util/Clock.mc | 2 | ||||
| -rw-r--r-- | source/util/Diag.mc | 12 | ||||
| -rw-r--r-- | source/util/TestMode.mc | 4 | ||||
| -rw-r--r-- | test/crypto/KdfAesTest.mc | 2 | ||||
| -rw-r--r-- | test/ui/GlanceCodeTest.mc | 151 |
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"); +} |
