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/Forms.mc | |
| 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/Forms.mc')
| -rw-r--r-- | source/settings/Forms.mc | 255 |
1 files changed, 255 insertions, 0 deletions
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)); + } +} |
