summaryrefslogtreecommitdiff
path: root/source/settings/OtpForm.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 18:36:54 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 18:36:54 +0200
commit151bfda5d4c567ff909a61dc9c90bf8eece1db37 (patch)
treef8487b91cf6a9ce59dba02e95e67250fc84d8e80 /source/settings/OtpForm.mc
parent25910f3a7a7fb28c4a58367b6e56dd2468b60933 (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/OtpForm.mc')
-rw-r--r--source/settings/OtpForm.mc152
1 files changed, 152 insertions, 0 deletions
diff --git a/source/settings/OtpForm.mc b/source/settings/OtpForm.mc
new file mode 100644
index 0000000..40c766f
--- /dev/null
+++ b/source/settings/OtpForm.mc
@@ -0,0 +1,152 @@
+// Author: cirakg & claude ai
+// Summary: The Add OTP form (README 10.2): reads the otp* fields, validates them (README 10.4) and resets the form.
+//
+// read() turns the fields into a new Entry or the first problem, in the
+// words of README 10.3 ("Secret: '1' is not a Base32 character (position
+// 5)"). Messages never quote the secret. Steam ignores the algorithm, digits
+// and period fields (always SHA-1, 5 characters, 30 s); TOTP ignores the
+// counter and HOTP the period.
+
+import Toybox.Lang;
+
+module OtpForm {
+
+ const SWITCH = "otpAdd";
+ const STATUS = "otpStatus";
+ const SECRET = "otpSecret";
+ // Below this many bytes (80 bits) the secret gets a warning (RFC 4226 4).
+ const WEAK_SECRET_BYTES = 10;
+
+ // Every field and its value after a reset (the form's defaults in
+ // resources/settings/properties.xml). The secret comes first: it is
+ // cleared before anything else.
+ function defaults() as Dictionary<String, String or Number or Long or Boolean> {
+ return {
+ SECRET => "",
+ "otpType" => Entry.OTP_TOTP,
+ "otpIssuer" => "",
+ "otpAccount" => "",
+ "otpAlgorithm" => Hashes.SHA1,
+ "otpDigits" => 6,
+ "otpPeriod" => 30,
+ "otpCounter" => 0l,
+ "otpGroup" => "",
+ "otpLocked" => false
+ } as Dictionary<String, String or Number or Long or Boolean>;
+ }
+
+ // The form's fields in their hash order.
+ const FIELDS = ["otpType", "otpIssuer", "otpAccount", "otpSecret", "otpAlgorithm", "otpDigits", "otpPeriod", "otpCounter",
+ "otpGroup", "otpLocked"];
+
+ function read(store as Forms.Store) as Forms.Parsed {
+ var parsed = new Forms.Parsed("otp");
+ for (var i = 0; i < FIELDS.size(); i++) {
+ parsed.content.add(store.get(FIELDS[i] as String));
+ }
+ var type = Forms.number(store, "otpType");
+ if (type == null || type < Entry.OTP_TOTP || type > Entry.OTP_STEAM) {
+ return parsed.fail("Type: choose TOTP, HOTP or Steam");
+ }
+ var e = new Entry();
+ e.kind = Entry.KIND_OTP;
+ e.type = type;
+ e.source = Entry.SOURCE_MANUAL;
+ e.name = Forms.text(store, "otpIssuer");
+ e.account = Forms.text(store, "otpAccount");
+ e.group = Forms.text(store, "otpGroup");
+ e.locked = Forms.flag(store, "otpLocked");
+ if (e.name.length() == 0 && e.account.length() == 0) {
+ return parsed.fail("Fill in the issuer or the account");
+ }
+ var problem = Forms.checkName("Issuer", e.name, Entry.MAX_NAME_BYTES);
+ if (problem == null) {
+ problem = Forms.checkName("Account", e.account, Entry.MAX_NAME_BYTES);
+ }
+ if (problem == null) {
+ problem = Forms.checkName("Group", e.group, Entry.MAX_GROUP_BYTES);
+ }
+ if (problem != null) {
+ return parsed.fail(problem);
+ }
+ problem = readParameters(store, e);
+ if (problem != null) {
+ return parsed.fail(problem);
+ }
+ var secret = Forms.rawText(store, SECRET);
+ var decoded = decodeSecret(secret);
+ if (decoded instanceof String) {
+ return parsed.fail(decoded);
+ }
+ e.secret = decoded;
+ if (decoded.size() < WEAK_SECRET_BYTES) {
+ parsed.notes.add("Warning: the secret is only " + decoded.size() * 8 + " bits (80 or more recommended)");
+ }
+ parsed.entry = e;
+ return parsed;
+ }
+
+ // Algorithm, digits, period and counter by type; null or the problem.
+ function readParameters(store as Forms.Store, e as Entry) as String? {
+ if (e.type == Entry.OTP_STEAM) {
+ e.algorithm = Hashes.SHA1;
+ e.digits = Otp.MIN_DIGITS;
+ e.period = Otp.STEAM_PERIOD;
+ return null;
+ }
+ var algorithm = Forms.number(store, "otpAlgorithm");
+ if (algorithm == null || !Hashes.isValid(algorithm)) {
+ return "Algorithm: choose SHA1, SHA256 or SHA512";
+ }
+ e.algorithm = algorithm as Hashes.Algorithm;
+ var digits = Forms.number(store, "otpDigits");
+ if (digits == null || digits < Otp.MIN_DIGITS || digits > Otp.MAX_DIGITS) {
+ return "Digits: choose 6, 7 or 8";
+ }
+ e.digits = digits;
+ if (e.type == Entry.OTP_TOTP) {
+ var period = Forms.number(store, "otpPeriod");
+ if (period == null || period < Otp.MIN_PERIOD || period > Otp.MAX_PERIOD) {
+ return "Period: 1 to " + Otp.MAX_PERIOD + " seconds";
+ }
+ e.period = period;
+ } else {
+ var counter = Forms.long(store, "otpCounter");
+ if (counter == null || counter < 0) {
+ return "Counter: a whole number, 0 or more";
+ }
+ e.counter = counter;
+ }
+ return null;
+ }
+
+ // The secret's bytes, or the message for the status field.
+ function decodeSecret(text as String) as ByteArray or String {
+ if (Text.trim(text).length() == 0) {
+ return "Secret: missing";
+ }
+ var decoded = Base32.decode(text);
+ if (decoded instanceof Base32.DecodeError) {
+ return "Secret: " + decoded.message + (decoded.position > 0 ? " (position " + decoded.position + ")" : "");
+ }
+ if (decoded.size() == 0 || decoded.size() > Entry.MAX_SECRET_BYTES) {
+ Bytes.wipe(decoded);
+ return "Secret: must decode to 1 to " + Entry.MAX_SECRET_BYTES + " bytes";
+ }
+ return decoded;
+ }
+
+ // Turn the switch off and clear the form: everything after it was
+ // applied, only the secret after a problem (so the rest can be fixed).
+ 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 or Boolean);
+ }
+ }
+ store.set(SWITCH, false);
+ }
+}