feat: match gsmol's v2 encrypted export format; fix export on desktop
This commit is contained in:
parent
f80315e7c4
commit
7d3c8b423c
3 changed files with 104 additions and 28 deletions
|
|
@ -68,20 +68,37 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
// it is picked up (SPEC.md §5). This backs up inbox, sent mail, contacts and
|
// it is picked up (SPEC.md §5). This backs up inbox, sent mail, contacts and
|
||||||
// pinned servers into one shareable file; never the seed.
|
// pinned servers into one shareable file; never the seed.
|
||||||
Future<void> _export() async {
|
Future<void> _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 stamp = DateTime.now().toIso8601String().substring(0, 10);
|
||||||
final file = File("${Directory.systemTemp.path}/smolmail-export-$stamp.json");
|
final fileName = "kirakira-backup-$stamp.json";
|
||||||
await file.writeAsString(jsonEncode(data));
|
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(
|
await SharePlus.instance.share(ShareParams(
|
||||||
files: [XFile(file.path)], text: "kirakira backup $stamp"));
|
files: [XFile(file.path)], text: "kirakira backup $stamp"));
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final messages = (data["inbox"] as List).length + (data["sent"] as List).length;
|
// 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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text("exported $messages messages, "
|
SnackBar(content: Text("exported $messages messages, "
|
||||||
"${(data["contacts"] as Map).length} contacts, "
|
"${store.allContacts().length} contacts, "
|
||||||
"${(data["servers"] as Map).length} server keys")),
|
"${store.allPins().length} server keys")),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (mounted) showErrorSnackBar(context, err.toString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _import() async {
|
Future<void> _import() async {
|
||||||
|
|
|
||||||
|
|
@ -301,15 +301,23 @@ class SmolStore {
|
||||||
|
|
||||||
// --- export / import: mail, contacts, pins — never the seed --------------------
|
// --- export / import: mail, contacts, pins — never the seed --------------------
|
||||||
|
|
||||||
/// The marker matches gsmol's web export, so backups move between the two
|
/// Label kept as gsmol wrote it originally; the export format version
|
||||||
/// clients. Deliberately excludes the seed: it has its own reveal-and-copy
|
/// (gsmolExport) is what actually changed between v1 and v2.
|
||||||
/// flow in settings, meant for a password manager, not a shareable file.
|
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<String, dynamic> exportData() {
|
Map<String, dynamic> exportData() {
|
||||||
|
final seed = this.seed();
|
||||||
|
if (seed == null) throw const SmolError("no identity yet");
|
||||||
final state = _load();
|
final state = _load();
|
||||||
final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>();
|
final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>();
|
||||||
return {
|
final payload = {
|
||||||
"gsmolExport": 1,
|
|
||||||
"exportedAt": DateTime.now().millisecondsSinceEpoch,
|
|
||||||
"servers": ((state["servers"] as Map?) ?? {}).cast<String, String>(),
|
"servers": ((state["servers"] as Map?) ?? {}).cast<String, String>(),
|
||||||
"contacts": {
|
"contacts": {
|
||||||
for (final entry in contacts.entries)
|
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
|
/// 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
|
/// entry is skipped and counted, not fatal: one bad record cannot abort the
|
||||||
/// rest of the import.
|
/// rest of the import.
|
||||||
Future<ImportSummary> importData(Map data) async {
|
Future<ImportSummary> importData(Map data) async {
|
||||||
if (data["gsmolExport"] != 1) {
|
Map payload;
|
||||||
throw const SmolError("not a SmolMail export file");
|
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();
|
final summary = ImportSummary();
|
||||||
|
|
||||||
_update((state) {
|
_update((state) {
|
||||||
final servers = (state["servers"] as Map? ?? {}).cast<String, String>();
|
final servers = (state["servers"] as Map? ?? {}).cast<String, String>();
|
||||||
final incomingPins = data["servers"] is Map ? data["servers"] as Map : null;
|
final incomingPins = payload["servers"] is Map ? payload["servers"] as Map : null;
|
||||||
if (data["servers"] != null && incomingPins == null) summary.malformed++;
|
if (payload["servers"] != null && incomingPins == null) summary.malformed++;
|
||||||
for (final entry in (incomingPins ?? const {}).entries) {
|
for (final entry in (incomingPins ?? const {}).entries) {
|
||||||
final host = entry.key, key = entry.value;
|
final host = entry.key, key = entry.value;
|
||||||
if (host is! String || key is! String || _pinKeyOk(key) != true) {
|
if (host is! String || key is! String || _pinKeyOk(key) != true) {
|
||||||
|
|
@ -370,8 +407,8 @@ class SmolStore {
|
||||||
state["servers"] = servers;
|
state["servers"] = servers;
|
||||||
|
|
||||||
final contacts = (state["contacts"] as Map? ?? {}).cast<String, Map>();
|
final contacts = (state["contacts"] as Map? ?? {}).cast<String, Map>();
|
||||||
final incoming = data["contacts"] is Map ? data["contacts"] as Map : null;
|
final incoming = payload["contacts"] is Map ? payload["contacts"] as Map : null;
|
||||||
if (data["contacts"] != null && incoming == null) summary.malformed++;
|
if (payload["contacts"] != null && incoming == null) summary.malformed++;
|
||||||
for (final entry in (incoming ?? const {}).entries) {
|
for (final entry in (incoming ?? const {}).entries) {
|
||||||
final address = entry.key, contact = entry.value;
|
final address = entry.key, contact = entry.value;
|
||||||
if (address is! String ||
|
if (address is! String ||
|
||||||
|
|
@ -399,8 +436,8 @@ class SmolStore {
|
||||||
});
|
});
|
||||||
|
|
||||||
for (final folder in ["inbox", "sent"]) {
|
for (final folder in ["inbox", "sent"]) {
|
||||||
final rows = data[folder] is List ? data[folder] as List : null;
|
final rows = payload[folder] is List ? payload[folder] as List : null;
|
||||||
if (data[folder] != null && rows == null) summary.malformed++;
|
if (payload[folder] != null && rows == null) summary.malformed++;
|
||||||
for (final row in rows ?? const []) {
|
for (final row in rows ?? const []) {
|
||||||
try {
|
try {
|
||||||
final map = row as Map;
|
final map = row as Map;
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,12 @@ void main() {
|
||||||
MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6));
|
MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6));
|
||||||
|
|
||||||
final data = a.exportData();
|
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);
|
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);
|
final summary = await b.importData(data);
|
||||||
expect(summary.mailAdded, 2);
|
expect(summary.mailAdded, 2);
|
||||||
expect(summary.pinsAdded, 1);
|
expect(summary.pinsAdded, 1);
|
||||||
|
|
@ -88,7 +91,26 @@ void main() {
|
||||||
expect(b.contact("alice@example.org")!.history.length, 1);
|
expect(b.contact("alice@example.org")!.history.length, 1);
|
||||||
expect(b.getMessage("inbox", "aa"), isNotNull);
|
expect(b.getMessage("inbox", "aa"), isNotNull);
|
||||||
expect(b.getMessage("sent", "bb"), 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<SmolError>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
test("import never overwrites a differing trust binding", () async {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue