From 40d9a1d5c3e85486aea964c1446402a3be6ec05a Mon Sep 17 00:00:00 2001 From: cirakg Date: Tue, 20 Oct 2026 01:51:05 +0200 Subject: feat(import): parse otpauth URIs and unencrypted Aegis JSON in resumable steps Streaming JSON tokenizer with depth and length limits and native skipping of long strings; Aegis reader for database versions 1-3 with groups and encrypted-export detection; otpauth parser with ';' separators and Steam; import job with Replace mode, duplicate and replacement checks, a sealed multi-part buffer and a report. Google Authenticator exports deferred. Co-Authored-By: Claude Opus 5.5 --- source/import/ImportItem.mc | 148 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 source/import/ImportItem.mc (limited to 'source/import/ImportItem.mc') diff --git a/source/import/ImportItem.mc b/source/import/ImportItem.mc new file mode 100644 index 0000000..a242aa0 --- /dev/null +++ b/source/import/ImportItem.mc @@ -0,0 +1,148 @@ +// 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; + } +} -- cgit v1.2.3