From bc4cc19df5f53519ca1aabd3cf161b6d78ffc16c Mon Sep 17 00:00:00 2001 From: cirakg Date: Tue, 27 Oct 2026 01:01:18 +0100 Subject: fix(pin): the work after a PIN and the change queue run in steps After a correct or new PIN, promoting pending entries and applying waiting "Require PIN" changes ran in the event of the last PBKDF2 step; 16 waiting changes tripped the watchdog on the Forerunner 70. ChangeQueue.LockJob now does it in steps while the PIN screen shows "Checking...", saves what it did to the results and refreshes the glance after Set PIN too. The list title decrypted every waiting change at each start. Queue items are now [action, box] (aad OTPW-Q-2 + action code), so they are counted without decrypting; 0.9.0 items are still read. A replacement is checked when it arrives and its material is sealed apart (qm.). Large buffers are wiped and UTF-8-checked in pieces (Jobs.Wiper, Bytes.utf8Prefix, SealedBox.openKeep/sealKeep). "Phone changes" reads behind "Reading...", and Confirm all / Reject all act only on the changes shown. Storage full no longer stops the app at the PIN. Found by the review of b8addc8/096b5a4 and a new make watchdog test; the diff was reviewed again and its findings fixed. fr70: make lint, make test (176), make watchdog (19 cases at two steps per event), make screenshots pass; list_locked and list_reset goldens show "Results ready (menu)". Co-Authored-By: Claude Opus 5.5 --- source/crypto/Aes.mc | 23 +- source/crypto/SealedBox.mc | 21 +- source/settings/ChangeQueue.mc | 553 ++++++++++++++++++++++++++++++++--------- source/settings/CodeForm.mc | 10 +- source/settings/ManageForm.mc | 265 ++++++++++++++++---- source/settings/Results.mc | 13 + source/storage/KeyManager.mc | 24 +- source/storage/Record.mc | 33 ++- source/storage/Vault.mc | 91 +++++-- source/ui/MainMenu.mc | 2 +- source/ui/PinView.mc | 62 +++-- source/ui/QueueMenu.mc | 78 +++++- source/util/Bytes.mc | 33 ++- source/util/Jobs.mc | 63 +++++ source/util/StressTest.mc | 410 +++++++++++++++++++++++++++++- 15 files changed, 1424 insertions(+), 257 deletions(-) (limited to 'source') diff --git a/source/crypto/Aes.mc b/source/crypto/Aes.mc index 242448b..9e4f320 100644 --- a/source/crypto/Aes.mc +++ b/source/crypto/Aes.mc @@ -42,19 +42,34 @@ module Aes { // Encrypt with PKCS#7 padding: 1 to 16 bytes, each holding the pad length. function cbcEncrypt(key as ByteArray, iv as ByteArray, plaintext as ByteArray) as ByteArray { + return cbcEncryptKeep(key, iv, plaintext, null); + } + + // As cbcEncrypt(); with `leftovers`, the padded copy is added there for + // the caller to wipe (in steps, for kilobytes) instead of wiped here. + function cbcEncryptKeep(key as ByteArray, iv as ByteArray, plaintext as ByteArray, leftovers as Array?) as ByteArray { var padLength = BLOCK_BYTES - (plaintext.size() % BLOCK_BYTES); var padded = Bytes.copy(plaintext); for (var i = 0; i < padLength; i++) { padded.add(padLength); } var out = cbcRaw(key, iv, padded, true); - Bytes.wipe(padded); + if (leftovers != null) { + leftovers.add(padded); + } else { + Bytes.wipe(padded); + } return out; } // Decrypt and remove PKCS#7 padding; null when the length or the padding is // wrong. function cbcDecrypt(key as ByteArray, iv as ByteArray, ciphertext as ByteArray) as ByteArray? { + return cbcDecryptKeep(key, iv, ciphertext, null); + } + + // As cbcDecrypt(); with `leftovers`, the padded plaintext is added there. + function cbcDecryptKeep(key as ByteArray, iv as ByteArray, ciphertext as ByteArray, leftovers as Array?) as ByteArray? { var size = ciphertext.size(); if (size == 0 || size % BLOCK_BYTES != 0) { return null; @@ -68,7 +83,11 @@ module Aes { } } var plaintext = valid ? padded.slice(0, size - padLength) : null; - Bytes.wipe(padded); + if (leftovers != null) { + leftovers.add(padded); + } else { + Bytes.wipe(padded); + } return plaintext; } } diff --git a/source/crypto/SealedBox.mc b/source/crypto/SealedBox.mc index 765a1d8..3877e1a 100644 --- a/source/crypto/SealedBox.mc +++ b/source/crypto/SealedBox.mc @@ -55,7 +55,19 @@ module SealedBox { // Seal with a given IV. Only tests call this directly (fixed IVs make the // output reproducible); reusing an IV in real use would be a mistake. function sealWithIv(keys as Keys, aad as ByteArray, plaintext as ByteArray, iv as ByteArray) as ByteArray { - var ciphertext = Aes.cbcEncrypt(keys.encKey, iv, plaintext); + return sealKeepWithIv(keys, aad, plaintext, iv, null); + } + + // As seal(); with `leftovers`, the padded plaintext copy is added there + // for the caller to wipe in steps (zeroing 4 KB is a large part of a + // watchdog step) instead of wiped here. + function sealKeep(keys as Keys, aad as ByteArray, plaintext as ByteArray, leftovers as Array?) as ByteArray { + return sealKeepWithIv(keys, aad, plaintext, Cryptography.randomBytes(IV_BYTES), leftovers); + } + + function sealKeepWithIv(keys as Keys, aad as ByteArray, plaintext as ByteArray, iv as ByteArray, + leftovers as Array?) as ByteArray { + var ciphertext = Aes.cbcEncryptKeep(keys.encKey, iv, plaintext, leftovers); var box = [VERSION]b; box.addAll(iv); box.addAll(ciphertext); @@ -66,6 +78,11 @@ module SealedBox { // The plaintext, or null if the box is malformed, was sealed with another // key or aad, or was changed in any way. function open(keys as Keys, aad as ByteArray, box as ByteArray) as ByteArray? { + return openKeep(keys, aad, box, null); + } + + // As open(); with `leftovers`, the padded plaintext is added there. + function openKeep(keys as Keys, aad as ByteArray, box as ByteArray, leftovers as Array?) as ByteArray? { var size = box.size(); if (size < MIN_SIZE || (size - 1 - IV_BYTES - TAG_BYTES) % Aes.BLOCK_BYTES != 0 || box[0] != VERSION) { return null; @@ -77,7 +94,7 @@ module SealedBox { if (!valid) { return null; } - return Aes.cbcDecrypt(keys.encKey, box.slice(1, 1 + IV_BYTES), box.slice(1 + IV_BYTES, size - TAG_BYTES)); + return Aes.cbcDecryptKeep(keys.encKey, box.slice(1, 1 + IV_BYTES), box.slice(1 + IV_BYTES, size - TAG_BYTES), leftovers); } // HMAC over the label, the aad (with its length) and the box body. diff --git a/source/settings/ChangeQueue.mc b/source/settings/ChangeQueue.mc index 5de74e0..dc6facd 100644 --- a/source/settings/ChangeQueue.mc +++ b/source/settings/ChangeQueue.mc @@ -4,15 +4,25 @@ // Anyone who controls the phone can send settings, so edits, new secrets, // "Don't require PIN" and deletes of locked entries are not applied when // they arrive: they wait here until someone confirms them with the PIN on -// the watch (or rejects them). Each item is one ManageAction sealed on its -// own with the device key (aad "OTPW-Q-1"), so a damaged item costs only -// itself. A new secret in an item is no more exposed than it was in the -// settings it came from, as with pending entries (README 7.7). +// the watch (or rejects them). A new secret in an item is no more exposed +// than it was in the settings it came from, as with pending entries +// (README 7.7). +// +// Each item is [action code, box] or, for a REPLACE, [action code, box, +// material number]: one ManageAction sealed on its own with the device key +// (aad "OTPW-Q-2" and the code byte, so a changed code doesn't open), so a +// damaged item costs only itself. Counting reads only the codes. The new +// secret or contents of a REPLACE (checked before queueing, up to 4 KB) are +// a storage value of their own, "qm.", sealed with an aad naming the +// number and the entry, opened only when that action is applied, and +// deleted with its item. Items from 0.9.0 are a bare box (aad "OTPW-Q-1", +// material inside) and stay readable; they count as "to confirm". // // "Require PIN" waits here only while the watch is locked (it needs the -// master key). It only adds protection, so applyLocks() applies it at the -// next correct (or newly set) PIN, without "Confirm all", the way pending -// entries are promoted. +// master key). It only adds protection, so LockJob applies it at the next +// correct (or newly set) PIN, without "Confirm all", the way pending +// entries are promoted. Every walk that opens items is a job, one item per +// step (the watchdog: opening an item is a few byte loops). import Toybox.Application; import Toybox.Lang; @@ -21,78 +31,202 @@ module ChangeQueue { const KEY = "q"; const MAX_ITEMS = 16; - const AAD = "OTPW-Q-1"; + const AAD = "OTPW-Q-2"; + // Items queued by 0.9.0. + const AAD_V1 = "OTPW-Q-1"; + const MATERIAL_KEY = "qm."; + const MATERIAL_AAD = "OTPW-QM-1"; - // What applyLocks() did at the last PIN, for the results of a "Confirm - // all" that follows it (ChangeQueueJob). Replaced at every PIN. + // What LockJob did at this PIN, for the results of a "Confirm all" that + // follows it (ChangeQueueJob). A PIN's first LockJob starts them afresh, + // a legacy PIN's second one (the new PIN) adds to them, and the PIN + // screen takes them when it closes, however it closes (they are in the + // results by then). var _lockLines as Array = [] as Array; // countToConfirm()'s cached result and the vault it is for. var _toConfirm as Number? = null; var _toConfirmFor as Vault? = null; - function count(vault as Vault) as Number { + // The stored items (not opened). + function items(vault as Vault) as Array { var stored = vault.storage().getValue(KEY); - return stored instanceof Array ? stored.size() : 0; + return stored instanceof Array ? stored as Array : [] as Array; + } + + function count(vault as Vault) as Number { + return items(vault).size(); } - // Append an action. Throws VaultError LIMIT (too many waiting) or FULL. + // Append an action (a REPLACE's material goes to a value of its own). + // Throws VaultError LIMIT (too many waiting) or FULL. function add(vault as Vault, action as ManageAction) as Void { - var stored = vault.storage().getValue(KEY); - var items = stored instanceof Array ? stored as Array : [] as Array; - if (items.size() >= MAX_ITEMS) { + addWith(vault, action, null); + } + + // As add(); the copy sealing makes of up to 4 KB of material goes to + // `wiper` when given (ManageRun wipes it in steps). + function addWith(vault as Vault, action as ManageAction, wiper as Jobs.Wiper?) as Void { + var stored = items(vault); + if (stored.size() >= MAX_ITEMS) { throw new VaultError(VaultError.LIMIT, MAX_ITEMS + " changes already wait for your PIN. Confirm or reject them on the watch first"); } + var keys = vault.keyManager.deviceKeys(); + var material = action.material; + var ref = material != null ? freeRef(stored) : -1; + if (material != null) { + try { + var leftovers = [] as Array; + vault.storage().setValue(MATERIAL_KEY + ref, SealedBox.sealKeep(keys, materialAad(ref, action.id), material, + wiper != null ? leftovers : null)); + if (wiper != null) { + wiper.addAll(leftovers); + } + } catch (e instanceof Lang.Exception) { + throw new VaultError(VaultError.FULL, "storage full"); + } + } + action.material = null; + action.ref = ref; var plain = action.toBytes(); - items.add(SealedBox.seal(vault.keyManager.deviceKeys(), Bytes.fromUtf8(AAD), plain)); + action.material = material; + action.ref = -1; + var code = action.action; + var item = [code, SealedBox.seal(keys, itemAad(code), plain)] as Array; + if (ref >= 0) { + item.add(ref); + } Bytes.wipe(plain); + stored.add(item as Application.Storage.ValueType); changed(); try { - vault.storage().setValue(KEY, items); + vault.storage().setValue(KEY, stored); } catch (e instanceof Lang.Exception) { + if (ref >= 0) { + vault.storage().deleteValue(MATERIAL_KEY + ref); + } throw new VaultError(VaultError.FULL, "storage full"); } } - // The waiting actions in arrival order; items that don't verify are - // left out (and dropped by the next clear()). - function load(vault as Vault) as Array { - var out = [] as Array; - var stored = vault.storage().getValue(KEY); - if (!(stored instanceof Array)) { - return out; + // The action code of a stored item, read without opening it; -1 for a + // 0.9.0 item or a malformed one. + function codeOf(item as Application.Storage.ValueType) as Number { + if (item instanceof Array && item.size() >= 2 && item[0] instanceof Number) { + return item[0] as Number; + } + return -1; + } + + // The material number of a stored item, -1 for none. + function refOf(item as Application.Storage.ValueType) as Number { + if (item instanceof Array && item.size() >= 3 && item[2] instanceof Number) { + return item[2] as Number; } + return -1; + } + + // Open one stored item: the action, or null when it doesn't verify. + // With `material`, a REPLACE's material is opened too (null when it + // doesn't verify); without, it isn't (descriptions, locks). A 0.9.0 item + // always has its material inside (release() hands it to the wiper). + // Kilobyte buffers to zero go to `wiper`, for the caller's next steps. + function open(vault as Vault, item as Application.Storage.ValueType, wiper as Jobs.Wiper, material as Boolean) as ManageAction? { var keys = vault.keyManager.deviceKeys(); - var aad = Bytes.fromUtf8(AAD); + var leftovers = [] as Array; + if (item instanceof ByteArray) { + var old = SealedBox.openKeep(keys, Bytes.fromUtf8(AAD_V1), item, leftovers); + wiper.addAll(leftovers); + if (old == null) { + return null; + } + var a = ManageAction.fromBytes(old); + wiper.add(old); + return a; + } + var code = codeOf(item); + var box = code >= 0 ? (item as Array)[1] : null; + var plain = box instanceof ByteArray ? SealedBox.open(keys, itemAad(code), box) : null; + if (plain == null) { + return null; + } + var action = ManageAction.fromBytes(plain); + Bytes.wipe(plain); // small: names and an ID + if (action == null || action.action != code || action.ref != refOf(item)) { + return null; + } + if (action.ref >= 0 && material) { + var stored = vault.storage().getValue(MATERIAL_KEY + action.ref); + var opened = stored instanceof ByteArray ? SealedBox.openKeep(keys, materialAad(action.ref, action.id), stored, leftovers) + : null; + wiper.addAll(leftovers); + if (opened == null) { + return null; + } + action.material = opened; + } + return action; + } + + // Done with an action: its material goes to the wiper. + function release(action as ManageAction, wiper as Jobs.Wiper) as Void { + wiper.add(action.material); + action.material = null; + action.text = null; + } + + // An item's identity while it waits: its box's random IV, as hex ("-" + // for a malformed item). The menu, "Confirm all", "Reject all" and + // LockJob act on items by it, so changes that arrive meanwhile (or + // items another job removed) are never mixed up with the ones read. + function keyOf(item as Application.Storage.ValueType) as String { + var box = item instanceof ByteArray ? item : (codeOf(item) >= 0 ? (item as Array)[1] : null); + if (!(box instanceof ByteArray) || box.size() < SealedBox.MIN_SIZE) { + return "-"; + } + return Bytes.toHex(box.slice(1, 1 + SealedBox.IV_BYTES)); + } + + // Remove the items with these keys (and their material); the others + // keep waiting, in order. Returns how many are left. + function remove(vault as Vault, keys as Array) as Number { + changed(); + var stored = items(vault); + var kept = [] as Array; for (var i = 0; i < stored.size(); i++) { - var box = stored[i]; - var plain = box instanceof ByteArray ? SealedBox.open(keys, aad, box) : null; - if (plain != null) { - var action = ManageAction.fromBytes(plain); - Bytes.wipe(plain); - if (action != null) { - out.add(action); - } + if (keys.indexOf(keyOf(stored[i])) >= 0) { + deleteMaterial(vault, stored[i]); + } else { + kept.add(stored[i]); } } - return out; + if (kept.size() == stored.size()) { + return kept.size(); + } + if (kept.size() == 0) { + vault.storage().deleteValue(KEY); + return 0; + } + try { + vault.storage().setValue(KEY, kept); + } catch (e instanceof Lang.Exception) { + // Can't happen when shrinking; if it did, the items stay (an + // applied one again is harmless: "already requires the PIN"). + } + return kept.size(); } - // Short descriptions for the confirmation screen ("Delete: K7F2 Bank"). + // Short descriptions for the confirmation screen ("Delete: K7F2 Bank"), + // in one event: for tests (the menu runs DescribeJob). function descriptions(vault as Vault) as Array { - var actions = load(vault); - var out = [] as Array; - for (var i = 0; i < actions.size(); i++) { - out.add(actions[i].describe()); - actions[i].wipe(); - } - return out; + var job = new DescribeJob(vault); + Jobs.runToCompletion(job, 1); + return job.lines; } // The number of waiting changes that need "Confirm all": all but - // "Require PIN", which applies at the next PIN by itself. For the list's - // title and the menu; cached until the queue changes, because the list - // asks at every draw and counting opens every item. + // "Require PIN", which applies at the next PIN by itself. Cached until + // the queue changes, because the list asks at every draw. function countToConfirm(vault as Vault) as Number { var n = _toConfirm; if (n == null || _toConfirmFor != vault) { @@ -109,67 +243,27 @@ module ChangeQueue { _toConfirm = null; } - // The number of waiting "Require PIN" actions (applied at the next PIN). + // The number of waiting "Require PIN" actions (applied at the next PIN), + // from the item codes alone. function countLocks(vault as Vault) as Number { - var actions = load(vault); + var stored = items(vault); var n = 0; - for (var i = 0; i < actions.size(); i++) { - if (actions[i].action == ManageAction.LOCK) { + for (var i = 0; i < stored.size(); i++) { + if (codeOf(stored[i]) == ManageAction.LOCK) { n++; } - actions[i].wipe(); } return n; } - // After a correct or new PIN (session unlocked): apply the waiting - // "Require PIN" actions and remove them from the queue; the other items - // keep waiting, in order. Each is one re-seal (ManageRun, no code - // check), like promotePending. One that fails (the entry is gone or - // already locked) is dropped too. - function applyLocks(vault as Vault) as Void { - _lockLines = [] as Array; + // LockJob's lines (added to those of the PIN's first job, for a legacy + // PIN's second); the queue changed. + function addLockLines(lines as Array) as Void { + _lockLines.addAll(lines); changed(); - var stored = vault.storage().getValue(KEY); - if (!(stored instanceof Array) || !vault.session.isUnlocked()) { - return; - } - var keys = vault.keyManager.deviceKeys(); - var aad = Bytes.fromUtf8(AAD); - var kept = [] as Array; - for (var i = 0; i < stored.size(); i++) { - var box = stored[i]; - var plain = box instanceof ByteArray ? SealedBox.open(keys, aad, box) : null; - var action = plain != null ? ManageAction.fromBytes(plain) : null; - Bytes.wipe(plain); - if (action != null && action.action == ManageAction.LOCK) { - var run = new ManageRun(vault, action, true); - Jobs.runToCompletion(run, 1); - _lockLines.add(run.failed ? "Not applied: " + action.describe() + ". " + run.message : run.message); - } else { - if (action != null) { - action.wipe(); - } - kept.add(box as Application.Storage.ValueType); // damaged items are dropped by the next clear() - } - } - Diag.data("locks_applied", "count=" + _lockLines.size()); // for UI scripts: no names - if (kept.size() == stored.size()) { - return; - } - if (kept.size() == 0) { - clear(vault); - return; - } - try { - vault.storage().setValue(KEY, kept); - } catch (e instanceof Lang.Exception) { - // Can't happen when shrinking; if it did, the locks just apply - // again at the next PIN ("already requires the PIN"). - } } - // The lines of the last applyLocks(), once (then forgotten). + // The lines of the last LockJob, once (then forgotten). function takeLockLines() as Array { var lines = _lockLines; _lockLines = [] as Array; @@ -178,68 +272,281 @@ module ChangeQueue { function clear(vault as Vault) as Void { changed(); + var stored = items(vault); + for (var i = 0; i < stored.size(); i++) { + deleteMaterial(vault, stored[i]); + } vault.storage().deleteValue(KEY); } - // Remove the first n items (the ones just applied), keeping any that - // arrived from the phone meanwhile. - function drop(vault as Vault, n as Number) as Void { - changed(); - var stored = vault.storage().getValue(KEY); - if (!(stored instanceof Array) || stored.size() <= n) { - clear(vault); - return; + function deleteMaterial(vault as Vault, item as Application.Storage.ValueType) as Void { + var ref = refOf(item); + if (ref >= 0) { + vault.storage().deleteValue(MATERIAL_KEY + ref); + } + } + + // The lowest material number no stored item uses. + function freeRef(stored as Array) as Number { + var ref = 0; + var used = true; + while (used) { + used = false; + for (var i = 0; i < stored.size(); i++) { + if (refOf(stored[i]) == ref) { + used = true; + ref++; + break; + } + } + } + return ref; + } + + function itemAad(code as Number) as ByteArray { + var aad = Bytes.fromUtf8(AAD); + aad.add(code & 0xFF); + return aad; + } + + function materialAad(ref as Number, id as String) as ByteArray { + return Bytes.fromUtf8(MATERIAL_AAD + "." + ref + "." + id); + } +} + +// The queue's descriptions ("Delete: K7F2 Bank"), one item per step, and +// the keys of every item read (damaged ones too): "Confirm all" and +// "Reject all" act on exactly these. +class DescribeJob extends Jobs.Job { + + var lines as Array = [] as Array; + var keys as Array = [] as Array; + + private var _vault as Vault; + private var _items as Array; + private var _at as Number = 0; + private var _wiper as Jobs.Wiper = new Jobs.Wiper(); + + function initialize(vault as Vault) { + Job.initialize(); + _vault = vault; + _items = ChangeQueue.items(vault); + } + + function step(budget as Number) as Boolean { + if (_wiper.left() > 0) { + // A 0.9.0 item's contents: zeroed in steps of their own. + return _wiper.step(Jobs.Wiper.STEP_BYTES) && _at >= _items.size(); + } + if (_at < _items.size()) { + var item = _items[_at]; + keys.add(ChangeQueue.keyOf(item)); + var action = ChangeQueue.open(_vault, item, _wiper, false); + if (action != null) { + lines.add(action.describe()); + ChangeQueue.release(action, _wiper); + } + _at++; + } + return _at >= _items.size() && _wiper.left() == 0; + } +} + +// After a correct or new PIN (PinView, while it shows its busy state): +// promotes the pending locked entries (Vault.promoteNext, a few per step), +// then applies the waiting "Require PIN" actions, one re-seal (ManageRun, +// no code check) per item, and removes them from the queue; the other +// items keep waiting, in order, unopened. One that fails (the entry is gone +// or already locked) is dropped too, except for a full store: then it waits +// for the next PIN. The queue is written once at the end, so a stop midway +// re-applies the locks already done at the next PIN, reported as "already +// requires the PIN"; promotion saves after each entry. +class LockJob extends Jobs.Job { + + // Record bytes promoted per step: one large code, or a few OTP entries + // (each is an open, a re-seal and the metadata). + static const PROMOTE_BYTES = 600; + + // One line per lock applied or failed (also kept for takeLockLines). + var lines as Array = [] as Array; + + private var _vault as Vault; + // The items when promotion ended; null before. + private var _items as Array? = null; + // Keys of the items applied or dropped. + private var _done as Array = [] as Array; + private var _index as Number = 0; + private var _run as ManageRun? = null; + private var _action as ManageAction? = null; + private var _wiper as Jobs.Wiper = new Jobs.Wiper(); + + // first: the PIN's first job, which starts the lock lines afresh (a + // legacy PIN's second job, for the new PIN, adds to them). + function initialize(vault as Vault, first as Boolean) { + Job.initialize(); + _vault = vault; + if (first) { + ChangeQueue.takeLockLines(); + } + ChangeQueue.changed(); + } + + function step(budget as Number) as Boolean { + if (_wiper.left() > Jobs.Wiper.INLINE_BYTES) { + _wiper.step(Jobs.Wiper.STEP_BYTES); + return false; + } + _wiper.step(Jobs.Wiper.INLINE_BYTES); + var items = _items; + if (!_vault.session.isUnlocked()) { + // Locked meanwhile: what is left waits for the next PIN. + if (items != null) { + finish(); + } + return true; + } + if (items == null) { + promote(); + return false; + } + var run = _run; + if (run != null) { + if (!run.step(budget)) { + return false; + } + var action = _action as ManageAction; + lines.add(run.failed ? "Not applied: " + action.describe() + ". " + run.message : run.message); + if (!run.failed || !run.message.equals("storage full")) { + _done.add(ChangeQueue.keyOf(items[_index])); // a full store: it waits for the next PIN + } + ChangeQueue.release(action, _wiper); + _run = null; + _action = null; + _index++; + return false; + } + if (_index >= items.size()) { + finish(); + return true; } + var item = items[_index]; + var code = ChangeQueue.codeOf(item); + // A 0.9.0 item must be opened to know what it is. + var action = code == ManageAction.LOCK || item instanceof ByteArray ? ChangeQueue.open(_vault, item, _wiper, false) : null; + if (action != null && action.action == ManageAction.LOCK) { + _action = action; + _run = new ManageRun(_vault, action, true); + return false; + } + if (action != null) { + ChangeQueue.release(action, _wiper); + } + _index++; // damaged items stay until confirmed or rejected + return false; + } + + // Promote up to PROMOTE_BYTES of pending entries. A full store stops + // promotion (the rest wait for the next PIN) but not the locks. + private function promote() as Void { + var bytes = 0; try { - vault.storage().setValue(KEY, stored.slice(n, null) as Array); - } catch (e instanceof Lang.Exception) { - clear(vault); // can't happen when shrinking; never re-apply + while (bytes < PROMOTE_BYTES) { + var size = _vault.promoteNext(_wiper); + if (size == 0) { + _items = ChangeQueue.items(_vault); + return; + } + bytes += size > 0 ? size : 1; + } + } catch (e instanceof VaultError) { + var left = _vault.pendingIds().size(); + lines.add((e.code == VaultError.FULL ? "Storage full: " : "Not promoted: ") + left + + (left == 1 ? " locked entry still waits" : " locked entries still wait") + " for your PIN"); + _items = ChangeQueue.items(_vault); + } + } + + // Remove the locks just applied (and the failed ones) from the queue. + private function finish() as Void { + Diag.data("locks_applied", "count=" + lines.size()); // for UI scripts: no names + ChangeQueue.addLockLines(lines); + if (_done.size() > 0) { + ChangeQueue.remove(_vault, _done); } } } -// Applies every waiting change after the PIN was entered, one per step -// (each re-seals a record; a code may need its CodeCheck). The applied -// items leave the queue only at the end, so a crash midway loses nothing; +// Applies the waiting changes after the PIN was entered: per step, it opens +// one item or advances its ManageRun (each re-seals a record; a code may +// need its CodeCheck). Only the items the menu showed (their keys) are +// applied; changes that arrived meanwhile keep waiting. The applied items +// leave the queue only at the end, so a crash midway loses nothing; // applying one again is harmless (a deleted entry is then just missing). class ChangeQueueJob extends Jobs.Job { var lines as Array = [] as Array; private var _vault as Vault; - private var _actions as Array; - private var _loaded as Number = 0; + private var _keys as Array?; + private var _items as Array = [] as Array; + private var _done as Array = [] as Array; private var _index as Number = 0; + private var _action as ManageAction? = null; private var _run as ManageRun? = null; + private var _wiper as Jobs.Wiper = new Jobs.Wiper(); - function initialize(vault as Vault) { + // keys: the items to apply (DescribeJob.keys), or null for all. + function initialize(vault as Vault, keys as Array?) { Job.initialize(); _vault = vault; - var stored = vault.storage().getValue(ChangeQueue.KEY); - _loaded = stored instanceof Array ? stored.size() : 0; - _actions = ChangeQueue.load(vault); - // "Require PIN" changes the PIN just applied (ChangeQueue.applyLocks). + _keys = keys; + var stored = ChangeQueue.items(vault); + for (var i = 0; i < stored.size(); i++) { + if (keys == null || keys.indexOf(ChangeQueue.keyOf(stored[i])) >= 0) { + _items.add(stored[i]); + } + } + // "Require PIN" changes the PIN just applied (LockJob). lines.addAll(ChangeQueue.takeLockLines()); } function step(budget as Number) as Boolean { + if (_wiper.left() > Jobs.Wiper.INLINE_BYTES) { + _wiper.step(Jobs.Wiper.STEP_BYTES); + return false; + } + _wiper.step(Jobs.Wiper.INLINE_BYTES); var run = _run; if (run == null) { - if (_index >= _actions.size()) { - ChangeQueue.drop(_vault, _loaded); + if (_index >= _items.size()) { + var left = ChangeQueue.remove(_vault, _done); + if (_keys != null && left > 0) { + lines.add(left + (left == 1 ? " change" : " changes") + " arrived meanwhile and still " + + (left == 1 ? "waits" : "wait") + " (menu, Phone changes)"); + } if (lines.size() == 0) { lines.add("No changes were waiting."); } return true; } - run = new ManageRun(_vault, _actions[_index], true); - _run = run; + _done.add(ChangeQueue.keyOf(_items[_index])); + var action = ChangeQueue.open(_vault, _items[_index], _wiper, true); + if (action == null) { + lines.add("Not applied: a change was damaged"); + _index++; + return false; + } + _action = action; + _run = new ManageRun(_vault, action, true); + return false; } if (run.step(budget)) { - var problem = run.failed ? "Not applied: " + _actions[_index].describe() + ". " : ""; + var action = _action as ManageAction; + var problem = run.failed ? "Not applied: " + action.describe() + ". " : ""; lines.add(problem + run.message); lines.addAll(run.notes); - _actions[_index].wipe(); + ChangeQueue.release(action, _wiper); + _action = null; _index++; _run = null; } diff --git a/source/settings/CodeForm.mc b/source/settings/CodeForm.mc index 810cd6e..0c3470c 100644 --- a/source/settings/CodeForm.mc +++ b/source/settings/CodeForm.mc @@ -89,8 +89,14 @@ module CodeForm { // The contents as stored (trimmed for linear codes, whose digits are // often typed with spaces around), or the problem. function contents(text as String, symbology as Number) as ByteArray or String { - var value = Codes.isLinear(symbology) ? Text.trim(text) : text; - if (Text.trim(value).length() == 0) { + return contentsOf(text, Text.trim(text), symbology); + } + + // As contents(), with the text already trimmed (ManageRun trims + // kilobytes in steps). + function contentsOf(text as String, trimmed as String, symbology as Number) as ByteArray or String { + var value = Codes.isLinear(symbology) ? trimmed : text; + if (trimmed.length() == 0) { return "Contents: missing"; } var bytes = Bytes.fromUtf8(value); diff --git a/source/settings/ManageForm.mc b/source/settings/ManageForm.mc index 70d707f..ba2d45f 100644 --- a/source/settings/ManageForm.mc +++ b/source/settings/ManageForm.mc @@ -14,7 +14,11 @@ // move twice can't hit another entry. // Replacing a code's contents runs CodeCheck with the entry's own options // (a sub-job, so a large 2D code can't trip the watchdog). For a locked -// entry the options are encrypted, so that check runs when the PIN confirms. +// entry the options are encrypted, so that check runs when the PIN confirms; +// everything else about a REPLACE (Base32, the contents' size) is checked +// before it is queued. With 4 KB of contents every byte loop (UTF-8 check, +// opening and re-sealing the record, wiping) gets a step of its own, and +// the wipes at the end run in pieces (Jobs.Wiper). import Toybox.Lang; @@ -46,6 +50,7 @@ class ManageAction { static const T_COUNTER = 8; static const T_LABEL = 9; static const T_DECODED = 10; + static const T_REF = 11; // In "Edit details", this value clears a field; empty keeps it. static const CLEAR = "-"; @@ -57,10 +62,18 @@ class ManageAction { var group as String = ""; var position as Number = 0; // REPLACE: the decoded OTP secret or the new code contents, once - // checked against the entry (raw text before). Wiped after use. + // checked against the entry (a 0.9.0 queue item may hold the raw text + // of a code's contents instead). Wiped after use. Queued, it is a + // storage value of its own (ChangeQueue, `ref`). var material as ByteArray? = null; + // REPLACE from the form: the raw field text until ManageRun checks it. + // Never queued. + var text as String? = null; + // A queued REPLACE: the number of its material record, -1 for none. + var ref as Number = -1; var counter as Long = 0l; - // True once an OTP secret in `material` is decoded from Base32. + // True once `material` holds the checked secret (decoded from Base32) + // or contents (CodeForm.contents), not raw text. var decoded as Boolean = false; // "Bank (carol)", kept so the queue can be shown while locked. var label as String = ""; @@ -84,6 +97,7 @@ class ManageAction { function wipe() as Void { Bytes.wipe(material); material = null; + text = null; } function toBytes() as ByteArray { @@ -101,6 +115,9 @@ class ManageAction { w.addInt(T_COUNTER, counter); w.addText(T_LABEL, label); w.addBool(T_DECODED, decoded); + if (ref >= 0) { + w.addInt(T_REF, ref); + } return w.bytes; } @@ -133,6 +150,8 @@ class ManageAction { a.material = m instanceof ByteArray ? m : null; var decoded = map[T_DECODED]; a.decoded = decoded instanceof Boolean && decoded; + var ref = map[T_REF]; + a.ref = ref instanceof Long && ref >= 0 && ref < 0x10000 ? ref.toNumber() : -1; return a; } @@ -205,10 +224,10 @@ module ManageForm { a.position = position; } else if (action == ManageAction.REPLACE) { var secret = Forms.rawText(store, SECRET); - if (Text.trim(secret).length() == 0) { - return "Replace: enter the new secret or contents"; + if (secret.length() == 0) { + return "Replace: enter the new secret or contents"; // only spaces: ManageRun says so, in steps } - a.material = Bytes.fromUtf8(secret); + a.text = secret; // checked against the entry by ManageRun } else if (action == ManageAction.COUNTER) { var counter = Forms.long(store, "mgCounter"); if (counter == null || counter < 0) { @@ -249,6 +268,35 @@ class ManageRun extends Jobs.Job { private var _header as Entry? = null; private var _check as CodeCheck? = null; private var _cache as ByteArray? = null; + // Buffers to wipe once the run ends; large ones take steps of their own. + private var _wiper as Jobs.Wiper = new Jobs.Wiper(); + private var _wiping as Boolean = false; + // A code REPLACE: CodeCheck is still to start, with this payload text + // (null: from the queue, still to be read from `material`). + private var _needsCheck as Boolean = false; + private var _payload as String? = null; + // How far queued contents are checked as UTF-8. + private var _utf8At as Number = 0; + // A REPLACE's raw text, prepared in steps before it is checked + // (prepareText): a 0.9.0 item's raw bytes checked as UTF-8, then the + // spaces around it found, in pieces (kilobytes of either are a byte or + // character loop). + private var _rawAt as Number = 0; + private var _chars as Array? = null; + private var _trimStart as Number = 0; + private var _trimEnd as Number = -1; + private var _trimmed as String? = null; + // A REPLACE: the entry opened in the step before it is re-sealed. + private var _entry as Entry? = null; + + // Raw OTP secrets longer than this are refused before Base32.decode's + // character loop (128 bytes are 205 Base32 characters; this leaves room + // for spaces). + static const MAX_SECRET_TEXT = 1024; + // Contents checked as UTF-8 per step (up to 30 bytecodes a byte). + static const UTF8_STEP_BYTES = 1024; + // Characters looked at per step when trimming the raw text. + static const TRIM_STEP_CHARS = 512; // confirmed: the PIN was just entered for this action (from the queue), // so it is applied, never queued again. @@ -260,6 +308,9 @@ class ManageRun extends Jobs.Job { } function step(budget as Number) as Boolean { + if (_wiping) { + return _wiper.step(Jobs.Wiper.STEP_BYTES); + } var check = _check; if (check != null) { if (!check.step(budget)) { @@ -277,12 +328,22 @@ class ManageRun extends Jobs.Job { return false; } if (_header == null) { + if (_a.action == ManageAction.REPLACE && !_a.decoded && _trimmed == null) { + return prepareText(); + } try { return resolve(); } catch (e instanceof VaultError) { return fail(problemText(e)); } } + if (_needsCheck) { + try { + return prepareCheck(); + } catch (e instanceof VaultError) { + return fail(problemText(e)); + } + } return finish(_cache); } @@ -317,7 +378,8 @@ class ManageRun extends Jobs.Job { return queue(); } if (a.action == ManageAction.REPLACE && header.kind == Entry.KIND_CODE) { - return startCodeCheck(header); + _needsCheck = true; // CodeCheck with the entry's options (prepareCheck) + return false; } return false; } @@ -344,12 +406,26 @@ class ManageRun extends Jobs.Job { 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); - Bytes.wipe(raw); - a.material = null; - var decoded = OtpForm.decodeSecret(text != null ? text : "?"); + if (a.action == ManageAction.REPLACE && !a.decoded) { + // Checked before queueing, so a locked entry's queue item holds + // only the decoded secret or the checked contents. + var text = a.text as String; + var trimmed = _trimmed as String; + a.text = null; + if (!isOtp) { + var contents = CodeForm.contentsOf(text, trimmed, header.type); + if (contents instanceof String) { + return contents; + } + a.material = contents; + a.decoded = true; + _payload = Codes.isLinear(header.type) ? trimmed : text; + return null; + } + if (text.length() > MAX_SECRET_TEXT) { + return "Secret: too long"; + } + var decoded = OtpForm.decodeSecret(trimmed); // trimmed: no character loop there if (decoded instanceof String) { return decoded; } @@ -379,45 +455,105 @@ class ManageRun extends Jobs.Job { private function queue() as Boolean { try { - ChangeQueue.add(_vault, _a); + ChangeQueue.addWith(_vault, _a, _wiper); } catch (e instanceof VaultError) { return fail(e.code == VaultError.LIMIT ? e.getErrorMessage() as String : "storage full"); } queued = true; message = (_a.action == ManageAction.LOCK ? "Locks when you next enter your PIN on the watch: " : "Waiting for your PIN on the watch: ") + _a.describe(); - _a.wipe(); - return true; + wipeAction(); + return ending(); } - // Replacing a code's contents: validate them with the entry's options. - private function startCodeCheck(header as Entry) as Boolean { + // One step of preparing a REPLACE's raw text: a 0.9.0 item's raw bytes + // become text (checked as UTF-8 in pieces); text longer than any valid + // input is refused; then the spaces around it are found in pieces. + private function prepareText() as Boolean { var a = _a; - var raw = a.material as ByteArray; - var text = Bytes.toUtf8(raw); - Bytes.wipe(raw); - a.material = null; - var contents = CodeForm.contents(text != null ? text : "", header.type); - if (contents instanceof String) { - return fail(contents); - } - a.material = contents; - var options = Codes.DEFAULT_OPTIONS; - try { - var entry = _vault.open(a.id); - options = entry.options; - entry.wipeSecrets(); - } catch (e instanceof VaultError) { - return fail(problemText(e)); + var text = a.text; + if (text == null) { + var raw = a.material; + if (raw == null) { + return fail("Replace: enter the new secret or contents"); + } + if (_rawAt < raw.size()) { + var end = _rawAt + UTF8_STEP_BYTES < raw.size() ? _rawAt + UTF8_STEP_BYTES : raw.size(); + _rawAt = Bytes.utf8Prefix(raw, _rawAt, end); + return _rawAt < 0 ? fail("Replace: the new secret or contents are damaged") : false; + } + a.text = Bytes.toUtf8Checked(raw); + a.material = null; + _wiper.add(raw); + return false; + } + var chars = _chars; + if (chars == null) { + if (text.length() > Entry.MAX_PAYLOAD_BYTES) { + return fail("Replace: too long (" + text.length() + " characters, at most " + Entry.MAX_PAYLOAD_BYTES + ")"); + } + chars = text.toCharArray(); + _chars = chars; + _trimEnd = chars.size(); + return false; + } + var budget = TRIM_STEP_CHARS; + while (budget > 0 && _trimStart < _trimEnd && Text.isSpace(chars[_trimStart])) { + _trimStart++; + budget--; + } + if (budget == 0) { + return false; } - var payload = Bytes.toUtf8(contents); - _check = new CodeCheck(header.type, payload != null ? payload : "", options); + while (budget > 0 && _trimEnd > _trimStart && Text.isSpace(chars[_trimEnd - 1])) { + _trimEnd--; + budget--; + } + if (budget == 0) { + return false; + } + _trimmed = text.substring(_trimStart, _trimEnd); + _chars = null; + return false; + } + + // One step towards CodeCheck: queued contents are read as text first + // (the UTF-8 check is a byte loop, done in pieces), then the entry is opened for its + // options. + private function prepareCheck() as Boolean { + var a = _a; + var payload = _payload; + if (payload == null) { + var m = a.material as ByteArray; + if (_utf8At < m.size()) { + var end = _utf8At + UTF8_STEP_BYTES < m.size() ? _utf8At + UTF8_STEP_BYTES : m.size(); + _utf8At = Bytes.utf8Prefix(m, _utf8At, end); + return _utf8At < 0 ? fail("the new contents are damaged") : false; + } + _payload = Bytes.toUtf8Checked(m); + return false; + } + var entry = _vault.openWith(a.id, _wiper); + var options = entry.options; + wipeLater(entry); + _needsCheck = false; + _payload = null; + _check = new CodeCheck((_header as Entry).type, payload, options); return false; } // Apply the action; cache: a checked code's packed matrix, or null. private function finish(cache as ByteArray?) as Boolean { var a = _a; + if (a.action == ManageAction.REPLACE && _entry == null) { + // Opening and re-sealing a large code are a step each. + try { + _entry = _vault.openWith(a.id, _wiper); + } catch (e instanceof VaultError) { + return fail(problemText(e)); + } + return false; + } var target = a.id + (a.label.length() > 0 ? " " + a.label : ""); try { if (a.action == ManageAction.MOVE) { @@ -435,15 +571,14 @@ class ManageRun extends Jobs.Job { return fail(problem); } if (cache != null) { - _vault.saveCodeCache(a.id, cache); + _vault.saveCodeCacheWith(a.id, cache, _wiper); } } } catch (e instanceof VaultError) { - a.wipe(); return fail(problemText(e)); } - a.wipe(); - return true; + wipeAction(); + return ending(); } // Edit, lock, unlock, (un)favourite or replace: open, change, re-seal. @@ -451,13 +586,15 @@ class ManageRun extends Jobs.Job { private function changeEntry() as String? { var a = _a; var target = a.id + (a.label.length() > 0 ? " " + a.label : ""); - var entry = _vault.open(a.id); + var opened = _entry; + _entry = null; + var entry = opened != null ? opened : _vault.openWith(a.id, _wiper); if (a.action == ManageAction.EDIT) { entry.name = edited(entry.name, a.name); entry.account = edited(entry.account, a.account); entry.group = edited(entry.group, a.group); if (entry.name.length() == 0 && (entry.kind == Entry.KIND_CODE || entry.account.length() == 0)) { - entry.wipeSecrets(); + wipeLater(entry); return entry.kind == Entry.KIND_CODE ? "Name: a barcode needs a name" : "An OTP entry needs an issuer or an account"; } message = "Edited " + a.id + ": " + Forms.label(entry.name, entry.account) @@ -471,22 +608,22 @@ class ManageRun extends Jobs.Job { } else { var material = a.material as ByteArray; if (entry.kind == Entry.KIND_OTP) { - Bytes.wipe(entry.secret); + _wiper.add(entry.secret); entry.secret = Bytes.copy(material); message = "Replaced the secret of " + target; } else { - Bytes.wipe(entry.payload); + _wiper.add(entry.payload); entry.payload = Bytes.copy(material); message = "Replaced the contents of " + target; } } try { - _vault.update(entry); + _vault.updateWith(entry, _wiper); } catch (e instanceof VaultError) { - entry.wipeSecrets(); + wipeLater(entry); throw e; } - entry.wipeSecrets(); + wipeLater(entry); return null; } @@ -501,8 +638,38 @@ class ManageRun extends Jobs.Job { private function fail(problem as String) as Boolean { failed = true; message = problem; - _a.wipe(); - return true; + var entry = _entry; + if (entry != null) { + wipeLater(entry); + _entry = null; + } + wipeAction(); + return ending(); + } + + // The action's material goes to the wiper. + private function wipeAction() as Void { + _wiper.add(_a.material); + _a.material = null; + _a.text = null; + } + + private function wipeLater(entry as Entry) as Void { + _wiper.add(entry.secret); + _wiper.add(entry.payload); + entry.secret = null; + entry.payload = null; + } + + // The run is over once its buffers are wiped: small ones now, large + // ones in the next steps. + private function ending() as Boolean { + if (_wiper.left() <= Jobs.Wiper.INLINE_BYTES) { + _wiper.step(Jobs.Wiper.INLINE_BYTES); + return true; + } + _wiping = true; + return false; } static function problemText(e as VaultError) as String { diff --git a/source/settings/Results.mc b/source/settings/Results.mc index 37c932f..f5800a5 100644 --- a/source/settings/Results.mc +++ b/source/settings/Results.mc @@ -15,6 +15,19 @@ module Results { Application.Storage.setValue(KEY, {"l" => kept, "u" => true} as Dictionary); } + // Add lines to results not seen yet (an import's, say), or replace seen + // ones (marked unseen). + function add(lines as Array) as Void { + if (!unseen()) { + save(lines); + return; + } + var all = [] as Array; + all.addAll(Results.lines()); + all.addAll(lines); + save(all); + } + // The stored lines, or a short explanation when there are none. function lines() as Array { var stored = Application.Storage.getValue(KEY); diff --git a/source/storage/KeyManager.mc b/source/storage/KeyManager.mc index 2dd3712..ae2c5e8 100644 --- a/source/storage/KeyManager.mc +++ b/source/storage/KeyManager.mc @@ -81,17 +81,23 @@ class KeyManager { // entries while the watch is locked (docs/SECURITY.md). function fingerprint(entry as Entry) as ByteArray { deviceKeys(); - var input = Bytes.fromUtf8("OTPW-FP-1"); - input.add(entry.kind); - input.add(entry.type); + // The secret or contents go to the native HMAC as they are: a copy + // would need a byte-loop wipe (4 KB of contents, the watchdog). + var mac = new Cryptography.HashBasedMessageAuthenticationCode({ + :algorithm => Cryptography.HASH_SHA256, + :key => _fingerprintKey as ByteArray + }); + var prefix = Bytes.fromUtf8("OTPW-FP-1"); + prefix.add(entry.kind); + prefix.add(entry.type); + mac.update(prefix); var material = entry.kind == Entry.KIND_OTP ? entry.secret : entry.payload; - if (material != null) { - input.addAll(material); + if (material != null && material.size() > 0) { + mac.update(material); } - var mac = Hmac.nativeSha256(_fingerprintKey as ByteArray, input); - Bytes.wipe(input); - var out = mac.slice(0, FINGERPRINT_BYTES); - Bytes.wipe(mac); + var digest = mac.digest(); + var out = digest.slice(0, FINGERPRINT_BYTES); + Bytes.wipe(digest); return out; } diff --git a/source/storage/Record.mc b/source/storage/Record.mc index b26a4ba..2cfc046 100644 --- a/source/storage/Record.mc +++ b/source/storage/Record.mc @@ -63,12 +63,24 @@ module Record { // Seal an entry into record bytes. keyRef is KEY_DEVICE or KEY_MASTER and // must match `keys`. The plaintext TLV is wiped before returning. function seal(entry as Entry, keyRef as Number, keys as SealedBox.Keys) as ByteArray { + return sealWith(entry, keyRef, keys, null); + } + + // As seal(), but the plaintext TLV goes to `wiper` when given: wiping a + // large code's contents is a step of its own (the watchdog). + function sealWith(entry as Entry, keyRef as Number, keys as SealedBox.Keys, wiper as Jobs.Wiper?) as ByteArray { var header = encodeHeader(entry); var record = [VERSION, keyRef, (header.size() >> 8) & 0xFF, header.size() & 0xFF]b; record.addAll(header); var plain = encodeSecret(entry); - var box = SealedBox.seal(keys, aad(entry.id, record), plain); - Bytes.wipe(plain); + var leftovers = wiper != null ? [] as Array : null; + var box = SealedBox.sealKeep(keys, aad(entry.id, record), plain, leftovers); + if (wiper != null) { + wiper.add(plain); + wiper.addAll(leftovers as Array); + } else { + Bytes.wipe(plain); + } record.addAll(box); return record; } @@ -119,17 +131,30 @@ module Record { // keys, stored under another ID, or changed in any way. The decrypted // TLV is wiped; the caller must call entry.wipeSecrets() when done. function open(record as ByteArray, id as String, keys as SealedBox.Keys) as Entry? { + return openWith(record, id, keys, null); + } + + // As open(), but the decrypted TLV goes to `wiper` when given. + function openWith(record as ByteArray, id as String, keys as SealedBox.Keys, wiper as Jobs.Wiper?) as Entry? { var entry = decodeHeader(record); var end = headerEnd(record); if (entry == null || end == null || !entry.id.equals(id)) { return null; } - var plain = SealedBox.open(keys, aad(id, record.slice(0, end)), record.slice(end, record.size())); + var leftovers = wiper != null ? [] as Array : null; + var plain = SealedBox.openKeep(keys, aad(id, record.slice(0, end)), record.slice(end, record.size()), leftovers); + if (wiper != null) { + wiper.addAll(leftovers as Array); + } if (plain == null) { return null; } var map = Codec.decode(plain); - Bytes.wipe(plain); + if (wiper != null) { + wiper.add(plain); + } else { + Bytes.wipe(plain); + } if (map == null) { return null; } diff --git a/source/storage/Vault.mc b/source/storage/Vault.mc index 2890477..ef240e0 100644 --- a/source/storage/Vault.mc +++ b/source/storage/Vault.mc @@ -7,7 +7,7 @@ // header says locked; anything else is reported as damaged. // * A locked entry added while the session is locked (or before a PIN // exists) is sealed with the device key under "p." and hidden until -// promotePending() moves it under the master key (README 7.7). +// promotePending() / promoteNext() moves it under the master key (README 7.7). // * Writes go record first, metadata second, so a crash between the two // leaves at most an unreferenced record, never an index entry without // its record. Deletes go the other way round. @@ -108,12 +108,18 @@ class Vault { // The full entry with its secret. Throws NOT_FOUND, LOCKED (locked entry // while the session is locked) or DAMAGED. Call wipeSecrets() when done. function open(id as String) as Entry { + return openWith(id, null); + } + + // As open(), but the decrypted record TLV goes to `wiper` when given + // (Record.openWith): for a large code, wiping is a step of its own. + function openWith(id as String, wiper as Jobs.Wiper?) as Entry { var record = recordFor(id); var header = record != null ? Record.decodeHeader(record) : null; if (record == null || header == null || !consistent(header, record)) { throw new VaultError(VaultError.DAMAGED, "damaged entry " + id); } - var entry = Record.open(record, id, keysFor(header.locked)); + var entry = Record.openWith(record, id, keysFor(header.locked), wiper); if (entry == null) { throw new VaultError(VaultError.DAMAGED, "damaged entry " + id); } @@ -138,7 +144,7 @@ class Vault { entry.created = Clock.nowUnix(0); } if (entry.locked && !session.isUnlocked()) { - write("p." + entry.id, sealChecked(entry, Record.KEY_DEVICE, keyManager.deviceKeys())); + write("p." + entry.id, sealChecked(entry, Record.KEY_DEVICE, keyManager.deviceKeys(), null)); _pending.add(entry.id); saveMetaOrUndo("p." + entry.id, _pending, entry.id); } else { @@ -153,13 +159,19 @@ class Vault { // entry, or locking one, needs an unlocked session. Throws NOT_FOUND, // INVALID, LOCKED, LIMIT or FULL. function update(entry as Entry) as Void { + updateWith(entry, null); + } + + // As update(), but the plaintext TLV goes to `wiper` when given. + function updateWith(entry as Entry, wiper as Jobs.Wiper?) as Void { var record = recordFor(entry.id); checkStorable(entry); entry.fingerprint = keyManager.fingerprint(entry); if ((record != null && Record.keyRef(record) == Record.KEY_MASTER) || entry.locked) { session.keys(); // throws LOCKED } - write("e." + entry.id, seal(entry)); + write("e." + entry.id, sealChecked(entry, entry.locked ? Record.KEY_MASTER : Record.KEY_DEVICE, keysFor(entry.locked), + wiper)); _store.deleteValue("x." + entry.id); // the cached matrix may be stale } @@ -184,12 +196,21 @@ class Vault { // key (device key, or the master key for locked entries) and bound to the // ID. A full store just means no cache. Throws NOT_FOUND or LOCKED. function saveCodeCache(id as String, data as ByteArray) as Void { + saveCodeCacheWith(id, data, null); + } + + // As saveCodeCache(); the padded copy goes to `wiper` when given. + function saveCodeCacheWith(id as String, data as ByteArray, wiper as Jobs.Wiper?) as Void { var keys = keysFor(isLockedRecord(id)); + var leftovers = [] as Array; try { - _store.setValue("x." + id, SealedBox.seal(keys, cacheAad(id), data)); + _store.setValue("x." + id, SealedBox.sealKeep(keys, cacheAad(id), data, wiper != null ? leftovers : null)); } catch (e instanceof Lang.Exception) { // Storage full: the code is simply encoded again next time. } + if (wiper != null) { + wiper.addAll(leftovers); + } } // The cached matrix, or null when there is none or it doesn't verify. @@ -257,30 +278,54 @@ class Vault { // Move pending locked entries under the master key and into the list. // Needs an unlocked session. Damaged pending items are dropped. Returns - // the number of entries promoted. + // the number of entries promoted. All in one event: for tests and small + // queues; after a PIN the app promotes in steps (promoteNext, LockJob). function promotePending() as Number { - var keys = session.keys(); // throws LOCKED + session.keys(); // throws LOCKED var promoted = 0; while (_pending.size() > 0) { - var id = _pending[0]; - var stored = _store.getValue("p." + id); - var entry = stored instanceof ByteArray ? Record.open(stored, id, keyManager.deviceKeys()) : null; - if (entry != null) { - entry.locked = true; - write("e." + id, sealChecked(entry, Record.KEY_MASTER, keys)); - entry.wipeSecrets(); - if (_ids.indexOf(id) < 0) { - _ids.add(id); - } + if (promoteNext(null) > 0) { promoted++; } - _pending.remove(id); - saveMeta(); - _store.deleteValue("p." + id); } return promoted; } + // Promote the first pending entry (one record, then the metadata, so a + // crash in between at most promotes it again). Returns the record's + // size when it was promoted, -1 when it was damaged (dropped), 0 when + // none waits. The entry's secrets go to `wiper` when given (wiping a + // large code's contents is a step of its own), else are wiped here. + // Needs an unlocked session. + function promoteNext(wiper as Jobs.Wiper?) as Number { + var keys = session.keys(); // throws LOCKED + if (_pending.size() == 0) { + return 0; + } + var id = _pending[0]; + var stored = _store.getValue("p." + id); + var entry = stored instanceof ByteArray ? Record.openWith(stored, id, keyManager.deviceKeys(), wiper) : null; + if (entry != null) { + entry.locked = true; + write("e." + id, sealChecked(entry, Record.KEY_MASTER, keys, wiper)); + if (wiper != null) { + wiper.add(entry.secret); + wiper.add(entry.payload); + entry.secret = null; + entry.payload = null; + } else { + entry.wipeSecrets(); + } + if (_ids.indexOf(id) < 0) { + _ids.add(id); + } + } + _pending.remove(id); + saveMeta(); + _store.deleteValue("p." + id); + return entry != null ? (stored as ByteArray).size() : -1; + } + // ---- PIN and session ----------------------------------------------- function hasPin() as Boolean { @@ -409,12 +454,12 @@ class Vault { } private function seal(entry as Entry) as ByteArray { - return sealChecked(entry, entry.locked ? Record.KEY_MASTER : Record.KEY_DEVICE, keysFor(entry.locked)); + return sealChecked(entry, entry.locked ? Record.KEY_MASTER : Record.KEY_DEVICE, keysFor(entry.locked), null); } // Seal and enforce the per-record size limit. - private function sealChecked(entry as Entry, keyRef as Number, keys as SealedBox.Keys) as ByteArray { - var record = Record.seal(entry, keyRef, keys); + private function sealChecked(entry as Entry, keyRef as Number, keys as SealedBox.Keys, wiper as Jobs.Wiper?) as ByteArray { + var record = Record.sealWith(entry, keyRef, keys, wiper); if (record.size() > Record.MAX_BYTES) { throw new VaultError(VaultError.LIMIT, "entry too big"); } diff --git a/source/ui/MainMenu.mc b/source/ui/MainMenu.mc index 21f427c..a4a57da 100644 --- a/source/ui/MainMenu.mc +++ b/source/ui/MainMenu.mc @@ -133,7 +133,7 @@ class MainMenuDelegate extends WatchUi.Menu2InputDelegate { WatchUi.SLIDE_LEFT); Results.markSeen(); } else if (id == :queue) { - WatchUi.switchToView(QueueMenu.build(), new QueueMenuDelegate(), WatchUi.SLIDE_LEFT); + QueueMenu.open(); } else if (id == :ids) { var screen = new EntryIdsScreen(); MainMenu.idsScreen = screen; diff --git a/source/ui/PinView.mc b/source/ui/PinView.mc index af18e2b..da032ad 100644 --- a/source/ui/PinView.mc +++ b/source/ui/PinView.mc @@ -16,7 +16,9 @@ // * Entered digits show as dots. A wrong PIN vibrates and shows any wait. // * Creating or changing the PIN asks twice. // * Key derivation (about 1 s) runs as a job on a timer, so the screen -// shows "Checking..." and the watchdog never trips. +// shows "Checking..." and the watchdog never trips. So does the work +// after a correct or new PIN (LockJob: pending entries are promoted, +// waiting "Require PIN" changes applied); its lines go to the results. // The typed digits are a ByteArray that is wiped after every attempt. import Toybox.Attention; @@ -58,6 +60,10 @@ class PinView extends WatchUi.View { private var _jobPin as ByteArray? = null; // The running derivation's timer driver (kept so it isn't collected). var runner as Jobs.JobRunner? = null; + private var _lockJob as LockJob? = null; + // A LockJob ran for this screen's PIN (a legacy PIN's new PIN runs a + // second one, which adds to the first one's lines). + private var _lockJobRan as Boolean = false; private var _ticker as Timer.Timer? = null; private var _keys as Array> = [] as Array>; // [x, y, w, h, key] private var _shownLogged as Boolean = false; @@ -79,6 +85,11 @@ class PinView extends WatchUi.View { } function onHide() as Void { + if (_lockJobRan && _lockJob == null) { + // Closed (or replaced): a "Confirm all" that follows has taken + // the lock lines already; else they are in the results. + ChangeQueue.takeLockLines(); + } var ticker = _ticker; if (ticker != null) { ticker.stop(); @@ -234,24 +245,16 @@ class PinView extends WatchUi.View { } function onUnlockDone() as Void { - busy = false; - _shownLogged = false; Bytes.wipe(_jobPin); _jobPin = null; var vault = AppState.theVault(); if (vault.finishUnlock(_job as PinJob)) { _job = null; - vault.promotePending(); - ChangeQueue.applyLocks(vault); - // Unlocked: "Encrypt names" can now reach locked entries. - AppState.processSettings(); - if (legacy) { - askForNewPin(); - return; - } - succeed(); + startLockJob(); return; } + busy = false; + _shownLogged = false; _job = null; buzz(); if (!vault.hasPin()) { @@ -264,14 +267,39 @@ class PinView extends WatchUi.View { } function onSetDone() as Void { - busy = false; Bytes.wipe(_jobPin); _jobPin = null; - var vault = AppState.theVault(); - vault.finishSetPin(_job as PinJob); + AppState.theVault().finishSetPin(_job as PinJob); _job = null; - vault.promotePending(); - ChangeQueue.applyLocks(vault); + startLockJob(); + } + + // Unlocked: promote the pending entries and apply waiting "Require + // PIN" changes in steps, still showing the busy state (BACK waits). + private function startLockJob() as Void { + var job = new LockJob(AppState.theVault(), !_lockJobRan); + _lockJobRan = true; + _lockJob = job; + var r = new Jobs.JobRunner(job, 1, method(:onLockJobDone)); + r.start(); + runner = r; + } + + function onLockJobDone() as Void { + busy = false; + _shownLogged = false; + var job = _lockJob as LockJob; + _lockJob = null; + if (job.lines.size() > 0) { + Results.add(job.lines); // the list says "Results ready (menu)" + } + // "Encrypt names" can now reach locked entries, and a lock just + // applied changes what the glance may show. + AppState.processSettings(); + if (legacy) { + askForNewPin(); + return; + } succeed(); } diff --git a/source/ui/QueueMenu.mc b/source/ui/QueueMenu.mc index 3f9161d..0ec9daf 100644 --- a/source/ui/QueueMenu.mc +++ b/source/ui/QueueMenu.mc @@ -5,20 +5,32 @@ // the point is that a person at the watch agrees, not that a key is // available. (Before any PIN exists only "Require PIN" can wait here, and // confirming creates the PIN. "Require PIN" items are applied by the PIN -// screen itself, ChangeQueue.applyLocks, and listed in the results.) The changes are then applied by a -// ChangeQueueJob on a timer and the results screen shows what happened. +// screen itself, LockJob, and listed in the results.) The changes are then +// applied by a ChangeQueueJob on a timer and the results screen shows what +// happened. The menu is built from DescribeJob's lines, read behind a short +// "Reading…" screen (opening 16 items in one event trips the watchdog). import Toybox.Lang; import Toybox.WatchUi; module QueueMenu { - function build() as WatchUi.Menu2 { + // The "Reading…" screen while it runs (kept so the runner isn't collected). + var screen as QueueMenuScreen? = null; + + // Open the menu (replacing the current view). + function open() as Void { + var s = new QueueMenuScreen(); + screen = s; + s.start(); + } + + // The menu for these descriptions (DescribeJob.lines). + function build(items as Array) as WatchUi.Menu2 { var vault = AppState.theVault(); var menu = new WatchUi.Menu2({:title => "Phone changes"}); menu.addItem(new WatchUi.MenuItem("Confirm all", vault.hasPin() ? "Asks for your PIN" : "Sets a PIN first", :confirm, null)); menu.addItem(new WatchUi.MenuItem("Reject all", null, :reject, null)); - var items = ChangeQueue.descriptions(vault); for (var i = 0; i < items.size(); i++) { menu.addItem(new WatchUi.MenuItem(items[i], null, i, null)); } @@ -26,10 +38,44 @@ module QueueMenu { } } -class QueueMenuDelegate extends WatchUi.Menu2InputDelegate { +// Shows "Reading…", runs DescribeJob, then replaces that screen with the +// menu, unless the user left it meanwhile. +class QueueMenuScreen { + + var runner as Jobs.JobRunner? = null; + private var _job as DescribeJob = new DescribeJob(AppState.theVault()); + private var _reading as InfoView = new InfoView("Phone changes", ["Reading…"] as Array, "queue_reading"); function initialize() { + } + + function start() as Void { + WatchUi.switchToView(_reading, new InfoDelegate(), WatchUi.SLIDE_LEFT); + var r = new Jobs.JobRunner(_job, 1, method(:done)); + runner = r; + r.start(); + } + + function done() as Void { + runner = null; + QueueMenu.screen = null; + if (WatchUi.getCurrentView()[0] == _reading) { + WatchUi.switchToView(QueueMenu.build(_job.lines), new QueueMenuDelegate(_job.lines, _job.keys), WatchUi.SLIDE_IMMEDIATE); + } + } +} + +class QueueMenuDelegate extends WatchUi.Menu2InputDelegate { + + // The descriptions the menu shows, and the keys of the items read + // (DescribeJob): confirming or rejecting acts on exactly those. + private var _items as Array; + private var _keys as Array; + + function initialize(items as Array, keys as Array) { Menu2InputDelegate.initialize(); + _items = items; + _keys = keys; } function onSelect(item as WatchUi.MenuItem) as Void { @@ -37,22 +83,25 @@ class QueueMenuDelegate extends WatchUi.Menu2InputDelegate { var id = item.getId(); var vault = AppState.theVault(); if (id == :confirm) { - var then = new ApplyQueueAfterPin(); + var then = new ApplyQueueAfterPin(_keys); var mode = vault.hasPin() ? PinView.MODE_UNLOCK : PinView.MODE_CREATE; WatchUi.switchToView(new PinView(mode, then.method(:run)), new PinDelegate(), WatchUi.SLIDE_LEFT); } else if (id == :reject) { var lines = ["Rejected these changes from the phone:"] as Array; - lines.addAll(ChangeQueue.descriptions(vault)); - ChangeQueue.clear(vault); + lines.addAll(_items); + var late = ChangeQueue.remove(vault, _keys); + if (late > 0) { + lines.add(late + (late == 1 ? " change" : " changes") + " arrived meanwhile and still " + + (late == 1 ? "waits" : "wait") + " (menu, Phone changes)"); + } Results.save(lines); WatchUi.switchToView(new InfoView("Results", lines, "results"), new InfoDelegate(), WatchUi.SLIDE_LEFT); Results.markSeen(); AppState.processSettings(); // the About fields mention waiting changes } else if (id instanceof Number) { // The full text of one change (menu labels are cut short). - var items = ChangeQueue.descriptions(vault); - if (id < items.size()) { - WatchUi.pushView(new InfoView("Change", [items[id]] as Array, "change"), new InfoDelegate(), + if (id < _items.size()) { + WatchUi.pushView(new InfoView("Change", [_items[id]] as Array, "change"), new InfoDelegate(), WatchUi.SLIDE_LEFT); } } @@ -65,12 +114,15 @@ class ApplyQueueAfterPin { // The job's timer driver (kept so it isn't collected while it runs). var runner as Jobs.JobRunner? = null; private var _job as ChangeQueueJob? = null; + private var _keys as Array; - function initialize() { + // keys: the items the menu showed (DescribeJob.keys). + function initialize(keys as Array) { + _keys = keys; } function run() as Void { - var job = new ChangeQueueJob(AppState.theVault()); + var job = new ChangeQueueJob(AppState.theVault(), _keys); _job = job; WatchUi.switchToView(new InfoView("Results", ["Applying the changes…"] as Array, "applying"), new InfoDelegate(), WatchUi.SLIDE_LEFT); diff --git a/source/util/Bytes.mc b/source/util/Bytes.mc index aa5a28b..5fd7a96 100644 --- a/source/util/Bytes.mc +++ b/source/util/Bytes.mc @@ -60,6 +60,14 @@ module Bytes { if (!isValidUtf8(data)) { return null; } + return toUtf8Checked(data); + } + + // Bytes that utf8Prefix() found well-formed to the end, as a string. + function toUtf8Checked(data as ByteArray) as String { + if (data.size() == 0) { + return ""; + } return StringUtil.convertEncodedString(data, { :fromRepresentation => StringUtil.REPRESENTATION_BYTE_ARRAY, :toRepresentation => StringUtil.REPRESENTATION_STRING_PLAIN_TEXT, @@ -70,15 +78,24 @@ module Bytes { // True for well-formed UTF-8 (RFC 3629) without NUL bytes: no overlong // forms, no surrogates (U+D800-DFFF), nothing above U+10FFFF. function isValidUtf8(data as ByteArray) as Boolean { - var i = 0; + return utf8Prefix(data, 0, data.size()) >= 0; + } + + // Checks the sequences that start in [from, to) (to <= size) as + // isValidUtf8() does: + // the index where the next sequence starts (`to` or up to 3 past it), + // or -1 when one is malformed. A large array is checked in steps this + // way (the check is a byte loop in the VM). + function utf8Prefix(data as ByteArray, from as Number, to as Number) as Number { + var i = from; var n = data.size(); - while (i < n) { + while (i < to) { var b = data[i]; var more = 0; var min = 0; var cp = 0; if (b == 0) { - return false; + return -1; } else if (b < 0x80) { i++; continue; @@ -89,24 +106,24 @@ module Bytes { } else if (b >= 0xF0 && b <= 0xF4) { more = 3; min = 0x10000; cp = b & 0x07; } else { - return false; + return -1; } if (i + more >= n) { - return false; + return -1; } for (var k = 1; k <= more; k++) { var c = data[i + k]; if ((c & 0xC0) != 0x80) { - return false; + return -1; } cp = (cp << 6) | (c & 0x3F); } if (cp < min || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) { - return false; + return -1; } i += more + 1; } - return true; + return i; } // A new array holding a followed by b; neither input is changed. diff --git a/source/util/Jobs.mc b/source/util/Jobs.mc index 595562e..009e16c 100644 --- a/source/util/Jobs.mc +++ b/source/util/Jobs.mc @@ -86,6 +86,69 @@ module Jobs { } } + // Wipes buffers in pieces of at most `max` bytes per step(): zeroing is + // a byte loop in the VM (12 to 15 counted bytecodes per byte, measured + // in `make watchdog`), so a few kilobytes take a step of their own. + class Wiper { + + // Bytes per step: about 30,000 bytecodes, a quarter of the 255S's + // 120,000 per event (measured: 16 KB in one event pass on the + // 240,000-bytecode Forerunner 70). + static const STEP_BYTES = 2048; + // Up to this many bytes are wiped at once, without a step of their own. + static const INLINE_BYTES = 512; + + private var _buffers as Array = [] as Array; + private var _at as Number = 0; + private var _left as Number = 0; + + function initialize() { + } + + // Queue a buffer (null is ignored). + function add(data as ByteArray?) as Void { + if (data != null && data.size() > 0) { + _buffers.add(data); + _left += data.size(); + } + } + + function addAll(buffers as Array) as Void { + for (var i = 0; i < buffers.size(); i++) { + add(buffers[i]); + } + } + + // Bytes still to wipe. + function left() as Number { + return _left - _at; + } + + // Wipe at most `max` bytes; true when everything is wiped. + function step(max as Number) as Boolean { + var done = 0; + while (_buffers.size() > 0 && done < max) { + var b = _buffers[0]; + var end = _at + (max - done); + if (end > b.size()) { + end = b.size(); + } + for (var i = _at; i < end; i++) { + b[i] = 0; + } + done += end - _at; + if (end == b.size()) { + _left -= b.size(); + _at = 0; + _buffers = _buffers.slice(1, null) as Array; + } else { + _at = end; + } + } + return _buffers.size() == 0; + } + } + // App memory above this means a 240,000-bytecode watchdog. const LARGE_WATCH_MEMORY = 600000; var _stepsPerTick as Number = 0; diff --git a/source/util/StressTest.mc b/source/util/StressTest.mc index d1f99ea..a9d9293 100644 --- a/source/util/StressTest.mc +++ b/source/util/StressTest.mc @@ -1,5 +1,5 @@ // Author: cirakg & claude ai -// Summary: Watchdog stress build: the largest code of every 2D symbology, encoded, packed, turned into runs and drawn, at twice the normal steps per event. +// Summary: Watchdog stress build: the largest code of every 2D symbology, encoded, packed, turned into runs and drawn, at twice the normal steps per event; the PIN and change-queue work at the same margin. // // Two versions of this module exist; each build keeps exactly one: // * (:no_stress), in every normal build: ON is false. @@ -14,8 +14,13 @@ // where the runner already does two steps per tick). Codes that fit the // screen are then drawn the way CodeView draws them (RunsPainter: straight // from the runs, or from a bitmap painted in steps), in one onUpdate. +// Before the last code, VaultCase items measure the vault work after a PIN +// and on the change queue (LockJob, ChangeQueueJob, ManageRun replacing +// 4 KB of contents or a long secret, counting and describing the queue, +// 0.9.0 items) at the same margin. // docker/scripts/app-diag.sh watchdog reads the DIAG stress lines. +import Toybox.Application; import Toybox.Graphics; import Toybox.Lang; import Toybox.System; @@ -70,6 +75,362 @@ module StressTest { } } + // A RAM store for the vault cases. Values are kept as they are, without + // the copies Application.Storage (or the tests' MemoryBackend) makes, so + // the store adds no VM loops to what is measured, and two full vaults + // fit without the storage limits. + class RamBackend extends Persist.Backend { + var values as Dictionary = {} as Dictionary; + + function initialize() { + Backend.initialize(); + } + + function getValue(key as String) as Application.Storage.ValueType { + return values[key] as Application.Storage.ValueType; + } + + function setValue(key as String, value as Application.Storage.ValueType) as Void { + values[key] = value; + } + + function deleteValue(key as String) as Void { + values.remove(key); + } + } + + // One case of vault work after a PIN or on a queue change. Setup builds + // the vaults in small steps (one entry, queue item or KDF step each). + // The measured run then does the work at MARGIN: what runs in one event + // runs twice in it, and jobs run on two identical vaults (A and B), one + // step of each per step of this job, in stages (one job per vault each). + // * UNLOCK: each vault has a PIN, LOCKS unlocked entries with a queued + // "Require PIN" each, `pending` pending locked entries and a locked + // session. B's unlock derivation finishes during setup; A's runs in + // the measured run. Its last step finishes both unlocks, as + // PinView.onUnlockDone does; then the LockJobs run. + // * COUNT / DESCRIBE: one locked vault. COUNT's queue holds a REPLACE + // of a locked code with `material` bytes of checked contents and + // LOCKS - 1 LOCKs, and the work is ChangeQueue.changed() + + // countToConfirm(). DESCRIBE's holds LOCKS edits with names of the + // largest size, and the work is DescribeJob (QueueMenu), stepped on + // the one vault MARGIN times per step. + // * REPLACE_QUEUED: a locked code entry, a REPLACE with `material` + // digits queued by ManageRun (as the settings queue it) during + // setup; the measured run applies it with ChangeQueueJob. + // * REPLACE_DIRECT: an unlocked code entry, and the same REPLACE + // applied at once (ManageRun, not confirmed, as SettingsJob runs it). + // * LEGACY: a locked code entry and a 0.9.0 queue item (a bare box, + // aad "OTPW-Q-1") replacing it with `material` raw digits; locked, + // with both unlock derivations done in setup. Stages: DescribeJob + // (the menu), the unlock and LockJob, ChangeQueueJob ("Confirm all" + // with the menu's keys). + // * SECRET: a locked OTP entry, and a REPLACE with a valid Base32 + // secret padded with spaces to ManageRun.MAX_SECRET_TEXT characters: + // queued (ManageRun), then confirmed (ChangeQueueJob). + // * SPACES: an unlocked code entry and a REPLACE of `material` spaces + // (refused: "Contents: missing"). + class VaultCase extends Jobs.Job { + static const UNLOCK = 0; + static const COUNT = 1; + static const DESCRIBE = 2; + static const REPLACE_QUEUED = 3; + static const REPLACE_DIRECT = 4; + static const LEGACY = 5; + static const SECRET = 6; + static const SPACES = 7; + // Unlocked entries, one queued item each (ChangeQueue.MAX_ITEMS). + static const LOCKS = 16; + static const PIN = "1234"; + + var name as String; + var kind as Number; + var pending as Number; + var material as Number; + // False during setup, true in the measured run. + var measuring as Boolean = false; + var steps as Number = 0; + var measured as Number = 0; + var peak as Number = 0; + var result as String = ""; + + private var _vaults as Array = [] as Array; + private var _pins as Array = [] as Array; + private var _targets as Array = [] as Array; + private var _phase as Number = 0; + private var _n as Number = 0; + private var _job as PinJob? = null; + private var _setupRun as ManageRun? = null; + // The measured stage, its jobs (one per vault) and which are done. + private var _stage as Number = -1; + private var _jobs as Array = [] as Array; + private var _done as Array = [] as Array; + // LEGACY: the keys each vault's menu read. + private var _keys as Array> = [] as Array>; + + function initialize(label as String, type as Number, pendingCount as Number, materialBytes as Number) { + Job.initialize(); + name = label; + kind = type; + pending = pendingCount; + material = materialBytes; + } + + function step(budget as Number) as Boolean { + var used = System.getSystemStats().usedMemory; + peak = used > peak ? used : peak; + if (measuring) { + measured++; + return measure(budget); + } + steps++; + return setup(budget); + } + + private function vaultCount() as Number { + return kind == COUNT || kind == DESCRIBE ? 1 : 2; + } + + // Entries per vault. + private function entryCount() as Number { + return kind >= REPLACE_QUEUED ? 1 : LOCKS; + } + + // One bounded setup step; true when every vault is ready. + private function setup(budget as Number) as Boolean { + var v = _vaults.size() > 0 ? _vaults[_vaults.size() - 1] : null; + if (_phase == 0) { + v = new Vault(new RamBackend(), new TimeSource()); + _vaults.add(v); + _targets = [] as Array; + _job = v.beginSetPin(Bytes.fromUtf8(PIN), Kdf.MIN_ITERATIONS); + _phase = 1; + } else if (_phase == 1) { + if ((_job as PinJob).step(budget)) { + (v as Vault).finishSetPin(_job as PinJob); + _job = null; + _phase = 2; + _n = 0; + } + } else if (_phase == 2) { + _targets.add((v as Vault).add(newEntry())); + _n++; + if (_n == entryCount()) { + if (kind <= DESCRIBE || kind == LEGACY) { + (v as Vault).lock(); + } + _phase = 3; + _n = 0; + } + } else if (_phase == 3) { + // Locked entries added while locked wait under "p." (pending). + if (_n < pending) { + (v as Vault).add(Fixtures.otp("Pending " + _n, "acct", "Stress", Entry.OTP_TOTP, Hashes.SHA1, 6, 30, + "12345678901234567890", true)); + _n++; + } else { + _phase = 4; + _n = 0; + } + } else if (_phase == 4) { + if (queueStep(v as Vault)) { + if (kind != UNLOCK && kind != LEGACY) { + _phase = 0; + return _vaults.size() == vaultCount(); + } + _pins.add((v as Vault).beginUnlock(Bytes.fromUtf8(PIN))); + // UNLOCK: the last vault (A) is unlocked in the measured run. + if (kind == UNLOCK && _vaults.size() == vaultCount()) { + return true; + } + _phase = 5; + } + } else if (_pins[_pins.size() - 1].step(budget)) { + _phase = 0; + return _vaults.size() == vaultCount(); + } + return false; + } + + private function newEntry() as Entry { + if (kind == SECRET) { + return Fixtures.otp("Bank", "acct", "Stress", Entry.OTP_TOTP, Hashes.SHA1, 6, 30, "12345678901234567890", true); + } + if (kind >= REPLACE_QUEUED || (kind != UNLOCK && _n == 0)) { + // The code a REPLACE targets; locked unless replaced directly. + var e = Fixtures.code("Card", Codes.QR, "1234567890"); + e.locked = kind != REPLACE_DIRECT && kind != SPACES; + return e; + } + return Fixtures.otp("Stress " + _n, "acct", "Stress", Entry.OTP_TOTP, Hashes.SHA1, 6, 30, "12345678901234567890", false); + } + + // The REPLACE of the replace cases, as ManageForm.read gives it. + private function replaceAction(v as Vault) as ManageAction { + var a = new ManageAction(); + a.id = _targets[0]; + a.action = ManageAction.REPLACE; + if (kind == SECRET) { + // 200 Base32 characters (125 bytes) in groups, spaces around. + var group = repeat(" ", 16) + "AAAA"; + var groups = 200 / 4; + a.text = repeat(" ", ManageRun.MAX_SECRET_TEXT - groups * group.length()) + repeat(group, groups); + } else if (kind == SPACES) { + a.text = repeat(" ", material); + } else { + a.text = repeat("0123456789", (material + 9) / 10).substring(0, material) as String; + } + return a; + } + + // One step of queueing; true when the vault's queue is complete. + private function queueStep(v as Vault) as Boolean { + if (kind == REPLACE_DIRECT || kind == SECRET || kind == SPACES) { + return true; + } + if (kind == LEGACY) { + // As 0.9.0 queued it: raw contents inside one box. + var old = new ManageAction(); + old.id = _targets[0]; + old.action = ManageAction.REPLACE; + old.label = "Card"; + old.material = Bytes.fromUtf8(replaceAction(v).text as String); + var stored = ChangeQueue.items(v); + stored.add(SealedBox.sealKeep(v.keyManager.deviceKeys(), Bytes.fromUtf8(ChangeQueue.AAD_V1), old.toBytes(), + [] as Array)); // setup: nothing to wipe + v.storage().setValue(ChangeQueue.KEY, stored); + ChangeQueue.changed(); + return true; + } + if (kind == REPLACE_QUEUED) { + var run = _setupRun; + if (run == null) { + _setupRun = new ManageRun(v, replaceAction(v), false); + return false; + } + if (!run.step(1)) { + return false; + } + _setupRun = null; + if (!run.queued) { + result = " setup_problem=" + run.message; + } + return true; + } + var a = new ManageAction(); + a.id = _targets[_n]; + a.label = "Stress " + _n; + a.action = ManageAction.LOCK; + if (kind == DESCRIBE) { + a.action = ManageAction.EDIT; + a.name = repeat("N", Entry.MAX_NAME_BYTES); + a.account = repeat("A", Entry.MAX_NAME_BYTES); + a.group = repeat("G", Entry.MAX_GROUP_BYTES); + a.label = Forms.label(a.name, a.account); + } else if (kind == COUNT && _n == 0) { + // Checked contents, as ManageRun queues them. + a.action = ManageAction.REPLACE; + a.material = Bytes.fromUtf8(repeat("abcdefghij", (material + 9) / 10).substring(0, material) as String); + a.decoded = true; + } + ChangeQueue.add(v, a); + _n++; + return _n == LOCKS; + } + + // The measured run. + private function measure(budget as Number) as Boolean { + if (kind == COUNT) { + for (var i = 0; i < MARGIN; i++) { + ChangeQueue.changed(); + result = " to_confirm=" + ChangeQueue.countToConfirm(_vaults[0]); + } + return true; + } + if (_stage < 0) { + if (kind == UNLOCK) { + // A's derivation; its last step finishes both unlocks. + if (!_pins[_vaults.size() - 1].step(budget)) { + return false; + } + Diag.data("stress", "kdf_done name=" + name); + } + return nextStage(); + } + var all = true; + for (var i = 0; i < _jobs.size(); i++) { + if (!_done[i]) { + _done[i] = _jobs[i].step(1); + all = all && _done[i]; + } + } + if (!all) { + return false; + } + summary(); + return nextStage(); + } + + // Start the next stage's jobs (A first); true when there is none. + private function nextStage() as Boolean { + _stage++; + var stages = kind == LEGACY ? 3 : (kind == SECRET ? 2 : 1); + if (_stage >= stages) { + return true; + } + var previous = _jobs; + _jobs = [] as Array; + _done = [] as Array; + var last = _vaults.size() - 1; + for (var i = last; i >= 0; i--) { + var v = _vaults[i]; + var job = new Jobs.Job(); // replaced below + if (kind == DESCRIBE) { + _jobs.add(new DescribeJob(v)); + _done.add(false); + job = new DescribeJob(v); + } else if (kind == UNLOCK) { + result += " v" + i + "_unlocked=" + v.finishUnlock(_pins[i]); + job = new LockJob(v, true); + } else if (kind == REPLACE_QUEUED || (kind == SECRET && _stage == 1)) { + job = new ChangeQueueJob(v, null); + } else if (kind == LEGACY) { + if (_stage == 0) { + job = new DescribeJob(v); + } else if (_stage == 1) { + _keys.add((previous[last - i] as DescribeJob).keys); + result += " v" + i + "_unlocked=" + v.finishUnlock(_pins[i]); + job = new LockJob(v, true); + } else { + job = new ChangeQueueJob(v, _keys[last - i]); + } + } else { + job = new ManageRun(v, replaceAction(v), false); + } + _jobs.add(job as Jobs.Job); + _done.add(false); + } + return false; + } + + private function summary() as Void { + var job = _jobs[0]; + var v = _vaults[_vaults.size() - 1]; + result += " [" + _stage + "]"; + if (job instanceof LockJob) { + ChangeQueue.takeLockLines(); // as the PIN screen does when it closes + result += " lock_lines=" + job.lines.size() + " pending_left=" + v.pendingIds().size() + " ids=" + v.ids().size() + + " queue_left=" + ChangeQueue.count(v); + } else if (job instanceof ChangeQueueJob) { + result += " lines=" + job.lines.size() + " first='" + job.lines[0] + "' queue_left=" + ChangeQueue.count(v); + } else if (job instanceof DescribeJob) { + result += " described=" + job.lines.size(); + } else if (job instanceof ManageRun) { + result += " failed=" + job.failed + " queued=" + job.queued + " message='" + job.message + "'"; + } + } + } + // The screen: progress while encoding, the runs once an item is done. class StressView extends WatchUi.View { var title as String = "Stress test"; @@ -101,8 +462,9 @@ module StressTest { } var view as StressView? = null; - var items as Array = [] as Array; + var items as Array = [] as Array; var current as Item? = null; + var currentCase as VaultCase? = null; var runner as Jobs.JobRunner? = null; function initialView() as [WatchUi.Views] or [WatchUi.Views, WatchUi.InputDelegates] { @@ -136,9 +498,19 @@ module StressTest { new Item("pdf417_digits", Codes.PDF417, new Pdf417.Pdf417Job(digits.substring(0, 400) as String, 1, w, h, round)), new Item("dm_fit", Codes.DATAMATRIX, new DataMatrix.DataMatrixJob(letters.substring(0, largestDataMatrix(w, h, round)) as String)), + // Single-event vault work (VaultCase). + new VaultCase("queue_count_big", VaultCase.COUNT, 0, Entry.MAX_PAYLOAD_BYTES), + new VaultCase("queue_describe", VaultCase.DESCRIBE, 0, 0), + new VaultCase("unlock_locks", VaultCase.UNLOCK, 0, 0), + new VaultCase("unlock_pending", VaultCase.UNLOCK, Vault.MAX_ENTRIES - VaultCase.LOCKS, 0), + new VaultCase("replace_code_4k", VaultCase.REPLACE_QUEUED, 0, Entry.MAX_PAYLOAD_BYTES), + new VaultCase("replace_code_4k_direct", VaultCase.REPLACE_DIRECT, 0, Entry.MAX_PAYLOAD_BYTES), + new VaultCase("queue_legacy_4k", VaultCase.LEGACY, 0, Entry.MAX_PAYLOAD_BYTES), + new VaultCase("replace_secret_1k", VaultCase.SECRET, 0, 0), + new VaultCase("replace_spaces_4k", VaultCase.SPACES, 0, Entry.MAX_PAYLOAD_BYTES), // Last, so it stays on the screen: app-diag.sh decodes it. new Item("qr_fit", Codes.QR, new Qr.QrJob(SCREEN_TEXT, 1, largestQrVersion(w, h, round), -1)) - ] as Array; + ] as Array; Diag.data("stress", "start used_kb=" + System.getSystemStats().usedMemory / 1024); next(); } @@ -149,7 +521,16 @@ module StressTest { return; } var item = items[0]; - items = items.slice(1, null) as Array; + items = items.slice(1, null) as Array; + if (item instanceof VaultCase) { + currentCase = item; + (view as StressView).title = item.name; + WatchUi.requestUpdate(); + var r = new Jobs.JobRunner(item, Kdf.ITERATIONS_PER_STEP, new Lang.Method(StressTest, :onCaseReady)); + runner = r; + r.start(); + return; + } current = item; (view as StressView).title = item.name; WatchUi.requestUpdate(); @@ -172,6 +553,27 @@ module StressTest { r.start(); } + // Setup done: the measured run starts with a fresh runner, so its first + // event holds no setup step. + function onCaseReady() as Void { + var c = currentCase as VaultCase; + c.measuring = true; + Diag.data("stress", "start name=" + c.name + " setup_steps=" + c.steps + " used_kb=" + System.getSystemStats().usedMemory / 1024); + var r = new Jobs.JobRunner(c, Kdf.ITERATIONS_PER_STEP, new Lang.Method(StressTest, :onCaseDone)); + runner = r; + r.start(); + } + + function onCaseDone() as Void { + var c = currentCase as VaultCase; + Diag.data("stress", "done name=" + c.name + " setup_steps=" + c.steps + " measured_steps=" + c.measured + " peak_kb=" + c.peak / 1024 + + " total_kb=" + System.getSystemStats().totalMemory / 1024 + c.result); + currentCase = null; + var r = new Jobs.JobRunner(new Jobs.Job(), 1, new Lang.Method(StressTest, :next)); + runner = r; + r.start(); + } + function repeat(text as String, times as Number) as String { var out = ""; for (var i = 0; i < times; i++) { -- cgit v1.2.3