// 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", the favourite // mark 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), but is applied at the // next PIN without confirming (ChangeQueue.applyLocks). 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 FAVORITE = 7; static const UNFAVORITE = 8; static const NAMES = ["Edit details", "Move", "Require PIN", "Don't require PIN", "Replace contents", "Set counter", "Delete", "Mark as favourite", "Remove favourite"]; // 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 > UNFAVORITE || !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 { return { SECRET => "", "mgId" => "", "mgAction" => ManageAction.EDIT, "mgName" => "", "mgAccount" => "", "mgGroup" => "", "mgPosition" => 1, "mgCounter" => 0l } as Dictionary; } // 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.UNFAVORITE) { 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 = [] as Array; 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.FAVORITE && header.favorite) { return a.id + " is already a favourite"; } if (a.action == ManageAction.UNFAVORITE && !header.favorite) { return a.id + " is not a favourite"; } 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 = (_a.action == ManageAction.LOCK ? "Locks when you next enter your PIN on the watch: " : "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, (un)favourite 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 if (a.action == ManageAction.FAVORITE || a.action == ManageAction.UNFAVORITE) { entry.favorite = a.action == ManageAction.FAVORITE; message = target + (entry.favorite ? " is now a favourite" : " is no longer a favourite"); } 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"; } }