summaryrefslogtreecommitdiff
path: root/source/storage/Vault.mc
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 /source/storage/Vault.mc
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>
Diffstat (limited to 'source/storage/Vault.mc')
-rw-r--r--source/storage/Vault.mc91
1 files changed, 68 insertions, 23 deletions
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");
}