aboutsummaryrefslogtreecommitdiff
path: root/source/storage/Vault.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-19 15:27:37 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-19 15:27:37 +0200
commitdd5d0f68726fe56ee4d199867d414aea81905cc4 (patch)
tree1cb40b024ac13f454633198ad50cbc55f7283f39 /source/storage/Vault.mc
parent94181d58cf66c5b7b263fd435a344f1cd8872cff (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'source/storage/Vault.mc')
-rw-r--r--source/storage/Vault.mc393
1 files changed, 393 insertions, 0 deletions
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.<id>" 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.<id>" 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<Application.Storage.KeyType, Application.Storage.ValueType>;
+ private var _ids as Array<String>;
+ private var _pending as Array<String>;
+
+ // 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<String>;
+ _pending = _meta["pending"] as Array<String>;
+ 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<String> {
+ return _ids.slice(0, null) as Array<String>;
+ }
+
+ // IDs of pending locked entries (a copy).
+ function pendingIds() as Array<String> {
+ return _pending.slice(0, null) as Array<String>;
+ }
+
+ // 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<String>;
+ 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<String>;
+ var erased = [] as Array<String>;
+ 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<String>;
+ _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<String>, id as String) as Void {
+ try {
+ saveMeta();
+ } catch (e instanceof VaultError) {
+ list.remove(id);
+ _store.deleteValue(recordKey);
+ throw e;
+ }
+ }
+}