summaryrefslogtreecommitdiff
path: root/source
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
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')
-rw-r--r--source/storage/Backend.mc54
-rw-r--r--source/storage/Codec.mc101
-rw-r--r--source/storage/Entry.mc149
-rw-r--r--source/storage/KeyManager.mc188
-rw-r--r--source/storage/Lockout.mc138
-rw-r--r--source/storage/Migration.mc89
-rw-r--r--source/storage/Record.mc282
-rw-r--r--source/storage/Session.mc89
-rw-r--r--source/storage/TimeSource.mc27
-rw-r--r--source/storage/Vault.mc393
-rw-r--r--source/storage/VaultError.mc35
-rw-r--r--source/util/Bytes.mc60
12 files changed, 1605 insertions, 0 deletions
diff --git a/source/storage/Backend.mc b/source/storage/Backend.mc
new file mode 100644
index 0000000..6596d9c
--- /dev/null
+++ b/source/storage/Backend.mc
@@ -0,0 +1,54 @@
+// Author: cirakg & claude ai
+// Summary: Key-value persistence behind an interface: Application.Storage on the watch, a dictionary in tests.
+//
+// Everything the vault persists goes through a Backend, so unit tests can run
+// against MemoryBackend (test/storage/) and simulate a full store, while the
+// app uses AppStorageBackend. Values are the types Application.Storage
+// accepts: numbers, strings, booleans, ByteArrays, arrays and dictionaries.
+// Records are single ByteArrays, so their stored size is known exactly.
+
+import Toybox.Application;
+import Toybox.Lang;
+
+module Persist {
+
+ // Base class; subclasses override all three methods.
+ class Backend {
+
+ function initialize() {
+ }
+
+ // The stored value, or null when the key is absent.
+ function getValue(key as String) as Application.Storage.ValueType {
+ return null;
+ }
+
+ // Store a value. Throws when the store is full (the vault turns that
+ // into VaultError FULL).
+ function setValue(key as String, value as Application.Storage.ValueType) as Void {
+ }
+
+ function deleteValue(key as String) as Void {
+ }
+ }
+
+ // The real store: Application.Storage, private to this app.
+ class AppStorageBackend extends Backend {
+
+ function initialize() {
+ Backend.initialize();
+ }
+
+ function getValue(key as String) as Application.Storage.ValueType {
+ return Application.Storage.getValue(key);
+ }
+
+ function setValue(key as String, value as Application.Storage.ValueType) as Void {
+ Application.Storage.setValue(key, value);
+ }
+
+ function deleteValue(key as String) as Void {
+ Application.Storage.deleteValue(key);
+ }
+ }
+}
diff --git a/source/storage/Codec.mc b/source/storage/Codec.mc
new file mode 100644
index 0000000..469ba82
--- /dev/null
+++ b/source/storage/Codec.mc
@@ -0,0 +1,101 @@
+// Author: cirakg & claude ai
+// Summary: Deterministic TLV byte encoding of record fields, so the same fields always give the same MAC input.
+//
+// A field is: tag (1 byte) | type (1 byte) | length (2 bytes, big-endian) | value.
+// Types: bytes, UTF-8 text, 64-bit integer (8 bytes big-endian), boolean
+// (1 byte). Fields are written in the order the caller gives, and Record.mc
+// always gives them in tag order, so encoding is canonical.
+//
+// decode() is strict: a truncated field, an unknown type, invalid UTF-8 or a
+// duplicate tag gives null, never a partial result.
+
+import Toybox.Lang;
+
+module Codec {
+
+ const TYPE_BYTES = 1;
+ const TYPE_TEXT = 2;
+ const TYPE_INT = 3;
+ const TYPE_BOOL = 4;
+ const MAX_VALUE_BYTES = 65535;
+
+ // Builds an encoding field by field.
+ class Writer {
+ var bytes as ByteArray = []b;
+
+ function initialize() {
+ }
+
+ function header(tag as Number, type as Number, length as Number) as Void {
+ if (length > MAX_VALUE_BYTES) {
+ throw new Lang.InvalidValueException("Codec: field too long");
+ }
+ bytes.add(tag);
+ bytes.add(type);
+ bytes.add((length >> 8) & 0xFF);
+ bytes.add(length & 0xFF);
+ }
+
+ function addBytes(tag as Number, value as ByteArray) as Void {
+ header(tag, TYPE_BYTES, value.size());
+ bytes.addAll(value);
+ }
+
+ function addText(tag as Number, value as String) as Void {
+ var utf8 = Bytes.fromUtf8(value);
+ header(tag, TYPE_TEXT, utf8.size());
+ bytes.addAll(utf8);
+ }
+
+ function addInt(tag as Number, value as Number or Long) as Void {
+ header(tag, TYPE_INT, 8);
+ bytes.addAll(Bytes.uint64BigEndian(value.toLong()));
+ }
+
+ function addBool(tag as Number, value as Boolean) as Void {
+ header(tag, TYPE_BOOL, 1);
+ bytes.add(value ? 1 : 0);
+ }
+ }
+
+ // Tag -> value (ByteArray, String, Long or Boolean), or null when the
+ // encoding is malformed.
+ function decode(data as ByteArray) as Dictionary<Number, ByteArray or String or Long or Boolean>? {
+ var fields = {} as Dictionary<Number, ByteArray or String or Long or Boolean>;
+ var i = 0;
+ while (i < data.size()) {
+ if (i + 4 > data.size()) {
+ return null;
+ }
+ var tag = data[i];
+ var type = data[i + 1];
+ var length = (data[i + 2] << 8) | data[i + 3];
+ i += 4;
+ if (i + length > data.size() || fields.hasKey(tag)) {
+ return null;
+ }
+ var value = data.slice(i, i + length);
+ i += length;
+ if (type == TYPE_BYTES) {
+ fields[tag] = value;
+ } else if (type == TYPE_TEXT) {
+ var text = Bytes.toUtf8(value);
+ if (text == null) {
+ return null;
+ }
+ fields[tag] = text;
+ } else if (type == TYPE_INT && length == 8) {
+ var v = 0l;
+ for (var k = 0; k < 8; k++) {
+ v = (v << 8) | value[k].toLong();
+ }
+ fields[tag] = v;
+ } else if (type == TYPE_BOOL && length == 1 && value[0] <= 1) {
+ fields[tag] = value[0] == 1;
+ } else {
+ return null;
+ }
+ }
+ return fields;
+ }
+}
diff --git a/source/storage/Entry.mc b/source/storage/Entry.mc
new file mode 100644
index 0000000..57e0309
--- /dev/null
+++ b/source/storage/Entry.mc
@@ -0,0 +1,149 @@
+// Author: cirakg & claude ai
+// Summary: One wallet entry (an OTP account or a barcode card): its fields, limits, validation and wiping.
+//
+// The fields follow README 6.1. Header fields are readable without a key so
+// the list draws without decrypting; the sensitive fields (algorithm, digits,
+// period, secret, payload, options) only exist in memory after the vault has
+// opened the record, and wipeSecrets() zeroes them again.
+//
+// An Entry that came from Vault.header() has secret == null and
+// payload == null; one from Vault.open() has them filled in.
+
+import Toybox.Lang;
+
+class Entry {
+
+ // kind
+ static const KIND_OTP = 0;
+ static const KIND_CODE = 1;
+
+ // type, for OTP entries (code entries store their symbology, M6)
+ static const OTP_TOTP = 0;
+ static const OTP_HOTP = 1;
+ static const OTP_STEAM = 2;
+
+ // source
+ static const SOURCE_MANUAL = 0;
+ static const SOURCE_IMPORT = 1;
+
+ // ID alphabet: digits and capitals without 0, 1, I, L, O and U, so IDs
+ // are easy to read off the watch and type into the Manage form.
+ static const ID_ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ"; // lint: allow-literal
+ static const ID_LENGTH = 4;
+
+ // Limits, in UTF-8 bytes. They keep one record far below the 8 KB
+ // per-value limit of Application.Storage (README 6.2).
+ static const MAX_NAME_BYTES = 64;
+ static const MAX_GROUP_BYTES = 32;
+ static const MAX_SECRET_BYTES = 128;
+ static const MAX_PAYLOAD_BYTES = 4096;
+ static const MAX_SYMBOLOGY = 31;
+
+ // Header fields.
+ var id as String = "";
+ var kind as Number = KIND_OTP;
+ var type as Number = OTP_TOTP;
+ var name as String = "";
+ var account as String = "";
+ var group as String = "";
+ var locked as Boolean = false;
+ var source as Number = SOURCE_MANUAL;
+ var favorite as Boolean = false;
+ var counter as Long = 0l;
+ var created as Long = 0l;
+ var namesEncrypted as Boolean = false;
+
+ // Sensitive fields (encrypted at rest).
+ var algorithm as Number = Hashes.SHA1;
+ var digits as Number = 6;
+ var period as Number = 30;
+ var secret as ByteArray? = null;
+ var payload as ByteArray? = null;
+ var options as Number = 0;
+
+ function initialize() {
+ }
+
+ // True for a well-formed ID: ID_LENGTH characters from ID_ALPHABET.
+ static function isValidId(value as String) as Boolean {
+ if (value.length() != ID_LENGTH) {
+ return false;
+ }
+ var chars = value.toCharArray();
+ for (var i = 0; i < chars.size(); i++) {
+ if (ID_ALPHABET.find(chars[i].toString()) == null) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ // null when the entry can be stored, otherwise a short English reason.
+ // Checks the ID only when one is set (the vault assigns new IDs).
+ // Settings and import validate user input in more detail (M5, M7); this is
+ // the last line of defence before anything is written.
+ function validate() as String? {
+ if (id.length() > 0 && !isValidId(id)) {
+ return "bad id";
+ }
+ if (Bytes.fromUtf8(name).size() > MAX_NAME_BYTES || Bytes.fromUtf8(account).size() > MAX_NAME_BYTES) {
+ return "name too long";
+ }
+ if (Bytes.fromUtf8(group).size() > MAX_GROUP_BYTES) {
+ return "group too long";
+ }
+ if (source != SOURCE_MANUAL && source != SOURCE_IMPORT) {
+ return "bad source";
+ }
+ if (counter < 0) {
+ return "bad counter";
+ }
+ if (kind == KIND_OTP) {
+ return validateOtp();
+ }
+ if (kind == KIND_CODE) {
+ return validateCode();
+ }
+ return "bad kind";
+ }
+
+ private function validateOtp() as String? {
+ if (type != OTP_TOTP && type != OTP_HOTP && type != OTP_STEAM) {
+ return "bad otp type";
+ }
+ if (!Hashes.isValid(algorithm)) {
+ return "bad algorithm";
+ }
+ if (digits < Otp.MIN_DIGITS || digits > Otp.MAX_DIGITS) {
+ return "bad digits";
+ }
+ if (period < Otp.MIN_PERIOD || period > Otp.MAX_PERIOD) {
+ return "bad period";
+ }
+ var s = secret;
+ if (s == null || s.size() == 0 || s.size() > MAX_SECRET_BYTES) {
+ return "bad secret";
+ }
+ return null;
+ }
+
+ private function validateCode() as String? {
+ if (type < 0 || type > MAX_SYMBOLOGY) {
+ return "bad symbology";
+ }
+ var p = payload;
+ if (p == null || p.size() == 0 || p.size() > MAX_PAYLOAD_BYTES) {
+ return "bad payload";
+ }
+ return null;
+ }
+
+ // Zero the secret and payload in place and drop them. Call as soon as a
+ // code has been computed or a barcode encoded (README 7.8).
+ function wipeSecrets() as Void {
+ Bytes.wipe(secret);
+ Bytes.wipe(payload);
+ secret = null;
+ payload = null;
+ }
+}
diff --git a/source/storage/KeyManager.mc b/source/storage/KeyManager.mc
new file mode 100644
index 0000000..ebb0a62
--- /dev/null
+++ b/source/storage/KeyManager.mc
@@ -0,0 +1,188 @@
+// Author: cirakg & claude ai
+// Summary: The device key and the PIN-wrapped locked master key: creation, PIN derivation jobs, wrap and unwrap.
+//
+// Keys (README 7.1, 7.2):
+// * Device key "dk": 32 random bytes, created on first use, stored as is.
+// It seals unlocked entries, pending items and the import buffer.
+// * Locked master key (LMK): 32 random bytes, stored only wrapped under
+// the PIN key, in "w".
+// * PIN key = PBKDF2-HMAC-SHA256(PIN as ASCII digits, salt, n, 32 bytes).
+//
+// Wrap format, version 1 (one ByteArray under "w"):
+// salt (16 bytes) | n (4 bytes, big-endian) | SealedBox(Keys(PIN key), aad, LMK)
+// aad = "OTPW-WRAP-1" || salt || n
+// A correct PIN is exactly "the box verifies"; no PIN hash exists. The salt
+// and iteration count are in the aad, so changing either fails the MAC.
+// Changing the PIN re-wraps the same LMK; entries aren't re-encrypted.
+//
+// PBKDF2 runs as a PinJob (a Jobs.Job) because one derivation is calibrated
+// to about a second, far beyond one event's watchdog budget.
+
+import Toybox.Cryptography;
+import Toybox.Lang;
+
+class KeyManager {
+
+ static const DEVICE_KEY = "dk";
+ static const WRAP_KEY = "w";
+ static const KEY_BYTES = 32;
+ static const SALT_BYTES = 16;
+ static const WRAP_LABEL = "OTPW-WRAP-1";
+ static const MIN_PIN_DIGITS = 4;
+ static const MAX_PIN_DIGITS = 10;
+
+ private var _store as Persist.Backend;
+ private var _deviceKeys as SealedBox.Keys? = null;
+
+ function initialize(store as Persist.Backend) {
+ _store = store;
+ }
+
+ // The device key's derived keys, creating the device key on first use.
+ function deviceKeys() as SealedBox.Keys {
+ var keys = _deviceKeys;
+ if (keys != null) {
+ return keys;
+ }
+ var stored = _store.getValue(DEVICE_KEY);
+ var raw = null;
+ if (stored instanceof ByteArray && stored.size() == KEY_BYTES) {
+ raw = stored;
+ } else {
+ // First start (or a lost key, which makes unlocked records
+ // unreadable; they then show as damaged).
+ raw = Cryptography.randomBytes(KEY_BYTES);
+ _store.setValue(DEVICE_KEY, raw);
+ }
+ keys = new SealedBox.Keys(raw);
+ Bytes.wipe(raw);
+ _deviceKeys = keys;
+ return keys;
+ }
+
+ // True when a PIN has been set.
+ function hasPin() as Boolean {
+ return _store.getValue(WRAP_KEY) != null;
+ }
+
+ // A fresh random master key (when the first PIN is created).
+ function newMasterKey() as ByteArray {
+ return Cryptography.randomBytes(KEY_BYTES);
+ }
+
+ // True for 4 to 10 ASCII digits.
+ static function isValidPin(pin as ByteArray) as Boolean {
+ if (pin.size() < MIN_PIN_DIGITS || pin.size() > MAX_PIN_DIGITS) {
+ return false;
+ }
+ for (var i = 0; i < pin.size(); i++) {
+ if (pin[i] < 0x30 || pin[i] > 0x39) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ // The derivation for checking `pin` against the stored wrap. Throws
+ // NO_PIN when no PIN is set, DAMAGED when the wrap is malformed.
+ function unlockJob(pin as ByteArray) as PinJob {
+ var wrap = _store.getValue(WRAP_KEY);
+ if (wrap == null) {
+ throw new VaultError(VaultError.NO_PIN, "no PIN set");
+ }
+ if (!(wrap instanceof ByteArray) || wrap.size() < SALT_BYTES + 4 + SealedBox.MIN_SIZE) {
+ throw new VaultError(VaultError.DAMAGED, "PIN data damaged");
+ }
+ var iterations = wrap.decodeNumber(Lang.NUMBER_FORMAT_UINT32, {:offset => SALT_BYTES, :endianness => Lang.ENDIAN_BIG}) as Number;
+ if (iterations < Kdf.MIN_ITERATIONS || iterations > Kdf.MAX_ITERATIONS) {
+ // Also stops a tampered count from making the derivation endless.
+ throw new VaultError(VaultError.DAMAGED, "PIN data damaged");
+ }
+ return new PinJob(pin, wrap.slice(0, SALT_BYTES), iterations);
+ }
+
+ // The derivation for a new PIN, with a fresh salt.
+ function newPinJob(pin as ByteArray, iterations as Number) as PinJob {
+ if (iterations < Kdf.MIN_ITERATIONS || iterations > Kdf.MAX_ITERATIONS) {
+ throw new Lang.InvalidValueException("PIN: iteration count out of range");
+ }
+ return new PinJob(pin, Cryptography.randomBytes(SALT_BYTES), iterations);
+ }
+
+ // After a finished unlockJob: the master key, or null for a wrong PIN.
+ // Wipes the PIN key.
+ function unwrap(job as PinJob) as ByteArray? {
+ var wrap = _store.getValue(WRAP_KEY);
+ if (!(wrap instanceof ByteArray) || wrap.size() < SALT_BYTES + 4 + SealedBox.MIN_SIZE) {
+ job.wipe();
+ return null;
+ }
+ var keys = job.keys();
+ var masterKey = SealedBox.open(keys, wrapAad(job), wrap.slice(SALT_BYTES + 4, wrap.size()));
+ keys.wipe();
+ job.wipe();
+ if (masterKey != null && masterKey.size() != KEY_BYTES) {
+ Bytes.wipe(masterKey);
+ return null;
+ }
+ return masterKey;
+ }
+
+ // After a finished newPinJob: wrap masterKey under the new PIN and store
+ // it, replacing any previous wrap. Wipes the PIN key, not masterKey.
+ function storeWrap(job as PinJob, masterKey as ByteArray) as Void {
+ var keys = job.keys();
+ var wrap = Bytes.copy(job.salt);
+ wrap.addAll(Bytes.uint32BigEndian(job.iterations));
+ wrap.addAll(SealedBox.seal(keys, wrapAad(job), masterKey));
+ keys.wipe();
+ job.wipe();
+ _store.setValue(WRAP_KEY, wrap);
+ }
+
+ // Forget the PIN (reset or erase). The master key is then gone for good.
+ function deleteWrap() as Void {
+ _store.deleteValue(WRAP_KEY);
+ }
+
+ private function wrapAad(job as PinJob) as ByteArray {
+ var aad = Bytes.fromUtf8(WRAP_LABEL);
+ aad.addAll(job.salt);
+ aad.addAll(Bytes.uint32BigEndian(job.iterations));
+ return aad;
+ }
+}
+
+// PBKDF2 of a PIN as a resumable job, remembering its salt and count.
+class PinJob extends Jobs.Job {
+
+ var salt as ByteArray;
+ var iterations as Number;
+ private var _pbkdf2 as Kdf.Pbkdf2;
+
+ function initialize(pin as ByteArray, salt as ByteArray, iterations as Number) {
+ Job.initialize();
+ if (!KeyManager.isValidPin(pin)) {
+ throw new Lang.InvalidValueException("PIN: 4 to 10 digits");
+ }
+ self.salt = salt;
+ self.iterations = iterations;
+ _pbkdf2 = new Kdf.Pbkdf2(pin, salt, iterations, KeyManager.KEY_BYTES);
+ }
+
+ function step(budget as Number) as Boolean {
+ return _pbkdf2.step(budget);
+ }
+
+ // The wrap keys derived from the PIN key; only after step() returned true.
+ function keys() as SealedBox.Keys {
+ var pinKey = _pbkdf2.result();
+ var keys = new SealedBox.Keys(pinKey);
+ Bytes.wipe(pinKey);
+ return keys;
+ }
+
+ function wipe() as Void {
+ _pbkdf2.wipe();
+ }
+}
diff --git a/source/storage/Lockout.mc b/source/storage/Lockout.mc
new file mode 100644
index 0000000..939b91b
--- /dev/null
+++ b/source/storage/Lockout.mc
@@ -0,0 +1,138 @@
+// Author: cirakg & claude ai
+// Summary: Wrong-PIN policy: persistent failure count, growing delays on a trusted clock, and the erase threshold.
+//
+// Rules (README 7.6):
+// * Every attempt is counted and written to storage BEFORE the PIN is
+// checked (recordAttempt), and the count is cleared on success. Killing
+// the app mid-check therefore costs the attacker an attempt.
+// * Four free attempts. After the 5th failure the next attempt waits 30 s,
+// then 1 min, 2 min, 4 min and so on, capped at 1 hour.
+// * Delays are timed with the trusted clock when it is synced. Otherwise
+// they count system uptime from the moment this app instance started
+// (or from the attempt, if later), so restarting the app restarts the
+// delay in full: never shorter, sometimes longer.
+// * failures() >= ERASE_AFTER is the point where the optional "erase locked
+// entries" setting applies; the vault does the erasing.
+//
+// Stored under "lk" as a dictionary:
+// "f" failures (attempts not followed by a success), "d" delay in seconds
+// before the next attempt, "t" trusted unix time of the last attempt (only
+// when the trusted clock was synced). The uptime anchor is kept in memory
+// only: every app start sets it anew.
+
+import Toybox.Application;
+import Toybox.Lang;
+
+class Lockout {
+
+ static const STORAGE_KEY = "lk";
+ static const FREE_ATTEMPTS = 4;
+ static const BASE_DELAY_S = 30;
+ static const MAX_DELAY_S = 3600;
+ static const ERASE_AFTER = 10;
+
+ private var _store as Persist.Backend;
+ private var _time as TimeSource;
+ private var _failures as Number = 0;
+ private var _delay as Number = 0;
+ private var _trusted as Long? = null;
+ private var _uptime as Number = 0;
+
+ // Loads the state. A running delay restarts its uptime count here, which
+ // is what makes an app restart unable to shorten it.
+ function initialize(store as Persist.Backend, time as TimeSource) {
+ _store = store;
+ _time = time;
+ var state = store.getValue(STORAGE_KEY);
+ if (state instanceof Dictionary) {
+ var f = state["f"];
+ var d = state["d"];
+ var t = state["t"];
+ if (f instanceof Number && f >= 0 && d instanceof Number && d >= 0) {
+ _failures = f;
+ _delay = d > MAX_DELAY_S ? MAX_DELAY_S : d;
+ _trusted = t instanceof Long ? t : null;
+ } else {
+ // Unreadable state is not a reset: no free attempts are left,
+ // so the next failure starts the delays.
+ _failures = FREE_ATTEMPTS;
+ }
+ }
+ _uptime = time.uptimeMs();
+ }
+
+ // The delay after `failures` consecutive wrong PINs, in seconds.
+ static function delayFor(failures as Number) as Number {
+ if (failures <= FREE_ATTEMPTS) {
+ return 0;
+ }
+ var doublings = failures - FREE_ATTEMPTS - 1;
+ if (doublings >= 7) { // 30 s * 2^7 is already over the cap
+ return MAX_DELAY_S;
+ }
+ var delay = BASE_DELAY_S << doublings;
+ return delay > MAX_DELAY_S ? MAX_DELAY_S : delay;
+ }
+
+ function failures() as Number {
+ return _failures;
+ }
+
+ // Seconds until the next attempt is allowed; 0 when it is allowed now.
+ function remainingSeconds() as Number {
+ if (_delay == 0) {
+ return 0;
+ }
+ var elapsed = 0;
+ var trustedNow = _time.trustedUnix();
+ var trustedThen = _trusted;
+ if (trustedNow != null && trustedThen != null && trustedNow >= trustedThen) {
+ elapsed = (trustedNow - trustedThen).toNumber();
+ } else {
+ var now = _time.uptimeMs();
+ if (now < _uptime) {
+ // The timer restarted or wrapped: count from now.
+ _uptime = now;
+ }
+ elapsed = (now - _uptime) / 1000;
+ }
+ var remaining = _delay - elapsed;
+ return remaining > 0 ? remaining : 0;
+ }
+
+ // Count an attempt before the PIN is checked. Throws VaultError
+ // LOCKED_OUT, without counting, while a delay runs.
+ function recordAttempt() as Void {
+ var remaining = remainingSeconds();
+ if (remaining > 0) {
+ throw new VaultError(VaultError.LOCKED_OUT, "wait " + remaining + " s");
+ }
+ _failures++;
+ _delay = delayFor(_failures);
+ _trusted = _time.trustedUnix();
+ _uptime = _time.uptimeMs();
+ save();
+ }
+
+ // The PIN was right: forget all failures.
+ function recordSuccess() as Void {
+ clear();
+ }
+
+ // Forget all failures (after success, a PIN reset or an erase).
+ function clear() as Void {
+ _failures = 0;
+ _delay = 0;
+ _trusted = null;
+ _store.deleteValue(STORAGE_KEY);
+ }
+
+ private function save() as Void {
+ var state = {"f" => _failures, "d" => _delay} as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>;
+ var t = _trusted;
+ if (t != null) {
+ state["t"] = t;
+ }
+ _store.setValue(STORAGE_KEY, state);
+ }
+}
diff --git a/source/storage/Migration.mc b/source/storage/Migration.mc
new file mode 100644
index 0000000..e41d3fb
--- /dev/null
+++ b/source/storage/Migration.mc
@@ -0,0 +1,89 @@
+// Author: cirakg & claude ai
+// Summary: Storage schema versions: create the metadata on first start, migrate old schemas, refuse newer ones.
+//
+// The metadata "m" is a dictionary:
+// "schema" data version (CURRENT)
+// "ids" entry IDs in display order
+// "pending" IDs of pending locked entries (README 7.7)
+//
+// Rules (README 6.3):
+// * Each migration is one small, idempotent function from version k to
+// k + 1, run in order at start-up.
+// * A migration that rewrites records writes each new record under a
+// temporary key and switches the index to it before deleting the old
+// one, so a crash or power loss mid-migration loses nothing. The only
+// migration so far (0 to 1) changes just the metadata, in one write.
+// * Data from a newer schema (after a downgrade) is never modified.
+//
+// Version 0 never shipped: it is a fixture layout ("m" = {"order" => ids})
+// that exists to test the migration machinery before a real migration is
+// needed.
+
+import Toybox.Application;
+import Toybox.Lang;
+
+module Migration {
+
+ const CURRENT = 1;
+ const META_KEY = "m";
+
+ // The metadata at the CURRENT schema, migrating or creating it as needed.
+ // Throws VaultError NEWER_SCHEMA or DAMAGED.
+ function run(store as Persist.Backend) as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType> {
+ var stored = store.getValue(META_KEY);
+ if (stored == null) {
+ var fresh = {"schema" => CURRENT, "ids" => [], "pending" => []} as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>;
+ store.setValue(META_KEY, fresh);
+ return fresh;
+ }
+ if (!(stored instanceof Dictionary)) {
+ throw new VaultError(VaultError.DAMAGED, "metadata damaged");
+ }
+ var meta = stored as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>;
+ var schema = meta.hasKey("schema") ? meta["schema"] : 0;
+ if (!(schema instanceof Number) || schema < 0) {
+ throw new VaultError(VaultError.DAMAGED, "metadata damaged");
+ }
+ if (schema > CURRENT) {
+ throw new VaultError(VaultError.NEWER_SCHEMA, "data from a newer version");
+ }
+ if (schema == 0) {
+ meta = v0ToV1(meta);
+ store.setValue(META_KEY, meta);
+ }
+ if (!isValidV1(meta)) {
+ throw new VaultError(VaultError.DAMAGED, "metadata damaged");
+ }
+ return meta;
+ }
+
+ // Version 0 to 1: "order" becomes "ids", and "pending" is added.
+ function v0ToV1(meta as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>) as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType> {
+ var order = meta["order"];
+ return {
+ "schema" => 1,
+ "ids" => order instanceof Array ? order : [],
+ "pending" => []
+ } as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>;
+ }
+
+ // True when "ids" and "pending" are arrays of distinct valid IDs.
+ function isValidV1(meta as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>) as Boolean {
+ var seen = {} as Dictionary<String, Boolean>;
+ var lists = [meta["ids"], meta["pending"]];
+ for (var l = 0; l < lists.size(); l++) {
+ var list = lists[l];
+ if (!(list instanceof Array)) {
+ return false;
+ }
+ for (var i = 0; i < list.size(); i++) {
+ var id = list[i];
+ if (!(id instanceof String) || !Entry.isValidId(id) || seen.hasKey(id)) {
+ return false;
+ }
+ seen[id] = true;
+ }
+ }
+ return true;
+ }
+}
diff --git a/source/storage/Record.mc b/source/storage/Record.mc
new file mode 100644
index 0000000..01c3c53
--- /dev/null
+++ b/source/storage/Record.mc
@@ -0,0 +1,282 @@
+// Author: cirakg & claude ai
+// Summary: The stored byte format of one entry: readable TLV header plus a SealedBox with the sensitive fields.
+//
+// Record format, version 1 (one ByteArray under the key "e.<id>"):
+//
+// v (1 byte, = 1) | kr (1 byte) | hlen (2 bytes, big-endian) | header (hlen bytes) | box
+//
+// kr = 0: sealed with the device key, 1: sealed with the locked master key
+// header = Codec TLV of the header fields (tags 1-12), readable without a key
+// box = SealedBox of the Codec TLV of the sensitive fields (tags 20-28)
+// aad = UTF-8 id || v || kr || hlen || header
+//
+// Because the aad holds the ID and the whole header, the box's MAC covers
+// them: changing the HOTP counter, the locked flag or a name, or copying a
+// record under another ID, makes open() fail (README 7.3). Reading only the
+// header (for the list) doesn't check the MAC; the header is not secret and
+// the device key, which would be needed to forge it, is stored next to it
+// anyway (README 7.4).
+//
+// With "Encrypt names" the name, account and group move from the header
+// into the box (tags 26-28).
+//
+// Decoding never throws: malformed input gives null and the vault reports
+// the entry as damaged.
+
+import Toybox.Lang;
+
+module Record {
+
+ const VERSION = 1;
+ const KEY_DEVICE = 0;
+ const KEY_MASTER = 1;
+ const PREFIX_BYTES = 4;
+ // Application.Storage allows 8 KB per value; stay clearly below it.
+ const MAX_BYTES = 8000;
+
+ // Header tags.
+ const H_ID = 1;
+ const H_KIND = 2;
+ const H_TYPE = 3;
+ const H_NAME = 4;
+ const H_ACCOUNT = 5;
+ const H_GROUP = 6;
+ const H_LOCKED = 7;
+ const H_SOURCE = 8;
+ const H_FAVORITE = 9;
+ const H_COUNTER = 10;
+ const H_CREATED = 11;
+ const H_NAMES_ENCRYPTED = 12;
+
+ // Sensitive tags (inside the box).
+ const S_ALGORITHM = 20;
+ const S_DIGITS = 21;
+ const S_PERIOD = 22;
+ const S_SECRET = 23;
+ const S_PAYLOAD = 24;
+ const S_OPTIONS = 25;
+ const S_NAME = 26;
+ const S_ACCOUNT = 27;
+ const S_GROUP = 28;
+
+ // Seal an entry into record bytes. keyRef is KEY_DEVICE or KEY_MASTER and
+ // must match `keys`. The plaintext TLV is wiped before returning.
+ function seal(entry as Entry, keyRef as Number, keys as SealedBox.Keys) as ByteArray {
+ var header = encodeHeader(entry);
+ var record = [VERSION, keyRef, (header.size() >> 8) & 0xFF, header.size() & 0xFF]b;
+ record.addAll(header);
+ var plain = encodeSecret(entry);
+ var box = SealedBox.seal(keys, aad(entry.id, record), plain);
+ Bytes.wipe(plain);
+ record.addAll(box);
+ return record;
+ }
+
+ // The key reference (KEY_DEVICE or KEY_MASTER), or null when the bytes
+ // aren't a version-1 record.
+ function keyRef(record as ByteArray) as Number? {
+ var headerEnd = headerEnd(record);
+ if (headerEnd == null) {
+ return null;
+ }
+ return record[1];
+ }
+
+ // The header fields only (no key needed, MAC not checked), or null when
+ // malformed. The returned entry has no secret or payload.
+ function decodeHeader(record as ByteArray) as Entry? {
+ var end = headerEnd(record);
+ if (end == null) {
+ return null;
+ }
+ var map = Codec.decode(record.slice(PREFIX_BYTES, end));
+ if (map == null) {
+ return null;
+ }
+ var f = new Fields(map);
+ var entry = new Entry();
+ entry.id = f.text(H_ID, true);
+ entry.kind = f.number(H_KIND, Entry.KIND_OTP, Entry.KIND_CODE, true);
+ entry.type = f.number(H_TYPE, 0, Entry.MAX_SYMBOLOGY, true);
+ entry.name = f.text(H_NAME, false);
+ entry.account = f.text(H_ACCOUNT, false);
+ entry.group = f.text(H_GROUP, false);
+ entry.locked = f.flag(H_LOCKED);
+ entry.source = f.number(H_SOURCE, Entry.SOURCE_MANUAL, Entry.SOURCE_IMPORT, false);
+ entry.favorite = f.flag(H_FAVORITE);
+ entry.counter = f.long(H_COUNTER);
+ entry.created = f.long(H_CREATED);
+ entry.namesEncrypted = f.flag(H_NAMES_ENCRYPTED);
+ if (!f.ok || !Entry.isValidId(entry.id) || entry.counter < 0) {
+ return null;
+ }
+ return entry;
+ }
+
+ // The full entry, or null when the record is malformed, sealed with other
+ // keys, stored under another ID, or changed in any way. The decrypted
+ // TLV is wiped; the caller must call entry.wipeSecrets() when done.
+ function open(record as ByteArray, id as String, keys as SealedBox.Keys) as Entry? {
+ var entry = decodeHeader(record);
+ var end = headerEnd(record);
+ if (entry == null || end == null || !entry.id.equals(id)) {
+ return null;
+ }
+ var plain = SealedBox.open(keys, aad(id, record.slice(0, end)), record.slice(end, record.size()));
+ if (plain == null) {
+ return null;
+ }
+ var map = Codec.decode(plain);
+ Bytes.wipe(plain);
+ if (map == null) {
+ return null;
+ }
+ var f = new Fields(map);
+ if (entry.kind == Entry.KIND_OTP) {
+ entry.algorithm = f.number(S_ALGORITHM, Hashes.SHA1, Hashes.SHA512, true);
+ entry.digits = f.number(S_DIGITS, 0, 255, true);
+ entry.period = f.number(S_PERIOD, 0, 65535, true);
+ entry.secret = f.bytes(S_SECRET);
+ } else {
+ entry.payload = f.bytes(S_PAYLOAD);
+ entry.options = f.number(S_OPTIONS, 0, 0x7FFFFFFF, true);
+ }
+ if (entry.namesEncrypted) {
+ entry.name = f.text(S_NAME, false);
+ entry.account = f.text(S_ACCOUNT, false);
+ entry.group = f.text(S_GROUP, false);
+ }
+ if (!f.ok || entry.validate() != null) {
+ entry.wipeSecrets();
+ return null;
+ }
+ return entry;
+ }
+
+ // Header TLV, in tag order. Names are left out when they are encrypted.
+ function encodeHeader(entry as Entry) as ByteArray {
+ var w = new Codec.Writer();
+ w.addText(H_ID, entry.id);
+ w.addInt(H_KIND, entry.kind);
+ w.addInt(H_TYPE, entry.type);
+ if (!entry.namesEncrypted) {
+ w.addText(H_NAME, entry.name);
+ w.addText(H_ACCOUNT, entry.account);
+ w.addText(H_GROUP, entry.group);
+ }
+ w.addBool(H_LOCKED, entry.locked);
+ w.addInt(H_SOURCE, entry.source);
+ w.addBool(H_FAVORITE, entry.favorite);
+ w.addInt(H_COUNTER, entry.counter);
+ w.addInt(H_CREATED, entry.created);
+ w.addBool(H_NAMES_ENCRYPTED, entry.namesEncrypted);
+ return w.bytes;
+ }
+
+ // Sensitive TLV, in tag order. The caller wipes the result.
+ function encodeSecret(entry as Entry) as ByteArray {
+ var w = new Codec.Writer();
+ if (entry.kind == Entry.KIND_OTP) {
+ w.addInt(S_ALGORITHM, entry.algorithm);
+ w.addInt(S_DIGITS, entry.digits);
+ w.addInt(S_PERIOD, entry.period);
+ w.addBytes(S_SECRET, entry.secret as ByteArray);
+ } else {
+ w.addBytes(S_PAYLOAD, entry.payload as ByteArray);
+ w.addInt(S_OPTIONS, entry.options);
+ }
+ if (entry.namesEncrypted) {
+ w.addText(S_NAME, entry.name);
+ w.addText(S_ACCOUNT, entry.account);
+ w.addText(S_GROUP, entry.group);
+ }
+ return w.bytes;
+ }
+
+ // The authenticated data: the ID and every byte before the box.
+ function aad(id as String, prefixAndHeader as ByteArray) as ByteArray {
+ var out = Bytes.fromUtf8(id);
+ out.addAll(prefixAndHeader);
+ return out;
+ }
+
+ // Index just past the header, or null when the prefix is invalid.
+ function headerEnd(record as ByteArray) as Number? {
+ if (record.size() < PREFIX_BYTES + SealedBox.MIN_SIZE || record[0] != VERSION
+ || (record[1] != KEY_DEVICE && record[1] != KEY_MASTER)) {
+ return null;
+ }
+ var end = PREFIX_BYTES + ((record[2] << 8) | record[3]);
+ if (end + SealedBox.MIN_SIZE > record.size()) {
+ return null;
+ }
+ return end;
+ }
+
+ // Typed access to decoded TLV fields. A missing required field or a
+ // field of the wrong type or range clears `ok` and returns a default,
+ // so decoding reads straight through and checks `ok` once at the end.
+ class Fields {
+ var ok as Boolean = true;
+ private var _map as Dictionary<Number, ByteArray or String or Long or Boolean>;
+
+ function initialize(map as Dictionary<Number, ByteArray or String or Long or Boolean>) {
+ _map = map;
+ }
+
+ function text(tag as Number, required as Boolean) as String {
+ var v = _map[tag];
+ if (v instanceof String) {
+ return v;
+ }
+ if (v != null || required) {
+ ok = false;
+ }
+ return "";
+ }
+
+ function number(tag as Number, min as Number, max as Number, required as Boolean) as Number {
+ var v = _map[tag];
+ if (v instanceof Long && v >= min && v <= max) {
+ return v.toNumber();
+ }
+ if (v != null || required) {
+ ok = false;
+ }
+ return min;
+ }
+
+ function long(tag as Number) as Long {
+ var v = _map[tag];
+ if (v instanceof Long) {
+ return v;
+ }
+ if (v != null) {
+ ok = false;
+ }
+ return 0l;
+ }
+
+ function flag(tag as Number) as Boolean {
+ var v = _map[tag];
+ if (v instanceof Boolean) {
+ return v;
+ }
+ if (v != null) {
+ ok = false;
+ }
+ return false;
+ }
+
+ function bytes(tag as Number) as ByteArray? {
+ var v = _map[tag];
+ if (v instanceof ByteArray) {
+ return v;
+ }
+ if (v != null) {
+ ok = false;
+ }
+ return null;
+ }
+ }
+}
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;
+ }
+}
diff --git a/source/storage/TimeSource.mc b/source/storage/TimeSource.mc
new file mode 100644
index 0000000..426a56a
--- /dev/null
+++ b/source/storage/TimeSource.mc
@@ -0,0 +1,27 @@
+// Author: cirakg & claude ai
+// Summary: The clocks the PIN lockout uses, behind a class so tests can inject a fake clock.
+//
+// Lockout delays use the trusted clock (GPS-synced, not user-adjustable) when
+// it is synced, and the system uptime timer otherwise (README 7.6). Tests
+// replace this class with one whose clocks they set by hand, which makes the
+// lockout schedule, restarts and clock loss testable.
+
+import Toybox.Lang;
+import Toybox.System;
+
+class TimeSource {
+
+ function initialize() {
+ }
+
+ // Trusted unix time in seconds, or null when the trusted clock isn't synced.
+ function trustedUnix() as Long? {
+ return Clock.trustedUnix();
+ }
+
+ // Milliseconds from the system timer. It restarts at zero when the watch
+ // restarts and wraps after about 24.8 days.
+ function uptimeMs() as Number {
+ return System.getTimer();
+ }
+}
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;
+ }
+ }
+}
diff --git a/source/storage/VaultError.mc b/source/storage/VaultError.mc
new file mode 100644
index 0000000..7320ce4
--- /dev/null
+++ b/source/storage/VaultError.mc
@@ -0,0 +1,35 @@
+// Author: cirakg & claude ai
+// Summary: The one exception type the storage layer throws, with a code the UI turns into a message.
+
+import Toybox.Lang;
+
+class VaultError extends Lang.Exception {
+
+ // The session is locked and the operation needs the master key.
+ static const LOCKED = 1;
+ // A record or the PIN wrap failed to decode or verify.
+ static const DAMAGED = 2;
+ // Application.Storage refused a write (store full).
+ static const FULL = 3;
+ // The entry cap is reached, or one record would be too big.
+ static const LIMIT = 4;
+ // No entry with that ID.
+ static const NOT_FOUND = 5;
+ // The data was written by a newer app version; nothing may be changed.
+ static const NEWER_SCHEMA = 6;
+ // The entry failed validation (see Entry.validate()).
+ static const INVALID = 7;
+ // A wrong-PIN delay is running; try again later.
+ static const LOCKED_OUT = 8;
+ // The operation needs a PIN and none is set, or a PIN is already set.
+ static const NO_PIN = 9;
+ static const PIN_EXISTS = 10;
+
+ var code as Number;
+
+ function initialize(errorCode as Number, message as String) {
+ Exception.initialize();
+ code = errorCode;
+ mMessage = message;
+ }
+}
diff --git a/source/util/Bytes.mc b/source/util/Bytes.mc
index 1008fd2..a32475a 100644
--- a/source/util/Bytes.mc
+++ b/source/util/Bytes.mc
@@ -47,6 +47,66 @@ module Bytes {
}) as ByteArray;
}
+ // Decode UTF-8 bytes to a string. Empty input gives "". Malformed UTF-8
+ // or a NUL byte gives null (callers treat that as damaged data). The
+ // bytes are checked first because convertEncodedString stops the app
+ // with an uncatchable system error on invalid UTF-8.
+ function toUtf8(data as ByteArray) as String? {
+ if (data.size() == 0) {
+ return "";
+ }
+ if (!isValidUtf8(data)) {
+ return null;
+ }
+ return StringUtil.convertEncodedString(data, {
+ :fromRepresentation => StringUtil.REPRESENTATION_BYTE_ARRAY,
+ :toRepresentation => StringUtil.REPRESENTATION_STRING_PLAIN_TEXT,
+ :encoding => StringUtil.CHAR_ENCODING_UTF8
+ }) as String;
+ }
+
+ // True for well-formed UTF-8 (RFC 3629) without NUL bytes: no overlong
+ // forms, no surrogates (U+D800-DFFF), nothing above U+10FFFF.
+ function isValidUtf8(data as ByteArray) as Boolean {
+ var i = 0;
+ var n = data.size();
+ while (i < n) {
+ var b = data[i];
+ var more = 0;
+ var min = 0;
+ var cp = 0;
+ if (b == 0) {
+ return false;
+ } else if (b < 0x80) {
+ i++;
+ continue;
+ } else if (b >= 0xC2 && b <= 0xDF) {
+ more = 1; min = 0x80; cp = b & 0x1F;
+ } else if (b >= 0xE0 && b <= 0xEF) {
+ more = 2; min = 0x800; cp = b & 0x0F;
+ } else if (b >= 0xF0 && b <= 0xF4) {
+ more = 3; min = 0x10000; cp = b & 0x07;
+ } else {
+ return false;
+ }
+ if (i + more >= n) {
+ return false;
+ }
+ for (var k = 1; k <= more; k++) {
+ var c = data[i + k];
+ if ((c & 0xC0) != 0x80) {
+ return false;
+ }
+ cp = (cp << 6) | (c & 0x3F);
+ }
+ if (cp < min || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) {
+ return false;
+ }
+ i += more + 1;
+ }
+ return true;
+ }
+
// A new array holding a followed by b; neither input is changed.
function concat(a as ByteArray, b as ByteArray) as ByteArray {
var out = new [0]b;