// 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; // The "Encrypt names" setting: new entries keep their names in the // encrypted part (existing ones are converted by AboutScan). var encryptNames as Boolean = false; // The "Show the favourite code in the glance" option (AppState); the // About scan stores or removes the glance item by it (GlanceStore). var glanceCode as Boolean = false; private var _store as Persist.Backend; private var _meta as Dictionary; 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 ------------------------------------------------------- // The underlying store (for the import buffer, which lives next to the // entries and uses the same device key). function storage() as Persist.Backend { return _store; } // 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 header of a listed entry or of a pending one ("p."), or null // when it doesn't exist or is damaged. Never throws. function anyHeader(id as String) as Entry? { var key = _ids.indexOf(id) >= 0 ? "e." + id : (_pending.indexOf(id) >= 0 ? "p." + id : null); if (key == null) { return null; } var stored = _store.getValue(key); return stored instanceof ByteArray ? Record.decodeHeader(stored) : null; } // 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(); entry.namesEncrypted = encryptNames; entry.fingerprint = keyManager.fingerprint(entry); 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); 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)); _store.deleteValue("x." + entry.id); // the cached matrix may be stale } // Re-seal an entry with or without encrypted names ("Encrypt names"). // Nothing else changes, so the barcode cache stays. Throws NOT_FOUND, // LOCKED, DAMAGED or FULL. function setNamesEncrypted(id as String, encrypted as Boolean) as Void { var entry = open(id); entry.namesEncrypted = encrypted; try { write("e." + id, seal(entry)); } catch (e instanceof VaultError) { entry.wipeSecrets(); throw e; } entry.wipeSecrets(); } // ---- Encoded barcode cache (README 12.2) ----------------------------- // Store an encoded matrix for a code entry, sealed with the entry's own // 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 { var keys = keysFor(isLockedRecord(id)); try { _store.setValue("x." + id, SealedBox.seal(keys, cacheAad(id), data)); } catch (e instanceof Lang.Exception) { // Storage full: the code is simply encoded again next time. } } // The cached matrix, or null when there is none or it doesn't verify. // Throws NOT_FOUND or LOCKED. function loadCodeCache(id as String) as ByteArray? { var keys = keysFor(isLockedRecord(id)); var stored = _store.getValue("x." + id); return stored instanceof ByteArray ? SealedBox.open(keys, cacheAad(id), stored) : null; } private function isLockedRecord(id as String) as Boolean { var record = recordFor(id); return record != null && Record.keyRef(record) == Record.KEY_MASTER; } private function cacheAad(id as String) as ByteArray { return Bytes.fromUtf8("OTPW-X-1" + id); } // 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); _store.deleteValue("x." + 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(); } // True when the PIN predates the 4-digit rule (KeyManager.isLegacyPin). function isLegacyPin() as Boolean { return keyManager.isLegacyPin(); } // 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 (exactly KeyManager.PIN_DIGITS digits): the first // PIN, or a new one while unlocked. Throws LOCKED when a PIN exists and // the session is locked, INVALID for any other length. 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]); _store.deleteValue("x." + 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 = TestMode.ON ? testIdBytes() : 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; } // UI-test build only: predictable IDs, so screenshots are reproducible. private var _testIdCounter as Number = 0; private function testIdBytes() as ByteArray { _testIdCounter++; var out = new [Entry.ID_LENGTH]b; for (var i = 0; i < Entry.ID_LENGTH; i++) { out[i] = (_testIdCounter * (7 + i * 3) + i * 11) & 0xFF; } return out; } // 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; } } }