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/Session.mc | 89 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 source/storage/Session.mc (limited to 'source/storage/Session.mc') diff --git a/source/storage/Session.mc b/source/storage/Session.mc new file mode 100644 index 0000000..891b713 --- /dev/null +++ b/source/storage/Session.mc @@ -0,0 +1,89 @@ +// Author: cirakg & claude ai +// Summary: The unlocked session: holds the master key in RAM and locks after inactivity or on request. +// +// README 7.5: unlocking puts the locked master key (and its derived keys) in +// RAM; locking zeroes and drops them. The UI calls touch() on every user +// action and checkExpiry() from its 1 Hz timer; the app calls lock() in +// onStop. An auto-lock time of 0 means "Immediately": the UI locks as soon as +// you leave a locked entry, and checkExpiry() locks on its next call. + +import Toybox.Lang; + +class Session { + + static const DEFAULT_AUTO_LOCK_MS = 60000; + + // Inactivity time before locking, in ms (0 = immediately). + var autoLockMs as Number = DEFAULT_AUTO_LOCK_MS; + + private var _time as TimeSource; + private var _masterKey as ByteArray? = null; + private var _keys as SealedBox.Keys? = null; + private var _lastActivity as Number = 0; + + function initialize(time as TimeSource) { + _time = time; + } + + // Take ownership of the master key (the caller must not wipe or keep it). + function unlock(masterKey as ByteArray) as Void { + lock(); + _masterKey = masterKey; + _keys = new SealedBox.Keys(masterKey); + touch(); + } + + function isUnlocked() as Boolean { + return _keys != null; + } + + // The master key's derived keys. Throws VaultError LOCKED when locked. + function keys() as SealedBox.Keys { + var keys = _keys; + if (keys == null) { + throw new VaultError(VaultError.LOCKED, "locked"); + } + return keys; + } + + // The master key itself (only to re-wrap it under a new PIN). Throws + // VaultError LOCKED when locked. + function masterKey() as ByteArray { + var key = _masterKey; + if (key == null) { + throw new VaultError(VaultError.LOCKED, "locked"); + } + return key; + } + + // Note user activity; restarts the inactivity timeout. + function touch() as Void { + _lastActivity = _time.uptimeMs(); + } + + // Lock if the inactivity timeout has passed. Returns true when this call + // locked the session, so the UI can close open locked entries. + function checkExpiry() as Boolean { + if (!isUnlocked()) { + return false; + } + var now = _time.uptimeMs(); + // A timer that went backwards (watch restart, wrap) counts as expired. + if (now < _lastActivity || now - _lastActivity >= autoLockMs) { + lock(); + return true; + } + return false; + } + + // Zero and drop every key. Safe to call when already locked. + function lock() as Void { + var keys = _keys; + if (keys != null) { + keys.wipe(); + } + Bytes.wipe(_masterKey); + _keys = null; + _masterKey = null; + } +} -- cgit v1.2.3