From 7d3c8b423c15870c53641847877103bc0c626903 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 27 Sep 2026 00:09:29 +0300 Subject: [PATCH] feat: match gsmol's v2 encrypted export format; fix export on desktop --- lib/presentation/screens/settings_screen.dart | 41 ++++++++---- lib/smol/store.dart | 65 +++++++++++++++---- test/store_test.dart | 26 +++++++- 3 files changed, 104 insertions(+), 28 deletions(-) diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index 0d01b8c..4dc1642 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -68,19 +68,36 @@ class _SettingsScreenState extends ConsumerState { // it is picked up (SPEC.md §5). This backs up inbox, sent mail, contacts and // pinned servers into one shareable file; never the seed. Future _export() async { - final data = ref.read(storeProvider).exportData(); + final store = ref.read(storeProvider); + final data = store.exportData(); final stamp = DateTime.now().toIso8601String().substring(0, 10); - final file = File("${Directory.systemTemp.path}/smolmail-export-$stamp.json"); - await file.writeAsString(jsonEncode(data)); - await SharePlus.instance.share(ShareParams( - files: [XFile(file.path)], text: "kirakira backup $stamp")); - if (mounted) { - final messages = (data["inbox"] as List).length + (data["sent"] as List).length; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("exported $messages messages, " - "${(data["contacts"] as Map).length} contacts, " - "${(data["servers"] as Map).length} server keys")), - ); + final fileName = "kirakira-backup-$stamp.json"; + final bytes = Uint8List.fromList(utf8.encode(jsonEncode(data))); + try { + // share_plus has no file-sharing implementation on desktop platforms — + // it throws UnimplementedError for Linux/Windows/macOS — so those save + // straight to a chosen location instead of going through a share sheet. + if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + await FilePicker.saveFile(fileName: fileName, bytes: bytes); + } else { + final file = File("${Directory.systemTemp.path}/$fileName"); + await file.writeAsBytes(bytes); + await SharePlus.instance.share(ShareParams( + files: [XFile(file.path)], text: "kirakira backup $stamp")); + } + if (mounted) { + // The export payload is sealed now (v2), so the counts for this + // notice come straight from the store rather than the ciphertext. + final messages = + store.listMessages("inbox").length + store.listMessages("sent").length; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("exported $messages messages, " + "${store.allContacts().length} contacts, " + "${store.allPins().length} server keys")), + ); + } + } catch (err) { + if (mounted) showErrorSnackBar(context, err.toString()); } } diff --git a/lib/smol/store.dart b/lib/smol/store.dart index 788e520..3813aef 100644 --- a/lib/smol/store.dart +++ b/lib/smol/store.dart @@ -301,15 +301,23 @@ class SmolStore { // --- export / import: mail, contacts, pins — never the seed -------------------- - /// The marker matches gsmol's web export, so backups move between the two - /// clients. Deliberately excludes the seed: it has its own reveal-and-copy - /// flow in settings, meant for a password manager, not a shareable file. + /// Label kept as gsmol wrote it originally; the export format version + /// (gsmolExport) is what actually changed between v1 and v2. + static final _exportLabel = utf8Bytes("gsmol/1 export"); + Uint8List _exportKey(Uint8List seed) => + hkdfSha256(seed, Uint8List(0), _exportLabel, 32); + + /// v2 matches gsmol's own current export: the whole payload — mail, + /// contacts, pins — is sealed to a key derived from the identity's seed, so + /// a backup file is only readable by whoever holds that seed. Deliberately + /// excludes the seed itself: it has its own reveal-and-copy flow in + /// settings, meant for a password manager, not a shareable file. Map exportData() { + final seed = this.seed(); + if (seed == null) throw const SmolError("no identity yet"); final state = _load(); final contacts = ((state["contacts"] as Map?) ?? {}).cast(); - return { - "gsmolExport": 1, - "exportedAt": DateTime.now().millisecondsSinceEpoch, + final payload = { "servers": ((state["servers"] as Map?) ?? {}).cast(), "contacts": { for (final entry in contacts.entries) @@ -338,6 +346,15 @@ class SmolStore { } ], }; + final nonce = randomBytes(12); + final ciphertext = aeadEncrypt( + _exportKey(seed), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0)); + return { + "gsmolExport": 2, + "exportedAt": DateTime.now().millisecondsSinceEpoch, + "nonce": base64Encode(nonce), + "ciphertext": base64Encode(ciphertext), + }; } /// Never overwrites a trust binding that already differs locally — the same @@ -345,15 +362,35 @@ class SmolStore { /// entry is skipped and counted, not fatal: one bad record cannot abort the /// rest of the import. Future importData(Map data) async { - if (data["gsmolExport"] != 1) { - throw const SmolError("not a SmolMail export file"); + Map payload; + if (data["gsmolExport"] == 2) { + final seed = this.seed(); + if (seed == null) { + throw const SmolError("no identity yet — restore it before importing"); + } + try { + final plaintext = aeadDecrypt( + _exportKey(seed), + base64Decode(data["nonce"] as String), + base64Decode(data["ciphertext"] as String), + Uint8List(0), + ); + payload = jsonDecode(utf8.decode(plaintext)) as Map; + } catch (_) { + throw const SmolError("couldn't decrypt — exported by a different " + "identity, or the file is corrupted"); + } + } else if (data["gsmolExport"] == 1) { + payload = data; // pre-encryption shape: fields already sit at the top level + } else { + throw const SmolError("not a gsmol export file"); } final summary = ImportSummary(); _update((state) { final servers = (state["servers"] as Map? ?? {}).cast(); - final incomingPins = data["servers"] is Map ? data["servers"] as Map : null; - if (data["servers"] != null && incomingPins == null) summary.malformed++; + final incomingPins = payload["servers"] is Map ? payload["servers"] as Map : null; + if (payload["servers"] != null && incomingPins == null) summary.malformed++; for (final entry in (incomingPins ?? const {}).entries) { final host = entry.key, key = entry.value; if (host is! String || key is! String || _pinKeyOk(key) != true) { @@ -370,8 +407,8 @@ class SmolStore { state["servers"] = servers; final contacts = (state["contacts"] as Map? ?? {}).cast(); - final incoming = data["contacts"] is Map ? data["contacts"] as Map : null; - if (data["contacts"] != null && incoming == null) summary.malformed++; + final incoming = payload["contacts"] is Map ? payload["contacts"] as Map : null; + if (payload["contacts"] != null && incoming == null) summary.malformed++; for (final entry in (incoming ?? const {}).entries) { final address = entry.key, contact = entry.value; if (address is! String || @@ -399,8 +436,8 @@ class SmolStore { }); for (final folder in ["inbox", "sent"]) { - final rows = data[folder] is List ? data[folder] as List : null; - if (data[folder] != null && rows == null) summary.malformed++; + final rows = payload[folder] is List ? payload[folder] as List : null; + if (payload[folder] != null && rows == null) summary.malformed++; for (final row in rows ?? const []) { try { final map = row as Map; diff --git a/test/store_test.dart b/test/store_test.dart index 74ee0b7..bf8ea19 100644 --- a/test/store_test.dart +++ b/test/store_test.dart @@ -77,9 +77,12 @@ void main() { MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6)); final data = a.exportData(); - expect(data["gsmolExport"], 1); // gsmol-compatible marker + expect(data["gsmolExport"], 2); // sealed to the identity's seed, like gsmol expect(jsonEncode(data).contains(hex(a.seed()!)), isFalse); + // v2 is sealed to the exporting identity's seed — a restore-on-new-device + // scenario, not a transfer to someone else's identity (see the test below). + b.setIdentity(a.seed()!); final summary = await b.importData(data); expect(summary.mailAdded, 2); expect(summary.pinsAdded, 1); @@ -88,7 +91,26 @@ void main() { expect(b.contact("alice@example.org")!.history.length, 1); expect(b.getMessage("inbox", "aa"), isNotNull); expect(b.getMessage("sent", "bb"), isNotNull); - expect(b.seed(), isNull); // never the seed + }); + + test("v2 import refuses a different identity's export", () async { + final a = await freshStore("export-wrong-identity"); + final c = await freshStore("round-trip-wrong-identity"); + a.setIdentity(randomBytes(32)); + a.pinServer("example.org", randomBytes(32)); + final data = a.exportData(); + + c.setIdentity(randomBytes(32)); // a different seed than a's + expect(() => c.importData(data), throwsA(isA())); + }); + + test("v1 legacy export still imports without an identity", () async { + final store = await freshStore("import-legacy-v1"); + final summary = await store.importData({ + "gsmolExport": 1, + "servers": {"example.org": b32encode(randomBytes(32))}, + }); + expect(summary.pinsAdded, 1); }); test("import never overwrites a differing trust binding", () async {