kirakira/lib/smol/client.dart

427 lines
15 KiB
Dart
Raw Normal View History

// App-level client: the flows of gsmol's app.js — connect with pinning, fetch
// with verification and acknowledgment, send with sent copies, contacts and
// rotation — on top of the pure protocol modules.
import "dart:convert";
import "dart:typed_data";
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/store.dart";
import "package:smol_mail/smol/transport.dart";
class RefreshOutcome {
final String message;
final bool warn;
const RefreshOutcome(this.message, this.warn);
}
class FetchSummary {
final int stored;
final List<String> rejected;
const FetchSummary(this.stored, this.rejected);
}
class OpenedRecord {
final String id;
final Uint8List? sender;
final int? time;
final Map<String, String> fields;
final String body;
final String? error;
const OpenedRecord(this.id,
{this.sender, this.time, this.fields = const {}, this.body = "", this.error});
String get subject => error == null ? (fields["Subject"] ?? "") : "";
}
class SmolClient {
final SmolStore store;
/// Trust warnings (§4/§8) the UI must not let the user miss; the app wires
/// this to a persistent banner in app_widget.dart.
void Function(String message)? onWarning;
SmolClient(this.store);
void _warn(String message) => onWarning?.call(message);
// Opening an envelope costs an X25519 agreement and an Ed25519
// verification, and an envelope's plaintext never changes — so the result
// is kept. Failures are cached too, so one bad message is not retried on
// every render. Rotation clears it, since the key set grew.
final _openedCache = <String, OpenedRecord>{};
SmolIdentity? get identity => store.identity();
SmolAddress? accountAddress() {
final account = store.account();
if (account == null) return null;
final suffix = account.port == defaultPort ? "" : ":${account.port}";
return parseAddress("${account.user}@${account.host}$suffix");
}
// §4: registration and fetching demand a pinned key; sending to a recipient
// whose key we already hold tolerates an unpinned server.
Future<OpenedSession> connect(SmolAddress addr,
{required bool requirePin}) async {
final pinned = store.serverPin(addr.host);
if (requirePin && pinned == null) {
throw SmolError("no pinned key for ${addr.host}. Obtain it from the "
"operator through a trusted channel, then pin it in settings.");
}
final wire = await TcpWire.connect(addr.host, addr.port);
try {
final opened = await openSession(wire, addr.host, pinned: pinned);
if (pinned == null) {
_warn("${addr.host} is not pinned; its key is "
"${b32encode(opened.serverStatic)}.\n"
"RESOLVE results from this session are UNVERIFIED (SPEC.md §8).");
}
return opened;
} catch (_) {
wire.close();
rethrow;
}
}
// --- identity setup ------------------------------------------------------------
SmolIdentity createIdentity() {
final fresh = newIdentity();
store.setIdentity(fresh.seed);
return fresh;
}
SmolIdentity restoreIdentity(String seedHex) {
Uint8List seed;
try {
seed = unhex(seedHex.trim());
} on Exception {
throw const SmolError("seed must be 64 hex characters");
}
if (seed.length != keyLen) {
throw SmolError("seed is ${seed.length} bytes, expected $keyLen");
}
final restored = identityFromSeed(seed);
store.setIdentity(seed);
return restored;
}
void pinServer(String host, String keyB32) {
final key = b32decode(keyB32);
if (key.length != keyLen) {
throw SmolError("server key is ${key.length} bytes, expected $keyLen");
}
store.pinServer(host.trim().toLowerCase(), key);
}
Future<void> registerAccount(String addressText, {String token = ""}) async {
final me = identity;
if (me == null) throw const SmolError("no identity yet");
final addr = parseAddress(addressText);
final opened = await connect(addr, requirePin: true);
try {
await registerOp(opened.session, addr.user, me,
RegisterOptions(token: token));
} finally {
opened.session.wire.close();
}
store.setAccount(addr);
}
/// Restoring a seed 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
/// typo or someone else's address cannot misfile fetch and Reply-To.
Future<SmolAddress> recallAccount(String addressText) async {
final me = identity;
if (me == null) throw const SmolError("no identity yet");
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();
}
if (!timingSafeEqual(current, me.publicKey)) {
throw SmolError(
"${addr.short} resolves to a different key — not this identity");
}
store.setAccount(addr);
return addr;
}
// --- fetch ----------------------------------------------------------------------
Future<FetchSummary> fetch() async {
final me = identity;
final addr = accountAddress();
if (me == 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>[];
final opened = await connect(addr, requirePin: true);
try {
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
while (true) {
final records = await fetchOp(opened.session);
if (records.isEmpty) break;
final acked = <Uint8List>[];
for (final record in records) {
try {
if (!timingSafeEqual(messageId(record.envelope), record.id)) {
throw const SmolError("id does not match the envelope");
}
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++;
acked.add(record.id);
}
if (acked.isEmpty) break;
await deleteOp(opened.session, acked);
}
} finally {
opened.session.wire.close();
}
return FetchSummary(stored, rejected);
}
// --- compose and send -----------------------------------------------------------
// Prefer a key we already trust; fall back to RESOLVE with trust on first
// use.
Future<Uint8List> resolveRecipient(SmolAddress addr) async {
if (addr.identity != null) {
store.saveContact(addr.short, addr.identity!, true);
return addr.identity!;
}
final known = store.contact(addr.short);
if (known != null) return known.key;
final opened = await connect(addr, requirePin: false);
Uint8List current;
try {
current = (await resolveOp(opened.session, addr.user)).identity;
} finally {
opened.session.wire.close();
}
store.saveContact(addr.short, current, false);
return current;
}
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 addr = parseAddress(toText);
final recipient = await resolveRecipient(addr);
final account = accountAddress();
final fields = <String, String>{"Subject": subject, "In-Reply-To": replyTo ?? ""};
// A signed Reply-To lets a first-time recipient name and answer us
// (§5.5 allows unknown keys); "anonymous" omits it.
if (account != null && !anonymous) {
fields["Reply-To"] = account.uri(me.publicKey);
}
final bodyBytes = utf8Bytes(buildFrontmatter(
fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n"));
final envelope = seal(me, recipient, bodyBytes);
final opened = await connect(addr, requirePin: false);
try {
await sendOp(opened.session, envelope);
} finally {
opened.session.wire.close();
}
// §5.6: the ephemeral is gone, so keep a copy sealed to ourselves.
await store.storeMessage("sent", MailRecord(hex(messageId(envelope)),
seal(me, me.publicKey, bodyBytes),
recipient: addr.short, sentAt: nowSeconds()));
return addr.short;
}
// --- reading -----------------------------------------------------------------
// What is known about a sender changes as the user binds addresses to keys,
// so this layer sits over the cached envelope and is recomputed per call —
// it is a map lookup, not crypto.
OpenedRecord describe(MailRecord row) {
final opened = _openEnvelope(row);
if (opened.error != null) return opened;
return OpenedRecord(
row.id,
sender: opened.sender,
time: opened.time,
fields: opened.fields,
body: opened.body,
);
}
OpenedRecord _openEnvelope(MailRecord row) {
var entry = _openedCache[row.id];
if (entry == null) {
try {
final opened = unseal(store.identities(), row.envelope);
final parsed = parseFrontmatter(utf8.decode(opened.body, allowMalformed: true));
entry = OpenedRecord(row.id,
sender: opened.sender,
time: opened.time,
fields: parsed.fields,
body: parsed.body);
} on SmolError catch (err) {
entry = OpenedRecord(row.id, error: err.message);
}
_openedCache[row.id] = entry;
}
return entry;
}
/// The Reply-To address carried inside the message, but only when it is a
/// 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"];
if (claim == null || opened.sender == null) return null;
try {
final parsed = parseAddress(claim);
if (parsed.identity != null &&
timingSafeEqual(parsed.identity!, opened.sender!)) {
return parsed;
}
} on SmolError {
// malformed claim: display, never bind
}
return null;
}
/// Bind a user-supplied address to the key that signed a message. A smol://
/// address carries its own key (verified); a short address is resolved and
/// the result kept on first use. Anything that binds a different key is
/// refused.
Future<void> nameSender(String text, Uint8List senderKey) async {
final addr = parseAddress(text.trim());
Uint8List key;
var verified = true;
if (addr.identity == null) {
final opened = await connect(addr, requirePin: false);
try {
key = (await resolveOp(opened.session, addr.user)).identity;
} finally {
opened.session.wire.close();
}
verified = false; // trust on first use, as with any RESOLVE
} else {
key = addr.identity!;
}
if (!timingSafeEqual(key, senderKey)) {
throw const SmolError(
"that address carries a different key than this message's sender");
}
store.saveContact(addr.short, senderKey, verified);
}
/// A signed Reply-To is the sender's own claim, so it saves as verified —
/// but never over an address already pinned to a different key (§8: a key
/// change without a rotation chain needs out-of-band confirmation).
Future<void> saveReplyAddress(SmolAddress addr, Uint8List senderKey) async {
final existing = store.contact(addr.short);
if (existing != null && !timingSafeEqual(existing.key, senderKey)) {
throw SmolError("${addr.short} is already known with a different key — "
"verify out of band before replying");
}
store.saveContact(addr.short, senderKey, true);
}
// Re-resolve a contact and apply §8: a valid rotation chain is accepted and
// surfaced; anything else requires out-of-band verification.
Future<RefreshOutcome> refreshContact(String address) async {
final addr = parseAddress(address);
if (addr.identity != null) {
throw const SmolError("that address already carries a key; use import instead");
}
final known = store.contact(addr.short);
final opened = await connect(addr, requirePin: false);
Resolved resolved;
try {
resolved = await resolveOp(opened.session, addr.user);
} finally {
opened.session.wire.close();
}
if (known == null) {
store.saveContact(addr.short, resolved.identity, false);
return RefreshOutcome(
"${addr.short} pinned (trust on first use"
"${opened.pinned ? "" : ", UNVERIFIED server"})",
!opened.pinned);
}
if (timingSafeEqual(known.key, resolved.identity)) {
return RefreshOutcome("${addr.short}: key unchanged", false);
}
if (walkChain(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"
"now ${b32encode(resolved.identity)}",
true);
}
return RefreshOutcome(
"${addr.short} presents a different key with no valid rotation chain.\n"
"Verify out of band, then import the new smol:// address.",
true);
}
// Bind a smol:// address to the key it carries (§8's strong path); the
// displaced key, if any, lands in the contact's history.
void importContact(String text) {
final addr = parseAddress(text.trim());
if (addr.identity == null) {
throw const SmolError("import needs a smol:// address carrying a key");
}
store.saveContact(addr.short, addr.identity!, true);
}
// --- 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.
Future<SmolIdentity> rotateIdentity() async {
final me = identity;
final addr = accountAddress();
if (me == null || addr == null) {
throw const SmolError("rotate needs a registered account");
}
final fresh = newIdentity();
final cert = makeCert(me, fresh.seed);
final opened = await connect(addr, requirePin: true);
try {
await registerOp(opened.session, addr.user, fresh,
RegisterOptions(cert: cert));
} finally {
opened.session.wire.close();
}
store.rotateIdentity(fresh.seed);
_openedCache.clear();
return fresh;
}
// A full wipe: every secret and every stored envelope. The UI must confirm.
Future<void> wipe() async {
await store.wipe();
_openedCache.clear();
}
}