diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-20 18:36:54 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-20 18:36:54 +0200 |
| commit | 151bfda5d4c567ff909a61dc9c90bf8eece1db37 (patch) | |
| tree | f8487b91cf6a9ce59dba02e95e67250fc84d8e80 /source/settings | |
| parent | 25910f3a7a7fb28c4a58367b6e56dd2468b60933 (diff) | |
feat(settings): add the Add OTP, Add code and Manage entry forms with a PIN queue for locked entries
The full phone settings form (README 10.2) and its processing (10.3) as one
resumable job: Import, Add OTP, Add code, Manage, then the About fields.
Forms are applied only when their switch is on, cleared afterwards (secret
first; only the secret after a problem), deduplicated by keyed hash, and
report into their Result field and the results screen.
- Add OTP validates per README 10.4; Add code runs the real encoder
(CodeCheck, a job for 2D codes) and caches 2D codes on the way in.
- Manage entry: edit, move, require/drop PIN, replace, counter, delete.
Changes to locked entries are sealed under "q" and applied only after
the PIN is entered under "Phone changes" in the menu.
- About: catalog (800 characters max), version, storage use.
- "Encrypt names" now applies to new and existing entries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source/settings')
| -rw-r--r-- | source/settings/AboutScan.mc | 174 | ||||
| -rw-r--r-- | source/settings/ChangeQueue.mc | 143 | ||||
| -rw-r--r-- | source/settings/CodeForm.mc | 214 | ||||
| -rw-r--r-- | source/settings/Forms.mc | 255 | ||||
| -rw-r--r-- | source/settings/ManageForm.mc | 509 | ||||
| -rw-r--r-- | source/settings/OtpForm.mc | 152 | ||||
| -rw-r--r-- | source/settings/SettingsJob.mc | 351 | ||||
| -rw-r--r-- | source/settings/SettingsProcessor.mc | 141 |
8 files changed, 1835 insertions, 104 deletions
diff --git a/source/settings/AboutScan.mc b/source/settings/AboutScan.mc new file mode 100644 index 0000000..8fc898a --- /dev/null +++ b/source/settings/AboutScan.mc @@ -0,0 +1,174 @@ +// Author: cirakg & claude ai +// Summary: Writes the read-only About fields (catalog, version, storage use) and applies "Encrypt names" to existing entries. +// +// One pass over every entry header, a few per step: +// * catalog: "K7F2 GitHub (alice) · 9QXR Gym card · ...", so the Manage +// form's IDs can be looked up on the phone. With "Encrypt names" on it +// lists only IDs and types: names stay off the phone too. +// * storageUse: entries, waiting items and the bytes their records take. +// * version: the app version. +// * Entries whose names are stored the other way from the current +// "Encrypt names" option are re-sealed, one per step. Locked ones need +// the session; if one can't be done now, the next scan retries (after +// the next PIN entry, which runs the processor again). +// The scan only runs when something it reports changed: a form was +// applied, or the signature of the entry list (IDs, pending, option, +// version) differs from the last complete scan. + +import Toybox.Lang; + +class AboutScan extends Jobs.Job { + + static const SIGNATURE_KEY = "cs"; + static const HEADERS_PER_STEP = 4; + static const CATALOG_MAX_CHARS = 800; // Garmin Express caps a text field at 800 (FEASIBILITY 3d) + static const SEPARATOR = " · "; + // README 6.2: the stricter of Garmin's two documented totals. + static const STORE_KB = 100; + + private var _vault as Vault; + private var _store as Forms.Store; + private var _ids as Array<String>; + private var _pending as Array<String>; + private var _index as Number = 0; + private var _catalog as String = ""; + private var _left as Number = 0; + private var _bytes as Number = 0; + private var _incomplete as Boolean = false; + + function initialize(vault as Vault, store as Forms.Store) { + Job.initialize(); + _vault = vault; + _store = store; + _ids = vault.ids(); + _pending = vault.pendingIds(); + } + + // True when the About fields may be out of date. + static function needed(vault as Vault) as Boolean { + var stored = vault.storage().getValue(SIGNATURE_KEY); + return !(stored instanceof String) || !stored.equals(signature(vault)); + } + + static function signature(vault as Vault) as String { + var content = new Forms.Content("about"); + content.add(vault.ids().toString()); + content.add(vault.pendingIds().toString()); + content.add(vault.encryptNames); + content.add(ChangeQueue.count(vault)); + content.add(AppInfo.VERSION); + return Forms.hash(vault, content); + } + + function step(budget as Number) as Boolean { + var total = _ids.size() + _pending.size(); + for (var n = 0; n < HEADERS_PER_STEP && _index < total; n++) { + var converted = _index < _ids.size() ? visit(_ids[_index]) : visitPending(_pending[_index - _ids.size()]); + _index++; + if (converted) { + return false; // one re-seal per step + } + } + if (_index < total) { + return false; + } + writeFields(); + return true; + } + + // Catalog and size of a listed entry; re-seals it when its names are + // stored the other way. True when it re-sealed. + private function visit(id as String) as Boolean { + var header = _vault.anyHeader(id); + addBytes("e." + id); + addBytes("x." + id); + if (header == null) { + addItem(id + " damaged"); + return false; + } + var converted = false; + if (header.namesEncrypted != _vault.encryptNames) { + if (!header.locked || _vault.session.isUnlocked()) { + try { + _vault.setNamesEncrypted(id, _vault.encryptNames); + converted = true; + var after = _vault.anyHeader(id); // names readable again after decrypting + header = after != null ? after : header; + } catch (e instanceof VaultError) { + _incomplete = true; + } + } else { + _incomplete = true; + } + } + addItem(id + " " + describe(header) + (header.locked ? " (PIN)" : "")); + return converted; + } + + private function visitPending(id as String) as Boolean { + var header = _vault.anyHeader(id); + addBytes("p." + id); + addItem(id + " " + (header != null ? describe(header) : "entry") + " (waits for the PIN)"); + return false; + } + + // The names, or the type when names are hidden or encrypted. + private function describe(header as Entry) as String { + var label = Forms.label(header.name, header.account); + if (!_vault.encryptNames && !header.namesEncrypted && label.length() > 0) { + return label; + } + if (header.kind == Entry.KIND_CODE) { + return Codes.name(header.type); + } + return header.type == Entry.OTP_HOTP ? "HOTP" : (header.type == Entry.OTP_STEAM ? "Steam" : "TOTP"); + } + + private function addItem(item as String) as Void { + if (_left > 0 || _catalog.length() + SEPARATOR.length() + item.length() > CATALOG_MAX_CHARS) { + _left++; + return; + } + _catalog += (_catalog.length() > 0 ? SEPARATOR : "") + item; + } + + private function addBytes(key as String) as Void { + var value = _vault.storage().getValue(key); + if (value instanceof ByteArray) { + _bytes += value.size(); + } + } + + private function writeFields() as Void { + var catalog = _catalog.length() > 0 ? _catalog : "No entries yet"; + if (_left > 0) { + catalog += SEPARATOR + "and " + _left + " more (see Entry IDs on the watch)"; + } + var use = _ids.size() + " of " + Vault.MAX_ENTRIES + " entries"; + if (_pending.size() > 0) { + use += " (+" + _pending.size() + " waiting for the PIN)"; + } + use += ", " + (_bytes + 1023) / 1024 + " KB of about " + STORE_KB + " KB"; + var waiting = ChangeQueue.count(_vault); + if (waiting > 0) { + use += ". " + waiting + (waiting == 1 ? " change waits" : " changes wait") + " for the PIN on the watch"; + } + setIfChanged("catalog", catalog); + setIfChanged("storageUse", use); + setIfChanged("version", AppInfo.VERSION); + if (!_incomplete) { + try { + _vault.storage().setValue(SIGNATURE_KEY, signature(_vault)); + } catch (e instanceof Lang.Exception) { + // A full store: the scan just runs again next time. + } + } + } + + private function setIfChanged(key as String, value as String) as Void { + var old = _store.get(key); + if (!(old instanceof String) || !old.equals(value)) { + _store.set(key, value); + } + } +} diff --git a/source/settings/ChangeQueue.mc b/source/settings/ChangeQueue.mc new file mode 100644 index 0000000..12f88f7 --- /dev/null +++ b/source/settings/ChangeQueue.mc @@ -0,0 +1,143 @@ +// Author: cirakg & claude ai +// Summary: Changes from the phone that wait for the PIN on the watch (README 7.9), sealed with the device key under "q". +// +// Anyone who controls the phone can send settings, so edits, new secrets, +// "Don't require PIN" and deletes of locked entries are not applied when +// they arrive: they wait here until someone confirms them with the PIN on +// the watch (or rejects them). Each item is one ManageAction sealed on its +// own with the device key (aad "OTPW-Q-1"), so a damaged item costs only +// itself. A new secret in an item is no more exposed than it was in the +// settings it came from, as with pending entries (README 7.7). + +import Toybox.Application; +import Toybox.Lang; + +module ChangeQueue { + + const KEY = "q"; + const MAX_ITEMS = 16; + const AAD = "OTPW-Q-1"; + + function count(vault as Vault) as Number { + var stored = vault.storage().getValue(KEY); + return stored instanceof Array ? stored.size() : 0; + } + + // Append an action. Throws VaultError LIMIT (too many waiting) or FULL. + function add(vault as Vault, action as ManageAction) as Void { + var stored = vault.storage().getValue(KEY); + var items = stored instanceof Array ? stored as Array<Application.Storage.ValueType> : [] as Array<Application.Storage.ValueType>; + if (items.size() >= MAX_ITEMS) { + throw new VaultError(VaultError.LIMIT, MAX_ITEMS + " changes already wait for your PIN. Confirm or reject them on the watch first"); + } + var plain = action.toBytes(); + items.add(SealedBox.seal(vault.keyManager.deviceKeys(), Bytes.fromUtf8(AAD), plain)); + Bytes.wipe(plain); + try { + vault.storage().setValue(KEY, items); + } catch (e instanceof Lang.Exception) { + throw new VaultError(VaultError.FULL, "storage full"); + } + } + + // The waiting actions in arrival order; items that don't verify are + // left out (and dropped by the next clear()). + function load(vault as Vault) as Array<ManageAction> { + var out = [] as Array<ManageAction>; + var stored = vault.storage().getValue(KEY); + if (!(stored instanceof Array)) { + return out; + } + var keys = vault.keyManager.deviceKeys(); + var aad = Bytes.fromUtf8(AAD); + for (var i = 0; i < stored.size(); i++) { + var box = stored[i]; + var plain = box instanceof ByteArray ? SealedBox.open(keys, aad, box) : null; + if (plain != null) { + var action = ManageAction.fromBytes(plain); + Bytes.wipe(plain); + if (action != null) { + out.add(action); + } + } + } + return out; + } + + // Short descriptions for the confirmation screen ("Delete: K7F2 Bank"). + function descriptions(vault as Vault) as Array<String> { + var actions = load(vault); + var out = [] as Array<String>; + for (var i = 0; i < actions.size(); i++) { + out.add(actions[i].describe()); + actions[i].wipe(); + } + return out; + } + + function clear(vault as Vault) as Void { + vault.storage().deleteValue(KEY); + } + + // Remove the first n items (the ones just applied), keeping any that + // arrived from the phone meanwhile. + function drop(vault as Vault, n as Number) as Void { + var stored = vault.storage().getValue(KEY); + if (!(stored instanceof Array) || stored.size() <= n) { + clear(vault); + return; + } + try { + vault.storage().setValue(KEY, stored.slice(n, null) as Array<Application.Storage.ValueType>); + } catch (e instanceof Lang.Exception) { + clear(vault); // can't happen when shrinking; never re-apply + } + } +} + +// Applies every waiting change after the PIN was entered, one per step +// (each re-seals a record; a code may need its CodeCheck). The applied +// items leave the queue only at the end, so a crash midway loses nothing; +// applying one again is harmless (a deleted entry is then just missing). +class ChangeQueueJob extends Jobs.Job { + + var lines as Array<String> = [] as Array<String>; + + private var _vault as Vault; + private var _actions as Array<ManageAction>; + private var _loaded as Number = 0; + private var _index as Number = 0; + private var _run as ManageRun? = null; + + function initialize(vault as Vault) { + Job.initialize(); + _vault = vault; + var stored = vault.storage().getValue(ChangeQueue.KEY); + _loaded = stored instanceof Array ? stored.size() : 0; + _actions = ChangeQueue.load(vault); + } + + function step(budget as Number) as Boolean { + var run = _run; + if (run == null) { + if (_index >= _actions.size()) { + ChangeQueue.drop(_vault, _loaded); + if (lines.size() == 0) { + lines.add("No changes were waiting."); + } + return true; + } + run = new ManageRun(_vault, _actions[_index], true); + _run = run; + } + if (run.step(budget)) { + var problem = run.failed ? "Not applied: " + _actions[_index].describe() + ". " : ""; + lines.add(problem + run.message); + lines.addAll(run.notes); + _actions[_index].wipe(); + _index++; + _run = null; + } + return false; + } +} diff --git a/source/settings/CodeForm.mc b/source/settings/CodeForm.mc new file mode 100644 index 0000000..89d519d --- /dev/null +++ b/source/settings/CodeForm.mc @@ -0,0 +1,214 @@ +// Author: cirakg & claude ai +// Summary: The Add code form (README 10.2): reads the code* fields, and CodeCheck, the job that proves a code encodes and fits. +// +// read() checks the fields that need no encoding (name, symbology, length). +// Whether the contents are valid for the symbology is decided by the real +// encoder (README 12.1), in CodeCheck: +// * Linear codes encode at once; the encoder's message ("EAN-13: check +// digit should be 7, got 3. Choose 'Fix if wrong' to correct it") becomes +// the status, and its note ("check digit 7 added") a remark. +// * 2D codes run their MatrixJob step by step (the watchdog), then the +// matrix is packed for the encrypted cache in a step of its own, so the +// first opening on the watch is instant. +// * A code that encodes but doesn't fit this screen is still added, with +// the "Too large for this screen" warning (README 12.2). + +import Toybox.Lang; +import Toybox.System; + +module CodeForm { + + const SWITCH = "codeAdd"; + const STATUS = "codeStatus"; + const PAYLOAD = "codePayload"; + const NAME_MAX_BYTES = Entry.MAX_NAME_BYTES; + + const FIELDS = ["codeName", "codeSymbology", "codePayload", "codeEcLevel", "codeCheckDigit", "codeShowText", "codeGroup", + "codeLocked"]; + + // Field values after a reset (resources/settings/properties.xml); + // the contents first. + function defaults() as Dictionary<String, String or Number or Boolean> { + return { + PAYLOAD => "", + "codeName" => "", + "codeSymbology" => Codes.QR, + "codeEcLevel" => 1, + "codeCheckDigit" => Codes.CHECK_ADD_VERIFY, + "codeShowText" => true, + "codeGroup" => "", + "codeLocked" => false + } as Dictionary<String, String or Number or Boolean>; + } + + // The entry described by the form (not yet checked by the encoder). + function read(store as Forms.Store) as Forms.Parsed { + var parsed = new Forms.Parsed("code"); + for (var i = 0; i < FIELDS.size(); i++) { + parsed.content.add(store.get(FIELDS[i] as String)); + } + var e = new Entry(); + e.kind = Entry.KIND_CODE; + e.source = Entry.SOURCE_MANUAL; + e.name = Forms.text(store, "codeName"); + e.group = Forms.text(store, "codeGroup"); + e.locked = Forms.flag(store, "codeLocked"); + if (e.name.length() == 0) { + return parsed.fail("Name: enter a name for the card"); + } + var problem = Forms.checkName("Name", e.name, NAME_MAX_BYTES); + if (problem == null) { + problem = Forms.checkName("Group", e.group, Entry.MAX_GROUP_BYTES); + } + if (problem != null) { + return parsed.fail(problem); + } + var symbology = Forms.number(store, "codeSymbology"); + if (symbology == null || symbology < Codes.QR || symbology > Codes.AZTEC) { + return parsed.fail("Type: choose one of the 12 barcode types"); + } + e.type = symbology; + var ec = Forms.number(store, "codeEcLevel"); + var check = Forms.number(store, "codeCheckDigit"); + if (ec == null || ec < 0 || ec > Codes.OPT_EC_MASK) { + ec = 1; + } + if (check == null || check < Codes.CHECK_ADD_VERIFY || check > Codes.CHECK_OFF) { + check = Codes.CHECK_ADD_VERIFY; + } + e.options = ec | (check << Codes.OPT_CHECK_SHIFT) | (Forms.flag(store, "codeShowText") ? Codes.OPT_SHOW_TEXT : 0); + var payload = contents(Forms.rawText(store, PAYLOAD), symbology); + if (payload instanceof String) { + return parsed.fail(payload); + } + e.payload = payload; + parsed.entry = e; + return parsed; + } + + // The contents as stored (trimmed for linear codes, whose digits are + // often typed with spaces around), or the problem. + function contents(text as String, symbology as Number) as ByteArray or String { + var value = Codes.isLinear(symbology) ? Text.trim(text) : text; + if (Text.trim(value).length() == 0) { + return "Contents: missing"; + } + var bytes = Bytes.fromUtf8(value); + if (bytes.size() > Entry.MAX_PAYLOAD_BYTES) { + return "Contents: too long (" + bytes.size() + " bytes, at most " + Entry.MAX_PAYLOAD_BYTES + ")"; + } + return bytes; + } + + function reset(store as Forms.Store, everything as Boolean) as Void { + store.set(PAYLOAD, ""); + if (everything) { + var d = defaults(); + var keys = d.keys(); + for (var i = 0; i < keys.size(); i++) { + store.set(keys[i], d[keys[i]] as String or Number or Boolean); + } + } + store.set(SWITCH, false); + } +} + +// Encodes a code once to prove it is valid and to check that it fits this +// screen. When step() returns true: error is set, or the code is fine +// (notes may hold remarks) and, for 2D codes, cache holds Matrix.toBytes(). +class CodeCheck extends Jobs.Job { + + // Linear codes draw with 10 quiet modules on each side (CodeView). + static const LINEAR_QUIET = 10; + + var error as String? = null; + var notes as Array<String> = [] as Array<String>; + var cache as ByteArray? = null; + + private var _symbology as Number; + private var _payload as String; + private var _options as Number; + private var _job as Codes.MatrixJob? = null; + private var _matrix as Codes.Matrix? = null; + private var _started as Boolean = false; + private var _w as Number; + private var _h as Number; + private var _round as Boolean; + + function initialize(symbology as Number, payload as String, options as Number) { + Job.initialize(); + _symbology = symbology; + _payload = payload; + _options = options; + var screen = System.getDeviceSettings(); + _w = screen.screenWidth; + _h = screen.screenHeight; + _round = screen.screenShape == System.SCREEN_SHAPE_ROUND; + } + + function step(budget as Number) as Boolean { + if (!_started) { + _started = true; + if (!Codes.isMatrix(_symbology)) { + checkLinear(); + return true; + } + _job = Codes.matrixJob(_symbology, _payload, _options, _w, _h, _round); + _payload = ""; + return false; + } + var matrix = _matrix; + if (matrix != null) { + // Packing gets a step of its own (it runs over every module). + cache = matrix.toBytes(); + _matrix = null; + return true; + } + var job = _job as Codes.MatrixJob; + if (!job.step(budget)) { + return false; + } + _job = null; + var m = job.matrix(); + var err = job.error; + if (err != null || m == null) { + error = withName(err != null ? err : "could not encode"); + return true; + } + var totalW = m.width + 2 * m.quiet; + var totalH = m.drawnHeight() + 2 * m.quiet; + var px = Codes.minModulePx(_symbology); + if (!Codes.fits(totalW, totalH, px, _w, _h, _round)) { + notes.add("Too large for this screen: needs " + px + " px per module, " + totalW * px + " x " + totalH * px + " px"); + } + _matrix = m; + return false; + } + + private function checkLinear() as Void { + var result = Codes.encode(_symbology, _payload, _options); + _payload = ""; + if (result instanceof Codes.CodeError) { + error = withName(result.message); + return; + } + var note = result.note; + if (note != null) { + notes.add(note); + } + // The widest band is a little under the screen width on round + // screens (CodeView measures it exactly when drawing). + var available = _round ? _w * 96 / 100 : _w - 4; + var needed = result.modules.size() + 2 * LINEAR_QUIET; + if (needed > available) { + notes.add("Too large for this screen: needs " + needed + " px, " + available + " px available"); + } + } + + // "too long for QR..." becomes "QR: too long for QR...", but a message + // that already names the symbology is kept as it is. + private function withName(message as String) as String { + var name = Codes.name(_symbology); + return message.find(name) == 0 ? message : name + ": " + message; + } +} diff --git a/source/settings/Forms.mc b/source/settings/Forms.mc new file mode 100644 index 0000000..d26cb7a --- /dev/null +++ b/source/settings/Forms.mc @@ -0,0 +1,255 @@ +// Author: cirakg & claude ai +// Summary: Shared pieces of the settings forms: where field values come from, typed reads, text checks, the applied-forms log. +// +// The processor never calls Application.Properties directly: it goes through +// a Forms.Store, so the unit tests can run every form against a dictionary +// (MemoryStore) while the app uses the real properties (PropertyStore). +// +// Values from the phone are untrusted: a field can be missing (an app update +// added it but the phone hasn't synced), of an unexpected type, or out of +// range. The typed reads below never throw; they return a default or null +// and the form validation turns that into a message. + +import Toybox.Application; +import Toybox.Lang; + +module Forms { + + // Form hashes remembered, most recent last (README 10.3, rule 3). + const LOG_KEY = "fh"; + const LOG_SIZE = 32; + // Status fields hold at most this many characters. + const STATUS_MAX_CHARS = 200; + // Field separator inside the hashed form content (not typeable on the phone). + const SEPARATOR = 0x1F; + + // Where field values come from and go to. + class Store { + function initialize() { + } + + // The value, or null when it doesn't exist. + function get(key as String) as Application.Properties.ValueType or Null { + return null; + } + + function set(key as String, value as Application.Properties.ValueType) as Void { + } + } + + // The app's properties (resources/settings/properties.xml). Reading a + // property that isn't defined there stops the app with an error that + // can't be caught, so only defined keys may be passed in. + class PropertyStore extends Store { + function initialize() { + Store.initialize(); + } + + function get(key as String) as Application.Properties.ValueType or Null { + try { + return Application.Properties.getValue(key); + } catch (e instanceof Lang.Exception) { + return null; + } + } + + function set(key as String, value as Application.Properties.ValueType) as Void { + try { + Application.Properties.setValue(key, value); + } catch (e instanceof Lang.Exception) { + // Nothing to clear or report into. + } + } + } + + // A dictionary of fields, for tests (and nothing else). + class MemoryStore extends Store { + var values as Dictionary<String, Application.Properties.ValueType> = {} as Dictionary<String, Application.Properties.ValueType>; + + function initialize() { + Store.initialize(); + } + + function get(key as String) as Application.Properties.ValueType or Null { + return values[key]; + } + + function set(key as String, value as Application.Properties.ValueType) as Void { + values[key] = value; + } + } + + // ---- Typed reads ---------------------------------------------------- + + // A text field, trimmed; "" when missing or not text. + function text(store as Store, key as String) as String { + var v = store.get(key); + return v instanceof String ? Text.trim(v) : ""; + } + + // A text field exactly as sent (secrets, barcode contents). + function rawText(store as Store, key as String) as String { + var v = store.get(key); + return v instanceof String ? v : ""; + } + + function flag(store as Store, key as String) as Boolean { + var v = store.get(key); + return v instanceof Boolean && v; + } + + // A whole number (list index, numeric field), or null. Longs that fit, + // whole floats and decimal text are accepted too: the phone's numeric + // fields don't promise a type. + function number(store as Store, key as String) as Number? { + var v = long(store, key); + return v != null && v >= -2147483648l && v <= 2147483647l ? v.toNumber() : null; + } + + // A whole number as a Long, or null (also null for negative text). + function long(store as Store, key as String) as Long? { + var v = store.get(key); + if (v instanceof Number || v instanceof Long) { + return v.toLong(); + } + if (v instanceof Float || v instanceof Double) { + var whole = v.toLong(); + return whole.toDouble() == v.toDouble() ? whole : null; + } + if (v instanceof String) { + var t = Text.trim(v); + if (t.find("-") == 0) { + var magnitude = Text.parseLong(t.substring(1, t.length()) as String); + return magnitude != null ? -magnitude : null; + } + return Text.parseLong(t); + } + return null; + } + + // ---- Text checks ---------------------------------------------------- + + // The 1-based position of the first control character, or 0. + function controlCharAt(value as String) as Number { + var chars = value.toCharArray(); + for (var i = 0; i < chars.size(); i++) { + var c = chars[i].toNumber(); + if (c < 32 || c == 127) { + return i + 1; + } + } + return 0; + } + + // null when a name-like field is acceptable, else the message. + function checkName(label as String, value as String, maxBytes as Number) as String? { + var at = controlCharAt(value); + if (at > 0) { + return label + ": control character at position " + at; + } + var size = Bytes.fromUtf8(value).size(); + if (size > maxBytes) { + return label + ": too long (" + size + " bytes, at most " + maxBytes + ")"; + } + return null; + } + + // "GitHub (alice)", "GitHub", "alice" or "" for messages. + function label(name as String, account as String) as String { + if (name.length() > 0 && account.length() > 0) { + return name + " (" + account + ")"; + } + return name.length() > 0 ? name : account; + } + + // What reading a form gave: an entry to add, or the first problem, plus + // remarks that don't stop it (a short secret, a check digit added). + class Parsed { + var entry as Entry? = null; + var error as String? = null; + var notes as Array<String> = [] as Array<String>; + var content as Content; + + function initialize(formName as String) { + content = new Content(formName); + } + + function fail(message as String) as Parsed { + error = message; + var e = entry; + if (e != null) { + e.wipeSecrets(); + entry = null; + } + return self; + } + } + + // ---- Content hash and the applied-forms log -------------------------- + + // Collects the content of a form for its hash: every field in a fixed + // order, each as its own UTF-8 bytes (converted natively, never copied + // into one buffer: an import form holds kilobytes). The parts hold + // secrets: hash() wipes them, or call wipe(). + class Content { + var parts as Array<ByteArray> = [] as Array<ByteArray>; + + function initialize(formName as String) { + parts.add(Bytes.fromUtf8(formName)); + } + + function add(value as Application.Properties.ValueType or Null) as Void { + if (value instanceof String) { + parts.add(Bytes.fromUtf8(value)); + } else { + parts.add(value != null ? Bytes.fromUtf8(value.toString()) : []b); + } + } + + // Zero the short parts (fields, secrets) and drop all of them. + // Long parts (import text) are dropped unwiped: zeroing kilobytes + // byte by byte would trip the watchdog, and the same text stays in + // memory as the property's String anyway (README 7.8 limits). + function wipe() as Void { + for (var i = 0; i < parts.size(); i++) { + if (parts[i].size() <= WIPE_MAX_BYTES) { + Bytes.wipe(parts[i]); + } + } + parts = [] as Array<ByteArray>; + } + } + + const WIPE_MAX_BYTES = 512; + + // Keyed hash of a form's content (KeyManager.formHash), wiping the content. + function hash(vault as Vault, content as Content) as String { + var h = vault.keyManager.formHash(content.parts, SEPARATOR); + content.wipe(); + return h; + } + + function alreadyApplied(vault as Vault, hash as String) as Boolean { + var log = vault.storage().getValue(LOG_KEY); + return log instanceof Array && log.indexOf(hash) >= 0; + } + + function rememberApplied(vault as Vault, hash as String) as Void { + var stored = vault.storage().getValue(LOG_KEY); + var log = stored instanceof Array ? stored as Array<Application.Storage.ValueType> : [] as Array<Application.Storage.ValueType>; + log.add(hash); + if (log.size() > LOG_SIZE) { + log = log.slice(log.size() - LOG_SIZE, null) as Array<Application.Storage.ValueType>; + } + try { + vault.storage().setValue(LOG_KEY, log); + } catch (e instanceof Lang.Exception) { + // A full store: the form may be applied again if re-sent. + } + } + + // Write a form's status field (shortened to fit). + function status(store as Store, key as String, message as String) as Void { + store.set(key, Text.clip(message, STATUS_MAX_CHARS)); + } +} diff --git a/source/settings/ManageForm.mc b/source/settings/ManageForm.mc new file mode 100644 index 0000000..b92c40d --- /dev/null +++ b/source/settings/ManageForm.mc @@ -0,0 +1,509 @@ +// Author: cirakg & claude ai +// Summary: The Manage entry form (README 10.2): one action on an entry by its ID, applied now or queued for the PIN (README 7.9). +// +// Three parts: +// * ManageAction: one parsed request, and its byte form for the queue. +// * ManageForm.read(): the mg* fields to a ManageAction or a problem. +// * ManageRun: a job that checks the action against the entry and then +// applies it, or queues it (ChangeQueue) when it touches a locked +// entry: edit, replace, counter, "Don't require PIN" and delete wait +// until the PIN is entered on the watch. "Require PIN" waits too while +// the watch is locked (it needs the master key). Moving never waits: +// the order isn't protected and a position is absolute, so applying a +// move twice can't hit another entry. +// Replacing a code's contents runs CodeCheck with the entry's own options +// (a sub-job, so a large 2D code can't trip the watchdog). For a locked +// entry the options are encrypted, so that check runs when the PIN confirms. + +import Toybox.Lang; + +class ManageAction { + + // mgAction list values (resources/settings/settings.xml). Stored in the + // queue: never renumber. + static const EDIT = 0; + static const MOVE = 1; + static const LOCK = 2; + static const UNLOCK = 3; + static const REPLACE = 4; + static const COUNTER = 5; + static const DELETE = 6; + + static const NAMES = ["Edit details", "Move", "Require PIN", "Don't require PIN", "Replace contents", "Set counter", "Delete"]; + + // Codec tags of the queued form. + static const T_ID = 1; + static const T_ACTION = 2; + static const T_NAME = 3; + static const T_ACCOUNT = 4; + static const T_GROUP = 5; + static const T_POSITION = 6; + static const T_MATERIAL = 7; + static const T_COUNTER = 8; + static const T_LABEL = 9; + static const T_DECODED = 10; + + // In "Edit details", this value clears a field; empty keeps it. + static const CLEAR = "-"; + + var id as String = ""; + var action as Number = EDIT; + var name as String = ""; + var account as String = ""; + var group as String = ""; + var position as Number = 0; + // REPLACE: the decoded OTP secret or the new code contents, once + // checked against the entry (raw text before). Wiped after use. + var material as ByteArray? = null; + var counter as Long = 0l; + // True once an OTP secret in `material` is decoded from Base32. + var decoded as Boolean = false; + // "Bank (carol)", kept so the queue can be shown while locked. + var label as String = ""; + + function initialize() { + } + + // "Delete K7F2 Bank (carol)". + function describe() as String { + var verb = NAMES[action] as String; + var target = label.length() > 0 ? id + " " + label : id; + if (action == MOVE) { + return "Move " + target + " to position " + position; + } + if (action == COUNTER) { + return "Set the counter of " + target + " to " + counter; + } + return verb + ": " + target; + } + + function wipe() as Void { + Bytes.wipe(material); + material = null; + } + + function toBytes() as ByteArray { + var w = new Codec.Writer(); + w.addText(T_ID, id); + w.addInt(T_ACTION, action); + w.addText(T_NAME, name); + w.addText(T_ACCOUNT, account); + w.addText(T_GROUP, group); + w.addInt(T_POSITION, position); + var m = material; + if (m != null) { + w.addBytes(T_MATERIAL, m); + } + w.addInt(T_COUNTER, counter); + w.addText(T_LABEL, label); + w.addBool(T_DECODED, decoded); + return w.bytes; + } + + // The action, or null when the bytes are malformed. + static function fromBytes(data as ByteArray) as ManageAction? { + var map = Codec.decode(data); + if (map == null) { + return null; + } + var a = new ManageAction(); + var id = map[T_ID]; + var action = map[T_ACTION]; + var position = map[T_POSITION]; + var counter = map[T_COUNTER]; + if (!(id instanceof String) || !(action instanceof Long) || !(position instanceof Long) || !(counter instanceof Long)) { + return null; + } + if (action < EDIT || action > DELETE || !Entry.isValidId(id)) { + return null; + } + a.id = id; + a.action = action.toNumber(); + a.position = position.toNumber(); + a.counter = counter; + a.name = textOf(map[T_NAME]); + a.account = textOf(map[T_ACCOUNT]); + a.group = textOf(map[T_GROUP]); + a.label = textOf(map[T_LABEL]); + var m = map[T_MATERIAL]; + a.material = m instanceof ByteArray ? m : null; + var decoded = map[T_DECODED]; + a.decoded = decoded instanceof Boolean && decoded; + return a; + } + + static function textOf(value as ByteArray or String or Long or Boolean or Null) as String { + return value instanceof String ? value : ""; + } +} + +module ManageForm { + + const SWITCH = "mgApply"; + const STATUS = "mgStatus"; + const SECRET = "mgSecret"; + + const FIELDS = ["mgId", "mgAction", "mgName", "mgAccount", "mgGroup", "mgPosition", "mgSecret", "mgCounter"]; + + function defaults() as Dictionary<String, String or Number or Long> { + return { + SECRET => "", + "mgId" => "", + "mgAction" => ManageAction.EDIT, + "mgName" => "", + "mgAccount" => "", + "mgGroup" => "", + "mgPosition" => 1, + "mgCounter" => 0l + } as Dictionary<String, String or Number or Long>; + } + + // The requested action (not yet checked against the entry), or the + // problem. The form content goes into `content` for the form hash. + function read(store as Forms.Store, content as Forms.Content) as ManageAction or String { + for (var i = 0; i < FIELDS.size(); i++) { + content.add(store.get(FIELDS[i] as String)); + } + var a = new ManageAction(); + a.id = Forms.text(store, "mgId").toUpper(); + if (a.id.length() == 0) { + return "Entry ID: missing. The IDs are under Entry IDs in the watch menu"; + } + if (!Entry.isValidId(a.id)) { + return "Entry ID: '" + Text.clip(a.id, 8) + "' is not an ID. IDs have 4 characters, like K7F2"; + } + var action = Forms.number(store, "mgAction"); + if (action == null || action < ManageAction.EDIT || action > ManageAction.DELETE) { + return "Action: choose one from the list"; + } + a.action = action; + if (action == ManageAction.EDIT) { + a.name = Forms.text(store, "mgName"); + a.account = Forms.text(store, "mgAccount"); + a.group = Forms.text(store, "mgGroup"); + if (a.name.length() == 0 && a.account.length() == 0 && a.group.length() == 0) { + return "Edit details: fill in a new name, account or group ('-' clears one)"; + } + var problem = Forms.checkName("Name", a.name, Entry.MAX_NAME_BYTES); + if (problem == null) { + problem = Forms.checkName("Account", a.account, Entry.MAX_NAME_BYTES); + } + if (problem == null) { + problem = Forms.checkName("Group", a.group, Entry.MAX_GROUP_BYTES); + } + return problem != null ? problem : a; + } + if (action == ManageAction.MOVE) { + var position = Forms.number(store, "mgPosition"); + if (position == null || position < 1) { + return "Position: 1 or more"; + } + a.position = position; + } else if (action == ManageAction.REPLACE) { + var secret = Forms.rawText(store, SECRET); + if (Text.trim(secret).length() == 0) { + return "Replace: enter the new secret or contents"; + } + a.material = Bytes.fromUtf8(secret); + } else if (action == ManageAction.COUNTER) { + var counter = Forms.long(store, "mgCounter"); + if (counter == null || counter < 0) { + return "Counter: a whole number, 0 or more"; + } + a.counter = counter; + } + return a; + } + + function reset(store as Forms.Store, everything as Boolean) as Void { + store.set(SECRET, ""); + if (everything) { + var d = defaults(); + var keys = d.keys(); + for (var i = 0; i < keys.size(); i++) { + store.set(keys[i], d[keys[i]] as String or Number or Long); + } + } + store.set(SWITCH, false); + } +} + +// Checks one action against its entry and applies or queues it. When step() +// returns true, `message` says what happened; `failed` is true when nothing +// changed because of a problem, `queued` when it waits for the PIN. +class ManageRun extends Jobs.Job { + + var message as String = ""; + var failed as Boolean = false; + var queued as Boolean = false; + // Remarks (a check digit added, too large for the screen). + var notes as Array<String> = [] as Array<String>; + + private var _vault as Vault; + private var _a as ManageAction; + private var _confirmed as Boolean; + private var _header as Entry? = null; + private var _check as CodeCheck? = null; + private var _cache as ByteArray? = null; + + // confirmed: the PIN was just entered for this action (from the queue), + // so it is applied, never queued again. + function initialize(vault as Vault, action as ManageAction, confirmed as Boolean) { + Job.initialize(); + _vault = vault; + _a = action; + _confirmed = confirmed; + } + + function step(budget as Number) as Boolean { + var check = _check; + if (check != null) { + if (!check.step(budget)) { + return false; + } + _check = null; + var err = check.error; + if (err != null) { + return fail(err); + } + notes.addAll(check.notes); + // Applying gets the next step: packing a large matrix used + // much of this event's budget. + _cache = check.cache; + return false; + } + if (_header == null) { + try { + return resolve(); + } catch (e instanceof VaultError) { + return fail(problemText(e)); + } + } + return finish(_cache); + } + + // Find the entry and check the action against it. Returns true when + // the run ends here (a problem); false to continue with the next step. + private function resolve() as Boolean { + var a = _a; + if (_vault.pendingIds().indexOf(a.id) >= 0) { + return fail(a.id + " waits for your PIN on the watch. Enter the PIN there first"); + } + if (_vault.ids().indexOf(a.id) < 0) { + return fail("No entry " + a.id + ". The IDs are under Entry IDs in the watch menu"); + } + var header = _vault.header(a.id); + if (header == null) { + if (a.action == ManageAction.DELETE) { + _vault.remove(a.id); // a damaged entry can always be deleted + message = "Deleted damaged entry " + a.id; + return true; + } + return fail(a.id + " is damaged. Delete it and add it again"); + } + _header = header; + if (a.label.length() == 0) { + a.label = Forms.label(header.name, header.account); + } + var problem = checkAgainst(header); + if (problem != null) { + return fail(problem); + } + if (mustWait(header)) { + return queue(); + } + if (a.action == ManageAction.REPLACE && header.kind == Entry.KIND_CODE) { + return startCodeCheck(header); + } + return false; + } + + // Problems that don't depend on the PIN. + private function checkAgainst(header as Entry) as String? { + var a = _a; + var isOtp = header.kind == Entry.KIND_OTP; + if (a.action == ManageAction.COUNTER && !(isOtp && header.type == Entry.OTP_HOTP)) { + return "Set counter: " + a.id + " is not a HOTP entry"; + } + if (a.action == ManageAction.EDIT && !isOtp && a.account.length() > 0) { + return "Account: " + a.id + " is a barcode, which has no account"; + } + if (a.action == ManageAction.LOCK && header.locked) { + return a.id + " already requires the PIN"; + } + if (a.action == ManageAction.UNLOCK && !header.locked) { + return a.id + " doesn't require the PIN"; + } + if (a.action == ManageAction.REPLACE && isOtp && !a.decoded) { + var raw = a.material as ByteArray; + var text = Bytes.toUtf8(raw); + Bytes.wipe(raw); + a.material = null; + var decoded = OtpForm.decodeSecret(text != null ? text : "?"); + if (decoded instanceof String) { + return decoded; + } + a.material = decoded; + a.decoded = true; + if (decoded.size() < OtpForm.WEAK_SECRET_BYTES) { + notes.add("Warning: the secret is only " + decoded.size() * 8 + " bits (80 or more recommended)"); + } + } + return null; + } + + // Locked entries' sensitive actions wait for the PIN (README 7.9). + private function mustWait(header as Entry) as Boolean { + if (_confirmed) { + return false; + } + var action = _a.action; + if (action == ManageAction.MOVE) { + return false; + } + if (action == ManageAction.LOCK) { + return !_vault.session.isUnlocked(); + } + return header.locked; + } + + private function queue() as Boolean { + try { + ChangeQueue.add(_vault, _a); + } catch (e instanceof VaultError) { + return fail(e.code == VaultError.LIMIT ? e.getErrorMessage() as String : "storage full"); + } + queued = true; + message = "Waiting for your PIN on the watch: " + _a.describe(); + _a.wipe(); + return true; + } + + // Replacing a code's contents: validate them with the entry's options. + private function startCodeCheck(header as Entry) as Boolean { + var a = _a; + var raw = a.material as ByteArray; + var text = Bytes.toUtf8(raw); + Bytes.wipe(raw); + a.material = null; + var contents = CodeForm.contents(text != null ? text : "", header.type); + if (contents instanceof String) { + return fail(contents); + } + a.material = contents; + var options = Codes.DEFAULT_OPTIONS; + try { + var entry = _vault.open(a.id); + options = entry.options; + entry.wipeSecrets(); + } catch (e instanceof VaultError) { + return fail(problemText(e)); + } + var payload = Bytes.toUtf8(contents); + _check = new CodeCheck(header.type, payload != null ? payload : "", options); + return false; + } + + // Apply the action; cache: a checked code's packed matrix, or null. + private function finish(cache as ByteArray?) as Boolean { + var a = _a; + var target = a.id + (a.label.length() > 0 ? " " + a.label : ""); + try { + if (a.action == ManageAction.MOVE) { + _vault.move(a.id, a.position - 1); + message = "Moved " + target + " to position " + (_vault.ids().indexOf(a.id) + 1); + } else if (a.action == ManageAction.COUNTER) { + _vault.setCounter(a.id, a.counter); + message = "Set the counter of " + target + " to " + a.counter; + } else if (a.action == ManageAction.DELETE) { + _vault.remove(a.id); + message = "Deleted " + target; + } else { + var problem = changeEntry(); + if (problem != null) { + return fail(problem); + } + if (cache != null) { + _vault.saveCodeCache(a.id, cache); + } + } + } catch (e instanceof VaultError) { + a.wipe(); + return fail(problemText(e)); + } + a.wipe(); + return true; + } + + // Edit, lock, unlock or replace: open, change, re-seal. Null or the problem. + private function changeEntry() as String? { + var a = _a; + var target = a.id + (a.label.length() > 0 ? " " + a.label : ""); + var entry = _vault.open(a.id); + if (a.action == ManageAction.EDIT) { + entry.name = edited(entry.name, a.name); + entry.account = edited(entry.account, a.account); + entry.group = edited(entry.group, a.group); + if (entry.name.length() == 0 && (entry.kind == Entry.KIND_CODE || entry.account.length() == 0)) { + entry.wipeSecrets(); + return entry.kind == Entry.KIND_CODE ? "Name: a barcode needs a name" : "An OTP entry needs an issuer or an account"; + } + message = "Edited " + a.id + ": " + Forms.label(entry.name, entry.account) + + (entry.group.length() > 0 ? ", group " + entry.group : ""); + } else if (a.action == ManageAction.LOCK || a.action == ManageAction.UNLOCK) { + entry.locked = a.action == ManageAction.LOCK; + message = target + (entry.locked ? " now requires the PIN" : " no longer requires the PIN"); + } else { + var material = a.material as ByteArray; + if (entry.kind == Entry.KIND_OTP) { + Bytes.wipe(entry.secret); + entry.secret = Bytes.copy(material); + message = "Replaced the secret of " + target; + } else { + Bytes.wipe(entry.payload); + entry.payload = Bytes.copy(material); + message = "Replaced the contents of " + target; + } + } + try { + _vault.update(entry); + } catch (e instanceof VaultError) { + entry.wipeSecrets(); + throw e; + } + entry.wipeSecrets(); + return null; + } + + // Blank keeps the old value, "-" clears it, anything else replaces it. + private function edited(old as String, requested as String) as String { + if (requested.length() == 0) { + return old; + } + return requested.equals(ManageAction.CLEAR) ? "" : requested; + } + + private function fail(problem as String) as Boolean { + failed = true; + message = problem; + _a.wipe(); + return true; + } + + static function problemText(e as VaultError) as String { + if (e.code == VaultError.LOCKED) { + return "the watch is locked; try again after entering the PIN"; + } + if (e.code == VaultError.FULL) { + return "storage full"; + } + if (e.code == VaultError.NOT_FOUND) { + return "the entry no longer exists"; + } + if (e.code == VaultError.DAMAGED) { + return "the entry is damaged"; + } + var m = e.getErrorMessage(); + return m != null ? m : "not possible"; + } +} diff --git a/source/settings/OtpForm.mc b/source/settings/OtpForm.mc new file mode 100644 index 0000000..40c766f --- /dev/null +++ b/source/settings/OtpForm.mc @@ -0,0 +1,152 @@ +// Author: cirakg & claude ai +// Summary: The Add OTP form (README 10.2): reads the otp* fields, validates them (README 10.4) and resets the form. +// +// read() turns the fields into a new Entry or the first problem, in the +// words of README 10.3 ("Secret: '1' is not a Base32 character (position +// 5)"). Messages never quote the secret. Steam ignores the algorithm, digits +// and period fields (always SHA-1, 5 characters, 30 s); TOTP ignores the +// counter and HOTP the period. + +import Toybox.Lang; + +module OtpForm { + + const SWITCH = "otpAdd"; + const STATUS = "otpStatus"; + const SECRET = "otpSecret"; + // Below this many bytes (80 bits) the secret gets a warning (RFC 4226 4). + const WEAK_SECRET_BYTES = 10; + + // Every field and its value after a reset (the form's defaults in + // resources/settings/properties.xml). The secret comes first: it is + // cleared before anything else. + function defaults() as Dictionary<String, String or Number or Long or Boolean> { + return { + SECRET => "", + "otpType" => Entry.OTP_TOTP, + "otpIssuer" => "", + "otpAccount" => "", + "otpAlgorithm" => Hashes.SHA1, + "otpDigits" => 6, + "otpPeriod" => 30, + "otpCounter" => 0l, + "otpGroup" => "", + "otpLocked" => false + } as Dictionary<String, String or Number or Long or Boolean>; + } + + // The form's fields in their hash order. + const FIELDS = ["otpType", "otpIssuer", "otpAccount", "otpSecret", "otpAlgorithm", "otpDigits", "otpPeriod", "otpCounter", + "otpGroup", "otpLocked"]; + + function read(store as Forms.Store) as Forms.Parsed { + var parsed = new Forms.Parsed("otp"); + for (var i = 0; i < FIELDS.size(); i++) { + parsed.content.add(store.get(FIELDS[i] as String)); + } + var type = Forms.number(store, "otpType"); + if (type == null || type < Entry.OTP_TOTP || type > Entry.OTP_STEAM) { + return parsed.fail("Type: choose TOTP, HOTP or Steam"); + } + var e = new Entry(); + e.kind = Entry.KIND_OTP; + e.type = type; + e.source = Entry.SOURCE_MANUAL; + e.name = Forms.text(store, "otpIssuer"); + e.account = Forms.text(store, "otpAccount"); + e.group = Forms.text(store, "otpGroup"); + e.locked = Forms.flag(store, "otpLocked"); + if (e.name.length() == 0 && e.account.length() == 0) { + return parsed.fail("Fill in the issuer or the account"); + } + var problem = Forms.checkName("Issuer", e.name, Entry.MAX_NAME_BYTES); + if (problem == null) { + problem = Forms.checkName("Account", e.account, Entry.MAX_NAME_BYTES); + } + if (problem == null) { + problem = Forms.checkName("Group", e.group, Entry.MAX_GROUP_BYTES); + } + if (problem != null) { + return parsed.fail(problem); + } + problem = readParameters(store, e); + if (problem != null) { + return parsed.fail(problem); + } + var secret = Forms.rawText(store, SECRET); + var decoded = decodeSecret(secret); + if (decoded instanceof String) { + return parsed.fail(decoded); + } + e.secret = decoded; + if (decoded.size() < WEAK_SECRET_BYTES) { + parsed.notes.add("Warning: the secret is only " + decoded.size() * 8 + " bits (80 or more recommended)"); + } + parsed.entry = e; + return parsed; + } + + // Algorithm, digits, period and counter by type; null or the problem. + function readParameters(store as Forms.Store, e as Entry) as String? { + if (e.type == Entry.OTP_STEAM) { + e.algorithm = Hashes.SHA1; + e.digits = Otp.MIN_DIGITS; + e.period = Otp.STEAM_PERIOD; + return null; + } + var algorithm = Forms.number(store, "otpAlgorithm"); + if (algorithm == null || !Hashes.isValid(algorithm)) { + return "Algorithm: choose SHA1, SHA256 or SHA512"; + } + e.algorithm = algorithm as Hashes.Algorithm; + var digits = Forms.number(store, "otpDigits"); + if (digits == null || digits < Otp.MIN_DIGITS || digits > Otp.MAX_DIGITS) { + return "Digits: choose 6, 7 or 8"; + } + e.digits = digits; + if (e.type == Entry.OTP_TOTP) { + var period = Forms.number(store, "otpPeriod"); + if (period == null || period < Otp.MIN_PERIOD || period > Otp.MAX_PERIOD) { + return "Period: 1 to " + Otp.MAX_PERIOD + " seconds"; + } + e.period = period; + } else { + var counter = Forms.long(store, "otpCounter"); + if (counter == null || counter < 0) { + return "Counter: a whole number, 0 or more"; + } + e.counter = counter; + } + return null; + } + + // The secret's bytes, or the message for the status field. + function decodeSecret(text as String) as ByteArray or String { + if (Text.trim(text).length() == 0) { + return "Secret: missing"; + } + var decoded = Base32.decode(text); + if (decoded instanceof Base32.DecodeError) { + return "Secret: " + decoded.message + (decoded.position > 0 ? " (position " + decoded.position + ")" : ""); + } + if (decoded.size() == 0 || decoded.size() > Entry.MAX_SECRET_BYTES) { + Bytes.wipe(decoded); + return "Secret: must decode to 1 to " + Entry.MAX_SECRET_BYTES + " bytes"; + } + return decoded; + } + + // Turn the switch off and clear the form: everything after it was + // applied, only the secret after a problem (so the rest can be fixed). + function reset(store as Forms.Store, everything as Boolean) as Void { + store.set(SECRET, ""); + if (everything) { + var d = defaults(); + var keys = d.keys(); + for (var i = 0; i < keys.size(); i++) { + store.set(keys[i], d[keys[i]] as String or Number or Long or Boolean); + } + } + store.set(SWITCH, false); + } +} diff --git a/source/settings/SettingsJob.mc b/source/settings/SettingsJob.mc new file mode 100644 index 0000000..e6e7bcf --- /dev/null +++ b/source/settings/SettingsJob.mc @@ -0,0 +1,351 @@ +// Author: cirakg & claude ai +// Summary: Applies every settings form that asks for it, in README 10.3 order, as one resumable job. +// +// Order (README 10.3, rule 6): Import, Add OTP, Add code, Manage entry, then +// the About fields (the Options group needs no step: AppState reloads the +// options before this job starts). For each form whose switch is on: +// 1. Read and validate the fields; hash their content (keyed, see +// KeyManager.formHash). +// 2. A hash in the applied-forms log means the phone sent the same form +// again: nothing is applied (rule 3). +// 3. Apply it. On success the form is cleared (secret fields first) and +// its hash remembered; after a problem only the secret field is +// cleared, so the rest can be corrected. The switch always goes off. +// 4. The status field and the results lines say what happened. +// Each phase runs in its own steps (one timer event each), so the watchdog +// never sees a whole import or a large 2D code at once. + +import Toybox.Lang; +import Toybox.System; + +class SettingsJob extends Jobs.Job { + + static const P_IMPORT = 0; + static const P_OTP = 1; + static const P_CODE = 2; + static const P_MANAGE = 3; + static const P_SCAN = 4; + static const P_DONE = 5; + + static const IMPORT_TEXT_FIELDS = 8; + + var phase as Number = P_IMPORT; + // Lines for the results screen. + var lines as Array<String> = [] as Array<String>; + // Forms looked at (applied, refused or repeated). + var forms as Number = 0; + // Entries were added, changed or removed (cached codes are stale). + var changed as Boolean = false; + + private var _vault as Vault; + private var _store as Forms.Store; + private var _now as Long; + private var _hash as String = ""; + private var _importer as Importer? = null; + private var _importStart as Number = 0; + private var _code as Forms.Parsed? = null; + private var _check as CodeCheck? = null; + private var _manage as ManageRun? = null; + private var _scan as AboutScan? = null; + + function initialize(vault as Vault, store as Forms.Store, now as Long) { + Job.initialize(); + _vault = vault; + _store = store; + _now = now; + } + + // True when a form's switch is on or the About fields are out of date. + static function hasWork(vault as Vault, store as Forms.Store) as Boolean { + return Forms.flag(store, "impApply") || Forms.flag(store, OtpForm.SWITCH) || Forms.flag(store, CodeForm.SWITCH) + || Forms.flag(store, ManageForm.SWITCH) || AboutScan.needed(vault); + } + + // "Importing… 40%" while an import runs, else a general text. + function busyText() as String { + var importer = _importer; + return importer != null ? "Importing… " + importer.percent() + "%" : "Applying settings…"; + } + + function step(budget as Number) as Boolean { + if (phase == P_IMPORT) { + stepImport(budget); + } else if (phase == P_OTP) { + stepOtp(); + } else if (phase == P_CODE) { + stepCode(budget); + } else if (phase == P_MANAGE) { + stepManage(budget); + } else if (phase == P_SCAN) { + stepScan(budget); + } + return phase == P_DONE; + } + + // ---- Import (README 11) --------------------------------------------- + + private function stepImport(budget as Number) as Void { + var importer = _importer; + if (importer != null) { + if (importer.step(budget)) { + finishImport(importer); + } + return; + } + if (!Forms.flag(_store, "impApply")) { + phase = P_OTP; + return; + } + forms++; + var mode = Forms.number(_store, "impMode"); + var locked = Forms.flag(_store, "impLocked"); + var importMode = mode != null && mode >= Importer.MODE_APPEND && mode <= Importer.MODE_CANCEL ? mode : Importer.MODE_APPEND; + if (importMode == Importer.MODE_CANCEL) { + ImportBuffer.clear(_vault.storage()); + endImport(["Import: waiting import cancelled."] as Array<String>, "Waiting import cancelled", false); + return; + } + var content = new Forms.Content("import"); + content.add(importMode); + content.add(locked); + var text = ""; + for (var i = 1; i <= IMPORT_TEXT_FIELDS; i++) { + var part = Forms.rawText(_store, "impText" + i); + content.add(part); + text += part; + } + _hash = Forms.hash(_vault, content); + if (Forms.alreadyApplied(_vault, _hash)) { + endImport(["Import: already applied. Change the text to import again."] as Array<String>, "Already imported", true); + return; + } + _importStart = System.getTimer(); + _importer = new Importer(_vault, text, importMode, locked, _now); + } + + private function finishImport(importer as Importer) as Void { + _importer = null; + var report = importer.report; + Diag.importDone(report.imported, System.getTimer() - _importStart); + if (report.failure == null && report.waitingChars < 0) { + Forms.rememberApplied(_vault, _hash); + } + if (report.imported > 0 || report.removed > 0) { + changed = true; + } + var out = report.lines(); + out[0] = "Import: " + out[0]; + endImport(out, report.summary(), true); + } + + // The text fields are cleared whenever their content was used (they + // hold secrets); a cancel leaves them. + private function endImport(out as Array<String>, status as String, clearText as Boolean) as Void { + if (clearText) { + for (var i = 1; i <= IMPORT_TEXT_FIELDS; i++) { + _store.set("impText" + i, ""); + } + } + _store.set("impApply", false); + Forms.status(_store, "impStatus", status); + lines.addAll(out); + phase = P_OTP; + } + + // ---- Add OTP --------------------------------------------------------- + + private function stepOtp() as Void { + phase = P_CODE; + if (!Forms.flag(_store, OtpForm.SWITCH)) { + return; + } + forms++; + var parsed = OtpForm.read(_store); + var problem = parsed.error; + if (problem != null) { + parsed.content.wipe(); + OtpForm.reset(_store, false); + report("Add OTP", OtpForm.STATUS, "Not added: " + problem, [] as Array<String>); + return; + } + var hash = Forms.hash(_vault, parsed.content); + var entry = parsed.entry as Entry; + if (Forms.alreadyApplied(_vault, hash)) { + entry.wipeSecrets(); + OtpForm.reset(_store, true); + report("Add OTP", OtpForm.STATUS, "Already added. Change a field to add another entry", [] as Array<String>); + return; + } + addEntry(entry, hash, "Add OTP", OtpForm.STATUS, parsed.notes, null); + if (entry.id.length() > 0) { + OtpForm.reset(_store, true); + } else { + OtpForm.reset(_store, false); + } + } + + // Store a checked entry, write the status; on success remember the hash + // (entry.id is set). cache: a code's packed matrix, or null. + private function addEntry(entry as Entry, hash as String, form as String, statusKey as String, notes as Array<String>, + cache as ByteArray?) as Void { + var id = ""; + try { + id = _vault.add(entry); + } catch (e instanceof VaultError) { + entry.wipeSecrets(); + entry.id = ""; + var why = e.code == VaultError.LIMIT ? e.getErrorMessage() as String : ManageRun.problemText(e); + report(form, statusKey, "Not added: " + why, [] as Array<String>); + return; + } + entry.wipeSecrets(); + changed = true; + Forms.rememberApplied(_vault, hash); + var pending = _vault.pendingIds().indexOf(id) >= 0; + if (cache != null && !pending) { + try { + _vault.saveCodeCache(id, cache); + } catch (e instanceof VaultError) { + // Locked meanwhile: the code is encoded when first opened. + } + } + var label = Forms.label(entry.name, entry.account); + var text = "Added " + id + " " + label + (pending ? ". It waits for your PIN on the watch" : ""); + report(form, statusKey, text, notes); + } + + // Status field (with the remarks) and results lines. + private function report(form as String, statusKey as String, text as String, notes as Array<String>) as Void { + var status = text; + for (var i = 0; i < notes.size(); i++) { + status += "; " + notes[i]; + } + Forms.status(_store, statusKey, status); + lines.add(form + ": " + text); + lines.addAll(notes); + } + + // ---- Add code -------------------------------------------------------- + + private function stepCode(budget as Number) as Void { + var check = _check; + var parsed = _code; + if (check != null && parsed != null) { + if (check.step(budget)) { + _check = null; + _code = null; + finishCode(parsed, check); + } + return; + } + if (!Forms.flag(_store, CodeForm.SWITCH)) { + phase = P_MANAGE; + return; + } + forms++; + parsed = CodeForm.read(_store); + var problem = parsed.error; + if (problem != null) { + parsed.content.wipe(); + CodeForm.reset(_store, false); + report("Add code", CodeForm.STATUS, "Not added: " + problem, [] as Array<String>); + phase = P_MANAGE; + return; + } + _hash = Forms.hash(_vault, parsed.content); + var entry = parsed.entry as Entry; + if (Forms.alreadyApplied(_vault, _hash)) { + entry.wipeSecrets(); + CodeForm.reset(_store, true); + report("Add code", CodeForm.STATUS, "Already added. Change a field to add another card", [] as Array<String>); + phase = P_MANAGE; + return; + } + var payload = Bytes.toUtf8(entry.payload as ByteArray); + _code = parsed; + _check = new CodeCheck(entry.type, payload != null ? payload : "", entry.options); + } + + private function finishCode(parsed as Forms.Parsed, check as CodeCheck) as Void { + phase = P_MANAGE; + var entry = parsed.entry as Entry; + var problem = check.error; + if (problem != null) { + entry.wipeSecrets(); + CodeForm.reset(_store, false); + report("Add code", CodeForm.STATUS, "Not added: " + problem, [] as Array<String>); + return; + } + var notes = parsed.notes; + notes.addAll(check.notes); + addEntry(entry, _hash, "Add code", CodeForm.STATUS, notes, check.cache); + CodeForm.reset(_store, entry.id.length() > 0); + } + + // ---- Manage entry ---------------------------------------------------- + + private function stepManage(budget as Number) as Void { + var run = _manage; + if (run != null) { + if (run.step(budget)) { + _manage = null; + finishManage(run); + } + return; + } + if (!Forms.flag(_store, ManageForm.SWITCH)) { + phase = P_SCAN; + return; + } + forms++; + var content = new Forms.Content("manage"); + var action = ManageForm.read(_store, content); + if (action instanceof String) { + content.wipe(); + ManageForm.reset(_store, false); + report("Manage", ManageForm.STATUS, "Not applied: " + action, [] as Array<String>); + phase = P_SCAN; + return; + } + _hash = Forms.hash(_vault, content); + if (Forms.alreadyApplied(_vault, _hash)) { + action.wipe(); + ManageForm.reset(_store, true); + report("Manage", ManageForm.STATUS, "Already applied. Change a field to send another action", [] as Array<String>); + phase = P_SCAN; + return; + } + _manage = new ManageRun(_vault, action, false); + } + + private function finishManage(run as ManageRun) as Void { + phase = P_SCAN; + if (run.failed) { + ManageForm.reset(_store, false); + report("Manage", ManageForm.STATUS, "Not applied: " + run.message, [] as Array<String>); + return; + } + changed = changed || !run.queued; + Forms.rememberApplied(_vault, _hash); + ManageForm.reset(_store, true); + report("Manage", ManageForm.STATUS, run.message, run.notes); + } + + // ---- About fields ---------------------------------------------------- + + private function stepScan(budget as Number) as Void { + var scan = _scan; + if (scan == null) { + if (forms == 0 && !AboutScan.needed(_vault)) { + phase = P_DONE; + return; + } + scan = new AboutScan(_vault, _store); + _scan = scan; + } + if (scan.step(budget)) { + _scan = null; + phase = P_DONE; + } + } +} diff --git a/source/settings/SettingsProcessor.mc b/source/settings/SettingsProcessor.mc index 4c0a429..cf7684b 100644 --- a/source/settings/SettingsProcessor.mc +++ b/source/settings/SettingsProcessor.mc @@ -1,34 +1,25 @@ // Author: cirakg & claude ai -// Summary: Applies the phone's settings forms on the watch (README 10.3); in M5 the Import form. +// Summary: Runs the settings forms (SettingsJob) on a timer from the app, then stores and shows the results (README 10.3). // // Rules (README 10.3): -// * Runs from onSettingsChanged and at every start, after the first frame. -// * A form is applied only when its switch ("Import now") is on. Afterwards -// the watch turns the switch off and clears the text fields (they hold -// secrets) and writes a short result to the status field. -// * Idempotency: a hash of every applied form is remembered (the last 32), -// so the same form sent again does nothing. -// * The import runs as a job on a timer; when it ends, the results are -// stored and the results screen opens. +// * Runs from onSettingsChanged, at every start (after the first frame) +// and after the PIN is entered (which may let "Encrypt names" finish). +// * The work itself is SettingsJob; this class only drives it on a timer +// (one step per tick), and when forms were looked at, stores the +// results lines and opens the results screen. +// * New settings arriving while a run is going start another run when it +// ends, so nothing is missed. // Only the full app runs this, never the glance. -import Toybox.Application; import Toybox.Lang; -import Toybox.System; import Toybox.WatchUi; class SettingsProcessor { - static const TEXT_FIELDS = 8; - static const FORM_LOG_KEY = "fh"; - static const FORM_LOG_SIZE = 32; - static const STATUS_MAX_CHARS = 200; - - private var _job as Importer? = null; + private var _job as SettingsJob? = null; // The job's timer driver (kept so it isn't collected while it runs). var runner as Jobs.JobRunner? = null; - private var _formHash as String = ""; - private var _startedAt as Number = 0; + private var _again as Boolean = false; function initialize() { } @@ -37,114 +28,56 @@ class SettingsProcessor { return _job != null; } - // "Importing… 40%" while an import runs, else null. + // "Importing… 40%" or "Applying settings…" while a run goes, else null. function busyText() as String? { var job = _job; - return job != null ? "Importing… " + job.percent() + "%" : null; + return job != null && job.forms > 0 ? job.busyText() : null; } - // Look at the forms and start whatever they ask for. + // Start a run if any form asks for one (or the About fields are stale). function start() as Void { - if (_job != null || AppState.vault == null) { + if (AppState.vault == null) { return; } - if (!isTrue(prop("impApply"))) { + if (_job != null) { + _again = true; return; } - var mode = prop("impMode"); - var locked = isTrue(prop("impLocked")); - var importMode = mode instanceof Number && mode >= Importer.MODE_APPEND && mode <= Importer.MODE_CANCEL ? mode : Importer.MODE_APPEND; var vault = AppState.theVault(); - if (importMode == Importer.MODE_CANCEL) { - ImportBuffer.clear(vault.storage()); - finish(["Waiting import cancelled."], "Waiting import cancelled", false); - return; - } - var text = ""; - for (var i = 1; i <= TEXT_FIELDS; i++) { - var part = prop("impText" + i); - if (part instanceof String) { - text += part; - } - } - _formHash = formHash(importMode + "|" + locked + "|" + text); - if (alreadyApplied(_formHash)) { - finish(["This import was already applied. Change the text to import again."], "Already imported", false); + var store = new Forms.PropertyStore(); + if (!SettingsJob.hasWork(vault, store)) { return; } - _startedAt = System.getTimer(); - var job = new Importer(vault, text, importMode, locked, Clock.nowUnix(0)); + var job = new SettingsJob(vault, store, Clock.nowUnix(0)); _job = job; - var r = new Jobs.JobRunner(job, 1, method(:onImportDone)); + var r = new Jobs.JobRunner(job, 1, method(:onDone)); runner = r; r.start(); WatchUi.requestUpdate(); } - function onImportDone() as Void { - var job = _job as Importer; + function onDone() as Void { + var job = _job as SettingsJob; _job = null; runner = null; - var report = job.report; - Diag.importDone(report.imported, System.getTimer() - _startedAt); - if (report.failure == null && report.waitingChars < 0) { - rememberApplied(_formHash); + if (job.changed) { + AppState.theCodes().clear(); } - AppState.theCodes().clear(); - finish(report.lines(), report.summary(), true); - } - - // Clear the form, write the status, store and show the results. - private function finish(lines as Array<String>, status as String, clearText as Boolean) as Void { - if (clearText) { - for (var i = 1; i <= TEXT_FIELDS; i++) { - setProp("impText" + i, ""); + if (job.forms > 0) { + var lines = job.lines; + var waiting = ChangeQueue.count(AppState.theVault()); + if (waiting > 0) { + lines.add(waiting + (waiting == 1 ? " change waits" : " changes wait") + + " for your PIN: menu, Phone changes."); } + Results.save(lines); + WatchUi.pushView(new InfoView("Results", lines, "results"), new InfoDelegate(), WatchUi.SLIDE_UP); + Results.markSeen(); } - setProp("impApply", false); - setProp("impStatus", Text.clip(status, STATUS_MAX_CHARS)); - Results.save(lines); - WatchUi.pushView(new InfoView("Results", lines, "results"), new InfoDelegate(), WatchUi.SLIDE_UP); - Results.markSeen(); - } - - private function isTrue(value as Application.Properties.ValueType or Null) as Boolean { - return value instanceof Boolean && value; - } - - private function prop(key as String) as Application.Properties.ValueType or Null { - try { - return Application.Properties.getValue(key); - } catch (e instanceof Lang.Exception) { - return null; - } - } - - private function setProp(key as String, value as Application.Properties.ValueType) as Void { - try { - Application.Properties.setValue(key, value); - } catch (e instanceof Lang.Exception) { - // A property the phone hasn't got yet: nothing to clear. - } - } - - // Short hash of a form's content (SHA-256, first 8 bytes, hex). - static function formHash(content as String) as String { - return Bytes.toHex(Hashes.digest(Hashes.SHA256, Bytes.fromUtf8(content)).slice(0, 8)); - } - - private function alreadyApplied(hash as String) as Boolean { - var log = Application.Storage.getValue(FORM_LOG_KEY); - return log instanceof Array && log.indexOf(hash) >= 0; - } - - private function rememberApplied(hash as String) as Void { - var stored = Application.Storage.getValue(FORM_LOG_KEY); - var log = stored instanceof Array ? stored as Array<Application.Storage.ValueType> : [] as Array<Application.Storage.ValueType>; - log.add(hash); - if (log.size() > FORM_LOG_SIZE) { - log = log.slice(log.size() - FORM_LOG_SIZE, null) as Array<Application.Storage.ValueType>; + WatchUi.requestUpdate(); + if (_again) { + _again = false; + start(); } - Application.Storage.setValue(FORM_LOG_KEY, log); } } |
