From dd5d0f68726fe56ee4d199867d414aea81905cc4 Mon Sep 17 00:00:00 2001 From: cirakg Date: Mon, 19 Oct 2026 15:27:37 +0200 Subject: feat(storage): add the encrypted vault, PIN keys, lockout, session and migrations Records are one ByteArray each: a readable TLV header plus an encrypt-then-MAC box whose aad binds the entry ID and the header. The device key seals unlocked entries and pending items; a random master key, wrapped under a PBKDF2 PIN key, seals locked ones. Wrong PINs are counted before the check, with delays on the trusted clock and an uptime fallback. Bytes.toUtf8 validates UTF-8 first, because the native conversion crashes on invalid input. Co-Authored-By: Claude Opus 5.5 --- source/storage/Vault.mc | 393 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 source/storage/Vault.mc (limited to 'source/storage/Vault.mc') diff --git a/source/storage/Vault.mc b/source/storage/Vault.mc new file mode 100644 index 0000000..0bb3cbc --- /dev/null +++ b/source/storage/Vault.mc @@ -0,0 +1,393 @@ +// Author: cirakg & claude ai +// Summary: The storage facade: entries in order, sealing with the right key, PIN unlock and change, pending queue, erase. +// +// Everything outside storage/ goes through a Vault. It owns the key manager, +// the session and the lockout, and keeps these invariants: +// * A record under "e." is sealed with the master key exactly when its +// header says locked; anything else is reported as damaged. +// * A locked entry added while the session is locked (or before a PIN +// exists) is sealed with the device key under "p." and hidden until +// promotePending() moves it under the master key (README 7.7). +// * 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. +// * Store-full errors from Application.Storage become VaultError FULL. +// +// Callers own the Entry objects they pass in and get back, and must call +// wipeSecrets() on every entry returned by open(). + +import Toybox.Application; +import Toybox.Cryptography; +import Toybox.Lang; + +class Vault { + + // Entry cap (README 6.2), set from the record sizes measured in M3; see + // docs/SECURITY.md. + static const MAX_ENTRIES = 150; + + var keyManager as KeyManager; + var session as Session; + var lockout as Lockout; + // The "Erase locked entries after 10 wrong PINs" setting (off by default). + var eraseAfterFailures as Boolean = false; + + private var _store as Persist.Backend; + private var _meta as Dictionary; + private var _ids as Array; + private var _pending as Array; + + // Opens the store, migrating it if needed. Throws VaultError + // NEWER_SCHEMA or DAMAGED when the metadata can't be used. + function initialize(store as Persist.Backend, time as TimeSource) { + _store = store; + _meta = Migration.run(store); + _ids = _meta["ids"] as Array; + _pending = _meta["pending"] as Array; + keyManager = new KeyManager(store); + session = new Session(time); + lockout = new Lockout(store, time); + } + + // ---- Reading ------------------------------------------------------- + + // Entry IDs in display order (a copy). + function ids() as Array { + return _ids.slice(0, null) as Array; + } + + // IDs of pending locked entries (a copy). + function pendingIds() as Array { + return _pending.slice(0, null) as Array; + } + + // The header fields (no secret), or null when the record is damaged. + // With "Encrypt names", unlocked entries' names are decrypted with the + // device key; locked ones keep empty names until opened. + // Throws NOT_FOUND. + function header(id as String) as Entry? { + var record = recordFor(id); + var entry = record != null ? Record.decodeHeader(record) : null; + if (record == null || entry == null || !consistent(entry, record)) { + return null; + } + if (entry.namesEncrypted && !entry.locked) { + var full = Record.open(record, id, keyManager.deviceKeys()); + if (full == null) { + return null; + } + full.wipeSecrets(); + return full; + } + return entry; + } + + // 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 { + 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)); + if (entry == null) { + throw new VaultError(VaultError.DAMAGED, "damaged entry " + id); + } + return entry; + } + + // ---- Changing entries ---------------------------------------------- + + // Store a new entry and return its new ID (also set on `entry`). A + // locked entry while the session is locked goes to the pending queue. + // Throws INVALID, LIMIT or FULL. + function add(entry as Entry) as String { + entry.id = ""; + checkStorable(entry); + if (_ids.size() + _pending.size() >= MAX_ENTRIES) { + throw new VaultError(VaultError.LIMIT, "at most " + MAX_ENTRIES + " entries"); + } + entry.id = newId(); + if (entry.created == 0) { + entry.created = Clock.nowUnix(0); + } + if (entry.locked && !session.isUnlocked()) { + write("p." + entry.id, sealChecked(entry, Record.KEY_DEVICE, keyManager.deviceKeys())); + _pending.add(entry.id); + saveMetaOrUndo("p." + entry.id, _pending, entry.id); + } else { + write("e." + entry.id, seal(entry)); + _ids.add(entry.id); + saveMetaOrUndo("e." + entry.id, _ids, entry.id); + } + return entry.id; + } + + // Rewrite an existing entry (same ID, same position). Changing a locked + // entry, or locking one, needs an unlocked session. Throws NOT_FOUND, + // INVALID, LOCKED, LIMIT or FULL. + function update(entry as Entry) as Void { + var record = recordFor(entry.id); + checkStorable(entry); + if ((record != null && Record.keyRef(record) == Record.KEY_MASTER) || entry.locked) { + session.keys(); // throws LOCKED + } + write("e." + entry.id, seal(entry)); + } + + // Set a HOTP counter (re-seals the record, since the MAC covers it). + function setCounter(id as String, counter as Long) as Void { + var entry = open(id); + entry.counter = counter; + try { + update(entry); + } catch (e instanceof Lang.Exception) { + entry.wipeSecrets(); + throw e; + } + entry.wipeSecrets(); + } + + // Delete an entry. A locked one needs an unlocked session; a damaged one + // can always be deleted. Throws NOT_FOUND or LOCKED. + function remove(id as String) as Void { + var record = recordFor(id); + if (record != null && Record.keyRef(record) == Record.KEY_MASTER) { + session.keys(); // throws LOCKED + } + _ids.remove(id); + saveMeta(); + _store.deleteValue("e." + id); + } + + // Move an entry to position `index` (clamped to the list). Throws NOT_FOUND. + function move(id as String, index as Number) as Void { + recordFor(id); + _ids.remove(id); + var target = index < 0 ? 0 : (index > _ids.size() ? _ids.size() : index); + var reordered = [] as Array; + for (var i = 0; i < _ids.size(); i++) { + if (i == target) { + reordered.add(id); + } + reordered.add(_ids[i]); + } + if (target == _ids.size()) { + reordered.add(id); + } + _ids = reordered; + _meta["ids"] = _ids; + saveMeta(); + } + + // 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. + function promotePending() as Number { + var keys = 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); + } + promoted++; + } + _pending.remove(id); + saveMeta(); + _store.deleteValue("p." + id); + } + return promoted; + } + + // ---- PIN and session ----------------------------------------------- + + function hasPin() as Boolean { + return keyManager.hasPin(); + } + + // Start checking a PIN: counts the attempt first (README 7.6), then + // returns the derivation to run. Throws NO_PIN, LOCKED_OUT or DAMAGED. + function beginUnlock(pin as ByteArray) as PinJob { + if (!keyManager.hasPin()) { + throw new VaultError(VaultError.NO_PIN, "no PIN set"); + } + lockout.recordAttempt(); + return keyManager.unlockJob(pin); + } + + // Finish a finished unlock job. True: the session is unlocked. False: + // wrong PIN (and, with the erase option, locked entries are erased + // after ERASE_AFTER failures). + function finishUnlock(job as PinJob) as Boolean { + var masterKey = keyManager.unwrap(job); + if (masterKey != null) { + lockout.recordSuccess(); + session.unlock(masterKey); + return true; + } + if (eraseAfterFailures && lockout.failures() >= Lockout.ERASE_AFTER) { + eraseLocked(); + } + return false; + } + + // Start setting a PIN: the first PIN, or a new one while unlocked. + // Throws LOCKED when a PIN exists and the session is locked. + function beginSetPin(pin as ByteArray, iterations as Number) as PinJob { + if (keyManager.hasPin() && !session.isUnlocked()) { + throw new VaultError(VaultError.LOCKED, "locked"); + } + return keyManager.newPinJob(pin, iterations); + } + + // Finish a finished set-PIN job: re-wraps the current master key, or + // creates one for the first PIN, and leaves the session unlocked. + function finishSetPin(job as PinJob) as Void { + if (session.isUnlocked()) { + keyManager.storeWrap(job, session.masterKey()); + } else if (!keyManager.hasPin()) { + var masterKey = keyManager.newMasterKey(); + keyManager.storeWrap(job, masterKey); + session.unlock(masterKey); + } else { + job.wipe(); + throw new VaultError(VaultError.LOCKED, "locked"); + } + lockout.clear(); + } + + // Forgotten PIN: erase everything the PIN protected. A new PIN can then + // be set with beginSetPin/finishSetPin. + function resetPin() as Void { + eraseLocked(); + } + + // Lock now ("Lock now" in the menu, onStop, auto-lock). + function lock() as Void { + session.lock(); + } + + // Delete the PIN wrap, every locked entry and every pending item. + // Damaged entries are kept so the user sees and deletes them. + function eraseLocked() as Void { + session.lock(); + var kept = [] as Array; + var erased = [] as Array; + for (var i = 0; i < _ids.size(); i++) { + var stored = _store.getValue("e." + _ids[i]); + if (stored instanceof ByteArray && Record.keyRef(stored) == Record.KEY_MASTER) { + erased.add(_ids[i]); + } else { + kept.add(_ids[i]); + } + } + var pending = _pending; + _ids = kept; + _pending = [] as Array; + _meta["ids"] = _ids; + _meta["pending"] = _pending; + saveMeta(); + for (var i = 0; i < erased.size(); i++) { + _store.deleteValue("e." + erased[i]); + } + for (var i = 0; i < pending.size(); i++) { + _store.deleteValue("p." + pending[i]); + } + keyManager.deleteWrap(); + lockout.clear(); + } + + // ---- Internals ----------------------------------------------------- + + // The stored record for a listed ID, or null when it is missing or not + // a ByteArray (damaged). Throws NOT_FOUND for unlisted IDs. + private function recordFor(id as String) as ByteArray? { + if (_ids.indexOf(id) < 0) { + throw new VaultError(VaultError.NOT_FOUND, "no entry " + id); + } + var stored = _store.getValue("e." + id); + return stored instanceof ByteArray ? stored : null; + } + + // The locked flag in the header and the key the record names must agree. + private function consistent(entry as Entry, record as ByteArray) as Boolean { + return Record.keyRef(record) == (entry.locked ? Record.KEY_MASTER : Record.KEY_DEVICE); + } + + // Device keys for unlocked entries, session keys for locked ones (throws LOCKED). + private function keysFor(locked as Boolean) as SealedBox.Keys { + return locked ? session.keys() : keyManager.deviceKeys(); + } + + private function seal(entry as Entry) as ByteArray { + return sealChecked(entry, entry.locked ? Record.KEY_MASTER : Record.KEY_DEVICE, keysFor(entry.locked)); + } + + // 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); + if (record.size() > Record.MAX_BYTES) { + throw new VaultError(VaultError.LIMIT, "entry too big"); + } + return record; + } + + private function checkStorable(entry as Entry) as Void { + var problem = entry.validate(); + if (problem != null) { + throw new VaultError(VaultError.INVALID, problem); + } + } + + // A random unused ID. (The modulo bias over 30 letters doesn't matter: + // IDs only need to be unique, not unpredictable.) + private function newId() as String { + var id = ""; + do { + var random = Cryptography.randomBytes(Entry.ID_LENGTH); + var alphabetSize = Entry.ID_ALPHABET.length(); + id = ""; + for (var i = 0; i < Entry.ID_LENGTH; i++) { + var k = random[i] % alphabetSize; + id += Entry.ID_ALPHABET.substring(k, k + 1); + } + } while (_ids.indexOf(id) >= 0 || _pending.indexOf(id) >= 0 || _store.getValue("e." + id) != null + || _store.getValue("p." + id) != null); + return id; + } + + // Store a value, turning a store-full error into VaultError FULL. + private function write(key as String, value as Application.Storage.ValueType) as Void { + try { + _store.setValue(key, value); + } catch (e instanceof Lang.Exception) { + throw new VaultError(VaultError.FULL, "storage full"); + } + } + + private function saveMeta() as Void { + _meta["ids"] = _ids; + _meta["pending"] = _pending; + write(Migration.META_KEY, _meta); + } + + // Save the metadata after adding `id` to `list`; if that fails, take + // the new record back out so nothing half-added remains. + private function saveMetaOrUndo(recordKey as String, list as Array, id as String) as Void { + try { + saveMeta(); + } catch (e instanceof VaultError) { + list.remove(id); + _store.deleteValue(recordKey); + throw e; + } + } +} -- cgit v1.2.3