diff options
Diffstat (limited to 'source/settings/ManageForm.mc')
| -rw-r--r-- | source/settings/ManageForm.mc | 509 |
1 files changed, 509 insertions, 0 deletions
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"; + } +} |
