// Author: cirakg & claude ai // Summary: One parsed import candidate (an entry, an error or a skip) and the shared field validation of both formats. // // Both parsers (otpauth URIs and Aegis JSON) turn text fields into an Entry // through ImportFields.toEntry, so the same rules (README 10.4) and the same // messages apply to both. Messages name the field and the problem, never the // secret itself. import Toybox.Lang; class ImportItem { // 1-based position in the import (URI number or Aegis entry number). var index as Number; // A short name for messages: the issuer, else the account, else "". var label as String = ""; var entry as Entry? = null; // Why the item can't be imported (shown as an error), or null. var problem as String? = null; // Why the item was deliberately skipped (unsupported type), or null. var skipped as String? = null; // A remark that doesn't stop the import (issuer mismatch). var note as String? = null; // Aegis groups: a group UUID (database version 3) or a name, resolved by // the reader once the whole document is read. var groupRef as String? = null; function initialize(position as Number) { index = position; } // "entry 7 'GitHub'" or "URI 3", for messages. function describe(kind as String) as String { return label.length() > 0 ? kind + " " + index + " '" + label + "'" : kind + " " + index; } } // The raw fields one source item provides, as text, and their conversion. class ImportFields { var type as String = "totp"; var issuer as String = ""; var account as String = ""; var secret as String? = null; var algorithm as String = "SHA1"; var digits as Long = 6l; var period as Long = 30l; var counter as Long? = null; var favorite as Boolean = false; function initialize() { } // Fill item.entry, or set item.problem / item.skipped. function toEntry(item as ImportItem) as Void { item.label = issuer.length() > 0 ? issuer : account; var t = type.toLower(); var otpType = -1; if (t.equals("totp")) { otpType = Entry.OTP_TOTP; } else if (t.equals("hotp")) { otpType = Entry.OTP_HOTP; } else if (t.equals("steam")) { otpType = Entry.OTP_STEAM; } else if (t.equals("motp")) { item.skipped = "mOTP is not supported"; return; } else if (t.equals("yandex")) { item.skipped = "Yandex is not supported"; return; } else { item.skipped = "type '" + Text.clip(t, 12) + "' is not supported"; return; } if (issuer.length() == 0 && account.length() == 0) { item.problem = "needs an issuer or an account name"; return; } if (Bytes.fromUtf8(issuer).size() > Entry.MAX_NAME_BYTES || Bytes.fromUtf8(account).size() > Entry.MAX_NAME_BYTES) { item.problem = "issuer or account longer than " + Entry.MAX_NAME_BYTES + " bytes"; return; } var e = new Entry(); e.kind = Entry.KIND_OTP; e.type = otpType; e.name = issuer; e.account = account; e.source = Entry.SOURCE_IMPORT; e.favorite = favorite; if (otpType == Entry.OTP_STEAM) { // Steam always uses SHA-1, 30 s and 5 characters. e.algorithm = Hashes.SHA1; e.digits = Otp.MIN_DIGITS; e.period = Otp.STEAM_PERIOD; } else { var a = algorithm.toUpper(); if (a.equals("SHA1")) { e.algorithm = Hashes.SHA1; } else if (a.equals("SHA256")) { e.algorithm = Hashes.SHA256; } else if (a.equals("SHA512")) { e.algorithm = Hashes.SHA512; } else { item.problem = "algorithm '" + Text.clip(algorithm, 10) + "' is not supported (SHA1, SHA256, SHA512)"; return; } if (digits < Otp.MIN_DIGITS || digits > Otp.MAX_DIGITS) { item.problem = digits + " digits not supported (6-8)"; return; } e.digits = digits.toNumber(); if (period < Otp.MIN_PERIOD || period > Otp.MAX_PERIOD) { item.problem = "period " + period + " s not supported (1-300)"; return; } e.period = period.toNumber(); } if (otpType == Entry.OTP_HOTP) { var c = counter; if (c == null) { item.problem = "HOTP needs a counter"; return; } if (c < 0) { item.problem = "counter can't be negative"; return; } e.counter = c; } var s = secret; if (s == null || s.length() == 0) { item.problem = "secret missing"; return; } var decoded = Base32.decode(s); if (decoded instanceof Base32.DecodeError) { item.problem = "secret: " + decoded.message + (decoded.position > 0 ? " (position " + decoded.position + ")" : ""); return; } if (decoded.size() == 0 || decoded.size() > Entry.MAX_SECRET_BYTES) { item.problem = "secret must be 1 to " + Entry.MAX_SECRET_BYTES + " bytes"; Bytes.wipe(decoded); return; } e.secret = decoded; item.entry = e; } }