aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-27 01:01:18 +0100
committercirakg <cirakg@cirakg.xyz>2026-10-27 01:01:18 +0100
commitbc4cc19df5f53519ca1aabd3cf161b6d78ffc16c (patch)
tree3242a63f603882d723bd0d718d043c30a9f11784
parent34daecaa25e5bdd995179a2b4b9d922f8bbb3b54 (diff)
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.<n>). 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 <noreply@anthropic.com>
-rw-r--r--CHANGELOG.md26
-rw-r--r--README.md8
-rwxr-xr-xdocker/scripts/app-diag.sh2
-rw-r--r--docs/DEVELOPER_GUIDE.md4
-rw-r--r--docs/PERFORMANCE.md16
-rw-r--r--docs/SECURITY.md7
-rw-r--r--docs/TESTING.md14
-rw-r--r--docs/USER_GUIDE.md2
-rw-r--r--source/crypto/Aes.mc23
-rw-r--r--source/crypto/SealedBox.mc21
-rw-r--r--source/settings/ChangeQueue.mc553
-rw-r--r--source/settings/CodeForm.mc10
-rw-r--r--source/settings/ManageForm.mc265
-rw-r--r--source/settings/Results.mc13
-rw-r--r--source/storage/KeyManager.mc24
-rw-r--r--source/storage/Record.mc33
-rw-r--r--source/storage/Vault.mc91
-rw-r--r--source/ui/MainMenu.mc2
-rw-r--r--source/ui/PinView.mc62
-rw-r--r--source/ui/QueueMenu.mc78
-rw-r--r--source/util/Bytes.mc33
-rw-r--r--source/util/Jobs.mc63
-rw-r--r--source/util/StressTest.mc410
-rw-r--r--test/golden/fr70/list_locked.pngbin21890 -> 23054 bytes
-rw-r--r--test/golden/fr70/list_reset.pngbin22574 -> 23741 bytes
-rw-r--r--test/settings/ChangeQueueTest.mc239
-rw-r--r--test/settings/ManageTest.mc39
27 files changed, 1763 insertions, 275 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc7d9fa..42571b4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,32 @@ All notable changes to this project are documented here. The format follows
a wrong PIN. Tap the OK key instead.
- "Change PIN" with a PIN from 0.8.0 or earlier asked for the new PIN
twice: the new 4-digit PIN after the unlock is now the change.
+- Entering the PIN could stop the app (watchdog) when changes from the
+ phone were waiting: in 0.9.0, 16 waiting "Require PIN" changes were
+ enough on the Forerunner 70. After the PIN, pending locked entries are
+ promoted and "Require PIN" changes applied in small steps while the PIN
+ screen shows "Checking…". What was applied, or why not, appears under
+ "Results ready" in the menu, and the glance also refreshes after a PIN
+ is set.
+- Waiting phone changes are counted without decrypting them, so a large
+ waiting replacement can no longer stop the app at start. "Phone changes"
+ reads its list behind a short "Reading…" screen.
+- A replacement secret or code contents is checked when it arrives: an
+ invalid one is refused at once instead of failing after the PIN. Valid
+ contents are stored apart from the queue and deleted with their change;
+ replacing a code with 4 KB of contents runs in steps throughout. Changes
+ queued by 0.9.0 are still read.
+- "Confirm all" could apply changes that arrived from the phone after
+ the list was shown. "Confirm all" and "Reject all" now act only on the
+ changes shown; later ones keep waiting.
+- With storage full, every correct PIN stopped the app while promoting
+ pending entries; it now reports "Storage full" and continues.
+
+### Tests
+
+- `make watchdog` also runs the work after a PIN (150 entries pending, 16
+ "Require PIN" waiting), reading the change queue and a 4 KB code
+ replacement (also one queued by 0.9.0), at twice the work per step.
## [0.9.0] - 2026-10-25
diff --git a/README.md b/README.md
index 97b532c..7588ed2 100644
--- a/README.md
+++ b/README.md
@@ -219,6 +219,7 @@ Design rules:
| `p.<id>` | Pending locked entry, waiting for your PIN | Device key ([7.7](#77-pending-locked-entries)) |
| `imp`, `imp.<n>` | Partial import text, kept between syncs, in chunks below 8 KB | Device key |
| `q` | Phone changes to locked entries, waiting for your PIN ([7.9](#79-integrity-of-changes-coming-from-the-phone)) | Device key |
+| `qm.<n>` | The new secret or contents of a waiting "Replace", stored apart from `q` | Device key |
| `res`, `fh`, `cs`, `st` | Latest settings results; keyed hashes of applied forms; signature of the last About update; date of the last self-test | Not secret |
Limits: at most 8 KB per value and about 100 KB in total (the stricter of Garmin's two documented figures). M3 measured 239 bytes for a typical OTP record, so the entry cap stays at 150 (byte formats in [docs/SECURITY.md](docs/SECURITY.md)). A full store produces a clear message, never a crash.
@@ -308,7 +309,8 @@ A locked entry can arrive from settings or an import while the watch is locked,
Anyone who controls your phone or Garmin account can change the settings. So:
-- Actions on locked entries (edit, replace secret, unlock, set the counter, delete) wait in a queue until you confirm them with the PIN on the watch, even if the watch happens to be unlocked. The menu lists them under "Phone changes", where you confirm (the PIN is always asked) or reject them. "Require PIN" waits too while the watch is locked, because it needs the master key.
+- Actions on locked entries (edit, replace secret, unlock, set the counter, delete) wait in a queue until you confirm them with the PIN on the watch, even if the watch happens to be unlocked. The menu lists them under "Phone changes", where you confirm (the PIN is always asked) or reject them. "Require PIN" waits too while the watch is locked, because it needs the master key; it applies by itself at the next PIN entry, and what it did is listed under the menu's results.
+- A replacement secret or code contents is checked when it arrives: an invalid one is refused at once, and only a valid one waits.
- Deleting or editing *unlocked* entries applies directly, and the watch shows what changed.
- New entries always apply, and they're visible in the list.
@@ -745,7 +747,7 @@ The Garmin SDK and device files are never baked into an image (Garmin's licence)
| `make icons` | Regenerate the per-size launcher icons after the product list changes | no |
| `make perf` | Performance measurements against the budgets | no |
| `make memtest` | Memory after the first frame and in the glance, on `TEST_DEVICES` | no |
-| `make watchdog` | The largest code of every 2D symbology encoded, packed and drawn at twice the steps per event, on `WATCHDOG_DEVICES` (Forerunner 255S and 70); fails on a watchdog trip or if the last code doesn't decode | no |
+| `make watchdog` | The largest code of every 2D symbology encoded, packed and drawn, and the worst PIN-unlock and phone-change work, at twice the steps per event, on `WATCHDOG_DEVICES` (Forerunner 70; with `ALL=1` also the 255S); fails on a watchdog trip or if the last code doesn't decode | no |
| `make lint` | Strict type check (`-l 3`) with warnings as errors, project lint rules, settings XML schema check, Python lint for the tools | no |
| `make package` | Signed `.iq` for the Store beta, plus per-device `.prg` files | no |
| `make docs` | Markdown lint, link check, Mermaid render check | no (yes when building the docs image) |
@@ -953,7 +955,7 @@ Tests are written alongside the code, never after.
### 17.6 Performance and memory
-See [section 18](#18-performance-budgets). `make perf` and `make memtest` fail the build when a budget is exceeded. `make watchdog` (M8) runs the largest input of every 2D encoder with twice the normal steps per event, so each step is proven to use at most half the watchdog budget, and draws the largest codes each screen fits.
+See [section 18](#18-performance-budgets). `make perf` and `make memtest` fail the build when a budget is exceeded. `make watchdog` (M8) runs the largest input of every 2D encoder with twice the normal steps per event, so each step is proven to use at most half the watchdog budget, and draws the largest codes each screen fits. Since 0.9.0's review it also runs the work after a PIN (150 pending entries, 16 waiting "Require PIN" changes), reading the change queue and a 4 KB code replacement.
### 17.7 Static checks
diff --git a/docker/scripts/app-diag.sh b/docker/scripts/app-diag.sh
index 025fd49..773fd51 100755
--- a/docker/scripts/app-diag.sh
+++ b/docker/scripts/app-diag.sh
@@ -24,7 +24,7 @@ ciq_require_key
# The stress test takes a few minutes per device (about 3,500 job steps at
# 50 ms on the 512 KB watches).
WATCHDOG_TIMEOUT_S=1200
-STRESS_ITEMS=10
+STRESS_ITEMS=19
if [[ "${kind}" == watchdog ]]; then
for device in "$@"; do
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
index 74eff63..d9b7c98 100644
--- a/docs/DEVELOPER_GUIDE.md
+++ b/docs/DEVELOPER_GUIDE.md
@@ -87,7 +87,9 @@ The simulator is a GTK program. The scripts start it on a virtual display (Xvfb
- Anything long runs as a `Jobs.Job` with a bounded step (`Kdf.ITERATIONS_PER_STEP` = 100).
- Screens compute at most one SHA-512 code per event, in a timer event separate from drawing.
- The scripted UI flow runs on the 255S profile, so a regression trips there.
- - **M8 found that step sizes guessed from "bytecodes per loop" were 2 to 3 times too large**: a simple loop iteration costs 50 to 70 counted bytecodes, not 10 to 20. Every step of the 2D encoders, the cache packer and the run finder is now sized by measurement in `make watchdog`, which runs the largest input of each with *two* steps per event: a pass means every step uses at most half the budget. Do the same for any new job: add its worst input to `StressTest.start()`.
+ - **M8 found that step sizes guessed from "bytecodes per loop" were 2 to 3 times too large**: a simple loop iteration costs 50 to 70 counted bytecodes, not 10 to 20. Every step of the 2D encoders, the cache packer and the run finder is now sized by measurement in `make watchdog`, which runs the largest input of each with *two* steps per event: a pass means every step uses at most half the budget. Do the same for any new job: add its worst input to `StressTest.start()`. Work on the vault goes in a `StressTest.VaultCase`: a setup in bounded steps on a RAM backend, then the measured event, done for two identical vaults.
+ - Per-byte VM loops, measured on the Forerunner 70 in 2026-10: zeroing (`Bytes.wipe`) about 15 bytecodes per byte, the UTF-8 check 20 to 29. With about 60,000 bytecodes per step (half the 255S's event), a 4 KB buffer can't be wiped or checked in one step: `Jobs.Wiper` wipes 2 KB per step, `Bytes.utf8Prefix` checks in pieces, and `SealedBox.sealKeep`/`openKeep` hand AES's padded copies to the caller's wiper.
+ - The work after a correct PIN (promoting pending entries, applying "Require PIN") is a job too (`ChangeQueue.LockJob`), run by `PinView` before it continues. In 0.9.0 it ran in the event of the last PBKDF2 step and 16 waiting "Require PIN" changes tripped the watchdog on the Forerunner 70.
- `Jobs.JobRunner` runs one step per 50 ms tick on the 512 KB watches and two on the others (`Jobs.stepsPerTick`, by app memory). `tools/gen_products.py --check` (part of `make lint`) fails if a product's `watchdogCount` doesn't match that rule.
- Drawing counts too: one `fillRectangle` per run in `onUpdate` is fine up to about 1,500 runs per 120,000 bytecodes. `RunsPainter` paints larger codes into a buffered bitmap in steps first (the Venu X1 fits a QR with 8,000 runs).
- `StringUtil.convertEncodedString` stops the app with an uncatchable "System Error" on invalid UTF-8; `try` doesn't help. Decode untrusted bytes with `Bytes.toUtf8`, which validates first.
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index 0433457..d261125 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -44,6 +44,22 @@ Other timings reported from the same session: settings saved on the phone reach
What this means: unlocking stays at about 1 s because 2,000 iterations is also the minimum the app accepts. An attacker who copies the watch's storage still has to run those 2,000 iterations per guess, but on a PC that is microseconds, so the PIN protects mainly against someone holding the watch (lockout and the optional erase after 10 wrong PINs), not against an offline attack on a copied file system (docs/SECURITY.md).
+### Review of 0.9.0, 2026-10-26 (unreleased)
+
+`make watchdog` on the Forerunner 70 (240,000 bytecodes per event), two steps of work per event:
+
+| Case | 0.9.0 | Now |
+| --- | --- | --- |
+| After the PIN: 16 waiting "Require PIN" | trips even at one pass | passes, 70 steps |
+| After the PIN: 134 pending entries and 16 "Require PIN" | trips | passes, 159 steps |
+| Counting the queue with a 1 KB replacement waiting (list title) | trips at two passes | passes (no decryption) |
+| Reading 16 changes with the longest names ("Phone changes") | trips at two passes | passes, as a job behind "Reading…" |
+| Replacing a code with 4 KB of contents, queued / direct | not tested | passes, 735 / 727 steps |
+| A 4 KB replacement queued by 0.9.0, read, kept at the PIN and confirmed | not tested | passes, 757 steps |
+| A 1,024-character secret replacement / 4,096 spaces as code contents | not tested | passes, 10 / 12 steps |
+
+Not covered yet: opening a 4 KB code (`toUtf8` in one event, about 100,000 bytecodes) and the Add OTP form's secret decoding without a length limit; both are near the Forerunner 255S's limit, not the 70's.
+
### M8, 2026-10-21 (polish and release candidate, version 0.8.0)
- **Memory, Forerunner 255S (512 KB):**
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index c68cdc1..3091fb4 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -23,7 +23,8 @@ Everything lives in the app's private `Application.Storage`:
| `imp`, `imp.<n>` | dictionary, bytes | Waiting multi-part import: `imp` = `{"n": chunks, "t": saved at}`, `imp.<n>` = chunks of one SealedBox (device key, aad `OTPW-IMP-1`) of the text | `ImportBuffer` |
| `res` | dictionary | Latest settings results (`l` lines, `u` unseen) | `Results` |
| `fh` | array | Keyed hashes of the last 32 applied settings forms (below) | `Forms` |
-| `q` | array | Phone changes to locked entries waiting for the PIN: one SealedBox per change (device key, aad `OTPW-Q-1`), at most 16 | `ChangeQueue` |
+| `q` | array | Phone changes to locked entries waiting for the PIN, at most 16. Each item is `[action, box]` (plus the material number for a replacement): a SealedBox of the change (device key, aad `OTPW-Q-2` and the action code), so the action can be counted without decrypting and a changed code fails to open. Items written by 0.9.0 are bare SealedBoxes (aad `OTPW-Q-1`) and are still read | `ChangeQueue` |
+| `qm.<n>` | bytes | The validated new secret or contents of a waiting replacement: a SealedBox (device key, aad `OTPW-QM-1.<n>.<id>`), deleted with its item | `ChangeQueue` |
| `cs` | string | Keyed hash of what the About fields and the glance code were last written from (IDs, pending IDs, "Encrypt names", the glance option, waiting changes, version); absent while names still need converting | `AboutScan` |
| `st` | array | Version and day of the last passed self-test | `AppState` |
@@ -132,7 +133,7 @@ PIN key = PBKDF2-HMAC-SHA256(PIN as ASCII digits, salt, n, 32 bytes)
## Pending locked entries
-A locked entry that arrives while the session is locked, or before a PIN exists, is sealed with the device key under `p.<id>` and listed in `m.pending`. It doesn't appear in the list. After the next unlock, `promotePending()` re-seals it with the master key, writes `e.<id>`, updates the index and only then deletes `p.<id>` (`vaultQueuesLockedEntriesWhileLocked`).
+A locked entry that arrives while the session is locked, or before a PIN exists, is sealed with the device key under `p.<id>` and listed in `m.pending`. It doesn't appear in the list. After the next unlock, the PIN screen's `ChangeQueue.LockJob` promotes them one at a time (`Vault.promoteNext`): it re-seals each with the master key, writes `e.<id>`, updates the index and only then deletes `p.<id>` (`vaultQueuesLockedEntriesWhileLocked`). Then it applies the waiting "Require PIN" changes, one per step, before the app continues.
## Write order and crashes
@@ -191,7 +192,7 @@ Kfp = HKDF-SHA256(device key, info = "OTPW fp v1")
Kfp is the fingerprint key above. Without the device key a stored hash reveals nothing. With it, an attacker can only test guesses of a whole form, as with fingerprints. The fields go to the native HMAC one by one; the short ones (every secret field) are zeroed afterwards. An import form's text is too long to zero byte by byte within the watchdog limit; it stays in memory as the property's string anyway.
- **Secrets in the forms.** Add OTP's secret, Add code's contents and Manage entry's new secret are cleared first when the watch processes a form, also after a problem (the other fields stay so you can correct them). Messages never quote a secret.
-- **Changes from the phone to locked entries** (README 7.9). Edit, replace, set counter, "Don't require PIN", the favourite mark and delete of a locked entry are not applied when they arrive. Each one is sealed with the device key under `q`, which is no weaker than the settings text it came in. They are applied only after someone enters the PIN on the watch under "Phone changes", which always asks for the PIN, even while the session is unlocked. Moving an entry is applied at once: the order isn't protected, and positions are absolute. Changes to unlocked entries apply at once and are listed on the results screen (`manageQueuesLockedEntryChanges`, `manageQueueIsSealedAndLimited`).
+- **Changes from the phone to locked entries** (README 7.9). Edit, replace, set counter, "Don't require PIN", the favourite mark and delete of a locked entry are not applied when they arrive. Each one is sealed with the device key under `q`, which is no weaker than the settings text it came in. They are applied only after someone enters the PIN on the watch under "Phone changes", which always asks for the PIN, even while the session is unlocked. A replacement is validated when it arrives (Base32 decoded, or the code contents checked), and its material is sealed apart under `qm.<n>`, bound to its item number and entry ID. "Confirm all" and "Reject all" act only on the changes the menu showed (identified by their SealedBox IVs); any that arrive while the PIN is typed keep waiting (`queueStoresMaterialApart`, `queueChecksReplaceFirst`, `queueActsOnWhatWasShown`). Moving an entry is applied at once: the order isn't protected, and positions are absolute. Changes to unlocked entries apply at once and are listed on the results screen (`manageQueuesLockedEntryChanges`, `manageQueueIsSealedAndLimited`).
- **The About catalog** sends entry IDs and names to the phone's settings, where your entries came from in the first place. With "Encrypt names" on it sends only IDs and types.
- **"Encrypt names"** puts names and accounts into the encrypted part of each record. Turning it on or off re-seals every entry the watch can reach: unlocked ones at once, locked ones the next time the PIN is entered (`settingsAppliesEncryptNames`). The barcode cache is kept.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 4443a90..6063740 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -26,6 +26,20 @@ Test devices: the Forerunner 70 by default; with `ALL=1` (`TEST_DEVICES`): Forer
## Results
+### Review of 0.9.0, 2026-10-26 (unreleased)
+
+An independent review of 0.9.0's changes (b8addc8, 096b5a4, 404b505, 140a666) and a watchdog test of the PIN unlock found and fixed:
+
+- After a correct PIN, promoting pending entries and applying waiting "Require PIN" changes ran in one event; 16 waiting changes tripped the watchdog on the Forerunner 70. It is now a job (`ChangeQueue.LockJob`), and `make watchdog` has nine vault cases (19 items in total; PERFORMANCE.md).
+- The list title decrypted every waiting change at each start; a 1 KB replacement was enough to trip at two passes. Changes are now counted from their stored action codes.
+- A replacement was only checked after the PIN; it is now checked when it arrives, and its material is stored apart (`qm.<n>`).
+- "Confirm all" applied changes that arrived after the menu was read; Confirm all and Reject all now act only on what the menu showed.
+- On touch screens START could submit a legacy PIN early; "Change PIN" with a legacy PIN asked twice; a lock applied when a PIN is set didn't refresh the glance; the results of applied locks were lost.
+
+Rejected: a tap opening the focused row instead of the tapped one (not reproducible in the simulator; watch for it in round 2), and the 250 ms ticker on a locked code card (cost negligible next to the screen).
+
+Unit tests: 176 per device, including `test/settings/ChangeQueueTest.mc`. Two Forerunner 70 goldens changed (`list_locked`, `list_reset`: the title says "Results ready (menu)" after the lock applied at the PIN).
+
### Real-watch fixes, 2026-10-25 (version 0.9.0)
The first real-watch round on the Forerunner 70 (docs/YOUR_STEPS.md parts 3 and 4, 2026-10-21) reported: no touch scrolling outside the menus, a hard-to-use PIN keypad, "Wrong PIN" once before a correct PIN opened the code, more digits typed than dots shown, the QR progress starting at 90 %, and Manage entry's Require PIN not applying. All are fixed (CHANGELOG 0.9.0). Unit tests: 168 per device.
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index dd15901..d351088 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -108,7 +108,7 @@ Use the **Manage entry** form with the entry's **ID**: four characters such as `
| Delete | — |
| Mark as favourite (glance) / Remove favourite | — |
-**Changes to locked entries wait for you.** Anything that would change or reveal a locked entry (everything except moving it) is not applied when it arrives. The watch shows "Phone changes: N to confirm" at the top of its menu and in the list title. Open it, check the list, and choose **Confirm all** (it asks for the PIN, even if the app is unlocked) or **Reject all**. This way, someone with access to your phone alone can't unlock or rewrite your locked entries.
+**Changes to locked entries wait for you.** Anything that would change or reveal a locked entry (everything except moving it) is not applied when it arrives. The watch shows "Phone changes: N to confirm" at the top of its menu and in the list title. Open it ("Reading…" shows briefly), check the list, and choose **Confirm all** (it asks for the PIN, even if the app is unlocked) or **Reject all**. This way, someone with access to your phone alone can't unlock or rewrite your locked entries. A replacement secret or code contents is checked when it arrives, so a typo is reported in the Result at once. **Require PIN** sent while the watch is locked needs no confirming: it applies the next time you enter the PIN, and the list then says "Results ready (menu)".
## The PIN and locked entries
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<ByteArray>?) 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<ByteArray>?) 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<ByteArray>?) 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<ByteArray>?) 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<ByteArray>?) 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.<n>", 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<String> = [] as Array<String>;
// 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<Application.Storage.ValueType> {
var stored = vault.storage().getValue(KEY);
- return stored instanceof Array ? stored.size() : 0;
+ return stored instanceof Array ? stored as Array<Application.Storage.ValueType> : [] as Array<Application.Storage.ValueType>;
+ }
+
+ 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<Application.Storage.ValueType> : [] as Array<Application.Storage.ValueType>;
- 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<ByteArray>;
+ 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<Application.Storage.ValueType>;
+ 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<ManageAction> {
- var out = [] as Array<ManageAction>;
- 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<ByteArray>;
+ 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<Application.Storage.ValueType>)[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<Application.Storage.ValueType>)[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<String>) as Number {
+ changed();
+ var stored = items(vault);
+ var kept = [] as Array<Application.Storage.ValueType>;
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<String> {
- var actions = load(vault);
- var out = [] as Array<String>;
- 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<String>;
+ // 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<String>) 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<Application.Storage.ValueType>;
- 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<String> {
var lines = _lockLines;
_lockLines = [] as Array<String>;
@@ -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<Application.Storage.ValueType>) 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<String> = [] as Array<String>;
+ var keys as Array<String> = [] as Array<String>;
+
+ private var _vault as Vault;
+ private var _items as Array<Application.Storage.ValueType>;
+ 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<String> = [] as Array<String>;
+
+ private var _vault as Vault;
+ // The items when promotion ended; null before.
+ private var _items as Array<Application.Storage.ValueType>? = null;
+ // Keys of the items applied or dropped.
+ private var _done as Array<String> = [] as Array<String>;
+ 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<Application.Storage.ValueType>);
- } 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<String> = [] as Array<String>;
private var _vault as Vault;
- private var _actions as Array<ManageAction>;
- private var _loaded as Number = 0;
+ private var _keys as Array<String>?;
+ private var _items as Array<Application.Storage.ValueType> = [] as Array<Application.Storage.ValueType>;
+ private var _done as Array<String> = [] as Array<String>;
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<String>?) {
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<Char>? = 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<Application.Storage.KeyType, Application.Storage.ValueType>);
}
+ // Add lines to results not seen yet (an import's, say), or replace seen
+ // ones (marked unseen).
+ function add(lines as Array<String>) as Void {
+ if (!unseen()) {
+ save(lines);
+ return;
+ }
+ var all = [] as Array<String>;
+ all.addAll(Results.lines());
+ all.addAll(lines);
+ save(all);
+ }
+
// The stored lines, or a short explanation when there are none.
function lines() as Array<String> {
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<ByteArray> : null;
+ var box = SealedBox.sealKeep(keys, aad(entry.id, record), plain, leftovers);
+ if (wiper != null) {
+ wiper.add(plain);
+ wiper.addAll(leftovers as Array<ByteArray>);
+ } 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<ByteArray> : 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<ByteArray>);
+ }
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.<id>" 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<ByteArray>;
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<Array<Number>> = [] as Array<Array<Number>>; // [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<String>) 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<String>, "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<String>;
+ private var _keys as Array<String>;
+
+ function initialize(items as Array<String>, keys as Array<String>) {
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<String>;
- 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<String>, "change"), new InfoDelegate(),
+ if (id < _items.size()) {
+ WatchUi.pushView(new InfoView("Change", [_items[id]] as Array<String>, "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<String>;
- function initialize() {
+ // keys: the items the menu showed (DescribeJob.keys).
+ function initialize(keys as Array<String>) {
+ _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<String>, "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<ByteArray> = [] as Array<ByteArray>;
+ 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<ByteArray>) 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<ByteArray>;
+ } 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<String, Application.Storage.ValueType> = {} as Dictionary<String, Application.Storage.ValueType>;
+
+ 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<Vault> = [] as Array<Vault>;
+ private var _pins as Array<PinJob> = [] as Array<PinJob>;
+ private var _targets as Array<String> = [] as Array<String>;
+ 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<Jobs.Job> = [] as Array<Jobs.Job>;
+ private var _done as Array<Boolean> = [] as Array<Boolean>;
+ // LEGACY: the keys each vault's menu read.
+ private var _keys as Array<Array<String>> = [] as Array<Array<String>>;
+
+ 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<String>;
+ _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<ByteArray>)); // 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<Jobs.Job>;
+ _done = [] as Array<Boolean>;
+ 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<Item> = [] as Array<Item>;
+ var items as Array<Item or VaultCase> = [] as Array<Item or VaultCase>;
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<Item>;
+ ] as Array<Item or VaultCase>;
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<Item>;
+ items = items.slice(1, null) as Array<Item or VaultCase>;
+ 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++) {
diff --git a/test/golden/fr70/list_locked.png b/test/golden/fr70/list_locked.png
index 2981ae9..64bf739 100644
--- a/test/golden/fr70/list_locked.png
+++ b/test/golden/fr70/list_locked.png
Binary files differ
diff --git a/test/golden/fr70/list_reset.png b/test/golden/fr70/list_reset.png
index 7b6f369..40ff66f 100644
--- a/test/golden/fr70/list_reset.png
+++ b/test/golden/fr70/list_reset.png
Binary files differ
diff --git a/test/settings/ChangeQueueTest.mc b/test/settings/ChangeQueueTest.mc
new file mode 100644
index 0000000..8ad0feb
--- /dev/null
+++ b/test/settings/ChangeQueueTest.mc
@@ -0,0 +1,239 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the change queue's item format: codes readable without decrypting, material stored apart, 0.9.0 items, LockJob and stepwise promotion.
+
+import Toybox.Application;
+import Toybox.Lang;
+import Toybox.Test;
+
+(:test)
+module QueueFixtures {
+
+ // A queued action for `id`.
+ function action(id as String, code as Number) as ManageAction {
+ var a = new ManageAction();
+ a.id = id;
+ a.action = code;
+ a.label = "Bank";
+ return a;
+ }
+
+ // Queue an action the way 0.9.0 did: one bare box, material inside.
+ function addOld(v as Vault, a as ManageAction) as Void {
+ var stored = ChangeQueue.items(v);
+ stored.add(SealedBox.seal(v.keyManager.deviceKeys(), Bytes.fromUtf8(ChangeQueue.AAD_V1), a.toBytes()));
+ v.storage().setValue(ChangeQueue.KEY, stored);
+ ChangeQueue.changed();
+ }
+
+ function backend(v as Vault) as MemoryBackend {
+ return v.storage() as MemoryBackend;
+ }
+}
+
+// Items are [code, box] or [code, box, material number]; a REPLACE's
+// material is a sealed value of its own, not part of the item's box.
+(:test)
+function queueStoresMaterialApart(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var bank = ManageFixtures.addLocked(vault, "Bank");
+ var r = QueueFixtures.action(bank, ManageAction.REPLACE);
+ r.material = Bytes.fromUtf8("abcdefghijklmnopqrst");
+ r.decoded = true;
+ ChangeQueue.add(vault, QueueFixtures.action(bank, ManageAction.DELETE));
+ ChangeQueue.add(vault, r);
+ var raw = QueueFixtures.backend(vault).raw(ChangeQueue.KEY) as Array<Array<Application.Storage.ValueType>>;
+ var items = ChangeQueue.items(vault);
+ var shape = raw.size() == 2 && raw[0].size() == 2 && ChangeQueue.codeOf(items[0]) == ManageAction.DELETE && raw[1].size() == 3
+ && ChangeQueue.codeOf(items[1]) == ManageAction.REPLACE && ChangeQueue.refOf(items[1]) == 0;
+ var stored = QueueFixtures.backend(vault).raw(ChangeQueue.MATERIAL_KEY + "0");
+ var opened = ChangeQueue.open(vault, raw[1] as Application.Storage.ValueType, new Jobs.Wiper(), true);
+ var same = opened != null && opened.material != null
+ && Bytes.equal(opened.material as ByteArray, Bytes.fromUtf8("abcdefghijklmnopqrst")) && opened.ref == 0;
+ // The material opens only for its own number and entry.
+ raw[1][2] = 1;
+ var other = QueueFixtures.action(bank, ManageAction.REPLACE);
+ other.material = Bytes.fromUtf8("other");
+ ChangeQueue.add(vault, other); // takes number 0 again
+ var swapped = ChangeQueue.open(vault, raw[1] as Application.Storage.ValueType, new Jobs.Wiper(), true) == null;
+ if (!shape || !same || !swapped) {
+ logger.error("shape " + shape + " same " + same + " swapped " + swapped);
+ }
+ return shape && same && swapped && stored instanceof ByteArray && r.material != null && r.ref == -1;
+}
+
+// Counting reads only the codes: a damaged box still counts, and no item
+// is opened (a tampered code fails only when the item is opened).
+(:test)
+function queueCountsWithoutDecrypting(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var ids = vault.ids();
+ vault.lock();
+ ChangeQueue.add(vault, QueueFixtures.action(ids[0], ManageAction.LOCK));
+ ChangeQueue.add(vault, QueueFixtures.action(ids[1], ManageAction.LOCK));
+ ChangeQueue.add(vault, QueueFixtures.action(ids[2], ManageAction.DELETE));
+ var raw = QueueFixtures.backend(vault).raw(ChangeQueue.KEY) as Array<Array<Application.Storage.ValueType>>;
+ var box = raw[0][1] as ByteArray;
+ box[5] = box[5] ^ 1;
+ var counted = ChangeQueue.countLocks(vault) == 2 && ChangeQueue.countToConfirm(vault) == 1;
+ // A LOCK relabelled DELETE doesn't open.
+ raw[1][0] = ManageAction.DELETE;
+ ChangeQueue.changed();
+ var relabelled = ChangeQueue.countLocks(vault) == 1 && ChangeQueue.open(vault, raw[1] as Application.Storage.ValueType, new Jobs.Wiper(), true) == null
+ && ChangeQueue.open(vault, raw[0] as Application.Storage.ValueType, new Jobs.Wiper(), true) == null
+ && ChangeQueue.open(vault, raw[2] as Application.Storage.ValueType, new Jobs.Wiper(), true) != null;
+ return counted && relabelled;
+}
+
+// Items queued by 0.9.0 still count (as "to confirm"), describe, apply at
+// "Confirm all", and their locks apply at the next PIN.
+(:test)
+function queueReadsOldItems(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var ids = vault.ids();
+ var bank = ManageFixtures.addLocked(vault, "Bank");
+ vault.lock();
+ QueueFixtures.addOld(vault, QueueFixtures.action(ids[0], ManageAction.LOCK));
+ QueueFixtures.addOld(vault, QueueFixtures.action(bank, ManageAction.DELETE));
+ var counted = ChangeQueue.count(vault) == 2 && ChangeQueue.countLocks(vault) == 0 && ChangeQueue.countToConfirm(vault) == 2;
+ var described = ChangeQueue.descriptions(vault);
+ var listed = described.size() == 2 && described[1].equals("Delete: " + bank + " Bank");
+ StorageFixtures.unlock(vault, StorageFixtures.PIN);
+ var locks = ManageFixtures.applyLocks(vault);
+ var locked = ManageFixtures.isLocked(vault, ids[0]) && locks.lines.size() == 1 && ChangeQueue.count(vault) == 1;
+ var job = ManageFixtures.confirm(vault);
+ if (!counted || !listed || !locked) {
+ logger.error("counted " + counted + " listed " + listed + " locked " + locked + " " + described.toString());
+ }
+ return counted && listed && locked && vault.ids().indexOf(bank) < 0 && ChangeQueue.count(vault) == 0
+ && SettingsFixtures.hasLine(job.lines, "Deleted " + bank);
+}
+
+// A REPLACE is checked before it is queued: contents too long fail at once;
+// valid ones are queued as checked contents and applied after the PIN.
+(:test)
+function queueChecksReplaceFirst(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var card = StorageFixtures.code("Card");
+ card.type = Codes.QR;
+ card.locked = true;
+ var id = vault.add(card);
+ var s = ManageFixtures.send(vault, id, ManageAction.REPLACE);
+ var long = "";
+ for (var i = 0; i < Entry.MAX_PAYLOAD_BYTES / 8 + 1; i++) {
+ long += "abcdefgh";
+ }
+ s.set("mgSecret", long);
+ SettingsFixtures.run(vault, s);
+ var refused = SettingsFixtures.contains(logger, s, ManageForm.STATUS, "Replace: too long") && ChangeQueue.count(vault) == 0;
+ s = ManageFixtures.send(vault, id, ManageAction.REPLACE);
+ s.set("mgSecret", "new contents");
+ SettingsFixtures.run(vault, s);
+ var queued = ManageFixtures.load(vault);
+ var checked = queued.size() == 1 && queued[0].decoded && queued[0].ref == 0
+ && Bytes.equal(queued[0].material as ByteArray, Bytes.fromUtf8("new contents"));
+ var job = ManageFixtures.confirm(vault);
+ var applied = SettingsFixtures.secretOf(vault, id).equals("new contents");
+ if (!applied) {
+ logger.error(job.lines.toString());
+ }
+ return refused && checked && applied;
+}
+
+// A REPLACE's material record goes with its item: rejected, confirmed, or
+// dropped as damaged by clear().
+(:test)
+function queueDeletesMaterialWithItem(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var bank = ManageFixtures.addLocked(vault, "Bank");
+ var r = QueueFixtures.action(bank, ManageAction.REPLACE);
+ r.material = Bytes.fromUtf8("abcdefghijklmnopqrst");
+ r.decoded = true;
+ var key = ChangeQueue.MATERIAL_KEY + "0";
+ ChangeQueue.add(vault, r);
+ var stored = vault.storage().getValue(key) != null;
+ ChangeQueue.clear(vault);
+ var rejected = vault.storage().getValue(key) == null;
+ ChangeQueue.add(vault, r);
+ ManageFixtures.confirm(vault);
+ var confirmed = vault.storage().getValue(key) == null && ChangeQueue.count(vault) == 0
+ && SettingsFixtures.secretOf(vault, bank).equals("abcdefghijklmnopqrst");
+ // A missing material record makes its item fail to open (dropped).
+ ChangeQueue.add(vault, r);
+ vault.storage().deleteValue(key);
+ var job = ManageFixtures.confirm(vault);
+ return stored && rejected && confirmed && ChangeQueue.count(vault) == 0 && job.lines.size() == 1
+ && job.lines[0].equals("Not applied: a change was damaged");
+}
+
+// LockJob drops a lock that can't apply (with a line), keeps the other
+// items in order, and leaves its lines for a "Confirm all" once.
+(:test)
+function queueLockJobAppliesAndDrops(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var ids = vault.ids();
+ var bank = ManageFixtures.addLocked(vault, "Bank");
+ vault.lock();
+ ChangeQueue.add(vault, QueueFixtures.action(ids[0], ManageAction.LOCK));
+ ChangeQueue.add(vault, QueueFixtures.action(bank, ManageAction.DELETE));
+ ChangeQueue.add(vault, QueueFixtures.action("ZZZZ", ManageAction.LOCK));
+ ChangeQueue.add(vault, QueueFixtures.action(bank, ManageAction.FAVORITE));
+ StorageFixtures.unlock(vault, StorageFixtures.PIN);
+ var job = ManageFixtures.applyLocks(vault);
+ var items = ChangeQueue.items(vault);
+ var kept = items.size() == 2 && ChangeQueue.codeOf(items[0]) == ManageAction.DELETE
+ && ChangeQueue.codeOf(items[1]) == ManageAction.FAVORITE;
+ var lines = job.lines.size() == 2 && job.lines[1].find("Not applied: Require PIN: ZZZZ") == 0;
+ var taken = ChangeQueue.takeLockLines().size() == 2 && ChangeQueue.takeLockLines().size() == 0;
+ if (!kept || !lines) {
+ logger.error(job.lines.toString());
+ }
+ return kept && lines && taken && ManageFixtures.isLocked(vault, ids[0]);
+}
+
+// Pending entries are promoted a few per step, each saved on its own.
+(:test)
+function queuePromotesInSteps(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ vault.lock();
+ for (var i = 0; i < 8; i++) {
+ var e = StorageFixtures.totp("Pending " + i);
+ e.locked = true;
+ vault.add(e);
+ }
+ StorageFixtures.unlock(vault, StorageFixtures.PIN);
+ var job = new LockJob(vault, true);
+ job.step(1);
+ var partly = vault.pendingIds().size() > 0 && vault.pendingIds().size() < 8;
+ var steps = 1;
+ while (!job.step(1)) {
+ steps++;
+ }
+ return partly && steps > 2 && vault.pendingIds().size() == 0 && vault.ids().size() == 11
+ && ManageFixtures.isLocked(vault, vault.ids()[10]);
+}
+
+// "Confirm all" and "Reject all" act only on the items the menu read
+// (DescribeJob.keys): changes that arrive meanwhile keep waiting.
+(:test)
+function queueActsOnWhatWasShown(logger as Test.Logger) as Boolean {
+ var vault = ManageFixtures.vault();
+ var bank = ManageFixtures.addLocked(vault, "Bank");
+ var safe = ManageFixtures.addLocked(vault, "Safe");
+ ChangeQueue.add(vault, QueueFixtures.action(bank, ManageAction.DELETE));
+ var menu = new DescribeJob(vault);
+ Jobs.runToCompletion(menu, 1);
+ ChangeQueue.add(vault, QueueFixtures.action(safe, ManageAction.DELETE)); // while the PIN is typed
+ var job = new ChangeQueueJob(vault, menu.keys);
+ Jobs.runToCompletion(job, 1);
+ var confirmed = vault.ids().indexOf(bank) < 0 && vault.ids().indexOf(safe) >= 0 && ChangeQueue.count(vault) == 1
+ && SettingsFixtures.hasLine(job.lines, "1 change arrived meanwhile and still waits");
+ menu = new DescribeJob(vault);
+ Jobs.runToCompletion(menu, 1);
+ ChangeQueue.add(vault, QueueFixtures.action(bank, ManageAction.FAVORITE));
+ var left = ChangeQueue.remove(vault, menu.keys); // "Reject all"
+ var items = ChangeQueue.items(vault);
+ if (!confirmed) {
+ logger.error(job.lines.toString());
+ }
+ return confirmed && left == 1 && ChangeQueue.codeOf(items[0]) == ManageAction.FAVORITE && menu.keys.size() == 1;
+}
diff --git a/test/settings/ManageTest.mc b/test/settings/ManageTest.mc
index 1f050c5..4c93f60 100644
--- a/test/settings/ManageTest.mc
+++ b/test/settings/ManageTest.mc
@@ -1,6 +1,7 @@
// Author: cirakg & claude ai
// Summary: Tests for the Manage entry form: every action on unlocked entries, the PIN queue for locked ones, IDs, repeats and damage.
+import Toybox.Application;
import Toybox.Lang;
import Toybox.Test;
@@ -43,9 +44,29 @@ module ManageFixtures {
return s;
}
+ // What PinView runs after a correct PIN: promotion and waiting locks.
+ function applyLocks(v as Vault) as LockJob {
+ var job = new LockJob(v, true);
+ Jobs.runToCompletion(job, 1);
+ return job;
+ }
+
+ // Every queued item, opened (the ones that verify).
+ function load(v as Vault) as Array<ManageAction> {
+ var items = ChangeQueue.items(v);
+ var out = [] as Array<ManageAction>;
+ for (var i = 0; i < items.size(); i++) {
+ var a = ChangeQueue.open(v, items[i], new Jobs.Wiper(), true);
+ if (a != null) {
+ out.add(a);
+ }
+ }
+ return out;
+ }
+
// Confirm the waiting changes, as after a correct PIN.
function confirm(v as Vault) as ChangeQueueJob {
- var job = new ChangeQueueJob(v);
+ var job = new ChangeQueueJob(v, null);
Jobs.runToCompletion(job, 1);
return job;
}
@@ -256,7 +277,7 @@ function manageLocksEntries(logger as Test.Logger) as Boolean {
}
// Waiting "Require PIN" changes apply at the next PIN without "Confirm
-// all" (the PIN screen calls applyLocks); other changes keep waiting, and
+// all" (the PIN screen runs LockJob); other changes keep waiting, and
// the results of a following "Confirm all" list the locks too.
(:test)
function manageQueuedLocksApplyAtNextPin(logger as Test.Logger) as Boolean {
@@ -271,14 +292,14 @@ function manageQueuedLocksApplyAtNextPin(logger as Test.Logger) as Boolean {
s = ManageFixtures.send(vault, ids[1], ManageAction.LOCK);
SettingsFixtures.run(vault, s);
var counted = ChangeQueue.count(vault) == 3 && ChangeQueue.countLocks(vault) == 2;
- ChangeQueue.applyLocks(vault); // still locked: nothing happens
+ ManageFixtures.applyLocks(vault); // still locked: nothing happens
var untouched = ChangeQueue.count(vault) == 3 && !ManageFixtures.isLocked(vault, ids[0]);
StorageFixtures.unlock(vault, StorageFixtures.PIN);
- ChangeQueue.applyLocks(vault);
+ var locks = ManageFixtures.applyLocks(vault);
var left = ChangeQueue.descriptions(vault);
var applied = ManageFixtures.isLocked(vault, ids[0]) && ManageFixtures.isLocked(vault, ids[1])
&& left.size() == 1 && left[0].equals("Delete: " + bank + " Bank (alice@example.com)")
- && ChangeQueue.countLocks(vault) == 0 && vault.ids().indexOf(bank) >= 0;
+ && ChangeQueue.countLocks(vault) == 0 && vault.ids().indexOf(bank) >= 0 && locks.lines.size() == 2;
var job = ManageFixtures.confirm(vault);
var reported = job.lines.size() == 3 && job.lines[0].find(ids[0] + " ") == 0
&& job.lines[0].find("now requires the PIN") != null && SettingsFixtures.hasLine(job.lines, "Deleted " + bank);
@@ -299,7 +320,7 @@ function manageQueueKeepsLateArrivals(logger as Test.Logger) as Boolean {
var safe = ManageFixtures.addLocked(vault, "Safe");
var s = ManageFixtures.send(vault, bank, ManageAction.DELETE);
SettingsFixtures.run(vault, s);
- var job = new ChangeQueueJob(vault);
+ var job = new ChangeQueueJob(vault, null);
job.step(1);
s = ManageFixtures.send(vault, safe, ManageAction.DELETE);
SettingsFixtures.run(vault, s);
@@ -337,10 +358,10 @@ function manageQueueIsSealedAndLimited(logger as Test.Logger) as Boolean {
b.id = "M3P9";
b.action = ManageAction.DELETE;
ChangeQueue.add(vault, b);
- var raw = (vault.storage() as MemoryBackend).raw(ChangeQueue.KEY) as Array<ByteArray>;
- var box = raw[0];
+ var raw = (vault.storage() as MemoryBackend).raw(ChangeQueue.KEY) as Array<Array<Application.Storage.ValueType>>;
+ var box = raw[0][1] as ByteArray;
box[20] = box[20] ^ 1;
- var loaded = ChangeQueue.load(vault);
+ var loaded = ManageFixtures.load(vault);
var sealed = loaded.size() == 1 && loaded[0].id.equals("M3P9") && loaded[0].action == ManageAction.DELETE;
var roundTrip = ManageAction.fromBytes(a.toBytes());
var same = roundTrip != null && roundTrip.decoded && roundTrip.label.equals("Bank")