aboutsummaryrefslogtreecommitdiff
path: root/source/settings/ManageForm.mc
diff options
context:
space:
mode:
Diffstat (limited to 'source/settings/ManageForm.mc')
-rw-r--r--source/settings/ManageForm.mc509
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";
+ }
+}