// 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; // everything else about a REPLACE (Base32, the contents' size) is checked // before it is queued. With 4 KB of contents every byte loop (UTF-8 check, // opening and re-sealing the record, wiping) gets a step of its own, and // the wipes at the end run in pieces (Jobs.Wiper). 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; static const T_REF = 11; // 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 (a 0.9.0 queue item may hold the raw text // of a code's contents instead). Wiped after use. Queued, it is a // storage value of its own (ChangeQueue, `ref`). var material as ByteArray? = null; // REPLACE from the form: the raw field text until ManageRun checks it. // Never queued. var text as String? = null; // A queued REPLACE: the number of its material record, -1 for none. var ref as Number = -1; var counter as Long = 0l; // True once `material` holds the checked secret (decoded from Base32) // or contents (CodeForm.contents), not raw text. 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; text = 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); if (ref >= 0) { w.addInt(T_REF, ref); } 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; var ref = map[T_REF]; a.ref = ref instanceof Long && ref >= 0 && ref < 0x10000 ? ref.toNumber() : -1; 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 (secret.length() == 0) { return "Replace: enter the new secret or contents"; // only spaces: ManageRun says so, in steps } a.text = secret; // checked against the entry by ManageRun } 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; // Buffers to wipe once the run ends; large ones take steps of their own. private var _wiper as Jobs.Wiper = new Jobs.Wiper(); private var _wiping as Boolean = false; // A code REPLACE: CodeCheck is still to start, with this payload text // (null: from the queue, still to be read from `material`). private var _needsCheck as Boolean = false; private var _payload as String? = null; // How far queued contents are checked as UTF-8. private var _utf8At as Number = 0; // A REPLACE's raw text, prepared in steps before it is checked // (prepareText): a 0.9.0 item's raw bytes checked as UTF-8, then the // spaces around it found, in pieces (kilobytes of either are a byte or // character loop). private var _rawAt as Number = 0; private var _chars as Array? = null; private var _trimStart as Number = 0; private var _trimEnd as Number = -1; private var _trimmed as String? = null; // A REPLACE: the entry opened in the step before it is re-sealed. private var _entry as Entry? = null; // Raw OTP secrets longer than this are refused before Base32.decode's // character loop (128 bytes are 205 Base32 characters; this leaves room // for spaces). static const MAX_SECRET_TEXT = 1024; // Contents checked as UTF-8 per step (up to 30 bytecodes a byte). static const UTF8_STEP_BYTES = 1024; // Characters looked at per step when trimming the raw text. static const TRIM_STEP_CHARS = 512; // 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 { if (_wiping) { return _wiper.step(Jobs.Wiper.STEP_BYTES); } 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) { if (_a.action == ManageAction.REPLACE && !_a.decoded && _trimmed == null) { return prepareText(); } try { return resolve(); } catch (e instanceof VaultError) { return fail(problemText(e)); } } if (_needsCheck) { try { return prepareCheck(); } 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) { _needsCheck = true; // CodeCheck with the entry's options (prepareCheck) return false; } 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 && !a.decoded) { // Checked before queueing, so a locked entry's queue item holds // only the decoded secret or the checked contents. var text = a.text as String; var trimmed = _trimmed as String; a.text = null; if (!isOtp) { var contents = CodeForm.contentsOf(text, trimmed, header.type); if (contents instanceof String) { return contents; } a.material = contents; a.decoded = true; _payload = Codes.isLinear(header.type) ? trimmed : text; return null; } if (text.length() > MAX_SECRET_TEXT) { return "Secret: too long"; } var decoded = OtpForm.decodeSecret(trimmed); // trimmed: no character loop there 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.addWith(_vault, _a, _wiper); } 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(); wipeAction(); return ending(); } // One step of preparing a REPLACE's raw text: a 0.9.0 item's raw bytes // become text (checked as UTF-8 in pieces); text longer than any valid // input is refused; then the spaces around it are found in pieces. private function prepareText() as Boolean { var a = _a; var text = a.text; if (text == null) { var raw = a.material; if (raw == null) { return fail("Replace: enter the new secret or contents"); } if (_rawAt < raw.size()) { var end = _rawAt + UTF8_STEP_BYTES < raw.size() ? _rawAt + UTF8_STEP_BYTES : raw.size(); _rawAt = Bytes.utf8Prefix(raw, _rawAt, end); return _rawAt < 0 ? fail("Replace: the new secret or contents are damaged") : false; } a.text = Bytes.toUtf8Checked(raw); a.material = null; _wiper.add(raw); return false; } var chars = _chars; if (chars == null) { if (text.length() > Entry.MAX_PAYLOAD_BYTES) { return fail("Replace: too long (" + text.length() + " characters, at most " + Entry.MAX_PAYLOAD_BYTES + ")"); } chars = text.toCharArray(); _chars = chars; _trimEnd = chars.size(); return false; } var budget = TRIM_STEP_CHARS; while (budget > 0 && _trimStart < _trimEnd && Text.isSpace(chars[_trimStart])) { _trimStart++; budget--; } if (budget == 0) { return false; } while (budget > 0 && _trimEnd > _trimStart && Text.isSpace(chars[_trimEnd - 1])) { _trimEnd--; budget--; } if (budget == 0) { return false; } _trimmed = text.substring(_trimStart, _trimEnd); _chars = null; return false; } // One step towards CodeCheck: queued contents are read as text first // (the UTF-8 check is a byte loop, done in pieces), then the entry is opened for its // options. private function prepareCheck() as Boolean { var a = _a; var payload = _payload; if (payload == null) { var m = a.material as ByteArray; if (_utf8At < m.size()) { var end = _utf8At + UTF8_STEP_BYTES < m.size() ? _utf8At + UTF8_STEP_BYTES : m.size(); _utf8At = Bytes.utf8Prefix(m, _utf8At, end); return _utf8At < 0 ? fail("the new contents are damaged") : false; } _payload = Bytes.toUtf8Checked(m); return false; } var entry = _vault.openWith(a.id, _wiper); var options = entry.options; wipeLater(entry); _needsCheck = false; _payload = null; _check = new CodeCheck((_header as Entry).type, 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; if (a.action == ManageAction.REPLACE && _entry == null) { // Opening and re-sealing a large code are a step each. try { _entry = _vault.openWith(a.id, _wiper); } catch (e instanceof VaultError) { return fail(problemText(e)); } return false; } 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.saveCodeCacheWith(a.id, cache, _wiper); } } } catch (e instanceof VaultError) { return fail(problemText(e)); } wipeAction(); return ending(); } // 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 opened = _entry; _entry = null; var entry = opened != null ? opened : _vault.openWith(a.id, _wiper); 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)) { wipeLater(entry); 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) { _wiper.add(entry.secret); entry.secret = Bytes.copy(material); message = "Replaced the secret of " + target; } else { _wiper.add(entry.payload); entry.payload = Bytes.copy(material); message = "Replaced the contents of " + target; } } try { _vault.updateWith(entry, _wiper); } catch (e instanceof VaultError) { wipeLater(entry); throw e; } wipeLater(entry); 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; var entry = _entry; if (entry != null) { wipeLater(entry); _entry = null; } wipeAction(); return ending(); } // The action's material goes to the wiper. private function wipeAction() as Void { _wiper.add(_a.material); _a.material = null; _a.text = null; } private function wipeLater(entry as Entry) as Void { _wiper.add(entry.secret); _wiper.add(entry.payload); entry.secret = null; entry.payload = null; } // The run is over once its buffers are wiped: small ones now, large // ones in the next steps. private function ending() as Boolean { if (_wiper.left() <= Jobs.Wiper.INLINE_BYTES) { _wiper.step(Jobs.Wiper.INLINE_BYTES); return true; } _wiping = true; return false; } 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"; } }