diff options
Diffstat (limited to 'source')
| -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 |
21 files changed, 425 insertions, 53 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); + } +} 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; |
