From 151bfda5d4c567ff909a61dc9c90bf8eece1db37 Mon Sep 17 00:00:00 2001 From: cirakg Date: Tue, 20 Oct 2026 18:36:54 +0200 Subject: 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 --- source/settings/OtpForm.mc | 152 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 source/settings/OtpForm.mc (limited to 'source/settings/OtpForm.mc') 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 { + return { + SECRET => "", + "otpType" => Entry.OTP_TOTP, + "otpIssuer" => "", + "otpAccount" => "", + "otpAlgorithm" => Hashes.SHA1, + "otpDigits" => 6, + "otpPeriod" => 30, + "otpCounter" => 0l, + "otpGroup" => "", + "otpLocked" => false + } as Dictionary; + } + + // 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); + } +} -- cgit v1.2.3