fix: read back sent copies after the sec 5.6 upstream fix, pinned at 2d65d66
This commit is contained in:
parent
52947e153d
commit
d6f4c434fa
36 changed files with 4223 additions and 3118 deletions
|
|
@ -1,74 +1,46 @@
|
|||
// 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).
|
||||
// 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_flutter/hive_flutter.dart";
|
||||
import "package:hive/hive.dart";
|
||||
|
||||
import "package:smol_mail/native/client.dart";
|
||||
import "package:smol_mail/native/ffi.dart";
|
||||
|
||||
import "package:smol_mail/smol/crypto.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/proto.dart";
|
||||
import "package:smol_mail/smol/ui.dart";
|
||||
|
||||
const _stateBox = "smol";
|
||||
const _mailBox = "mail";
|
||||
const _stateKey = "state";
|
||||
const tierMain = 0, tierRequests = 1;
|
||||
|
||||
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.
|
||||
/// 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 Uint8List key;
|
||||
final int until; // epoch ms of the displacement
|
||||
final String key;
|
||||
final int until;
|
||||
|
||||
const ContactHistoryEntry(this.key, this.until);
|
||||
}
|
||||
|
||||
class StoredContact {
|
||||
final Uint8List key;
|
||||
final String key;
|
||||
final bool verified;
|
||||
final List<ContactHistoryEntry> history;
|
||||
|
||||
const StoredContact(this.key, this.verified, [this.history = const []]);
|
||||
const StoredContact(this.key, this.verified, this.history);
|
||||
}
|
||||
|
||||
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.
|
||||
/// 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 Uint8List identity;
|
||||
final String identity;
|
||||
final bool active;
|
||||
|
||||
const AcceptedContact(this.identity, this.active);
|
||||
|
|
@ -84,584 +56,303 @@ class ImportSummary {
|
|||
"$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 _state;
|
||||
final Box _mail;
|
||||
final Box _meta;
|
||||
final Box _read;
|
||||
final FumiNative _native;
|
||||
|
||||
SmolStore(this._state, this._mail);
|
||||
SmolStore._(this._meta, this._read, this._native);
|
||||
|
||||
/// [stateBox]/[mailBox] exist so tests can hold several isolated stores
|
||||
/// in one process; production always uses the defaults.
|
||||
/// 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(
|
||||
{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 (§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");
|
||||
{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!));
|
||||
}
|
||||
_update((state) => state..["rotations"] = current + 1);
|
||||
return store;
|
||||
}
|
||||
|
||||
/// §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<SmolIdentity> identities() {
|
||||
final m = master();
|
||||
if (m == null) return const [];
|
||||
return [for (var n = rotations(); n >= 0; n--) identityFromSeed(identitySeed(m, n))];
|
||||
/// 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!));
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
/// 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;
|
||||
}
|
||||
|
||||
void setAccount(SmolAddress address) {
|
||||
_update((state) => state
|
||||
..["account"] = {
|
||||
"user": address.user,
|
||||
"host": address.host,
|
||||
"port": address.port,
|
||||
});
|
||||
/// 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.
|
||||
unawaited(_meta.put("master", fresh));
|
||||
_native.setMaster(fresh, rotations: 0);
|
||||
}
|
||||
|
||||
Uint8List? serverPin(String host) {
|
||||
final raw = ((_load()["servers"] as Map?) ?? {})[host];
|
||||
return raw == null ? null : b32decode(raw as String);
|
||||
/// The master restored from a backup, already at the rotation index the
|
||||
/// server bound.
|
||||
void restoreMaster(Uint8List master, int index) {
|
||||
unawaited(_meta.put("master", master));
|
||||
_native.setMaster(master, rotations: index);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
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));
|
||||
}
|
||||
|
||||
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))];
|
||||
}
|
||||
|
||||
// --- 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>()
|
||||
.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;
|
||||
/// 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 contacts = ((_load()["contacts"] as Map?) ?? {}).cast<String, Map>();
|
||||
return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)];
|
||||
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),
|
||||
],
|
||||
)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// --- accept tokens (§5.8) --------------------------------------------------------
|
||||
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 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<String, Map>();
|
||||
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<String, Map>();
|
||||
if (!accepted.containsKey(address)) {
|
||||
throw SmolError("$address was never accepted");
|
||||
}
|
||||
_update((state) {
|
||||
final accepted = (state["accepted"] as Map? ?? {}).cast<String, Map>();
|
||||
accepted[address] = {...accepted[address]!, "active": false};
|
||||
state["accepted"] = accepted;
|
||||
return state;
|
||||
});
|
||||
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 accepted = ((_load()["accepted"] as Map?) ?? {}).cast<String, Map>();
|
||||
return [for (final e in accepted.entries) (e.key, this.accepted(e.key)!)];
|
||||
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)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// §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<Uint8List>) 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)]);
|
||||
}
|
||||
/// 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);
|
||||
|
||||
/// 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<String, Map>();
|
||||
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<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,
|
||||
"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<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 physical = _physicalFolder(folder);
|
||||
final prefix = "$physical/";
|
||||
final wantTier = folder == "requests" ? tierRequests : tierMain;
|
||||
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) continue;
|
||||
final record = _mapToRecord(row);
|
||||
if (physical == "inbox" && record.tier != wantTier) continue;
|
||||
rows.add(record);
|
||||
/// 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;
|
||||
}
|
||||
rows.sort((a, b) =>
|
||||
(b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0));
|
||||
return rows;
|
||||
return null;
|
||||
}
|
||||
|
||||
MailRecord? getMessage(String folder, String id) {
|
||||
final row = _mail.get(mailKey(_physicalFolder(folder), id));
|
||||
return row is Map ? _mapToRecord(row) : 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> deleteMessage(String folder, String id) =>
|
||||
_mail.delete(mailKey(_physicalFolder(folder), id));
|
||||
Future<void> unpinServer(String host) async => _native.unpinServer(host);
|
||||
|
||||
// --- export / import: mail, contacts, pins — never the seed --------------------
|
||||
// --- backups ----------------------------------------------------------------
|
||||
|
||||
/// 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);
|
||||
/// The gsmol backup container: sealed mail, contacts and pins — never the
|
||||
/// master — as one JSON file body.
|
||||
Future<String> exportData() => _native.exportBackup();
|
||||
|
||||
/// 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<String, dynamic> exportData() {
|
||||
final master = this.master();
|
||||
if (master == null) throw const SmolError("no identity yet");
|
||||
final state = _load();
|
||||
final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>();
|
||||
final payload = {
|
||||
"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"), ...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<ImportSummary> 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 {
|
||||
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 summary = ImportSummary();
|
||||
|
||||
_update((state) {
|
||||
final servers = (state["servers"] as Map? ?? {}).cast<String, String>();
|
||||
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<String, Map>();
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Map<String, dynamic> _decodeSummary(String text) =>
|
||||
jsonDecode(text) as Map<String, dynamic>;
|
||||
|
||||
/// Remove every secret and every stored envelope; the UI must confirm first.
|
||||
// --- 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 {
|
||||
await _state.delete(_stateKey);
|
||||
await _mail.clear();
|
||||
}
|
||||
|
||||
int unreadCount() {
|
||||
var count = 0;
|
||||
for (final row in listMessages("inbox")) {
|
||||
if (!isRead(row.id)) count++;
|
||||
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.
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int requestsUnreadCount() {
|
||||
var count = 0;
|
||||
for (final row in listMessages("requests")) {
|
||||
if (!isRead(row.id)) count++;
|
||||
}
|
||||
return count;
|
||||
await _native.open();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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