// 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); } }