361 lines
12 KiB
Dart
361 lines
12 KiB
Dart
// Local state, split by owner: fumi's SQLite store owns everything the
|
|
// protocol defines (mail, contacts, pins, accepted, tokens, seen ids), and
|
|
// this Hive layer owns only what the app owns — the master secret, read
|
|
// marks and UI settings. Reads are synchronous FFI calls (a query plus one
|
|
// JSON parse, microseconds); network operations stay on the client, where
|
|
// they run through isolates.
|
|
|
|
import "dart:async";
|
|
import "dart:convert";
|
|
import "dart:io";
|
|
import "dart:typed_data";
|
|
|
|
import "package:hive/hive.dart";
|
|
|
|
import "package:smol_mail/native/client.dart";
|
|
import "package:smol_mail/native/ffi.dart";
|
|
|
|
import "package:smol_mail/smol/errors.dart";
|
|
import "package:smol_mail/smol/ui.dart";
|
|
|
|
const tierMain = 0, tierRequests = 1;
|
|
|
|
/// A displaced key a contact no longer uses, with when it stopped being
|
|
/// current (epoch ms) — the record §8 turns on the user being able to see.
|
|
class ContactHistoryEntry {
|
|
final String key;
|
|
final int until;
|
|
|
|
const ContactHistoryEntry(this.key, this.until);
|
|
}
|
|
|
|
class StoredContact {
|
|
final String key;
|
|
final bool verified;
|
|
final List<ContactHistoryEntry> history;
|
|
|
|
const StoredContact(this.key, this.verified, this.history);
|
|
}
|
|
|
|
/// A correspondent admitted to the mailbox's main tier (§5.8). The identity
|
|
/// is frozen at acceptance because the token is derived from it.
|
|
class AcceptedContact {
|
|
final String 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 MailRecord {
|
|
final String id; // hex of the 32-byte message id
|
|
final String envelope; // sealed, base64 — plaintext is never at rest
|
|
final int? receivedAt;
|
|
final String? recipient; // sent copies only
|
|
final int? sentAt;
|
|
final int tier; // §5.8: tierMain or tierRequests; meaningless for sent
|
|
final bool keptOnServer;
|
|
|
|
const MailRecord(this.id, this.envelope,
|
|
{this.receivedAt,
|
|
this.recipient,
|
|
this.sentAt,
|
|
this.tier = tierMain,
|
|
this.keptOnServer = false});
|
|
}
|
|
|
|
/// The public half of the identity; the master never leaves the store
|
|
/// except through the reveal-and-copy flow in settings.
|
|
class SmolIdentity {
|
|
final String publicKey; // base32
|
|
|
|
const SmolIdentity(this.publicKey);
|
|
}
|
|
|
|
class SmolStore {
|
|
final Box _meta;
|
|
final Box _read;
|
|
final FumiNative _native;
|
|
|
|
SmolStore._(this._meta, this._read, this._native);
|
|
|
|
/// The SQLite file fumi's store owns. One store per process.
|
|
late final String dbPath = _native.dbPath;
|
|
|
|
/// Opens both layers. [dbPath] is the SQLite file fumi's store owns; the
|
|
/// box names exist so tests can hold several isolated stores in one
|
|
/// process.
|
|
static Future<SmolStore> open(
|
|
{required String dbPath,
|
|
String stateBox = "smol-state",
|
|
String readBox = "smol-read"}) async {
|
|
final native = FumiNative(dbPath);
|
|
await native.open();
|
|
final store = SmolStore._(
|
|
await Hive.openBox(stateBox), await Hive.openBox(readBox), native);
|
|
final master = store.master();
|
|
if (master != null) {
|
|
// The rotation index sits in the native store; read it through the
|
|
// synchronous ABI, not the async wrapper.
|
|
native.setMaster(master,
|
|
rotations: SmolFfi.open().rotations(native.store!));
|
|
}
|
|
return store;
|
|
}
|
|
|
|
/// The native binding this store fronts; the client drives its network
|
|
/// operations, the store its reads.
|
|
FumiNative get native => _native;
|
|
|
|
SmolFfi get _ffi => SmolFfi.open();
|
|
|
|
// --- identity ---------------------------------------------------------------
|
|
|
|
SmolIdentity? identity() {
|
|
if (master() == null) return null;
|
|
return SmolIdentity(_ffi.accountPk(_native.account!));
|
|
}
|
|
|
|
/// The master, as the one mutable buffer that owns it — wipe overwrites
|
|
/// these bytes rather than leaving them to the garbage collector, which
|
|
/// is the honest version of zeroization Dart allows. Hive keeps the
|
|
/// durable copy as bytes too; a hex string could never be scrubbed.
|
|
Uint8List? master() {
|
|
final stored = _meta.get("master");
|
|
if (stored == null) return null;
|
|
if (stored is Uint8List) return stored;
|
|
// The pre-swap app stored hex; convert once. Two alpha testers, so this
|
|
// shim retires when their stores have moved.
|
|
final bytes = unhex(stored as String);
|
|
_meta.put("master", bytes);
|
|
return bytes;
|
|
}
|
|
|
|
/// A fresh identity: the master at rotation index 0. Only this local step;
|
|
/// nothing is sent until registration.
|
|
void setMaster(Uint8List fresh) {
|
|
// Hive's in-memory state updates synchronously and persists in the
|
|
// background, so the store is consistent without awaiting the write.
|
|
// The put takes its own copy: the caller's buffer is the caller's to
|
|
// zeroize (the onboarding screen does, on dispose), and a shared object
|
|
// would scrub the store's view with it.
|
|
unawaited(_meta.put("master", Uint8List.fromList(fresh)));
|
|
_native.setMaster(fresh, rotations: 0);
|
|
}
|
|
|
|
/// The master restored from a backup, already at the rotation index the
|
|
/// server bound.
|
|
void restoreMaster(Uint8List master, int index) {
|
|
unawaited(_meta.put("master", Uint8List.fromList(master)));
|
|
_native.setMaster(master, rotations: index);
|
|
}
|
|
|
|
int rotations() => _ffi.rotations(_native.store!);
|
|
|
|
// --- settings ---------------------------------------------------------------
|
|
|
|
bool leaveOnServer() => _meta.get("leaveOnServer") == true;
|
|
|
|
Future<void> setLeaveOnServer(bool value) async =>
|
|
_meta.put("leaveOnServer", value);
|
|
|
|
bool syncOk() => _ffi.syncOk(_native.store!);
|
|
|
|
// --- mail -------------------------------------------------------------------
|
|
|
|
List<MailRecord> listMessages(String folder) {
|
|
final rows = _ffi.mail(_native.store!, folder);
|
|
return [
|
|
for (final row in rows.cast<Map<String, dynamic>>())
|
|
MailRecord(
|
|
row["id"] as String,
|
|
row["envelope"] as String,
|
|
receivedAt: folder == "sent" ? null : row["at"] as int,
|
|
recipient: row["recipient"] as String?,
|
|
sentAt: folder == "sent" ? row["at"] as int : null,
|
|
tier: (row["tier"] as int?) ?? tierMain,
|
|
keptOnServer: row["kept"] as bool? ?? false,
|
|
),
|
|
]..sort((a, b) => (b.receivedAt ?? b.sentAt ?? 0)
|
|
.compareTo(a.receivedAt ?? a.sentAt ?? 0));
|
|
}
|
|
|
|
/// One message by folder and id, for the reader screen's deep link.
|
|
MailRecord? getMessage(String folder, String id) {
|
|
for (final row in listMessages(folder)) {
|
|
if (row.id == id) return row;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
bool isRead(String id) => _read.get(id) == true;
|
|
|
|
void markRead(String id) => unawaited(_read.put(id, true));
|
|
|
|
int unreadCount() => _unread("inbox");
|
|
|
|
int requestsUnreadCount() => _unread("requests");
|
|
|
|
int _unread(String folder) {
|
|
final rows = _ffi.mail(_native.store!, folder);
|
|
return rows.cast<Map<String, dynamic>>()
|
|
.where((row) => _read.get(row["id"] as String) != true)
|
|
.length;
|
|
}
|
|
|
|
/// The reader's delete: local removal with the id marked seen (§10), so a
|
|
/// still-kept server copy is not re-stored by the next fetch.
|
|
Future<void> deleteMessage(String folder, String id) async =>
|
|
_native.deleteLocal(folder, [id]);
|
|
|
|
// --- contacts ---------------------------------------------------------------
|
|
|
|
List<(String, StoredContact)> allContacts() {
|
|
final rows = _ffi.contacts(_native.store!);
|
|
return [
|
|
for (final row in rows.cast<Map<String, dynamic>>())
|
|
(
|
|
row["address"] as String,
|
|
StoredContact(
|
|
row["key"] as String,
|
|
row["verified"] as bool,
|
|
[
|
|
for (final entry in (row["history"] as List).cast<Map<String, dynamic>>())
|
|
ContactHistoryEntry(
|
|
entry["key"] as String, entry["until"] as int),
|
|
],
|
|
)
|
|
),
|
|
];
|
|
}
|
|
|
|
StoredContact? contact(String address) {
|
|
final row = _ffi.contact(_native.store!, address);
|
|
if ((row["key"] as String).isEmpty) return null;
|
|
return StoredContact(
|
|
row["key"] as String,
|
|
row["verified"] as bool,
|
|
[
|
|
for (final entry in (row["history"] as List).cast<Map<String, dynamic>>())
|
|
ContactHistoryEntry(entry["key"] as String, entry["until"] as int),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// The acceptance state: main tier, blocked, or never accepted.
|
|
AcceptedContact? accepted(String address) {
|
|
final row = _ffi.contact(_native.store!, address);
|
|
final active = row["active"] as bool?;
|
|
final acceptedKey = row["acceptedKey"] as String?;
|
|
if (active == null || acceptedKey == null) return null;
|
|
return AcceptedContact(acceptedKey, active);
|
|
}
|
|
|
|
List<(String, AcceptedContact)> allAccepted() {
|
|
final rows = _ffi.contacts(_native.store!);
|
|
return [
|
|
for (final row in rows.cast<Map<String, dynamic>>())
|
|
if (row["active"] != null && row["acceptedKey"] != null)
|
|
(
|
|
row["address"] as String,
|
|
AcceptedContact(
|
|
row["acceptedKey"] as String, row["active"] as bool)
|
|
),
|
|
];
|
|
}
|
|
|
|
/// Binds an address to a key. A different key displaces the old one into
|
|
/// the contact's history (§8).
|
|
Future<void> saveContact(String address, String keyB32,
|
|
{required bool verified}) async =>
|
|
_native.saveContact(address, keyB32, verified: verified);
|
|
|
|
/// The address a key is known by, if any — naming a mailbox is not
|
|
/// trusting a key, so nothing is bound here.
|
|
String? addressForKey(String keyB32) {
|
|
for (final (address, contact) in allContacts()) {
|
|
if (contact.key == keyB32) return address;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- pins -------------------------------------------------------------------
|
|
|
|
/// Pins a server's static key (§4): sync, because it is one local write.
|
|
void pinServer(String host, String keyB32) =>
|
|
_ffi.pinServer(_native.store!, host, keyB32);
|
|
|
|
String? serverPin(String host) => _ffi.serverPin(_native.store!, host);
|
|
|
|
List<(String, String)> allPins() {
|
|
final rows = _ffi.pins(_native.store!);
|
|
return [
|
|
for (final row in rows.cast<Map<String, dynamic>>())
|
|
(row["host"] as String, row["key"] as String),
|
|
];
|
|
}
|
|
|
|
Future<void> unpinServer(String host) async => _native.unpinServer(host);
|
|
|
|
// --- backups ----------------------------------------------------------------
|
|
|
|
/// The gsmol backup container: sealed mail, contacts and pins — never the
|
|
/// master — as one JSON file body.
|
|
Future<String> exportData() => _native.exportBackup();
|
|
|
|
Future<ImportSummary> importData(String text) async {
|
|
final summary = await _native.importBackup(text);
|
|
final Map<String, dynamic> parsed;
|
|
try {
|
|
parsed = _decodeSummary(summary);
|
|
} on Exception {
|
|
throw const SmolError("not a gsmol export file");
|
|
}
|
|
final out = ImportSummary();
|
|
out.pinsAdded = parsed["pinsAdded"] as int;
|
|
out.pinsConflicted = parsed["pinsConflicted"] as int;
|
|
out.contactsAdded = parsed["contactsAdded"] as int;
|
|
out.contactsConflicted = parsed["contactsConflicted"] as int;
|
|
out.mailAdded = parsed["mailAdded"] as int;
|
|
out.malformed = parsed["malformed"] as int;
|
|
return out;
|
|
}
|
|
|
|
Map<String, dynamic> _decodeSummary(String text) =>
|
|
jsonDecode(text) as Map<String, dynamic>;
|
|
|
|
// --- teardown ---------------------------------------------------------------
|
|
|
|
/// A full wipe: every secret, envelope and mark. The UI must confirm.
|
|
/// The master's bytes are overwritten before their references go — Dart
|
|
/// cannot promise zeroed immutable strings, so the master is only ever
|
|
/// held as this one mutable buffer.
|
|
Future<void> wipe() async {
|
|
master()?.fillRange(0, 32, 0);
|
|
_native.clearMaster();
|
|
await _meta.delete("master");
|
|
await _read.clear();
|
|
await _meta.delete("master");
|
|
await _native.close();
|
|
try {
|
|
final db = File(dbPath);
|
|
if (await db.exists()) await db.delete();
|
|
for (final suffix in ["-wal", "-shm"]) {
|
|
final side = File("$dbPath$suffix");
|
|
if (await side.exists()) await side.delete();
|
|
}
|
|
} on FileSystemException {
|
|
// A wipe must not fail on files the store never created.
|
|
}
|
|
await _native.open();
|
|
}
|
|
}
|