// 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 master secret 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 32-byte message id final Uint8List envelope; final int? receivedAt; final String? recipient; // sent copies only final int? sentAt; final int tier; // §5.8: tierMain or tierRequests; meaningless for sent copies /// Whether "leave mail on server" was on when this was fetched, so a /// manual delete still has a server-side copy to remove. Always false for /// sent copies, which never had one (§5.6). final bool keptOnServer; const MailRecord(this.id, this.envelope, {this.receivedAt, this.recipient, this.sentAt, this.tier = tierMain, this.keptOnServer = false}); } const tierMain = 0, tierRequests = 1; /// A correspondent admitted to this mailbox's main tier (§5.8). The identity /// is frozen at acceptance because the token is derived from it: a contact's /// later rotation must not change the token they already hold. class AcceptedContact { final Uint8List identity; final bool active; const AcceptedContact(this.identity, this.active); } 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 (§2) ----------------------------------------------------------- /// The 32-byte master secret, or null before the user creates or restores /// one. Every signing key is derived from it plus the rotation index. Uint8List? master() { final raw = _load()["master"]; return raw == null ? null : unhex(raw as String); } /// The rotation index (§7) of the identity currently in use. int rotations() => (_load()["rotations"] as int?) ?? 0; /// The active identity, or null before the user creates or restores one. SmolIdentity? identity() { final m = master(); return m == null ? null : identityFromSeed(identitySeed(m, rotations())); } /// Whether the accepted-correspondent set held here may replace the /// server's on the next AUTH — false right after a restore from the master /// alone, whose empty set must not erase the server's (§4). bool syncOk() => (_load()["syncOk"] as bool?) ?? true; void setSyncOk(bool ok) => _update((state) => state..["syncOk"] = ok); void _bindMaster(Uint8List newMaster, int rotations, bool syncOk) { if (master() != null) throw const SmolIdentityExistsException(); _update((state) => state ..["master"] = hex(newMaster) ..["rotations"] = rotations ..["syncOk"] = syncOk); setCursor(0, Uint8List(idLen)); } /// A fresh identity: rotation index 0, and an empty accepted set is /// already complete, so it may sync. void setMaster(Uint8List newMaster) => _bindMaster(newMaster, 0, true); /// §2: recovering a master alone does not recover which correspondents were /// accepted, so that set must not overwrite the server's until rebuilt. void restoreMaster(Uint8List newMaster, int rotationIndex) => _bindMaster(newMaster, rotationIndex, false); // Rotation (§7): only the index advances; the superseded key stays // derivable from the master, so nothing has to be archived. void advanceRotation() { final current = rotations(); if (master() == null) throw const SmolNoIdentityException(); if (current >= maxChain) { throw SmolError("the rotation chain is full at $maxChain links"); } _update((state) => state..["rotations"] = current + 1); } /// §7: every key rotated away from is re-derivable from the master, since /// mail sealed to a superseded key is readable with nothing else. List identities() { final m = master(); if (m == null) return const []; return [for (var n = rotations(); n >= 0; n--) identityFromSeed(identitySeed(m, n))]; } // --- 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))]; } // --- FETCH behavior -------------------------------------------------------- /// When true, FETCH does not acknowledge (delete) what it retrieves — /// mail stays on the server until explicitly deleted. Defaults to the /// original behavior: fetched mail is acknowledged immediately. bool leaveOnServer() => (_load()["leaveOnServer"] as bool?) ?? false; void setLeaveOnServer(bool value) => _update((state) => state..["leaveOnServer"] = value); // --- FETCH cursor (§6.1) ------------------------------------------------------- (int, Uint8List) cursor() { final state = _load(); final afterId = state["afterId"] as String?; return ( (state["afterTime"] as int?) ?? 0, afterId == null ? Uint8List(idLen) : unhex(afterId), ); } void setCursor(int afterTime, Uint8List afterId) => _update((state) => state ..["afterTime"] = afterTime ..["afterId"] = hex(afterId)); // --- 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)!)]; } // --- accept tokens (§5.8) -------------------------------------------------------- AcceptedContact? accepted(String address) { final a = ((_load()["accepted"] as Map?) ?? {})[address]; if (a is! Map) return null; return AcceptedContact(b32decode(a["identity"] as String), a["active"] as bool); } /// Admit a contact to the main tier. The identity is frozen at acceptance — /// re-accepting after a block must not change which key the token is /// derived from (§5.8). void accept(String address, Uint8List identity) { _update((state) { final accepted = (state["accepted"] as Map? ?? {}).cast(); final previous = accepted[address]; accepted[address] = { "identity": previous?["identity"] ?? b32encode(identity), "active": true, "addedAt": previous?["addedAt"] ?? DateTime.now().millisecondsSinceEpoch, }; state["accepted"] = accepted; return state; }); } /// Withdraw a contact's accept token; their mail lands in the requests tier /// from their next message on. Throws if the contact was never accepted. void block(String address) { final accepted = (_load()["accepted"] as Map? ?? {}).cast(); if (!accepted.containsKey(address)) { throw SmolError("$address was never accepted"); } _update((state) { final accepted = (state["accepted"] as Map? ?? {}).cast(); accepted[address] = {...accepted[address]!, "active": false}; state["accepted"] = accepted; return state; }); } List<(String, AcceptedContact)> allAccepted() { final accepted = ((_load()["accepted"] as Map?) ?? {}).cast(); return [for (final e in accepted.entries) (e.key, this.accepted(e.key)!)]; } /// §4: the tokens to push with AUTH, and whether to push at all. A client /// that cannot vouch for its own set — one restored from the master alone — /// must not replace the server's with an incomplete one. (int, List) tokenSet(Uint8List master) { if (!syncOk()) return (0, const []); final active = allAccepted().where((e) => e.$2.active).toList() ..sort((a, b) => a.$1.compareTo(b.$1)); return (1, [for (final e in active) tokenFor(master, e.$2.identity)]); } /// A token received from a correspondent, filed under the address that /// issued it: an address outlives the keys behind it, so the token keeps /// working across the issuer's rotations (§5.8). Uint8List? tokenFrom(String address) { final raw = ((_load()["tokens"] as Map?) ?? {})[address]; if (raw is! Map) return null; return b32decode(raw["token"] as String); } void learnToken(String address, Uint8List token) { _update((state) { final tokens = (state["tokens"] as Map? ?? {}).cast(); tokens[address] = { "token": b32encode(token), "seenAt": DateTime.now().millisecondsSinceEpoch, }; state["tokens"] = tokens; return state; }); } // --- 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, "tier": record.tier, "keptOnServer": record.keptOnServer, }; 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?, tier: (map["tier"] as int?) ?? tierMain, keptOnServer: (map["keptOnServer"] as bool?) ?? false, ); static String mailKey(String folder, String id) => "$folder/$id"; // "requests" is a view over the same physical "inbox" records, filtered by // tier (§5.8) — not a separate folder, so a message keeps one identity // regardless of which tier it arrived in. static String _physicalFolder(String folder) => folder == "requests" ? "inbox" : folder; 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 physical = _physicalFolder(folder); final prefix = "$physical/"; final wantTier = folder == "requests" ? tierRequests : tierMain; final rows = []; for (final key in _mail.keys.cast()) { if (!key.startsWith(prefix)) continue; final row = _mail.get(key); if (row is! Map) continue; final record = _mapToRecord(row); if (physical == "inbox" && record.tier != wantTier) continue; rows.add(record); } 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(_physicalFolder(folder), id)); return row is Map ? _mapToRecord(row) : null; } Future deleteMessage(String folder, String id) => _mail.delete(mailKey(_physicalFolder(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 master) => hkdfSha256(master, 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 master, /// so a backup file is only readable by whoever holds that master. /// Deliberately excludes the master itself: it has its own reveal-and-copy /// flow in settings, meant for a password manager, not a shareable file. Map exportData() { final master = this.master(); if (master == 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"), ...listMessages("requests")]) { "id": row.id, "receivedAt": row.receivedAt, "envelope": base64Encode(row.envelope), "tier": row.tier, "keptOnServer": row.keptOnServer, } ], "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(master), 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 master = this.master(); if (master == null) { throw const SmolError("no identity yet — restore it before importing"); } try { final plaintext = aeadDecrypt( _exportKey(master), 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, tier: (map["tier"] as int?) ?? tierMain, keptOnServer: (map["keptOnServer"] as bool?) ?? false, ); 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; } int requestsUnreadCount() { var count = 0; for (final row in listMessages("requests")) { 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"; }