feat: replace mail protocol with smolmail and rebuild the UI
This commit is contained in:
parent
cb24b68069
commit
e94e4158cc
93 changed files with 6387 additions and 3479 deletions
463
lib/smol/store.dart
Normal file
463
lib/smol/store.dart
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
// 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<ContactHistoryEntry> 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<SmolStore> 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 : <String, dynamic>{};
|
||||
}
|
||||
|
||||
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<SmolIdentity> identities() {
|
||||
final s = seed();
|
||||
if (s == null) return const [];
|
||||
final retired = (_load()["retired"] as List? ?? const [])
|
||||
.whereType<Map>()
|
||||
.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<String, String>();
|
||||
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<String, String>();
|
||||
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>()
|
||||
.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<String, Map>();
|
||||
final wanted = b32encode(key);
|
||||
final previous = contacts[address];
|
||||
final history = ((previous?["history"] as List?) ?? const [])
|
||||
.whereType<Map>()
|
||||
.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<String, Map>();
|
||||
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<String, Map>();
|
||||
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<String, bool>();
|
||||
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<void> 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<MailRecord?> storeIfNew(String folder, MailRecord record) async {
|
||||
if (_mail.containsKey(mailKey(folder, record.id))) return null;
|
||||
await storeMessage(folder, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
List<MailRecord> listMessages(String folder) {
|
||||
final prefix = "$folder/";
|
||||
final rows = <MailRecord>[];
|
||||
for (final key in _mail.keys.cast<String>()) {
|
||||
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<void> deleteMessage(String folder, String id) =>
|
||||
_mail.delete(mailKey(folder, id));
|
||||
|
||||
// --- 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.
|
||||
Map<String, dynamic> exportData() {
|
||||
final state = _load();
|
||||
final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>();
|
||||
return {
|
||||
"gsmolExport": 1,
|
||||
"exportedAt": DateTime.now().millisecondsSinceEpoch,
|
||||
"servers": ((state["servers"] as Map?) ?? {}).cast<String, String>(),
|
||||
"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),
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/// 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<ImportSummary> importData(Map data) async {
|
||||
if (data["gsmolExport"] != 1) {
|
||||
throw const SmolError("not a SmolMail 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++;
|
||||
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<String, Map>();
|
||||
final incoming = data["contacts"] is Map ? data["contacts"] as Map : null;
|
||||
if (data["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 = data[folder] is List ? data[folder] as List : null;
|
||||
if (data[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<void> 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";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue