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/ImportBuffer.mc | 90 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 source/import/ImportBuffer.mc (limited to 'source/import/ImportBuffer.mc') diff --git a/source/import/ImportBuffer.mc b/source/import/ImportBuffer.mc new file mode 100644 index 0000000..0aca548 --- /dev/null +++ b/source/import/ImportBuffer.mc @@ -0,0 +1,90 @@ +// Author: cirakg & claude ai +// Summary: Keeps the text of an unfinished multi-part import between syncs, sealed with the device key, capped and expiring. +// +// README 11.2: when pasted JSON is still incomplete, the watch keeps what it +// got and waits for the next part. The text is sealed (SealedBox, device +// key, aad "OTPW-IMP-1"), because it holds secrets, and split into chunks +// below Application.Storage's 8 KB per value: +// "imp" {"n" => chunk count, "t" => unix time saved} +// "imp." chunk i of the sealed box +// Chunks are written before "imp", and "imp" is deleted first, so a torn +// write leaves no readable half-buffer. Buffers older than MAX_AGE_S are +// discarded. + +import Toybox.Application; +import Toybox.Lang; + +module ImportBuffer { + + const META_KEY = "imp"; + const CHUNK_BYTES = 7000; + const MAX_TEXT_BYTES = 32768; + const MAX_AGE_S = 86400; + const AAD = "OTPW-IMP-1"; + + // The waiting text, or "" when there is none (or it expired or is damaged, + // in which case it is deleted). + function load(store as Persist.Backend, keys as SealedBox.Keys, now as Long) as String { + var meta = store.getValue(META_KEY); + if (!(meta instanceof Dictionary)) { + return ""; + } + var n = meta["n"]; + var t = meta["t"]; + if (!(n instanceof Number) || !(t instanceof Long) || n < 1 || n > 16 || now - t > MAX_AGE_S || now < t) { + clear(store); + return ""; + } + var box = []b; + for (var i = 0; i < n; i++) { + var chunk = store.getValue(META_KEY + "." + i); + if (!(chunk instanceof ByteArray)) { + clear(store); + return ""; + } + box.addAll(chunk); + } + var plain = SealedBox.open(keys, Bytes.fromUtf8(AAD), box); + var text = plain != null ? Bytes.toUtf8(plain) : null; + Bytes.wipe(plain); + if (text == null) { + clear(store); + return ""; + } + return text; + } + + // Save text as the waiting import. Returns false (and saves nothing) + // when it is over MAX_TEXT_BYTES. + function save(store as Persist.Backend, keys as SealedBox.Keys, text as String, now as Long) as Boolean { + var plain = Bytes.fromUtf8(text); + if (plain.size() > MAX_TEXT_BYTES) { + Bytes.wipe(plain); + return false; + } + clear(store); + var box = SealedBox.seal(keys, Bytes.fromUtf8(AAD), plain); + Bytes.wipe(plain); + var n = 0; + for (var at = 0; at < box.size(); at += CHUNK_BYTES) { + store.setValue(META_KEY + "." + n, box.slice(at, at + CHUNK_BYTES < box.size() ? at + CHUNK_BYTES : box.size())); + n++; + } + store.setValue(META_KEY, {"n" => n, "t" => now} as Dictionary); + return true; + } + + function hasPending(store as Persist.Backend) as Boolean { + return store.getValue(META_KEY) != null; + } + + // Delete the waiting import (after it completed, failed or was cancelled). + function clear(store as Persist.Backend) as Void { + var meta = store.getValue(META_KEY); + store.deleteValue(META_KEY); + var n = meta instanceof Dictionary && meta["n"] instanceof Number ? meta["n"] as Number : 16; + for (var i = 0; i < n && i < 16; i++) { + store.deleteValue(META_KEY + "." + i); + } + } +} -- cgit v1.2.3