summaryrefslogtreecommitdiff
path: root/source/import/ImportBuffer.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/ImportBuffer.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/ImportBuffer.mc')
-rw-r--r--source/import/ImportBuffer.mc90
1 files changed, 90 insertions, 0 deletions
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.<i>" 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<Application.Storage.KeyType, Application.Storage.ValueType>);
+ 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);
+ }
+ }
+}