aboutsummaryrefslogtreecommitdiff
path: root/source/import/Aegis.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 01:51:05 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 01:51:05 +0200
commit40d9a1d5c3e85486aea964c1446402a3be6ec05a (patch)
tree7af82c46a459afc99156e5a6022946b43e35a3de /source/import/Aegis.mc
parentbc228753bfb4a2faea540cddb20fc39ebd265db5 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'source/import/Aegis.mc')
-rw-r--r--source/import/Aegis.mc276
1 files changed, 276 insertions, 0 deletions
diff --git a/source/import/Aegis.mc b/source/import/Aegis.mc
new file mode 100644
index 0000000..05905e9
--- /dev/null
+++ b/source/import/Aegis.mc
@@ -0,0 +1,276 @@
+// Author: cirakg & claude ai
+// Summary: Reads unencrypted Aegis JSON exports (vault version 1, database 1-3) into import items, a few tokens per step.
+//
+// README 11.3. The reader consumes the token stream of Json.Tokenizer and
+// tracks the path of the current value ("/db/entries/[]/info/secret"), so it
+// can stop after any token and continue in the next event. It keeps only
+// the parsed items and the group names, never the document.
+//
+// * Encrypted exports are refused: header.slots is not null, or db is a
+// string.
+// * totp, hotp and steam entries are imported; motp, yandex and unknown
+// types are skipped and reported.
+// * Used: name (the account), issuer, favorite, info.secret/algo/digits/
+// period/counter, and the group: the older "group" string, or the first
+// UUID of "groups" resolved through db.groups (database version 3, which
+// may list the groups after the entries, so names are resolved at the
+// end).
+// * Ignored: icons (long strings are skipped unread), notes, UUIDs.
+// * A field that appears twice in one entry makes that entry an error, so
+// an ambiguous file can't smuggle in a second secret.
+
+import Toybox.Lang;
+
+class AegisReader {
+
+ static const RUNNING = 0;
+ static const DONE = 1;
+ static const INCOMPLETE = 2;
+ static const FAILED = 3;
+
+ // Tokens per step; with Json's per-token limit this bounds the work of
+ // one event.
+ static const TOKENS_PER_STEP = 40;
+
+ static const ENCRYPTED_MESSAGE = "This Aegis export is encrypted. In Aegis choose Export, then turn encryption off, and paste that file.";
+
+ var items as Array<ImportItem> = [] as Array<ImportItem>;
+ var error as String? = null;
+
+ private var _tok as Json.Tokenizer;
+ private var _path as Array<String> = [] as Array<String>;
+ private var _key as String? = null;
+ private var _item as ImportItem? = null;
+ private var _fields as ImportFields? = null;
+ private var _seen as Dictionary<String, Boolean> = {} as Dictionary<String, Boolean>;
+ private var _group as Dictionary<String, String>? = null;
+ private var _groups as Dictionary<String, String> = {} as Dictionary<String, String>;
+ private var _encrypted as Boolean = false;
+ private var _vaultVersion as Long = -1l;
+ private var _sawEntries as Boolean = false;
+
+ function initialize(data as ByteArray) {
+ _tok = new Json.Tokenizer(data);
+ }
+
+ // Bytes read so far (for progress).
+ function position() as Number {
+ return _tok.pos;
+ }
+
+ // Process up to TOKENS_PER_STEP tokens. Returns RUNNING, DONE,
+ // INCOMPLETE (the text ends early: wait for more) or FAILED (error set).
+ function step() as Number {
+ for (var n = 0; n < TOKENS_PER_STEP; n++) {
+ var token = _tok.next();
+ if (token == Json.T_END) {
+ return finish();
+ }
+ if (token == Json.T_INCOMPLETE) {
+ return _encrypted ? fail(ENCRYPTED_MESSAGE) : INCOMPLETE;
+ }
+ if (token == Json.T_ERROR) {
+ return fail("not valid JSON: " + _tok.error);
+ }
+ if (token == Json.T_KEY) {
+ _key = _tok.text;
+ continue;
+ }
+ var name = nameOfValue();
+ if (token == Json.T_BEGIN_OBJECT || token == Json.T_BEGIN_ARRAY) {
+ begin(name, token == Json.T_BEGIN_OBJECT);
+ _path.add(name);
+ } else if (token == Json.T_END_OBJECT || token == Json.T_END_ARRAY) {
+ var closed = _path[_path.size() - 1];
+ _path = _path.slice(0, _path.size() - 1) as Array<String>;
+ end(closed);
+ } else {
+ value(name, token);
+ }
+ _key = null;
+ }
+ return RUNNING;
+ }
+
+ // The name of the value about to be read: its key in an object, "[]"
+ // in an array, "" at the top.
+ private function nameOfValue() as String {
+ var key = _key;
+ if (key != null) {
+ return key;
+ }
+ return _path.size() > 0 ? "[]" : "";
+ }
+
+ private function pathOf(name as String) as String {
+ var p = "";
+ for (var i = 1; i < _path.size(); i++) {
+ p += "/" + _path[i];
+ }
+ return _path.size() == 0 ? name : p + "/" + name;
+ }
+
+ private function begin(name as String, isObject as Boolean) as Void {
+ var p = pathOf(name);
+ if (p.equals("/header/slots")) {
+ _encrypted = true;
+ } else if (p.equals("/db") && !isObject) {
+ _encrypted = true;
+ } else if (p.equals("/db/entries")) {
+ _sawEntries = true;
+ } else if (p.equals("/db/entries/[]") && isObject) {
+ _item = new ImportItem(items.size() + 1);
+ _fields = new ImportFields();
+ _seen = {} as Dictionary<String, Boolean>;
+ } else if (p.equals("/db/groups/[]") && isObject) {
+ _group = {} as Dictionary<String, String>;
+ }
+ }
+
+ private function end(closed as String) as Void {
+ var p = pathOf(closed);
+ if (p.equals("/db/entries/[]")) {
+ var item = _item;
+ var fields = _fields;
+ if (item != null && fields != null) {
+ if (item.problem == null) {
+ fields.toEntry(item);
+ } else {
+ item.label = fields.issuer.length() > 0 ? fields.issuer : fields.account;
+ }
+ fields.secret = null;
+ items.add(item);
+ }
+ _item = null;
+ _fields = null;
+ } else if (p.equals("/db/groups/[]")) {
+ var g = _group;
+ if (g != null && g.hasKey("uuid") && g.hasKey("name")) {
+ _groups[g["uuid"] as String] = g["name"] as String;
+ }
+ _group = null;
+ }
+ }
+
+ private function value(name as String, token as Number) as Void {
+ var p = pathOf(name);
+ if (p.equals("/version")) {
+ _vaultVersion = token == Json.T_NUMBER && _tok.numberIsInteger ? _tok.number : -2l;
+ } else if (p.equals("/header/slots")) {
+ if (token != Json.T_NULL) {
+ _encrypted = true;
+ }
+ } else if (p.equals("/db")) {
+ // An encrypted export stores db as a Base64 string.
+ _encrypted = true;
+ } else if (p.find("/db/entries/[]/") == 0) {
+ entryValue(p.substring(15, p.length()) as String, token);
+ } else if (p.find("/db/groups/[]/") == 0) {
+ var g = _group;
+ if (g != null && token == Json.T_STRING) {
+ g[p.substring(14, p.length()) as String] = _tok.text;
+ }
+ }
+ }
+
+ // A value inside an entry; field is "issuer", "info/secret", "groups/[]".
+ private function entryValue(field as String, token as Number) as Void {
+ var item = _item;
+ var fields = _fields;
+ if (item == null || fields == null) {
+ return;
+ }
+ if (field.equals("groups/[]")) {
+ if (item.groupRef == null && token == Json.T_STRING) {
+ item.groupRef = "uuid:" + _tok.text;
+ }
+ return;
+ }
+ var used = ["type", "name", "issuer", "favorite", "group", "info/secret", "info/algo", "info/digits", "info/period", "info/counter"];
+ if (used.indexOf(field) < 0) {
+ return; // icons, notes, uuid, anything new
+ }
+ if (_seen.hasKey(field)) {
+ item.problem = "field '" + field + "' appears twice";
+ return;
+ }
+ _seen[field] = true;
+ if (field.equals("favorite")) {
+ fields.favorite = token == Json.T_TRUE;
+ return;
+ }
+ if (field.equals("info/digits") || field.equals("info/period") || field.equals("info/counter")) {
+ if (token != Json.T_NUMBER || !_tok.numberIsInteger) {
+ item.problem = field.substring(5, field.length()) + " is not a whole number";
+ return;
+ }
+ if (field.equals("info/digits")) {
+ fields.digits = _tok.number;
+ } else if (field.equals("info/period")) {
+ fields.period = _tok.number;
+ } else {
+ fields.counter = _tok.number;
+ }
+ return;
+ }
+ if (token == Json.T_NULL && (field.equals("group") || field.equals("issuer"))) {
+ return;
+ }
+ if (token != Json.T_STRING) {
+ item.problem = "field '" + field + "' is not text" + (token == Json.T_LONG_STRING ? " (too long)" : "");
+ return;
+ }
+ var text = _tok.text;
+ if (field.equals("type")) {
+ fields.type = text;
+ } else if (field.equals("name")) {
+ fields.account = Text.trim(text);
+ } else if (field.equals("issuer")) {
+ fields.issuer = Text.trim(text);
+ } else if (field.equals("group")) {
+ item.groupRef = "name:" + text;
+ } else if (field.equals("info/secret")) {
+ fields.secret = text;
+ } else if (field.equals("info/algo")) {
+ fields.algorithm = text;
+ }
+ }
+
+ private function finish() as Number {
+ if (_encrypted) {
+ return fail(ENCRYPTED_MESSAGE);
+ }
+ if (_vaultVersion != 1 || !_sawEntries) {
+ return fail(_vaultVersion > 1 ? "Aegis export version " + _vaultVersion + " is not supported"
+ : "not an Aegis export (no version 1 vault with db.entries)");
+ }
+ // Resolve group references now that db.groups has been read.
+ for (var i = 0; i < items.size(); i++) {
+ var item = items[i];
+ var ref = item.groupRef;
+ var e = item.entry;
+ if (ref == null || e == null) {
+ continue;
+ }
+ var name = ref.find("uuid:") == 0 ? _groups[ref.substring(5, ref.length()) as String] : ref.substring(5, ref.length());
+ if (name != null) {
+ e.group = fitBytes(Text.trim(name as String), Entry.MAX_GROUP_BYTES);
+ }
+ }
+ return DONE;
+ }
+
+ // Text shortened (whole characters) to at most maxBytes UTF-8 bytes.
+ static function fitBytes(text as String, maxBytes as Number) as String {
+ var t = text;
+ while (Bytes.fromUtf8(t).size() > maxBytes) {
+ t = t.substring(0, t.length() - 1) as String;
+ }
+ return t;
+ }
+
+ private function fail(message as String) as Number {
+ error = message;
+ return FAILED;
+ }
+}