feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention

This commit is contained in:
randogoth 2026-09-27 11:07:11 +03:00
parent eaaa3f2ede
commit c693e6fcb9
27 changed files with 1190 additions and 592 deletions

View file

@ -1,7 +1,7 @@
// 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).
// 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";
@ -42,14 +42,36 @@ class StoredContact {
}
class MailRecord {
final String id; // hex of the 16-byte message id
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.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 {
@ -87,49 +109,66 @@ class SmolStore {
_state.put(_stateKey, next);
}
// --- identity --------------------------------------------------------------
// --- identity (§2) -----------------------------------------------------------
Uint8List? seed() {
final raw = _load()["seed"];
/// 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 s = seed();
return s == null ? null : identityFromSeed(s);
final m = master();
return m == null ? null : identityFromSeed(identitySeed(m, rotations()));
}
void setIdentity(Uint8List newSeed) {
if (seed() != null) {
throw const SmolIdentityExistsException();
/// 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..["seed"] = hex(newSeed));
_update((state) => state..["rotations"] = current + 1);
}
// 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.
/// §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 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];
final m = master();
if (m == null) return const [];
return [for (var n = rotations(); n >= 0; n--) identityFromSeed(identitySeed(m, n))];
}
// --- account and server pins -------------------------------------------------
@ -176,6 +215,31 @@ class SmolStore {
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) {
@ -233,6 +297,82 @@ class SmolStore {
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<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;
});
}
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)!)];
}
/// §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)]);
}
/// 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) {
@ -255,6 +395,8 @@ class SmolStore {
"receivedAt": record.receivedAt,
"recipient": record.recipient,
"sentAt": record.sentAt,
"tier": record.tier,
"keptOnServer": record.keptOnServer,
};
MailRecord _mapToRecord(Map map) => MailRecord(
@ -263,10 +405,18 @@ class SmolStore {
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));
@ -279,12 +429,17 @@ class SmolStore {
}
List<MailRecord> listMessages(String folder) {
final prefix = "$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) rows.add(_mapToRecord(row));
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));
@ -292,29 +447,29 @@ class SmolStore {
}
MailRecord? getMessage(String folder, String id) {
final row = _mail.get(mailKey(folder, id));
final row = _mail.get(mailKey(_physicalFolder(folder), id));
return row is Map ? _mapToRecord(row) : null;
}
Future<void> deleteMessage(String folder, String id) =>
_mail.delete(mailKey(folder, 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 seed) =>
hkdfSha256(seed, Uint8List(0), _exportLabel, 32);
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 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.
/// 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 seed = this.seed();
if (seed == null) throw const SmolError("no identity yet");
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 = {
@ -329,11 +484,13 @@ class SmolStore {
}
},
"inbox": [
for (final row in listMessages("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": [
@ -348,7 +505,7 @@ class SmolStore {
};
final nonce = randomBytes(12);
final ciphertext = aeadEncrypt(
_exportKey(seed), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0));
_exportKey(master), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0));
return {
"gsmolExport": 2,
"exportedAt": DateTime.now().millisecondsSinceEpoch,
@ -364,13 +521,13 @@ class SmolStore {
Future<ImportSummary> importData(Map data) async {
Map payload;
if (data["gsmolExport"] == 2) {
final seed = this.seed();
if (seed == null) {
final master = this.master();
if (master == null) {
throw const SmolError("no identity yet — restore it before importing");
}
try {
final plaintext = aeadDecrypt(
_exportKey(seed),
_exportKey(master),
base64Decode(data["nonce"] as String),
base64Decode(data["ciphertext"] as String),
Uint8List(0),
@ -447,6 +604,8 @@ class SmolStore {
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 {
@ -481,6 +640,14 @@ class SmolStore {
}
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"