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
48
lib/smol/address.dart
Normal file
48
lib/smol/address.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Address forms (SPEC.md §3): parsing goes through the native library, so
|
||||
// the username rules are fumi's, not a reimplementation that can drift.
|
||||
|
||||
import "package:smol_mail/native/ffi.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
|
||||
/// One parsed address. [identity] carries the self-certifying key a
|
||||
/// smol:// URI binds; a short form has none until it is resolved.
|
||||
class SmolAddress {
|
||||
final String user;
|
||||
final String host;
|
||||
final int port;
|
||||
final String? identity;
|
||||
final bool rns;
|
||||
|
||||
const SmolAddress(this.user, this.host, this.port,
|
||||
{this.identity, this.rns = false});
|
||||
|
||||
/// The short form a contact list shows; the default port is elided.
|
||||
String get short => rns
|
||||
? "smol+rns://$user@$host"
|
||||
: "$user@$host${port == defaultPort ? "" : ":$port"}";
|
||||
|
||||
/// The self-certifying form: the key inside makes the address verifiable.
|
||||
String uri(String publicKey) => rns
|
||||
? "smol+rns://$user@$host/$publicKey"
|
||||
: "smol://$user@$host${port == defaultPort ? "" : ":$port"}/$publicKey";
|
||||
}
|
||||
|
||||
const defaultPort = 1961;
|
||||
|
||||
/// Parses any of the four address forms. Throws [SmolError] on anything
|
||||
/// else — the onboarding and compose fields validate through this.
|
||||
SmolAddress parseAddress(String text) {
|
||||
final Map<String, dynamic> parsed;
|
||||
try {
|
||||
parsed = SmolFfi.open().parseAddress(text.trim());
|
||||
} on NativeSmolException catch (err) {
|
||||
throw SmolError(err.message);
|
||||
}
|
||||
return SmolAddress(
|
||||
parsed["user"] as String,
|
||||
parsed["host"] as String,
|
||||
parsed["port"] as int,
|
||||
identity: parsed["identity"] as String?,
|
||||
rns: parsed["scheme"] == "rns",
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
import "package:smol_mail/smol/proto.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/store.dart";
|
||||
|
||||
// Deploy-time configuration, empty by default (mirrors gsmol's config.js).
|
||||
// A release may bake in a server key with:
|
||||
// flutter build apk --dart-define=SMOL_PRESET_SERVER=example.org \
|
||||
|
|
@ -12,15 +8,15 @@ import "package:smol_mail/smol/store.dart";
|
|||
// This only seeds the first run — once written it is an ordinary pin,
|
||||
// removable in settings like any other, and never overwrites a host the user
|
||||
// (or a previous install) already pinned.
|
||||
|
||||
import "package:smol_mail/smol/store.dart";
|
||||
|
||||
const _presetHost = String.fromEnvironment("SMOL_PRESET_SERVER");
|
||||
const _presetKey = String.fromEnvironment("SMOL_PRESET_SERVER_KEY");
|
||||
|
||||
void applyPresetServer(SmolStore store) {
|
||||
if (_presetHost.isEmpty || _presetKey.isEmpty) return;
|
||||
if (store.serverPin(_presetHost) != null) return;
|
||||
try {
|
||||
store.pinServer(_presetHost, b32decode(_presetKey));
|
||||
} on SmolError {
|
||||
// malformed preset: leave unpinned rather than block boot
|
||||
}
|
||||
// A malformed preset leaves the host unpinned rather than blocking boot.
|
||||
store.pinServer(_presetHost, _presetKey);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,435 +0,0 @@
|
|||
// Smol Mail primitives (SPEC.md §1): SHA-2, HMAC/HKDF-SHA256, ChaCha20-Poly1305,
|
||||
// X25519, Ed25519, and the §2 key conversions between the two curves. Pure
|
||||
// Dart rather than PointyCastle so the byte-exact vectors from the reference
|
||||
// client (test/vectors.json) can pin every operation.
|
||||
|
||||
import "dart:convert";
|
||||
import "dart:math";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:crypto/crypto.dart" as hashes;
|
||||
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
|
||||
// --- bytes --------------------------------------------------------------------
|
||||
|
||||
Uint8List concat(List<List<int>> parts) {
|
||||
final out = Uint8List(parts.fold(0, (n, p) => n + p.length));
|
||||
var off = 0;
|
||||
for (final p in parts) {
|
||||
out.setRange(off, off + p.length, p);
|
||||
off += p.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Uint8List utf8Bytes(String text) => Uint8List.fromList(utf8.encode(text));
|
||||
|
||||
String hex(List<int> bytes) =>
|
||||
bytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join();
|
||||
|
||||
Uint8List unhex(String text) {
|
||||
if (text.length.isOdd) throw ArgumentError("odd-length hex string: $text");
|
||||
final out = Uint8List(text.length ~/ 2);
|
||||
for (var i = 0; i < out.length; i++) {
|
||||
out[i] = int.parse(text.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
BigInt leBytesToBigInt(Uint8List bytes) {
|
||||
var n = BigInt.zero;
|
||||
for (var i = bytes.length - 1; i >= 0; i--) {
|
||||
n = (n << 8) | BigInt.from(bytes[i]);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
Uint8List bigIntToLeBytes(BigInt value, int length) {
|
||||
final out = Uint8List(length);
|
||||
var v = value;
|
||||
for (var i = 0; i < length; i++) {
|
||||
out[i] = (v & BigInt.from(0xff)).toInt();
|
||||
v >>= 8;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Uint8List randomBytes(int n) {
|
||||
final out = Uint8List(n);
|
||||
final rng = Random.secure();
|
||||
for (var i = 0; i < n; i++) {
|
||||
out[i] = rng.nextInt(256);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool timingSafeEqual(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
var diff = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
diff |= a[i] ^ b[i];
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
// --- SHA-256 / SHA-512 / HMAC-SHA256 / HKDF (RFC 2104, RFC 5869) ---------------
|
||||
|
||||
Uint8List sha256(List<int> message) =>
|
||||
Uint8List.fromList(hashes.sha256.convert(message).bytes);
|
||||
|
||||
Uint8List sha512(List<int> message) =>
|
||||
Uint8List.fromList(hashes.sha512.convert(message).bytes);
|
||||
|
||||
Uint8List hmacSha256(List<int> key, List<int> message) =>
|
||||
Uint8List.fromList(hashes.Hmac(hashes.sha256, key).convert(message).bytes);
|
||||
|
||||
Uint8List hkdfSha256(List<int> ikm, List<int> salt, List<int> info,
|
||||
[int length = 32]) {
|
||||
final prk = hmacSha256(salt, ikm);
|
||||
var out = <int>[];
|
||||
var block = <int>[];
|
||||
var counter = 1;
|
||||
while (out.length < length) {
|
||||
block = hmacSha256(prk, concat([block, info, [counter]]));
|
||||
out.addAll(block);
|
||||
counter++;
|
||||
}
|
||||
return Uint8List.fromList(out.sublist(0, length));
|
||||
}
|
||||
|
||||
// --- ChaCha20-Poly1305 AEAD (RFC 8439) -----------------------------------------
|
||||
|
||||
const _mask32 = 0xFFFFFFFF;
|
||||
|
||||
int _rotl32(int x, int n) => ((x << n) | (x >>> (32 - n))) & _mask32;
|
||||
|
||||
Uint8List _chachaBlock(Uint8List key, int counter, Uint8List nonce) {
|
||||
final state = Uint32List(16);
|
||||
state.setAll(0, [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]);
|
||||
final kview = ByteData.view(key.buffer, key.offsetInBytes, key.length);
|
||||
for (var i = 0; i < 8; i++) {
|
||||
state[4 + i] = kview.getUint32(i * 4, Endian.little);
|
||||
}
|
||||
state[12] = counter & _mask32;
|
||||
final nview = ByteData.view(nonce.buffer, nonce.offsetInBytes, nonce.length);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
state[13 + i] = nview.getUint32(i * 4, Endian.little);
|
||||
}
|
||||
final x = Uint32List.fromList(state);
|
||||
void qr(int a, int b, int c, int d) {
|
||||
x[a] = (x[a] + x[b]) & _mask32;
|
||||
x[d] = _rotl32(x[d] ^ x[a], 16);
|
||||
x[c] = (x[c] + x[d]) & _mask32;
|
||||
x[b] = _rotl32(x[b] ^ x[c], 12);
|
||||
x[a] = (x[a] + x[b]) & _mask32;
|
||||
x[d] = _rotl32(x[d] ^ x[a], 8);
|
||||
x[c] = (x[c] + x[d]) & _mask32;
|
||||
x[b] = _rotl32(x[b] ^ x[c], 7);
|
||||
}
|
||||
|
||||
for (var i = 0; i < 10; i++) {
|
||||
qr(0, 4, 8, 12);
|
||||
qr(1, 5, 9, 13);
|
||||
qr(2, 6, 10, 14);
|
||||
qr(3, 7, 11, 15);
|
||||
qr(0, 5, 10, 15);
|
||||
qr(1, 6, 11, 12);
|
||||
qr(2, 7, 8, 13);
|
||||
qr(3, 4, 9, 14);
|
||||
}
|
||||
final out = Uint8List(64);
|
||||
final view = ByteData.view(out.buffer);
|
||||
for (var i = 0; i < 16; i++) {
|
||||
view.setUint32(i * 4, (x[i] + state[i]) & _mask32, Endian.little);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Uint8List _chacha20Xor(Uint8List key, int counter, Uint8List nonce, Uint8List data) {
|
||||
final out = Uint8List(data.length);
|
||||
for (var off = 0; off < data.length; off += 64) {
|
||||
final stream = _chachaBlock(key, counter + off ~/ 64, nonce);
|
||||
final n = min(64, data.length - off);
|
||||
for (var i = 0; i < n; i++) {
|
||||
out[off + i] = data[off + i] ^ stream[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Poly1305 over BigInt; correctness over speed, messages here stay small.
|
||||
Uint8List _poly1305(Uint8List key, List<int> message) {
|
||||
final p = (BigInt.one << 130) - BigInt.from(5);
|
||||
final r = leBytesToBigInt(key.sublist(0, 16)) &
|
||||
BigInt.parse("0x0ffffffc0ffffffc0ffffffc0fffffff");
|
||||
final s = leBytesToBigInt(key.sublist(16, 32));
|
||||
var acc = BigInt.zero;
|
||||
for (var off = 0; off < message.length; off += 16) {
|
||||
final block = message.sublist(off, min(off + 16, message.length));
|
||||
acc = (acc + leBytesToBigInt(Uint8List.fromList(block)) +
|
||||
(BigInt.one << (8 * block.length))) *
|
||||
r %
|
||||
p;
|
||||
}
|
||||
return bigIntToLeBytes((acc + s) & ((BigInt.one << 128) - BigInt.one), 16);
|
||||
}
|
||||
|
||||
Uint8List _pad16(int n) => Uint8List((16 - (n % 16)) % 16);
|
||||
|
||||
Uint8List _le64(int n) => bigIntToLeBytes(BigInt.from(n), 8);
|
||||
|
||||
Uint8List aeadEncrypt(Uint8List key, Uint8List nonce, Uint8List plaintext,
|
||||
Uint8List aad) {
|
||||
final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32);
|
||||
final ciphertext = _chacha20Xor(key, 1, nonce, plaintext);
|
||||
final mac = _poly1305(polyKey,
|
||||
concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)]));
|
||||
return concat([ciphertext, mac]);
|
||||
}
|
||||
|
||||
Uint8List aeadDecrypt(Uint8List key, Uint8List nonce, Uint8List sealed, Uint8List aad) {
|
||||
if (sealed.length < 16) {
|
||||
throw const SmolError("ciphertext shorter than the Poly1305 tag");
|
||||
}
|
||||
final ciphertext = sealed.sublist(0, sealed.length - 16);
|
||||
final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32);
|
||||
final expect = _poly1305(polyKey,
|
||||
concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)]));
|
||||
if (!timingSafeEqual(expect, sealed.sublist(sealed.length - 16))) {
|
||||
throw const SmolError("decryption failed: bad Poly1305 tag");
|
||||
}
|
||||
return _chacha20Xor(key, 1, nonce, ciphertext);
|
||||
}
|
||||
|
||||
// --- X25519 (RFC 7748) ---------------------------------------------------------
|
||||
|
||||
final BigInt _p = (BigInt.one << 255) - BigInt.from(19);
|
||||
final BigInt _mask255 = (BigInt.one << 255) - BigInt.one;
|
||||
|
||||
BigInt _mod(BigInt value, [BigInt? p]) {
|
||||
final m = p ?? _p;
|
||||
return ((value % m) + m) % m;
|
||||
}
|
||||
|
||||
BigInt _powMod(BigInt base, BigInt exponent, [BigInt? p]) {
|
||||
final m = p ?? _p;
|
||||
var out = BigInt.one;
|
||||
base = _mod(base, m);
|
||||
while (exponent > BigInt.zero) {
|
||||
if (exponent & BigInt.one == BigInt.one) out = out * base % m;
|
||||
base = base * base % m;
|
||||
exponent >>= 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Uint8List clampScalar(Uint8List scalar) {
|
||||
final k = Uint8List.fromList(scalar);
|
||||
k[0] &= 248;
|
||||
k[31] &= 127;
|
||||
k[31] |= 64;
|
||||
return k;
|
||||
}
|
||||
|
||||
BigInt _x25519Raw(Uint8List scalar, Uint8List u) {
|
||||
final k = leBytesToBigInt(clampScalar(scalar));
|
||||
final x1 = leBytesToBigInt(u) & _mask255;
|
||||
const a24 = 121665;
|
||||
var x2 = BigInt.one, z2 = BigInt.zero, x3 = x1, z3 = BigInt.one;
|
||||
var swap = BigInt.zero;
|
||||
for (var t = 254; t >= 0; t--) {
|
||||
final kt = (k >> t) & BigInt.one;
|
||||
swap ^= kt;
|
||||
if (swap == BigInt.one) {
|
||||
var tmp = x2;
|
||||
x2 = x3;
|
||||
x3 = tmp;
|
||||
tmp = z2;
|
||||
z2 = z3;
|
||||
z3 = tmp;
|
||||
}
|
||||
swap = kt;
|
||||
final a = _mod(x2 + z2), aa = a * a % _p;
|
||||
final b = _mod(x2 - z2), bb = b * b % _p;
|
||||
final e = _mod(aa - bb);
|
||||
final c = _mod(x3 + z3), d = _mod(x3 - z3);
|
||||
final da = d * a % _p, cb = c * b % _p;
|
||||
final sum = _mod(da + cb), diff = _mod(da - cb);
|
||||
x3 = sum * sum % _p;
|
||||
z3 = x1 * diff * diff % _p;
|
||||
x2 = aa * bb % _p;
|
||||
z2 = e * _mod(aa + BigInt.from(a24) * e) % _p;
|
||||
}
|
||||
if (swap == BigInt.one) {
|
||||
var tmp = x2;
|
||||
x2 = x3;
|
||||
x3 = tmp;
|
||||
tmp = z2;
|
||||
z2 = z3;
|
||||
z3 = tmp;
|
||||
}
|
||||
return x2 * _powMod(z2, _p - BigInt.two) % _p;
|
||||
}
|
||||
|
||||
// §2's low-order rejection: a clamped scalar is a multiple of 8, so any
|
||||
// low-order peer point yields an all-zero shared secret — rejecting the zero
|
||||
// output rejects all of them.
|
||||
Uint8List x25519(Uint8List scalar, Uint8List peerPublic) {
|
||||
final shared = bigIntToLeBytes(_x25519Raw(scalar, peerPublic), 32);
|
||||
if (shared.every((b) => b == 0)) {
|
||||
throw const SmolError("rejected low-order key agreement point");
|
||||
}
|
||||
return shared;
|
||||
}
|
||||
|
||||
Uint8List x25519Base(Uint8List scalar) => bigIntToLeBytes(
|
||||
_x25519Raw(scalar, unhex("0900000000000000000000000000000000000000000000000000000000000000")),
|
||||
32);
|
||||
|
||||
// --- Ed25519 (RFC 8032) --------------------------------------------------------
|
||||
|
||||
final BigInt _l = (BigInt.one << 252) +
|
||||
BigInt.parse("27742317777372353535851937790883648493");
|
||||
final BigInt _d = _mod(-BigInt.from(121665) * _powMod(BigInt.from(121666), _p - BigInt.two));
|
||||
final _Point _b = _Point.fromAffine(
|
||||
BigInt.parse(
|
||||
"15112221349535400772501151409588531511454012693041857206046113283949847762202"),
|
||||
_mod(BigInt.from(4) * _powMod(BigInt.from(5), _p - BigInt.two)));
|
||||
|
||||
class _Point {
|
||||
final BigInt x, y, z, t;
|
||||
|
||||
const _Point(this.x, this.y, this.z, this.t);
|
||||
|
||||
_Point.fromAffine(BigInt x, BigInt y)
|
||||
: this(x, y, BigInt.one, _mod(x * y));
|
||||
}
|
||||
|
||||
final _Point _identity = _Point(
|
||||
BigInt.zero, BigInt.one, BigInt.one, BigInt.zero);
|
||||
|
||||
_Point _pointAdd(_Point p, _Point q) {
|
||||
final a = _mod(p.y - p.x) * _mod(q.y - q.x) % _p;
|
||||
final b = _mod(p.y + p.x) * _mod(q.y + q.x) % _p;
|
||||
final c = BigInt.two * p.t * q.t % _p * _d % _p;
|
||||
final d = BigInt.two * p.z * q.z % _p;
|
||||
final e = _mod(b - a), f = _mod(d - c), g = _mod(d + c);
|
||||
final h = b + a;
|
||||
return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p);
|
||||
}
|
||||
|
||||
_Point _pointDouble(_Point p) {
|
||||
final a = p.x * p.x % _p;
|
||||
final b = p.y * p.y % _p;
|
||||
final c = BigInt.two * p.z * p.z % _p;
|
||||
final d = _p - a; // a = -1 on this curve, so d = -A
|
||||
final e = _mod(_mod(p.x + p.y) * _mod(p.x + p.y) - a - b);
|
||||
final g = _mod(d + b);
|
||||
final f = _mod(g - c);
|
||||
final h = _mod(d - b);
|
||||
return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p);
|
||||
}
|
||||
|
||||
_Point _scalarMult(BigInt scalar, _Point point) {
|
||||
var result = _identity;
|
||||
for (var t = 254; t >= 0; t--) {
|
||||
result = _pointDouble(result);
|
||||
if ((scalar >> t) & BigInt.one == BigInt.one) result = _pointAdd(result, point);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Uint8List _encodePoint(_Point p) {
|
||||
final zInv = _powMod(p.z, _p - BigInt.two);
|
||||
final x = p.x * zInv % _p, y = p.y * zInv % _p;
|
||||
final out = bigIntToLeBytes(y, 32);
|
||||
out[31] |= (x & BigInt.one).toInt() << 7;
|
||||
return out;
|
||||
}
|
||||
|
||||
_Point _decodePoint(Uint8List bytes) {
|
||||
if (bytes.length != 32) {
|
||||
throw const SmolError("Ed25519 public key must be 32 bytes");
|
||||
}
|
||||
final sign = bytes[31] >> 7;
|
||||
final y = leBytesToBigInt(bytes) & _mask255;
|
||||
if (y >= _p) {
|
||||
throw const SmolError("non-canonical Ed25519 public key");
|
||||
}
|
||||
final u = _mod(y * y - BigInt.one), v = _mod(_d * y * y + BigInt.one);
|
||||
final v2 = v * v % _p, v3 = v2 * v % _p, v4 = v2 * v2 % _p;
|
||||
var x = u * v3 % _p * _powMod(u * v4 % _p * v3 % _p, (_p - BigInt.from(5)) ~/ BigInt.from(8)) % _p;
|
||||
if (_mod(v * x % _p * x) != u) {
|
||||
if (_mod(v * x % _p * x) == _mod(-u)) {
|
||||
x = x * _powMod(BigInt.two, (_p - BigInt.one) ~/ BigInt.from(4)) % _p;
|
||||
} else {
|
||||
throw const SmolError("not a point on the Ed25519 curve");
|
||||
}
|
||||
}
|
||||
if (x == BigInt.zero && sign == 1) {
|
||||
throw const SmolError("invalid sign bit on x = 0");
|
||||
}
|
||||
if ((x & BigInt.one).toInt() != sign) x = _p - x;
|
||||
return _Point.fromAffine(x, y);
|
||||
}
|
||||
|
||||
BigInt _seedToScalar(Uint8List seed) {
|
||||
final h = sha512(seed);
|
||||
return leBytesToBigInt(clampScalar(h.sublist(0, 32)));
|
||||
}
|
||||
|
||||
Uint8List ed25519PublicKey(Uint8List seed) {
|
||||
if (seed.length != 32) {
|
||||
throw const SmolError("identity seed must be 32 bytes");
|
||||
}
|
||||
return _encodePoint(_scalarMult(_seedToScalar(seed), _Point.fromAffine(_b.x, _b.y)));
|
||||
}
|
||||
|
||||
Uint8List ed25519Sign(Uint8List seed, List<int> message) {
|
||||
final h = sha512(seed);
|
||||
final a = leBytesToBigInt(clampScalar(h.sublist(0, 32)));
|
||||
final publicKey =
|
||||
_encodePoint(_scalarMult(a, _Point.fromAffine(_b.x, _b.y)));
|
||||
final r = leBytesToBigInt(sha512(concat([h.sublist(32), message]))) % _l;
|
||||
final rEnc = _encodePoint(_scalarMult(r, _Point.fromAffine(_b.x, _b.y)));
|
||||
final k = leBytesToBigInt(sha512(concat([rEnc, publicKey, message]))) % _l;
|
||||
return concat([rEnc, bigIntToLeBytes((r + k * a) % _l, 32)]);
|
||||
}
|
||||
|
||||
bool ed25519Verify(Uint8List publicKey, List<int> message, Uint8List signature) {
|
||||
if (signature.length != 64) return false;
|
||||
try {
|
||||
final decodedPk = _decodePoint(publicKey);
|
||||
final decodedR = _decodePoint(signature.sublist(0, 32));
|
||||
final a = _Point.fromAffine(decodedPk.x, decodedPk.y);
|
||||
final r = _Point.fromAffine(decodedR.x, decodedR.y);
|
||||
final s = leBytesToBigInt(signature.sublist(32, 64));
|
||||
if (s >= _l) return false;
|
||||
final k = leBytesToBigInt(sha512(concat([signature.sublist(0, 32), publicKey, message]))) % _l;
|
||||
final lhs = _scalarMult(s, _Point.fromAffine(_b.x, _b.y));
|
||||
final rhs = _pointAdd(_scalarMult(k, a), r);
|
||||
return lhs.x * rhs.z % _p == rhs.x * lhs.z % _p &&
|
||||
lhs.y * rhs.z % _p == rhs.y * lhs.z % _p;
|
||||
} on Exception {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- §2 conversions between the identity key and X25519 -------------------------
|
||||
|
||||
Uint8List ed25519ToX25519(Uint8List publicKey) {
|
||||
final y = leBytesToBigInt(publicKey) & _mask255;
|
||||
if (y >= _p) {
|
||||
throw const SmolError("non-canonical Ed25519 public key");
|
||||
}
|
||||
if (_mod(BigInt.one - y) == BigInt.zero) {
|
||||
throw const SmolError("identity element has no X25519 image");
|
||||
}
|
||||
return bigIntToLeBytes(
|
||||
_mod(BigInt.one + y) * _powMod(BigInt.one - y, _p - BigInt.two) % _p, 32);
|
||||
}
|
||||
|
||||
Uint8List ed25519SeedToX25519(Uint8List seed) =>
|
||||
clampScalar(sha512(seed).sublist(0, 32));
|
||||
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
// Noise_NX_25519_ChaChaPoly_SHA256 initiator (SPEC.md §4), rev-34 semantics.
|
||||
// The initiator is anonymous; the responder's static key arrives encrypted in
|
||||
// message two, which is what server pinning checks.
|
||||
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:smol_mail/smol/crypto.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
|
||||
const String _protocol = "Noise_NX_25519_ChaChaPoly_SHA256"; // exactly 32 bytes, so h = name
|
||||
|
||||
Uint8List _prologue() => utf8Bytes("smolmail/1");
|
||||
|
||||
// Noise's ChaChaPoly nonce: 4 zero bytes then the counter as u64 LE.
|
||||
Uint8List _nonce(int n) {
|
||||
final out = Uint8List(12);
|
||||
ByteData.view(out.buffer).setUint64(4, n, Endian.little);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// One direction of the post-handshake transport; tests substitute a
|
||||
/// passthrough so framing guards can be exercised without crypto.
|
||||
abstract class SessionCipher {
|
||||
Uint8List encrypt(Uint8List plaintext);
|
||||
|
||||
Uint8List decrypt(Uint8List sealed);
|
||||
}
|
||||
|
||||
// The key is unique per session, so the counter starting at zero is safe.
|
||||
class CipherState implements SessionCipher {
|
||||
final Uint8List key;
|
||||
int counter = 0;
|
||||
|
||||
CipherState(this.key);
|
||||
|
||||
@override
|
||||
Uint8List encrypt(Uint8List plaintext) {
|
||||
final sealed = aeadEncrypt(key, _nonce(counter), plaintext, Uint8List(0));
|
||||
counter++;
|
||||
return sealed;
|
||||
}
|
||||
|
||||
@override
|
||||
Uint8List decrypt(Uint8List sealed) {
|
||||
final plaintext = aeadDecrypt(key, _nonce(counter), sealed, Uint8List(0));
|
||||
counter++;
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
class NxResult {
|
||||
final CipherState send, recv;
|
||||
final Uint8List serverStatic;
|
||||
final Uint8List handshakeHash;
|
||||
|
||||
const NxResult(this.send, this.recv, this.serverStatic, this.handshakeHash);
|
||||
}
|
||||
|
||||
class NxInitiator {
|
||||
late Uint8List h;
|
||||
late Uint8List ck;
|
||||
Uint8List? key;
|
||||
late Uint8List esk;
|
||||
late Uint8List epk;
|
||||
|
||||
NxInitiator() {
|
||||
h = utf8Bytes(_protocol);
|
||||
ck = Uint8List.fromList(h);
|
||||
mixHash(_prologue());
|
||||
}
|
||||
|
||||
void mixHash(Uint8List data) {
|
||||
h = sha256(concat([h, data]));
|
||||
}
|
||||
|
||||
void mixKey(Uint8List ikm) {
|
||||
final okm = hkdfSha256(ikm, ck, Uint8List(0), 64);
|
||||
ck = okm.sublist(0, 32);
|
||||
key = okm.sublist(32);
|
||||
}
|
||||
|
||||
// Message one is just our ephemeral public key. No key is set yet, so the
|
||||
// empty payload travels in the clear — and is still mixed into h.
|
||||
Uint8List writeMessage1([Uint8List? esk]) {
|
||||
this.esk = esk ?? randomBytes(32);
|
||||
epk = x25519Base(this.esk);
|
||||
mixHash(epk);
|
||||
mixHash(Uint8List(0));
|
||||
return Uint8List.fromList(epk);
|
||||
}
|
||||
|
||||
// Message two: e (plaintext), ee, then the responder's static and the
|
||||
// (empty) payload as AEAD ciphertexts chained through h. Each MixKey
|
||||
// restarts the nonce at zero.
|
||||
NxResult readMessage2(Uint8List message) {
|
||||
if (message.length != 32 + 48 + 16) {
|
||||
throw SmolError("unexpected NX message length ${message.length}");
|
||||
}
|
||||
final re = message.sublist(0, 32);
|
||||
mixHash(re);
|
||||
mixKey(x25519(esk, re));
|
||||
final serverStatic = decryptAndHash(message.sublist(32, 80));
|
||||
mixKey(x25519(esk, serverStatic)); // es
|
||||
final payload = decryptAndHash(message.sublist(80));
|
||||
if (payload.isNotEmpty) throw const SmolError("unexpected payload in handshake");
|
||||
final handshakeHash = h;
|
||||
// Split(): two transport keys from the final chaining key, zero-length ikm
|
||||
final okm = hkdfSha256(Uint8List(0), ck, Uint8List(0), 64);
|
||||
return NxResult(CipherState(okm.sublist(0, 32)), CipherState(okm.sublist(32)),
|
||||
serverStatic, handshakeHash);
|
||||
}
|
||||
|
||||
Uint8List decryptAndHash(Uint8List sealed) {
|
||||
final plaintext = aeadDecrypt(key!, _nonce(0), sealed, h);
|
||||
mixHash(sealed);
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,663 +0,0 @@
|
|||
// Smol Mail protocol, version 1.1 (../smolmail SPEC.md): addresses, sealed
|
||||
// and signed envelopes, body frontmatter, key rotation, accept tokens, and
|
||||
// the framed request and response bodies of the five operations.
|
||||
|
||||
import "dart:math";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:smol_mail/smol/crypto.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/noise.dart";
|
||||
|
||||
const defaultPort = 1961;
|
||||
const keyLen = 32, sigLen = 64, certLen = 200, idLen = 32, tokenLen = 32;
|
||||
const maxFrame = 1 << 20, noisePayload = 65535 - 16, padTo = 1024;
|
||||
const envelopeHeader = 69, payloadHeader = 45, maxChain = 16;
|
||||
const maxSkew = 86400; // §5.3: how far ahead of our clock a payload may be dated
|
||||
const flagRequests = 0x01; // §6.1: set when a FETCH record missed an accept token
|
||||
const _frontmatterMax = 4096, _frontmatterKeys = 64;
|
||||
|
||||
const opAuth = 0x00, opResolve = 0x01, opSend = 0x02, opFetch = 0x03,
|
||||
opDelete = 0x04, opRegister = 0x05;
|
||||
|
||||
const _statusNames = {
|
||||
0: "ok", 1: "malformed", 2: "bad version", 3: "unknown user",
|
||||
4: "auth required", 5: "auth failed", 6: "quota exceeded", 7: "too large",
|
||||
8: "rate limited", 9: "not permitted", 10: "internal error",
|
||||
};
|
||||
|
||||
String statusName(int status) => _statusNames[status] ?? "$status";
|
||||
|
||||
final _label = (
|
||||
auth: utf8Bytes("smolmail/1 auth"),
|
||||
seal: utf8Bytes("smolmail/1 seal"),
|
||||
msg: utf8Bytes("smolmail/1 msg"),
|
||||
id: utf8Bytes("smolmail/1 id"),
|
||||
rotate: utf8Bytes("smolmail/1 rotate"),
|
||||
identity: utf8Bytes("smolmail/1 identity"),
|
||||
accept: utf8Bytes("smolmail/1 accept"),
|
||||
mac: utf8Bytes("smolmail/1 mac"),
|
||||
register: utf8Bytes("smolmail/1 register"),
|
||||
);
|
||||
|
||||
// --- encoding helpers ---------------------------------------------------------
|
||||
|
||||
const _b32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
String b32encode(List<int> bytes) {
|
||||
var out = "";
|
||||
var value = 0, bits = 0;
|
||||
for (final b in bytes) {
|
||||
value = (value << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
out += _b32[(value >>> bits) & 31];
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += _b32[(value << (5 - bits)) & 31];
|
||||
return out.toLowerCase();
|
||||
}
|
||||
|
||||
Uint8List b32decode(String text) {
|
||||
final out = <int>[];
|
||||
var value = 0, bits = 0;
|
||||
final clean = text.trim().toUpperCase().replaceAll(RegExp(r"=+$"), "");
|
||||
for (final ch in clean.split("")) {
|
||||
final idx = _b32.indexOf(ch);
|
||||
if (idx < 0) throw SmolError("invalid base32 character '$ch'");
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.add((value >>> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
return Uint8List.fromList(out);
|
||||
}
|
||||
|
||||
// §3: the first 20 base32 characters of the identity, in groups of four.
|
||||
String fingerprint(Uint8List identity) {
|
||||
final s = b32encode(identity).substring(0, 20);
|
||||
return RegExp(".{4}").allMatches(s).map((m) => m[0]).join(" ");
|
||||
}
|
||||
|
||||
Uint8List u16be(int n) => Uint8List.fromList([(n >> 8) & 0xff, n & 0xff]);
|
||||
|
||||
Uint8List u32be(int n) => Uint8List.fromList(
|
||||
[(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||||
|
||||
// Dart 3.2's ByteData has no setBigInt, so write big-endian manually.
|
||||
Uint8List i64be(BigInt n) {
|
||||
final out = Uint8List(8);
|
||||
for (var i = 0; i < 8; i++) {
|
||||
out[i] = ((n >> (8 * (7 - i))) & BigInt.from(0xff)).toInt();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int nowSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// Fail-closed reader; every parse raises rather than reading past the end.
|
||||
class Reader {
|
||||
final Uint8List buf;
|
||||
int pos = 0;
|
||||
|
||||
Reader(this.buf);
|
||||
|
||||
Uint8List take(int n) {
|
||||
if (n < 0 || pos + n > buf.length) throw const SmolError("truncated message");
|
||||
final out = buf.sublist(pos, pos + n);
|
||||
pos += n;
|
||||
return out;
|
||||
}
|
||||
|
||||
int u8() => take(1)[0];
|
||||
|
||||
int u16() {
|
||||
final b = take(2);
|
||||
return (b[0] << 8) | b[1];
|
||||
}
|
||||
|
||||
int u32() => ByteData.view(take(4).buffer).getUint32(0);
|
||||
|
||||
int i64() => ByteData.view(take(8).buffer).getInt64(0);
|
||||
|
||||
int get left => buf.length - pos;
|
||||
}
|
||||
|
||||
// --- identity -----------------------------------------------------------------
|
||||
|
||||
// §2: an Ed25519 keypair with the X25519 agreement keys derived from it.
|
||||
class SmolIdentity {
|
||||
final Uint8List seed;
|
||||
final Uint8List publicKey;
|
||||
|
||||
const SmolIdentity(this.seed, this.publicKey);
|
||||
}
|
||||
|
||||
SmolIdentity identityFromSeed(Uint8List seed) {
|
||||
if (seed.length != keyLen) {
|
||||
throw const SmolError("identity seed must be $keyLen bytes");
|
||||
}
|
||||
return SmolIdentity(seed, ed25519PublicKey(seed));
|
||||
}
|
||||
|
||||
SmolIdentity newIdentity() => identityFromSeed(randomBytes(keyLen));
|
||||
|
||||
// §2: the only secret a user holds. Everything else — every rotation index's
|
||||
// signing seed, and the accept key — is derived from it with HKDF.
|
||||
Uint8List identitySeed(Uint8List master, int index) =>
|
||||
hkdfSha256(master, Uint8List(0), concat([_label.identity, u32be(index)]));
|
||||
|
||||
Uint8List acceptKeyFor(Uint8List master) =>
|
||||
hkdfSha256(master, Uint8List(0), _label.accept);
|
||||
|
||||
// §5.8: the token this account issues to one correspondent, independent of
|
||||
// the rotation index so it survives the owner's key rotation.
|
||||
Uint8List tokenFor(Uint8List master, Uint8List correspondentIdentity) =>
|
||||
hmacSha256(acceptKeyFor(master), correspondentIdentity);
|
||||
|
||||
// §5.8: what a sender attaches to SEND to reach the recipient's main tier.
|
||||
Uint8List acceptMac(Uint8List token, Uint8List id) =>
|
||||
hmacSha256(token, concat([_label.mac, id]));
|
||||
|
||||
// --- addressing (§3) -----------------------------------------------------------
|
||||
|
||||
final _address =
|
||||
RegExp(r"^(?<user>[a-z0-9._-]{1,63})@(?<host>[^/:]+)(?::(?<port>\d+))?$");
|
||||
|
||||
const _separators = "._-";
|
||||
|
||||
// §3: alphanumeric at both ends, never two separators in a row.
|
||||
bool validUsername(String name) {
|
||||
if (name.isEmpty) return false;
|
||||
if (_separators.contains(name[0]) || _separators.contains(name[name.length - 1])) {
|
||||
return false;
|
||||
}
|
||||
for (var i = 0; i < name.length - 1; i++) {
|
||||
if (_separators.contains(name[i]) && _separators.contains(name[i + 1])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class SmolAddress {
|
||||
final String user;
|
||||
final String host;
|
||||
final int port;
|
||||
|
||||
/// The key carried by a `smol://` address; null for short addresses.
|
||||
final Uint8List? identity;
|
||||
|
||||
const SmolAddress(this.user, this.host, this.port, this.identity);
|
||||
|
||||
String get short =>
|
||||
"$user@$host${port == defaultPort ? "" : ":$port"}";
|
||||
|
||||
String uri(Uint8List key) =>
|
||||
"smol://$user@$host${port == defaultPort ? "" : ":$port"}/${b32encode(key)}";
|
||||
}
|
||||
|
||||
SmolAddress parseAddress(String text) {
|
||||
text = text.trim();
|
||||
Uint8List? identity;
|
||||
if (text.startsWith("smol://")) {
|
||||
final rest = text.substring("smol://".length);
|
||||
final slash = rest.lastIndexOf("/");
|
||||
if (slash < 0) throw SmolError("$text: smol:// address carries no key");
|
||||
identity = b32decode(rest.substring(slash + 1));
|
||||
if (identity.length != keyLen) {
|
||||
throw SmolError(
|
||||
"$text: key is ${identity.length} bytes, expected $keyLen");
|
||||
}
|
||||
text = rest.substring(0, slash);
|
||||
}
|
||||
final m = _address.firstMatch(text.toLowerCase());
|
||||
if (m == null) throw SmolError("'$text' is not a valid address");
|
||||
final user = m.namedGroup("user")!;
|
||||
final host = m.namedGroup("host")!;
|
||||
if (!validUsername(user)) {
|
||||
throw SmolError("$user must begin and end with a letter or digit "
|
||||
"and may not contain two separators in a row");
|
||||
}
|
||||
final portText = m.namedGroup("port");
|
||||
final port = portText != null ? int.parse(portText) : defaultPort;
|
||||
return SmolAddress(user, host, port, identity);
|
||||
}
|
||||
|
||||
// --- message format (§5) -------------------------------------------------------
|
||||
|
||||
// §5.4: derived from the envelope so no sender can choose it; used whole,
|
||||
// nothing truncates it.
|
||||
Uint8List messageId(List<int> envelope) => sha256(concat([_label.id, envelope]));
|
||||
|
||||
class OpenedMessage {
|
||||
final Uint8List sender;
|
||||
final int time;
|
||||
final Uint8List body;
|
||||
final Uint8List id;
|
||||
|
||||
const OpenedMessage(this.sender, this.time, this.body, this.id);
|
||||
}
|
||||
|
||||
/// Options for [seal]; [esk] and [pad] exist so tests can pin them, mirroring
|
||||
/// the spec's fixed-ephemeral vectors.
|
||||
class SealOptions {
|
||||
final Uint8List? esk;
|
||||
final bool pad;
|
||||
|
||||
const SealOptions({this.esk, this.pad = true});
|
||||
}
|
||||
|
||||
// §5.2 and §5.3. The ephemeral key is thrown away after sealing, so the sender
|
||||
// cannot decrypt what they sent.
|
||||
Uint8List seal(SmolIdentity identity, Uint8List recipient, Uint8List body,
|
||||
[int? when, SealOptions opts = const SealOptions()]) {
|
||||
final esk = opts.esk ?? randomBytes(keyLen);
|
||||
final epk = x25519Base(esk);
|
||||
final key = hkdfSha256(x25519(esk, ed25519ToX25519(recipient)),
|
||||
concat([epk, recipient]), _label.seal);
|
||||
final header = concat([
|
||||
Uint8List.fromList([1]),
|
||||
identity.publicKey,
|
||||
i64be(BigInt.from(when ?? nowSeconds())),
|
||||
u32be(body.length),
|
||||
]);
|
||||
var plaintext = concat([
|
||||
header,
|
||||
body,
|
||||
ed25519Sign(identity.seed, concat([_label.msg, recipient, epk, header, body])),
|
||||
]);
|
||||
if (opts.pad) {
|
||||
plaintext = concat(
|
||||
[plaintext, Uint8List((padTo - plaintext.length % padTo) % padTo)]);
|
||||
}
|
||||
final aad = concat([utf8Bytes("SMOL"), Uint8List.fromList([1]), recipient, epk]);
|
||||
return concat([aad, aeadEncrypt(key, Uint8List(12), plaintext, aad)]);
|
||||
}
|
||||
|
||||
// Inverse of seal(); throws unless the signature and the recipient both check
|
||||
// out. [identities] may include retired keys, per §7.
|
||||
OpenedMessage unseal(List<SmolIdentity> identities, Uint8List envelope) {
|
||||
if (envelope.length < envelopeHeader + 16) {
|
||||
throw const SmolError("envelope too short");
|
||||
}
|
||||
final magic = utf8Bytes("SMOL");
|
||||
for (var i = 0; i < 4; i++) {
|
||||
if (magic[i] != envelope[i]) {
|
||||
throw const SmolError("not a Smol Mail envelope");
|
||||
}
|
||||
}
|
||||
if (envelope[4] != 1) {
|
||||
throw SmolError("unsupported envelope version ${envelope[4]}");
|
||||
}
|
||||
final to = envelope.sublist(5, 37), epk = envelope.sublist(37, 69);
|
||||
final sealed = envelope.sublist(69);
|
||||
SmolIdentity? me;
|
||||
for (final i in identities) {
|
||||
if (timingSafeEqual(i.publicKey, to)) {
|
||||
me = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (me == null) {
|
||||
throw SmolError(
|
||||
"addressed to ${b32encode(to).substring(0, 16)}…, not one of our keys");
|
||||
}
|
||||
final key = hkdfSha256(
|
||||
x25519(ed25519SeedToX25519(me.seed), epk), concat([epk, to]), _label.seal);
|
||||
Uint8List plaintext;
|
||||
try {
|
||||
plaintext = aeadDecrypt(key, Uint8List(12), sealed, envelope.sublist(0, envelopeHeader));
|
||||
} on SmolError {
|
||||
throw const SmolError("decryption failed: wrong key or corrupt envelope");
|
||||
}
|
||||
final r = Reader(plaintext);
|
||||
if (r.u8() != 1) throw const SmolError("unsupported payload version");
|
||||
final sender = r.take(keyLen);
|
||||
final when = r.i64();
|
||||
final bodyLen = r.u32();
|
||||
if (bodyLen > r.left) {
|
||||
throw const SmolError("payload body length exceeds the payload");
|
||||
}
|
||||
final body = r.take(bodyLen);
|
||||
final signature = r.take(sigLen); // trailing bytes are padding
|
||||
if (!ed25519Verify(sender,
|
||||
concat([_label.msg, to, epk, plaintext.sublist(0, payloadHeader), body]),
|
||||
signature)) {
|
||||
throw const SmolError("signature does not verify");
|
||||
}
|
||||
if (when > nowSeconds() + maxSkew) {
|
||||
throw const SmolError("payload is dated in the future");
|
||||
}
|
||||
return OpenedMessage(sender, when, body, messageId(envelope));
|
||||
}
|
||||
|
||||
// --- body frontmatter (§5.5) ---------------------------------------------------
|
||||
|
||||
final _fmKey = RegExp(r"^[A-Za-z0-9-]{1,64}$");
|
||||
|
||||
class Frontmatter {
|
||||
final Map<String, String> fields;
|
||||
final String body;
|
||||
|
||||
const Frontmatter(this.fields, this.body);
|
||||
}
|
||||
|
||||
// A flat `Key: value` block, deliberately not YAML. Any malformed line
|
||||
// invalidates the whole block, which is then returned as ordinary body text:
|
||||
// frontmatter fails closed toward display, never toward silent discard. Keys
|
||||
// are compared case-insensitively (§5.5), so they are kept lowercased.
|
||||
Frontmatter parseFrontmatter(String text) {
|
||||
if (!text.startsWith("---\n")) return Frontmatter(const {}, text);
|
||||
final lines = text.split("\n");
|
||||
final close = lines.indexOf("---", 1);
|
||||
if (close < 0) return Frontmatter(const {}, text);
|
||||
final block = lines.sublist(1, close);
|
||||
final rest = lines.sublist(close + 1).join("\n");
|
||||
var encoded = 0;
|
||||
for (final line in block) {
|
||||
encoded += utf8Bytes(line).length + 1;
|
||||
}
|
||||
if (block.length > _frontmatterKeys || encoded > _frontmatterMax) {
|
||||
return Frontmatter(const {}, text);
|
||||
}
|
||||
final fields = <String, String>{};
|
||||
for (final line in block) {
|
||||
final colon = line.indexOf(":");
|
||||
final head = colon < 0 ? "" : line.substring(0, colon);
|
||||
if (colon < 0 || !_fmKey.hasMatch(head)) {
|
||||
return Frontmatter(const {}, text);
|
||||
}
|
||||
// first occurrence wins
|
||||
fields.putIfAbsent(head.toLowerCase(), () => line.substring(colon + 1).trim());
|
||||
}
|
||||
return Frontmatter(fields, rest);
|
||||
}
|
||||
|
||||
// Emit a block only when needed, including to escape a body that genuinely
|
||||
// begins with `---` (§5.5).
|
||||
String buildFrontmatter(Map<String, String> fields, String body) {
|
||||
final entries = fields.entries.where((e) => e.value.isNotEmpty).toList();
|
||||
if (entries.isEmpty && !body.startsWith("---\n")) return body;
|
||||
final block = entries.map((e) => "${e.key}: ${e.value}\n").join();
|
||||
return "---\n$block---\n$body";
|
||||
}
|
||||
|
||||
// --- key rotation (§7) ---------------------------------------------------------
|
||||
|
||||
// §7: old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both
|
||||
// keys sign, so the old key alone cannot hand the username to a key nobody
|
||||
// controls; the username is covered but not carried, so a verifier always
|
||||
// supplies the one it is checking.
|
||||
Uint8List makeCert(
|
||||
String username, SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) {
|
||||
final newIdentity = identityFromSeed(newSeed);
|
||||
final time = i64be(BigInt.from(when ?? nowSeconds()));
|
||||
final signed = concat(
|
||||
[_label.rotate, utf8Bytes(username), oldIdentity.publicKey, newIdentity.publicKey, time]);
|
||||
return concat([
|
||||
oldIdentity.publicKey,
|
||||
newIdentity.publicKey,
|
||||
time,
|
||||
ed25519Sign(oldIdentity.seed, signed),
|
||||
ed25519Sign(newIdentity.seed, signed),
|
||||
]);
|
||||
}
|
||||
|
||||
// Accept a key change only when a signed chain leads from the key we hold to
|
||||
// the one the server now returns, both keys signing each link (§7).
|
||||
bool walkChain(
|
||||
String username, Uint8List pinned, Uint8List current, List<Uint8List> chain) {
|
||||
if (timingSafeEqual(pinned, current)) return true;
|
||||
if (chain.isEmpty || chain.length > maxChain) return false;
|
||||
var key = pinned;
|
||||
var started = false;
|
||||
for (final cert in chain) {
|
||||
final old = cert.sublist(0, 32), next = cert.sublist(32, 64);
|
||||
final when = cert.sublist(64, 72);
|
||||
final sigOld = cert.sublist(72, 136), sigNew = cert.sublist(136, 200);
|
||||
if (!started) {
|
||||
if (!timingSafeEqual(old, key)) continue; // a link predating the key we hold
|
||||
started = true;
|
||||
} else if (!timingSafeEqual(old, key)) {
|
||||
return false; // the chain is not continuous
|
||||
}
|
||||
final signed = concat([_label.rotate, utf8Bytes(username), old, next, when]);
|
||||
if (!ed25519Verify(old, signed, sigOld) || !ed25519Verify(next, signed, sigNew)) {
|
||||
return false;
|
||||
}
|
||||
key = next;
|
||||
}
|
||||
return started && timingSafeEqual(key, current);
|
||||
}
|
||||
|
||||
// --- framing and operations (§4, §6) -------------------------------------------
|
||||
|
||||
/// An ordered byte pipe (TCP socket, or an in-memory queue in tests).
|
||||
abstract class Wire {
|
||||
void send(Uint8List bytes);
|
||||
|
||||
void close();
|
||||
|
||||
Future<Uint8List> readExact(int n);
|
||||
}
|
||||
|
||||
// One Noise session: application frames split across u16-prefixed Noise
|
||||
// messages, requests and responses as in §6.1.
|
||||
class Session {
|
||||
final Wire wire;
|
||||
final SessionCipher send, recv;
|
||||
|
||||
Session(this.wire, this.send, this.recv);
|
||||
|
||||
Future<Uint8List> _readNoise() async {
|
||||
final head = await wire.readExact(2);
|
||||
final length = (head[0] << 8) | head[1];
|
||||
if (length < 16) throw SmolError("server sent a $length-byte Noise message");
|
||||
return recv.decrypt(await wire.readExact(length));
|
||||
}
|
||||
|
||||
Future<Response> call(int op, [Uint8List? body]) async {
|
||||
final payload = body ?? Uint8List(0);
|
||||
final frame = concat([u32be(1 + payload.length), Uint8List.fromList([op]), payload]);
|
||||
if (frame.length > maxFrame + 4) {
|
||||
throw const SmolError("request exceeds the maximum frame size");
|
||||
}
|
||||
for (var off = 0; off < frame.length; off += noisePayload) {
|
||||
final packet = send.encrypt(frame.sublist(off, min(off + noisePayload, frame.length)));
|
||||
wire.send(concat([u16be(packet.length), packet]));
|
||||
}
|
||||
var length = -1;
|
||||
var have = <int>[];
|
||||
while (length < 0 || have.length < 4 + length) {
|
||||
have.addAll(await _readNoise());
|
||||
if (length < 0 && have.length >= 4) {
|
||||
length = (have[0] << 24) | (have[1] << 16) | (have[2] << 8) | have[3];
|
||||
// §6.1: the shortest response is a type byte and a status byte.
|
||||
if (length < 2 || length > maxFrame) {
|
||||
throw SmolError("server sent a frame of length $length");
|
||||
}
|
||||
}
|
||||
}
|
||||
final payloadOut = Uint8List.fromList(have.sublist(4, 4 + length));
|
||||
// §6.1: a response reuses the request's type byte. A mismatch means the
|
||||
// session desynchronised, which must not be mistaken for a status.
|
||||
if (payloadOut[0] != op) {
|
||||
throw SmolError(
|
||||
"server answered op 0x${payloadOut[0].toRadixString(16)}, expected 0x${op.toRadixString(16)}");
|
||||
}
|
||||
return Response(payloadOut[1], payloadOut.sublist(2));
|
||||
}
|
||||
}
|
||||
|
||||
class Response {
|
||||
final int status;
|
||||
final Uint8List body;
|
||||
|
||||
const Response(this.status, this.body);
|
||||
}
|
||||
|
||||
class OpenedSession {
|
||||
final Session session;
|
||||
final Uint8List serverStatic;
|
||||
final bool pinned;
|
||||
final Uint8List handshakeHash;
|
||||
|
||||
const OpenedSession(this.session, this.serverStatic, this.pinned, this.handshakeHash);
|
||||
}
|
||||
|
||||
// Handshake plus §4 pinning. Returns the session, the server's static key as
|
||||
// revealed by the handshake, and whether that key was already pinned.
|
||||
Future<OpenedSession> openSession(Wire wire, String host,
|
||||
{Uint8List? pinned}) async {
|
||||
final nx = NxInitiator();
|
||||
final m1 = nx.writeMessage1();
|
||||
wire.send(concat([u16be(m1.length), m1]));
|
||||
final head = await wire.readExact(2);
|
||||
final result = nx.readMessage2(await wire.readExact((head[0] << 8) | head[1]));
|
||||
if (pinned != null && !timingSafeEqual(pinned, result.serverStatic)) {
|
||||
throw SmolError("$host presented a different key than the one pinned\n"
|
||||
" pinned: ${b32encode(pinned)}\n"
|
||||
" presented: ${b32encode(result.serverStatic)}");
|
||||
}
|
||||
return OpenedSession(
|
||||
Session(wire, result.send, result.recv),
|
||||
result.serverStatic,
|
||||
pinned != null,
|
||||
result.handshakeHash);
|
||||
}
|
||||
|
||||
void expectOk(int status, String what) {
|
||||
if (status != 0) {
|
||||
throw SmolError("$what failed: ${statusName(status)} ($status)");
|
||||
}
|
||||
}
|
||||
|
||||
// §4 session authentication: sign the handshake hash, which binds the
|
||||
// signature to this session's server ephemeral and cannot be replayed, and
|
||||
// push the accept token set (§5.8). `sync = 0` leaves the server's stored set
|
||||
// untouched and `tokens` MUST then be empty; `sync = 1` replaces it exactly.
|
||||
// Returns the number of accept tokens the server now holds.
|
||||
Future<int> authenticate(Session session, Uint8List handshakeHash, String username,
|
||||
SmolIdentity identity, {required int sync, List<Uint8List> tokens = const []}) async {
|
||||
final name = utf8Bytes(username);
|
||||
if (name.length > 255) throw const SmolError("username too long");
|
||||
if (tokens.length > 0xffff) throw const SmolError("too many accept tokens for one AUTH");
|
||||
final body = concat([
|
||||
Uint8List.fromList([name.length]),
|
||||
name,
|
||||
identity.publicKey,
|
||||
ed25519Sign(identity.seed, concat([_label.auth, handshakeHash])),
|
||||
Uint8List.fromList([sync]),
|
||||
u16be(tokens.length),
|
||||
...tokens,
|
||||
]);
|
||||
final response = await session.call(opAuth, body);
|
||||
expectOk(response.status, "authentication");
|
||||
return Reader(response.body).u16();
|
||||
}
|
||||
|
||||
class Resolved {
|
||||
final Uint8List identity;
|
||||
final List<Uint8List> chain;
|
||||
|
||||
const Resolved(this.identity, this.chain);
|
||||
}
|
||||
|
||||
// RESOLVE, returning the current key and its rotation chain (§6.1).
|
||||
Future<Resolved> resolveOp(Session session, String user) async {
|
||||
final name = utf8Bytes(user);
|
||||
if (name.length > 255) throw const SmolError("username too long");
|
||||
final response =
|
||||
await session.call(opResolve, concat([Uint8List.fromList([name.length]), name]));
|
||||
expectOk(response.status, "resolving $user");
|
||||
final r = Reader(response.body);
|
||||
return Resolved(
|
||||
r.take(keyLen),
|
||||
List.generate(r.u8(), (_) => r.take(certLen)));
|
||||
}
|
||||
|
||||
// §5.8: [mac] is the sender's proof of an accept token, 0 or 32 bytes.
|
||||
Future<Uint8List> sendOp(Session session, Uint8List envelope, {Uint8List? mac}) async {
|
||||
final macBytes = mac ?? Uint8List(0);
|
||||
if (macBytes.isNotEmpty && macBytes.length != tokenLen) {
|
||||
throw const SmolError("accept MAC must be $tokenLen bytes");
|
||||
}
|
||||
final body = concat([Uint8List.fromList([macBytes.length]), macBytes, envelope]);
|
||||
final response = await session.call(opSend, body);
|
||||
expectOk(response.status, "sending");
|
||||
return response.body.length == idLen
|
||||
? response.body
|
||||
: messageId(envelope);
|
||||
}
|
||||
|
||||
class FetchedRecord {
|
||||
final Uint8List id;
|
||||
final int receivedAt;
|
||||
final int flags;
|
||||
final Uint8List envelope;
|
||||
|
||||
const FetchedRecord(this.id, this.receivedAt, this.flags, this.envelope);
|
||||
|
||||
// §6.1: bit 0 is set when the message arrived without a matching accept token.
|
||||
bool get isRequest => flags & flagRequests != 0;
|
||||
}
|
||||
|
||||
// §6.1: pages forward from a cursor; an all-zero id starts at the beginning.
|
||||
Future<List<FetchedRecord>> fetchOp(
|
||||
Session session, int afterReceivedAt, Uint8List afterId) async {
|
||||
final body = concat([i64be(BigInt.from(afterReceivedAt)), afterId]);
|
||||
final response = await session.call(opFetch, body);
|
||||
expectOk(response.status, "fetching");
|
||||
final r = Reader(response.body);
|
||||
return List.generate(r.u16(), (_) {
|
||||
final id = r.take(idLen);
|
||||
final receivedAt = r.i64();
|
||||
final flags = r.u8();
|
||||
return FetchedRecord(id, receivedAt, flags, r.take(r.u32()));
|
||||
});
|
||||
}
|
||||
|
||||
Future<int> deleteOp(Session session, List<Uint8List> ids) async {
|
||||
if (ids.length > 0xffff) throw const SmolError("too many ids for one DELETE");
|
||||
final body = concat([u16be(ids.length), ...ids]);
|
||||
final response = await session.call(opDelete, body);
|
||||
expectOk(response.status, "acknowledging");
|
||||
return Reader(response.body).u16();
|
||||
}
|
||||
|
||||
class RegisterOptions {
|
||||
final String token;
|
||||
final Uint8List? cert;
|
||||
|
||||
const RegisterOptions({this.token = "", this.cert});
|
||||
}
|
||||
|
||||
// §6.1: the signature is proof of possession, bound to the server that will
|
||||
// store the binding so it cannot be replayed to another server.
|
||||
Uint8List registerSigned(Uint8List serverStatic, String username, Uint8List identity) =>
|
||||
concat([_label.register, serverStatic, utf8Bytes(username), identity]);
|
||||
|
||||
Future<void> registerOp(Session session, Uint8List serverStatic, String username,
|
||||
SmolIdentity identity, [RegisterOptions opts = const RegisterOptions()]) async {
|
||||
final name = utf8Bytes(username);
|
||||
final tokenBytes = utf8Bytes(opts.token);
|
||||
final cert = opts.cert ?? Uint8List(0);
|
||||
if (name.length > 255 || tokenBytes.length > 255 || cert.length > 255) {
|
||||
throw const SmolError("REGISTER field too long");
|
||||
}
|
||||
final body = concat([
|
||||
Uint8List.fromList([name.length]),
|
||||
name,
|
||||
identity.publicKey,
|
||||
ed25519Sign(identity.seed, registerSigned(serverStatic, username, identity.publicKey)),
|
||||
Uint8List.fromList([tokenBytes.length]),
|
||||
tokenBytes,
|
||||
Uint8List.fromList([cert.length]),
|
||||
cert,
|
||||
]);
|
||||
expectOk((await session.call(opRegister, body)).status, "registering $username");
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
// The byte pipe to a smolmaild server: raw TCP (dart:io), framed elsewhere.
|
||||
// Mobile is this app's only target platform, so dart:io is fine here.
|
||||
|
||||
import "dart:async";
|
||||
import "dart:io";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/proto.dart";
|
||||
|
||||
/// Buffers the socket's stream and serves exact-length reads, so protocol
|
||||
/// code never sees a partial frame.
|
||||
class TcpWire implements Wire {
|
||||
final Socket _socket;
|
||||
final _chunks = <Uint8List>[];
|
||||
final _waiters = <_ReadRequest>[];
|
||||
int _buffered = 0;
|
||||
Object? _closed;
|
||||
late final StreamSubscription<Uint8List> _subscription;
|
||||
|
||||
TcpWire(this._socket) {
|
||||
_subscription = _socket.listen(_onData,
|
||||
onError: (Object error) => _fail(error),
|
||||
onDone: () => _fail(const SmolError("server closed the connection")));
|
||||
}
|
||||
|
||||
static Future<TcpWire> connect(String host, int port) async {
|
||||
try {
|
||||
// Mobile networks routinely need longer than a LAN handshake; 30s keeps
|
||||
// flaky handovers from surfacing as user-facing timeouts.
|
||||
return TcpWire(await Socket.connect(host, port,
|
||||
timeout: const Duration(seconds: 30)));
|
||||
} on SocketException catch (error) {
|
||||
throw SmolError("cannot reach $host:$port (${error.message})");
|
||||
}
|
||||
}
|
||||
|
||||
void _onData(Uint8List data) {
|
||||
_chunks.add(data);
|
||||
_buffered += data.length;
|
||||
_wake();
|
||||
}
|
||||
|
||||
void _wake() {
|
||||
_waiters.removeWhere((w) {
|
||||
if (_closed != null) {
|
||||
w.completer.completeError(_closed!);
|
||||
return true;
|
||||
}
|
||||
if (_buffered >= w.need) {
|
||||
w.completer.complete();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
void _fail(Object error) {
|
||||
_closed = error;
|
||||
for (final w in _waiters) {
|
||||
w.completer.completeError(error);
|
||||
}
|
||||
_waiters.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void send(Uint8List bytes) {
|
||||
if (_closed != null) throw _closed!;
|
||||
_socket.add(bytes);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_subscription.cancel();
|
||||
_socket.destroy();
|
||||
_fail(const SmolError("connection closed"));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List> readExact(int n) async {
|
||||
if (_closed != null) throw _closed!;
|
||||
if (_buffered < n) {
|
||||
final request = _ReadRequest(n);
|
||||
_waiters.add(request);
|
||||
try {
|
||||
await request.completer.future;
|
||||
} finally {
|
||||
_waiters.remove(request);
|
||||
}
|
||||
if (_closed != null) throw _closed!;
|
||||
}
|
||||
final out = Uint8List(n);
|
||||
var off = 0;
|
||||
while (off < n) {
|
||||
final chunk = _chunks.first;
|
||||
final take = chunk.length < n - off ? chunk.length : n - off;
|
||||
out.setRange(off, off + take, chunk);
|
||||
if (take == chunk.length) {
|
||||
_chunks.removeAt(0);
|
||||
} else {
|
||||
_chunks[0] = Uint8List.sublistView(chunk, take);
|
||||
}
|
||||
off += take;
|
||||
_buffered -= take;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadRequest {
|
||||
final int need;
|
||||
final completer = Completer<void>();
|
||||
|
||||
_ReadRequest(this.need);
|
||||
}
|
||||
24
lib/smol/ui.dart
Normal file
24
lib/smol/ui.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Display helpers for the keys and ids the native library hands back as
|
||||
// base32 and hex — formatting only, no protocol meaning.
|
||||
|
||||
import "dart:typed_data";
|
||||
|
||||
/// Lowercase hex, as the message ids cross the ABI.
|
||||
String hex(List<int> bytes) =>
|
||||
bytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join();
|
||||
|
||||
/// The compact human check for a key: the first 20 base32 characters in
|
||||
/// groups of four, the same shape every smol client shows.
|
||||
String fingerprint(String keyB32) {
|
||||
final s = keyB32.substring(0, 20);
|
||||
return RegExp(".{4}").allMatches(s).map((m) => m[0]).join(" ");
|
||||
}
|
||||
|
||||
/// The inverse of [hex]; throws on anything that is not even-length hex.
|
||||
Uint8List unhex(String text) {
|
||||
if (text.length % 2 != 0) {
|
||||
throw FormatException("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