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

@ -36,7 +36,7 @@ class OpenedRecord {
const OpenedRecord(this.id,
{this.sender, this.time, this.fields = const {}, this.body = "", this.error});
String get subject => error == null ? (fields["Subject"] ?? "") : "";
String get subject => error == null ? (fields["subject"] ?? "") : "";
}
class SmolClient {
@ -58,6 +58,8 @@ class SmolClient {
SmolIdentity? get identity => store.identity();
Uint8List? get master => store.master();
SmolAddress? accountAddress() {
final account = store.account();
if (account == null) return null;
@ -91,25 +93,51 @@ class SmolClient {
// --- identity setup ------------------------------------------------------------
SmolIdentity createIdentity() {
final fresh = newIdentity();
store.setIdentity(fresh.seed);
/// A fresh master secret at rotation index 0. Only this local step; nothing
/// is sent until [registerAccount].
Uint8List createIdentity() {
final fresh = randomBytes(keyLen);
store.setMaster(fresh);
return fresh;
}
SmolIdentity restoreIdentity(String seedHex) {
Uint8List seed;
/// §2: a master alone does not say which rotation index a server has bound,
/// so restoring resolves the address and walks indices 0..[maxChain] until
/// one derives the key RESOLVE returned. Also binds "account" locally, like
/// [recallAccount] — restoring on a new device knows the identity but not
/// the address it was registered under.
Future<SmolAddress> restoreAndRecall(String masterHex, String addressText) async {
Uint8List master;
try {
seed = unhex(seedHex.trim());
master = unhex(masterHex.trim());
} on Exception {
throw const SmolError("seed must be 64 hex characters");
throw const SmolError("master must be 64 hex characters");
}
if (seed.length != keyLen) {
throw SmolError("seed is ${seed.length} bytes, expected $keyLen");
if (master.length != keyLen) {
throw SmolError("master is ${master.length} bytes, expected $keyLen");
}
final restored = identityFromSeed(seed);
store.setIdentity(seed);
return restored;
final addr = parseAddress(addressText);
final opened = await connect(addr, requirePin: true);
Uint8List current;
try {
current = (await resolveOp(opened.session, addr.user)).identity;
} finally {
opened.session.wire.close();
}
int? found;
for (var n = 0; n <= maxChain; n++) {
if (timingSafeEqual(identityFromSeed(identitySeed(master, n)).publicKey, current)) {
found = n;
break;
}
}
if (found == null) {
throw SmolError("the key bound to ${addr.short} is not derived from "
"this master within $maxChain rotations");
}
store.restoreMaster(master, found);
store.setAccount(addr);
return addr;
}
void pinServer(String host, String keyB32) {
@ -126,7 +154,7 @@ class SmolClient {
final addr = parseAddress(addressText);
final opened = await connect(addr, requirePin: true);
try {
await registerOp(opened.session, addr.user, me,
await registerOp(opened.session, opened.serverStatic, addr.user, me,
RegisterOptions(token: token));
} finally {
opened.session.wire.close();
@ -134,7 +162,7 @@ class SmolClient {
store.setAccount(addr);
}
/// Restoring a seed brings back the identity, not the memory of what
/// Restoring a master brings back the identity, not the memory of what
/// address a *different device* registered it under — "account" is
/// local-only state, never asked of the server. This binds it without
/// REGISTER: RESOLVE the address and require it name this exact key, so a
@ -162,46 +190,178 @@ class SmolClient {
Future<FetchSummary> fetch() async {
final me = identity;
final master = this.master;
final addr = accountAddress();
if (me == null) throw const SmolError("no identity yet");
if (me == null || master == null) throw const SmolError("no identity yet");
if (addr == null) {
throw const SmolError("not registered; register an address first");
}
var stored = 0;
final rejected = <String>[];
// §10: acknowledging (deleting) is the default; "leave mail on server"
// pages forward by cursor instead, so already-fetched mail is never
// re-downloaded even though it isn't deleted (store.storeIfNew also
// dedupes, as a second line of defense).
final leaveOnServer = store.leaveOnServer();
var (afterTime, afterId) = store.cursor();
final opened = await connect(addr, requirePin: true);
try {
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
final (sync, tokens) = store.tokenSet(master);
await authenticate(opened.session, opened.handshakeHash, addr.user, me,
sync: sync, tokens: tokens);
while (true) {
final records = await fetchOp(opened.session);
final records = await fetchOp(opened.session, afterTime, afterId);
if (records.isEmpty) break;
final acked = <Uint8List>[];
for (final record in records) {
afterTime = record.receivedAt;
afterId = record.id;
OpenedMessage msg;
try {
if (!timingSafeEqual(messageId(record.envelope), record.id)) {
throw const SmolError("id does not match the envelope");
}
unseal(store.identities(), record.envelope);
msg = unseal(store.identities(), record.envelope);
} on SmolError catch (err) {
// Left on the server rather than destroyed, so a client-side bug
// cannot lose mail.
rejected.add("${hex(record.id)}: ${err.message}");
continue;
}
final fresh = await store.storeIfNew("inbox", MailRecord(hex(record.id), record.envelope,
receivedAt: record.receivedAt));
if (fresh != null) stored++;
final fresh = await store.storeIfNew(
"inbox",
MailRecord(hex(record.id), record.envelope,
receivedAt: record.receivedAt,
tier: record.isRequest ? tierRequests : tierMain,
keptOnServer: leaveOnServer));
if (fresh != null) {
stored++;
_learnToken(msg);
}
acked.add(record.id);
}
if (acked.isEmpty) break;
await deleteOp(opened.session, acked);
if (leaveOnServer) {
// Persisted per batch, so an interrupted fetch resumes here rather
// than re-paging from the start next time.
store.setCursor(afterTime, afterId);
} else if (acked.isNotEmpty) {
await deleteOp(opened.session, acked);
}
}
} finally {
opened.session.wire.close();
}
if (!leaveOnServer) {
// Everything acknowledged is deleted, so the next fetch starts fresh; a
// record left on the server (rejected above) simply resurfaces then.
store.setCursor(0, Uint8List(idLen));
}
return FetchSummary(stored, rejected);
}
// --- delete ------------------------------------------------------------------
/// Deletes a message locally, and from the server too if it might still be
/// sitting there (only possible when "leave mail on server" was on when it
/// was fetched — §10). Sent copies are local-only; there is nothing
/// server-side to remove for them (§5.6). Throws, leaving the local copy in
/// place, if a needed server-side delete fails — otherwise a message could
/// look gone locally while silently persisting on the server.
Future<void> deleteMessage(String folder, MailRecord record) async {
if (folder != "sent" && record.keptOnServer) {
final me = identity;
final addr = accountAddress();
if (me == null || addr == null) {
throw const SmolError(
"not registered; cannot reach the server to delete this message");
}
final opened = await connect(addr, requirePin: true);
try {
await authenticate(opened.session, opened.handshakeHash, addr.user, me,
sync: 0, tokens: const []);
await deleteOp(opened.session, [unhex(record.id)]);
} finally {
opened.session.wire.close();
}
}
await store.deleteMessage(folder, record.id);
}
// §5.8: an Accept field is bound to the signer of the message that carried
// it, which unseal() has already verified.
void _learnToken(OpenedMessage msg) {
final parsed = parseFrontmatter(utf8.decode(msg.body, allowMalformed: true));
final raw = parsed.fields["accept"];
if (raw == null) return;
Uint8List token;
try {
token = b32decode(raw);
} on SmolError {
return;
}
if (token.length != tokenLen) return;
final address = _addressOfSigner(msg.sender, parsed.fields["reply-to"]);
if (address == null) return; // no address to send to, so no use for a token
store.learnToken(address, token);
}
/// The address we know a signer by: a contact, or the Reply-To it signed
/// for itself. Naming a mailbox is not trusting a key, so nothing is
/// pinned here (§5.7, §8).
String? _addressOfSigner(Uint8List sender, String? replyTo) {
final known = store.addressForKey(sender);
if (known != null) return known;
if (replyTo == null) return null;
try {
final parsed = parseAddress(replyTo);
if (parsed.identity != null && timingSafeEqual(parsed.identity!, sender)) {
return parsed.short;
}
} on SmolError {
// malformed claim: no address to learn a token under
}
return null;
}
// --- accept tokens (§5.8) --------------------------------------------------------
/// Admit a contact to the main tier; their token travels in our next
/// message to them. Pushes the change to the server right away, since an
/// accept or a block only takes effect once it holds the changed set.
Future<int> acceptContact(String address) async {
final key = store.contact(address)?.key;
if (key == null) throw SmolError("no key for $address yet");
store.accept(address, key);
store.setSyncOk(true);
return _pushTokens();
}
/// Withdraw a contact's accept token; their mail lands in requests from
/// their next message on.
Future<int> blockContact(String address) async {
store.block(address);
return _pushTokens();
}
Future<int> _pushTokens() async {
final me = identity;
final master = this.master;
final addr = accountAddress();
if (me == null || master == null) throw const SmolError("no identity yet");
if (addr == null) {
_warn("not registered; the set will be pushed with your first fetch");
return 0;
}
final opened = await connect(addr, requirePin: true);
try {
final (sync, tokens) = store.tokenSet(master);
return await authenticate(opened.session, opened.handshakeHash, addr.user, me,
sync: sync, tokens: tokens);
} finally {
opened.session.wire.close();
}
}
// --- compose and send -----------------------------------------------------------
// Prefer a key we already trust; fall back to RESOLVE with trust on first
@ -227,7 +387,8 @@ class SmolClient {
Future<String> send(String toText, String subject, String body,
{String? replyTo, bool anonymous = false}) async {
final me = identity;
if (me == null) throw const SmolError("no identity yet");
final master = this.master;
if (me == null || master == null) throw const SmolError("no identity yet");
final addr = parseAddress(toText);
final recipient = await resolveRecipient(addr);
final account = accountAddress();
@ -237,12 +398,21 @@ class SmolClient {
if (account != null && !anonymous) {
fields["Reply-To"] = account.uri(me.publicKey);
}
// §5.8: hand an accepted correspondent the token for our own mailbox, so
// a first reply from them reaches our main tier.
final accepted = store.accepted(addr.short);
if (accepted != null && accepted.active) {
fields["Accept"] = b32encode(tokenFor(master, accepted.identity));
}
final bodyBytes = utf8Bytes(buildFrontmatter(
fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n"));
final envelope = seal(me, recipient, bodyBytes);
// §5.8: our token for their mailbox, if they have given us one.
final held = store.tokenFrom(addr.short);
final mac = held == null ? null : acceptMac(held, messageId(envelope));
final opened = await connect(addr, requirePin: false);
try {
await sendOp(opened.session, envelope);
await sendOp(opened.session, envelope, mac: mac);
} finally {
opened.session.wire.close();
}
@ -293,7 +463,7 @@ class SmolClient {
/// full smol:// URI whose key matches the signer (§5.7); anything else is
/// ordinary text.
SmolAddress? replyAddress(OpenedRecord opened) {
final claim = opened.fields["Reply-To"];
final claim = opened.fields["reply-to"];
if (claim == null || opened.sender == null) return null;
try {
final parsed = parseAddress(claim);
@ -370,7 +540,7 @@ class SmolClient {
if (timingSafeEqual(known.key, resolved.identity)) {
return RefreshOutcome("${addr.short}: key unchanged", false);
}
if (walkChain(known.key, resolved.identity, resolved.chain)) {
if (walkChain(addr.user, known.key, resolved.identity, resolved.chain)) {
store.saveContact(addr.short, resolved.identity, known.verified);
return RefreshOutcome(
"${addr.short} rotated its key; a signed chain confirms it.\n"
@ -395,25 +565,27 @@ class SmolClient {
// --- rotation -----------------------------------------------------------------
// §7: rotate to a fresh seed and rebind the account with a signed
// certificate. The old seed is kept by the store, since mail sealed to it
// stays readable with nothing else.
// §7: rotate to the next index's derived key and rebind the account with a
// signed certificate. The superseded key stays derivable from the master,
// since mail sealed to it stays readable with nothing else.
Future<SmolIdentity> rotateIdentity() async {
final me = identity;
final master = this.master;
final addr = accountAddress();
if (me == null || addr == null) {
if (me == null || master == null || addr == null) {
throw const SmolError("rotate needs a registered account");
}
final fresh = newIdentity();
final cert = makeCert(me, fresh.seed);
final freshSeed = identitySeed(master, store.rotations() + 1);
final fresh = identityFromSeed(freshSeed);
final cert = makeCert(addr.user, me, freshSeed);
final opened = await connect(addr, requirePin: true);
try {
await registerOp(opened.session, addr.user, fresh,
await registerOp(opened.session, opened.serverStatic, addr.user, fresh,
RegisterOptions(cert: cert));
} finally {
opened.session.wire.close();
}
store.rotateIdentity(fresh.seed);
store.advanceRotation();
_openedCache.clear();
return fresh;
}