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,15 +1,19 @@
|
|||
// 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.
|
||||
// The app-level client: the same flows the screens have always called —
|
||||
// connect with pinning, fetch with verification and acknowledgment, send
|
||||
// with sent copies, contacts and rotation — now as one thin layer over
|
||||
// fumi-core through the native binding. Every network operation runs on an
|
||||
// isolate; the reads the UI makes per frame stay synchronous.
|
||||
|
||||
import "dart:convert";
|
||||
import "dart:io";
|
||||
import "dart:math";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:smol_mail/smol/crypto.dart";
|
||||
import "package:smol_mail/native/client.dart";
|
||||
import "package:smol_mail/native/ffi.dart";
|
||||
|
||||
import "package:smol_mail/smol/address.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;
|
||||
|
|
@ -27,7 +31,7 @@ class FetchSummary {
|
|||
|
||||
class OpenedRecord {
|
||||
final String id;
|
||||
final Uint8List? sender;
|
||||
final String? sender; // base32
|
||||
final int? time;
|
||||
final Map<String, String> fields;
|
||||
final String body;
|
||||
|
|
@ -50,10 +54,41 @@ class SmolClient {
|
|||
|
||||
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.
|
||||
FumiNative get _native => store.native;
|
||||
|
||||
SmolFfi get _ffi => SmolFfi.open();
|
||||
|
||||
/// Resolves the host the way that works everywhere this app runs: the
|
||||
/// platform resolver. On Android the native getaddrinfo that fumi-core's
|
||||
/// connect would use can be dead for app processes while this path works,
|
||||
/// so the facade resolves here and hands the IP across as a dial hint —
|
||||
/// the hostname keeps every identity role. IP literals dial themselves.
|
||||
Future<String> _dial(String host) async {
|
||||
final parsed = InternetAddress.tryParse(host);
|
||||
if (parsed != null) return parsed.address;
|
||||
try {
|
||||
final addresses = await InternetAddress.lookup(host);
|
||||
if (addresses.isEmpty) {
|
||||
throw SmolError("could not resolve $host");
|
||||
}
|
||||
return addresses.first.address;
|
||||
} on SocketException catch (err) {
|
||||
throw SmolError("could not resolve $host: ${err.message}");
|
||||
} catch (err) {
|
||||
if (err is SmolError) rethrow;
|
||||
throw SmolError("could not resolve $host");
|
||||
}
|
||||
}
|
||||
|
||||
/// The account host's dial hint, for the ops that connect home.
|
||||
Future<String> _accountDial() async {
|
||||
final addr = accountAddress();
|
||||
if (addr == null) return "";
|
||||
return _dial(addr.host);
|
||||
}
|
||||
|
||||
// Opening an envelope is microseconds native; the results never change, so
|
||||
// they are kept like the Dart core kept them. Failures cache too.
|
||||
final _openedCache = <String, OpenedRecord>{};
|
||||
|
||||
SmolIdentity? get identity => store.identity();
|
||||
|
|
@ -61,397 +96,211 @@ class SmolClient {
|
|||
Uint8List? get master => store.master();
|
||||
|
||||
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;
|
||||
}
|
||||
final text = _ffi.accountAddress(_native.store!);
|
||||
if (text == null) return null;
|
||||
return parseAddress(text);
|
||||
}
|
||||
|
||||
// --- identity setup ------------------------------------------------------------
|
||||
|
||||
/// A fresh master secret at rotation index 0. Only this local step; nothing
|
||||
/// is sent until [registerAccount].
|
||||
Uint8List createIdentity() {
|
||||
final fresh = randomBytes(keyLen);
|
||||
/// A fresh master secret at rotation index 0. Only this local step;
|
||||
/// nothing is sent until [registerAccount].
|
||||
Future<Uint8List> createIdentity() async {
|
||||
final fresh = _randomMaster();
|
||||
store.setMaster(fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
Uint8List _randomMaster() {
|
||||
// The platform CSPRNG: 32 random bytes are the whole identity.
|
||||
final rng = Random.secure();
|
||||
return Uint8List.fromList(
|
||||
List.generate(32, (_) => rng.nextInt(256)));
|
||||
}
|
||||
|
||||
/// §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.
|
||||
/// so restoring resolves the address and walks indices until one derives
|
||||
/// the key RESOLVE returned, then binds the account locally.
|
||||
Future<SmolAddress> restoreAndRecall(String masterHex, String addressText) async {
|
||||
Uint8List master;
|
||||
try {
|
||||
master = unhex(masterHex.trim());
|
||||
master = _unhex(masterHex.trim());
|
||||
} on Exception {
|
||||
throw const SmolError("master must be 64 hex characters");
|
||||
}
|
||||
if (master.length != keyLen) {
|
||||
throw SmolError("master is ${master.length} bytes, expected $keyLen");
|
||||
if (master.length != 32) {
|
||||
throw SmolError("master is ${master.length} bytes, expected 32");
|
||||
}
|
||||
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);
|
||||
store.restoreMaster(master, 0);
|
||||
await _restore(addr);
|
||||
master.fillRange(0, master.length, 0);
|
||||
return addr;
|
||||
}
|
||||
|
||||
void pinServer(String host, String keyB32) {
|
||||
final key = b32decode(keyB32);
|
||||
if (key.length != keyLen) {
|
||||
throw SmolError("server key is ${key.length} bytes, expected $keyLen");
|
||||
Future<void> _restore(SmolAddress addr) async {
|
||||
// fumi's restore would tolerate an unpinned server (sec 8 trust on
|
||||
// first use), but this app's onboarding teaches the pin up front:
|
||||
// registration and fetching demand it anyway (sec 4), so restoring is
|
||||
// stopped at the pin step rather than letting the account bind to a
|
||||
// server whose key nobody verified.
|
||||
if (store.serverPin(addr.host) == null) {
|
||||
throw SmolError("no pinned key for ${addr.host}. Obtain it from the "
|
||||
"operator through a trusted channel, then pin it in settings.");
|
||||
}
|
||||
try {
|
||||
// restore resolves, walks the rotation indices and writes the account
|
||||
// state; the account handle is rebuilt inside the binding.
|
||||
await _native.restore(addr.short, dial: await _dial(addr.host));
|
||||
} on NativeSmolException catch (err) {
|
||||
throw SmolError(err.message);
|
||||
}
|
||||
store.pinServer(host.trim().toLowerCase(), key);
|
||||
}
|
||||
|
||||
void pinServer(String host, String keyB32) => store.pinServer(host, keyB32);
|
||||
|
||||
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, opened.serverStatic, addr.user, me,
|
||||
RegisterOptions(token: token));
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
await _native.register(addr.short,
|
||||
invite: token.isEmpty ? null : token, dial: await _dial(addr.host));
|
||||
} on NativeSmolException catch (err) {
|
||||
throw SmolError(err.message);
|
||||
}
|
||||
store.setAccount(addr);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// typo or someone else's address cannot misfile fetch and Reply-To.
|
||||
/// Recall binds the identity to its registered address without
|
||||
/// re-REGISTER — the same resolve-and-walk a restore does (§2).
|
||||
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);
|
||||
await _restore(addr);
|
||||
return addr;
|
||||
}
|
||||
|
||||
// --- fetch ----------------------------------------------------------------------
|
||||
// --- fetch -----------------------------------------------------------------
|
||||
|
||||
Future<FetchSummary> fetch() 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) {
|
||||
Future<FetchSummary> fetch({bool reset = false}) async {
|
||||
if (master == null) throw const SmolError("no identity yet");
|
||||
if (accountAddress() == 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);
|
||||
final Map<String, dynamic> summary;
|
||||
try {
|
||||
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, 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");
|
||||
}
|
||||
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,
|
||||
tier: record.isRequest ? tierRequests : tierMain,
|
||||
keptOnServer: leaveOnServer));
|
||||
if (fresh != null) {
|
||||
stored++;
|
||||
_learnToken(msg);
|
||||
}
|
||||
acked.add(record.id);
|
||||
}
|
||||
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();
|
||||
summary = await _native.fetch(
|
||||
keep: store.leaveOnServer(), reset: reset, dial: await _accountDial());
|
||||
} on NativeSmolException catch (err) {
|
||||
throw SmolError(err.message);
|
||||
}
|
||||
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));
|
||||
_openedCache.clear();
|
||||
if (summary["cancelled"] == true) {
|
||||
_warn("fetch cancelled; partial results kept");
|
||||
}
|
||||
return FetchSummary(stored, rejected);
|
||||
return FetchSummary(
|
||||
summary["stored"] as int,
|
||||
[
|
||||
for (final r in (summary["rejected"] as List).cast<List<dynamic>>())
|
||||
"${r[0]}: ${r[1]}",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Raises the cancellation flag; a running fetch stops between envelopes
|
||||
/// and returns a partial summary.
|
||||
void cancelFetch() => _native.cancelFetch();
|
||||
|
||||
// --- 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.
|
||||
/// was fetched — §10). Sent copies are local-only (§5.6).
|
||||
Future<void> deleteMessage(String folder, MailRecord record) async {
|
||||
if (folder != "sent" && record.keptOnServer) {
|
||||
final me = identity;
|
||||
final addr = accountAddress();
|
||||
if (me == null || addr == null) {
|
||||
if (accountAddress() == 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 _native.delete([record.id], dial: await _accountDial());
|
||||
} on NativeSmolException catch (err) {
|
||||
throw SmolError(err.message);
|
||||
}
|
||||
}
|
||||
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;
|
||||
_openedCache.remove(record.id);
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
/// message to them. Pushes the changed set to the server right away.
|
||||
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();
|
||||
try {
|
||||
return await _native.accept(address, dial: await _accountDial());
|
||||
} on NativeSmolException catch (err) {
|
||||
throw SmolError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
await _native.block(address, dial: await _accountDial());
|
||||
return 0;
|
||||
} on NativeSmolException catch (err) {
|
||||
throw SmolError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
|
||||
/// Sends one message (§5, §6.1): recipient selection prefers a key we
|
||||
/// already trust, then RESOLVE with trust on first use; an accepted
|
||||
/// correspondent gets our token and a §5.6 sent copy is kept.
|
||||
Future<String> send(String toText, String subject, String body,
|
||||
{String? replyTo, bool anonymous = false}) async {
|
||||
final me = identity;
|
||||
final master = this.master;
|
||||
if (me == null || master == null) throw const SmolError("no identity yet");
|
||||
if (master == 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);
|
||||
}
|
||||
// §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);
|
||||
final Map<String, dynamic> sent;
|
||||
try {
|
||||
await sendOp(opened.session, envelope, mac: mac);
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
sent = await _native.send(addr.short, body,
|
||||
subject: subject.isEmpty ? null : subject,
|
||||
replyTo: replyTo,
|
||||
dial: await _dial(addr.host));
|
||||
} on NativeSmolException catch (err) {
|
||||
throw SmolError(err.message);
|
||||
}
|
||||
if (sent["warning"] != null) {
|
||||
_warn(sent["warning"] as String);
|
||||
}
|
||||
// §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.
|
||||
/// Opens one sealed message: the described view the reader shows. Sync —
|
||||
/// one unseal is microseconds native, and the old Dart core did the same
|
||||
/// work at fifty times the cost.
|
||||
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) {
|
||||
if (_native.account == null) {
|
||||
// No account handle: no identity to unseal with.
|
||||
entry = OpenedRecord(row.id, error: "no identity yet");
|
||||
_openedCache[row.id] = entry;
|
||||
return entry;
|
||||
}
|
||||
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) {
|
||||
final described =
|
||||
_ffi.describe(_native.store!, _native.account!, row.id);
|
||||
entry = OpenedRecord(
|
||||
row.id,
|
||||
sender: described["sender"] as String,
|
||||
time: described["time"] as int,
|
||||
fields: (described["fields"] as Map).cast<String, String>(),
|
||||
body: described["text"] as String,
|
||||
);
|
||||
} on NativeSmolException catch (err) {
|
||||
entry = OpenedRecord(row.id, error: err.message);
|
||||
}
|
||||
_openedCache[row.id] = entry;
|
||||
|
|
@ -467,8 +316,7 @@ class SmolClient {
|
|||
if (claim == null || opened.sender == null) return null;
|
||||
try {
|
||||
final parsed = parseAddress(claim);
|
||||
if (parsed.identity != null &&
|
||||
timingSafeEqual(parsed.identity!, opened.sender!)) {
|
||||
if (parsed.identity != null && parsed.identity == opened.sender) {
|
||||
return parsed;
|
||||
}
|
||||
} on SmolError {
|
||||
|
|
@ -481,38 +329,36 @@ class SmolClient {
|
|||
/// 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 {
|
||||
Future<void> nameSender(String text, String 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();
|
||||
await refreshContact(addr.short);
|
||||
final known = store.contact(addr.short);
|
||||
if (known == null) {
|
||||
throw const SmolError("could not resolve that address");
|
||||
}
|
||||
verified = false; // trust on first use, as with any RESOLVE
|
||||
} else {
|
||||
key = addr.identity!;
|
||||
if (known.key != senderKey) {
|
||||
throw const SmolError(
|
||||
"that address carries a different key than this message's sender");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!timingSafeEqual(key, senderKey)) {
|
||||
if (addr.identity != senderKey) {
|
||||
throw const SmolError(
|
||||
"that address carries a different key than this message's sender");
|
||||
}
|
||||
store.saveContact(addr.short, senderKey, verified);
|
||||
await store.saveContact(addr.short, senderKey, verified: true);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// but never over an address already pinned to a different key (§8).
|
||||
Future<void> saveReplyAddress(SmolAddress addr, String senderKey) async {
|
||||
final existing = store.contact(addr.short);
|
||||
if (existing != null && !timingSafeEqual(existing.key, senderKey)) {
|
||||
if (existing != null && 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);
|
||||
await store.saveContact(addr.short, senderKey, verified: true);
|
||||
}
|
||||
|
||||
// Re-resolve a contact and apply §8: a valid rotation chain is accepted and
|
||||
|
|
@ -520,74 +366,64 @@ class SmolClient {
|
|||
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");
|
||||
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;
|
||||
final Map<String, dynamic> resolved;
|
||||
try {
|
||||
resolved = await resolveOp(opened.session, addr.user);
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
resolved = await _native.resolve(addr.short, dial: await _dial(addr.host));
|
||||
} on NativeSmolException catch (err) {
|
||||
throw SmolError(err.message);
|
||||
}
|
||||
final change = resolved["change"] as String;
|
||||
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);
|
||||
"${addr.short} pinned (trust on first use)", change == "newUnverified");
|
||||
}
|
||||
if (timingSafeEqual(known.key, resolved.identity)) {
|
||||
return RefreshOutcome("${addr.short}: key unchanged", false);
|
||||
switch (change) {
|
||||
case "none":
|
||||
return RefreshOutcome("${addr.short}: key unchanged", false);
|
||||
case "rotated":
|
||||
_warn("${addr.short} rotated its key; a signed chain confirms it.\n"
|
||||
"now ${resolved["key"]}");
|
||||
return RefreshOutcome(
|
||||
"${addr.short} rotated its key; a signed chain confirms it.\n"
|
||||
"now ${resolved["key"]}",
|
||||
true);
|
||||
default:
|
||||
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);
|
||||
}
|
||||
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"
|
||||
"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) {
|
||||
// Bind a smol:// address to the key it carries (§8's strong path).
|
||||
Future<void> importContact(String text) async {
|
||||
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);
|
||||
await store.saveContact(addr.short, addr.identity!, verified: true);
|
||||
}
|
||||
|
||||
// --- rotation -----------------------------------------------------------------
|
||||
|
||||
// §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.
|
||||
/// §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 || master == null || addr == null) {
|
||||
if (identity == null || master == null || accountAddress() == null) {
|
||||
throw const SmolError("rotate needs a registered account");
|
||||
}
|
||||
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, opened.serverStatic, addr.user, fresh,
|
||||
RegisterOptions(cert: cert));
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
await _native.rotate(dial: await _accountDial());
|
||||
} on NativeSmolException catch (err) {
|
||||
throw SmolError(err.message);
|
||||
}
|
||||
store.advanceRotation();
|
||||
_openedCache.clear();
|
||||
return fresh;
|
||||
return identity!;
|
||||
}
|
||||
|
||||
// A full wipe: every secret and every stored envelope. The UI must confirm.
|
||||
|
|
@ -595,4 +431,14 @@ class SmolClient {
|
|||
await store.wipe();
|
||||
_openedCache.clear();
|
||||
}
|
||||
|
||||
Uint8List _unhex(String text) {
|
||||
if (text.length % 2 != 0) {
|
||||
throw const SmolError("odd-length hex string");
|
||||
}
|
||||
return Uint8List.fromList([
|
||||
for (var i = 0; i < text.length; i += 2)
|
||||
int.parse(text.substring(i, i + 2), radix: 16),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue