diff options
| -rw-r--r-- | source/storage/Backend.mc | 54 | ||||
| -rw-r--r-- | source/storage/Codec.mc | 101 | ||||
| -rw-r--r-- | source/storage/Entry.mc | 149 | ||||
| -rw-r--r-- | source/storage/KeyManager.mc | 188 | ||||
| -rw-r--r-- | source/storage/Lockout.mc | 138 | ||||
| -rw-r--r-- | source/storage/Migration.mc | 89 | ||||
| -rw-r--r-- | source/storage/Record.mc | 282 | ||||
| -rw-r--r-- | source/storage/Session.mc | 89 | ||||
| -rw-r--r-- | source/storage/TimeSource.mc | 27 | ||||
| -rw-r--r-- | source/storage/Vault.mc | 393 | ||||
| -rw-r--r-- | source/storage/VaultError.mc | 35 | ||||
| -rw-r--r-- | source/util/Bytes.mc | 60 |
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; |
