// Device state: identity, pins, contacts and read markers in one JSON blob; // sealed envelopes in a second Hive box, opened only on demand, so nothing at // rest is plaintext (the seed excepted — the device's app storage is the trust // boundary, like gsmol's browser profile). import "dart:convert"; import "dart:typed_data"; import "package:hive_flutter/hive_flutter.dart"; import "package:smol_mail/smol/crypto.dart"; import "package:smol_mail/smol/errors.dart"; import "package:smol_mail/smol/proto.dart"; const _stateBox = "smol"; const _mailBox = "mail"; const _stateKey = "state"; class StoredAccount { final String user; final String host; final int port; const StoredAccount(this.user, this.host, this.port); } /// A key this contact replaced, per §7/§8 — the only local record that a /// rotation happened, kept so the user can notice such changes. class ContactHistoryEntry { final Uint8List key; final int until; // epoch ms of the displacement const ContactHistoryEntry(this.key, this.until); } class StoredContact { final Uint8List key; final bool verified; final List history; const StoredContact(this.key, this.verified, [this.history = const []]); } class MailRecord { final String id; // hex of the 16-byte message id final Uint8List envelope; final int? receivedAt; final String? recipient; // sent copies only final int? sentAt; const MailRecord(this.id, this.envelope, {this.receivedAt, this.recipient, this.sentAt}); } class ImportSummary { int pinsAdded = 0, pinsConflicted = 0, contactsAdded = 0, contactsConflicted = 0, mailAdded = 0, malformed = 0; @override String toString() => "$mailAdded messages, $contactsAdded contacts ($contactsConflicted conflicted), " "$pinsAdded server keys ($pinsConflicted conflicted), $malformed malformed"; } class SmolStore { final Box _state; final Box _mail; SmolStore(this._state, this._mail); /// [stateBox]/[mailBox] exist so tests can hold several isolated stores /// in one process; production always uses the defaults. static Future open( {String stateBox = _stateBox, String mailBox = _mailBox}) async { final state = await Hive.openBox(stateBox); final mail = await Hive.openBox(mailBox); return SmolStore(state, mail); } Map _load() { final blob = _state.get(_stateKey); return blob is Map ? blob : {}; } void _update(Map Function(Map state) fn) { final next = fn(_load()); _state.put(_stateKey, next); } // --- identity -------------------------------------------------------------- Uint8List? seed() { final raw = _load()["seed"]; return raw == null ? null : unhex(raw as String); } /// The active identity, or null before the user creates or restores one. SmolIdentity? identity() { final s = seed(); return s == null ? null : identityFromSeed(s); } void setIdentity(Uint8List newSeed) { if (seed() != null) { throw const SmolIdentityExistsException(); } _update((state) => state..["seed"] = hex(newSeed)); } // Rotation (§7): the old seed is retained, since mail sealed to a // superseded key is readable with nothing else. void rotateIdentity(Uint8List newSeed) { final old = seed(); if (old == null) throw const SmolNoIdentityException(); _update((state) { final retired = (state["retired"] as List? ?? []) ..add({"seed": hex(old), "at": DateTime.now().millisecondsSinceEpoch}); state["retired"] = retired; state["seed"] = hex(newSeed); return state; }); } /// §7: seeds rotated away from are retained, since mail sealed to a /// superseded key is readable with nothing else. List identities() { final s = seed(); if (s == null) return const []; final retired = (_load()["retired"] as List? ?? const []) .whereType() .map((entry) => identityFromSeed(unhex(entry["seed"] as String))); return [identityFromSeed(s), ...retired]; } // --- account and server pins ------------------------------------------------- StoredAccount? account() { final a = _load()["account"]; if (a is! Map) return null; return StoredAccount( a["user"] as String, a["host"] as String, a["port"] as int); } void setAccount(SmolAddress address) { _update((state) => state ..["account"] = { "user": address.user, "host": address.host, "port": address.port, }); } Uint8List? serverPin(String host) { final raw = ((_load()["servers"] as Map?) ?? {})[host]; return raw == null ? null : b32decode(raw as String); } void pinServer(String host, Uint8List key) { _update((state) { final servers = (state["servers"] as Map? ?? {}).cast(); servers[host] = b32encode(key); state["servers"] = servers; return state; }); } void unpinServer(String host) { _update((state) { (state["servers"] as Map?)?.remove(host); return state; }); } List<(String, Uint8List)> allPins() { final servers = ((_load()["servers"] as Map?) ?? {}).cast(); return [for (final e in servers.entries) (e.key, b32decode(e.value))]; } // --- contacts ------------------------------------------------------------------ StoredContact? contact(String address) { final c = ((_load()["contacts"] as Map?) ?? {})[address]; if (c is! Map) return null; final history = ((c["history"] as List?) ?? const []) .whereType() .map((e) => ContactHistoryEntry( b32decode(e["key"] as String), e["until"] as int)) .toList(); return StoredContact(b32decode(c["key"] as String), c["verified"] as bool, history); } // A key that displaces another is kept in the history (§8): it is the // only local record that the contact rotated. Re-saving the same key is // not a rotation and must not add an entry. void saveContact(String address, Uint8List key, bool verified) { _update((state) { final contacts = (state["contacts"] as Map? ?? {}).cast(); final wanted = b32encode(key); final previous = contacts[address]; final history = ((previous?["history"] as List?) ?? const []) .whereType() .toList(); if (previous != null && previous["key"] != wanted) { history.add({ "key": previous["key"], "until": DateTime.now().millisecondsSinceEpoch, }); } contacts[address] = { "key": wanted, "verified": verified, "seenAt": DateTime.now().millisecondsSinceEpoch, if (history.isNotEmpty) "history": history, }; state["contacts"] = contacts; return state; }); } String? addressForKey(Uint8List key) { final contacts = ((_load()["contacts"] as Map?) ?? {}).cast(); final wanted = b32encode(key); for (final entry in contacts.entries) { if (entry.value["key"] == wanted) return entry.key; } return null; } List<(String, StoredContact)> allContacts() { final contacts = ((_load()["contacts"] as Map?) ?? {}).cast(); return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)]; } // --- read markers --------------------------------------------------------------- void markRead(String idHex) { _update((state) { final read = (state["read"] as Map? ?? {}).cast(); read[idHex] = true; state["read"] = read; return state; }); } bool isRead(String idHex) => ((_load()["read"] as Map?) ?? {})[idHex] == true; // --- sealed mail ------------------------------------------------------------ Map _recordToMap(MailRecord record) => { "id": record.id, "envelope": record.envelope, "receivedAt": record.receivedAt, "recipient": record.recipient, "sentAt": record.sentAt, }; MailRecord _mapToRecord(Map map) => MailRecord( map["id"] as String, (map["envelope"] as Uint8List), receivedAt: map["receivedAt"] as int?, recipient: map["recipient"] as String?, sentAt: map["sentAt"] as int?, ); static String mailKey(String folder, String id) => "$folder/$id"; Future storeMessage(String folder, MailRecord record) => _mail.put(mailKey(folder, record.id), _recordToMap(record)); /// Returns null when the id already exists, so fetch can leave server /// state alone. Future storeIfNew(String folder, MailRecord record) async { if (_mail.containsKey(mailKey(folder, record.id))) return null; await storeMessage(folder, record); return record; } List listMessages(String folder) { final prefix = "$folder/"; final rows = []; for (final key in _mail.keys.cast()) { if (!key.startsWith(prefix)) continue; final row = _mail.get(key); if (row is Map) rows.add(_mapToRecord(row)); } rows.sort((a, b) => (b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0)); return rows; } MailRecord? getMessage(String folder, String id) { final row = _mail.get(mailKey(folder, id)); return row is Map ? _mapToRecord(row) : null; } Future deleteMessage(String folder, String id) => _mail.delete(mailKey(folder, id)); // --- export / import: mail, contacts, pins — never the seed -------------------- /// 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(); final payload = { "servers": ((state["servers"] as Map?) ?? {}).cast(), "contacts": { for (final entry in contacts.entries) entry.key: { "key": entry.value["key"], "verified": entry.value["verified"], if ((entry.value["history"] as List?)?.isNotEmpty == true) "history": entry.value["history"], } }, "inbox": [ for (final row in listMessages("inbox")) { "id": row.id, "receivedAt": row.receivedAt, "envelope": base64Encode(row.envelope), } ], "sent": [ for (final row in listMessages("sent")) { "id": row.id, "recipient": row.recipient, "sentAt": row.sentAt, "envelope": base64Encode(row.envelope), } ], }; 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 /// rule refreshContact()/saveReplyAddress() apply elsewhere. A malformed /// entry is skipped and counted, not fatal: one bad record cannot abort the /// rest of the import. Future importData(Map data) async { 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 = 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) { summary.malformed++; continue; } if (!servers.containsKey(host)) { servers[host] = key; summary.pinsAdded++; } else if (servers[host] != key) { summary.pinsConflicted++; } } state["servers"] = servers; final contacts = (state["contacts"] as Map? ?? {}).cast(); 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 || contact is! Map || contact["key"] is! String || _pinKeyOk(contact["key"] as String) != true) { summary.malformed++; continue; } if (!contacts.containsKey(address)) { contacts[address] = { "key": contact["key"], "verified": contact["verified"] == true, "seenAt": DateTime.now().millisecondsSinceEpoch, if (contact["history"] is List && (contact["history"] as List).isNotEmpty) "history": contact["history"], }; summary.contactsAdded++; } else if (contacts[address]!["key"] != contact["key"]) { summary.contactsConflicted++; } } state["contacts"] = contacts; return state; }); for (final folder in ["inbox", "sent"]) { 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; final record = MailRecord( map["id"] as String, base64Decode(map["envelope"] as String), receivedAt: folder == "inbox" ? map["receivedAt"] as int? : null, recipient: folder == "sent" ? map["recipient"] as String? : null, sentAt: folder == "sent" ? map["sentAt"] as int? : null, ); if (await storeIfNew(folder, record) != null) summary.mailAdded++; } on Exception { summary.malformed++; } on TypeError { summary.malformed++; } } } return summary; } // A pin key must decode to exactly 32 bytes of base32. bool _pinKeyOk(String key) { try { return b32decode(key).length == keyLen; } on SmolError { return false; } } /// Remove every secret and every stored envelope; the UI must confirm first. Future wipe() async { await _state.delete(_stateKey); await _mail.clear(); } int unreadCount() { var count = 0; for (final row in listMessages("inbox")) { if (!isRead(row.id)) count++; } return count; } } /// The store throws these typed errors so the UI can tell "no identity yet" /// and "an identity already exists" apart without string matching. class SmolIdentityExistsException implements Exception { const SmolIdentityExistsException(); @override String toString() => "an identity already exists; rotate it instead"; } class SmolNoIdentityException implements Exception { const SmolNoIdentityException(); @override String toString() => "no identity to rotate"; }