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
|
|
@ -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<String, dynamic> exportData() {
|
||||
final seed = this.seed();
|
||||
if (seed == null) throw const SmolError("no identity yet");
|
||||
final state = _load();
|
||||
final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>();
|
||||
return {
|
||||
"gsmolExport": 1,
|
||||
"exportedAt": DateTime.now().millisecondsSinceEpoch,
|
||||
final payload = {
|
||||
"servers": ((state["servers"] as Map?) ?? {}).cast<String, String>(),
|
||||
"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<ImportSummary> 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<String, String>();
|
||||
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<String, Map>();
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue