feat: replace mail protocol with smolmail and rebuild the UI

This commit is contained in:
randogoth 2026-09-26 22:45:38 +03:00
parent cb24b68069
commit e94e4158cc
93 changed files with 6387 additions and 3479 deletions

426
lib/smol/client.dart Normal file
View file

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

26
lib/smol/config.dart Normal file
View file

@ -0,0 +1,26 @@
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 \
// --dart-define=SMOL_PRESET_SERVER_KEY=base32key
// It only makes sense when whoever ships this app and whoever runs that
// smolmaild are the same trusted party: the value arrives with the app
// itself, which is the trusted channel SPEC.md §4 asks a pin to come from.
// 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.
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
}
}

435
lib/smol/crypto.dart Normal file
View file

@ -0,0 +1,435 @@
// 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));

10
lib/smol/errors.dart Normal file
View file

@ -0,0 +1,10 @@
/// Raised for protocol-level failures (malformed envelopes, bad statuses,
/// unpinned servers); programmer errors keep throwing normally.
class SmolError implements Exception {
final String message;
const SmolError(this.message);
@override
String toString() => message;
}

118
lib/smol/noise.dart Normal file
View file

@ -0,0 +1,118 @@
// 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;
}
}

580
lib/smol/proto.dart Normal file
View file

@ -0,0 +1,580 @@
// Smol Mail protocol, version 1 (../smolmail SPEC.md): addresses, sealed and
// signed envelopes, body frontmatter, key rotation, 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 = 136, idLen = 16;
const maxFrame = 1 << 20, noisePayload = 65535 - 16, padTo = 1024;
const envelopeHeader = 69, payloadHeader = 45, maxChain = 16;
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"),
);
// --- 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));
// --- addressing (§3) -----------------------------------------------------------
final _address =
RegExp(r"^(?<user>[a-z0-9._-]{1,63})@(?<host>[^/:]+)(?::(?<port>\d+))?$");
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 ("._-".contains(user[0]) || "._-".contains(user[user.length - 1])) {
throw SmolError("$user may not begin or end with a separator");
}
final portText = m.namedGroup("port");
final port = portText != null ? int.parse(portText) : defaultPort;
return SmolAddress(user, host, port, identity);
}
// --- message format (§5) -------------------------------------------------------
Uint8List messageId(List<int> envelope) =>
sha256(concat([_label.id, envelope])).sublist(0, idLen);
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");
}
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.
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, () => 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) ---------------------------------------------------------
Uint8List makeCert(SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) {
final newPub = ed25519PublicKey(newSeed);
final time = i64be(BigInt.from(when ?? nowSeconds()));
return concat([
oldIdentity.publicKey,
newPub,
time,
ed25519Sign(oldIdentity.seed,
concat([_label.rotate, oldIdentity.publicKey, newPub, time])),
]);
}
// Accept a key change only when a signed chain leads from the key we hold to
// the one the server now returns (§7).
bool walkChain(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), sig = cert.sublist(72);
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
}
if (!ed25519Verify(old, concat([_label.rotate, old, next, when]), sig)) {
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.
Future<void> authenticate(
Session session, Uint8List handshakeHash, String username, SmolIdentity identity) async {
final name = utf8Bytes(username);
if (name.length > 255) throw const SmolError("username too long");
final body = concat([
Uint8List.fromList([name.length]),
name,
identity.publicKey,
ed25519Sign(identity.seed, concat([_label.auth, handshakeHash])),
]);
expectOk((await session.call(opAuth, body)).status, "authentication");
}
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)));
}
Future<Uint8List> sendOp(Session session, Uint8List envelope) async {
final response = await session.call(opSend, envelope);
expectOk(response.status, "sending");
return response.body.length == idLen
? response.body
: messageId(envelope);
}
class FetchedRecord {
final Uint8List id;
final int receivedAt;
final Uint8List envelope;
const FetchedRecord(this.id, this.receivedAt, this.envelope);
}
Future<List<FetchedRecord>> fetchOp(Session session) async {
final response = await session.call(opFetch);
expectOk(response.status, "fetching");
final r = Reader(response.body);
return List.generate(r.u16(), (_) {
final id = r.take(idLen);
final receivedAt = r.i64();
return FetchedRecord(id, receivedAt, 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});
}
Future<void> registerOp(
Session session, 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,
Uint8List.fromList([tokenBytes.length]),
tokenBytes,
Uint8List.fromList([cert.length]),
cert,
]);
expectOk((await session.call(opRegister, body)).status, "registering $username");
}

463
lib/smol/store.dart Normal file
View file

@ -0,0 +1,463 @@
// Device state: identity, pins, contacts and read markers in one JSON blob;
// sealed envelopes in a second Hive box, opened only on demand, so nothing at
// rest is plaintext (the seed excepted — the device's app storage is the trust
// boundary, like gsmol's browser profile).
import "dart:convert";
import "dart:typed_data";
import "package:hive_flutter/hive_flutter.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/proto.dart";
const _stateBox = "smol";
const _mailBox = "mail";
const _stateKey = "state";
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.
class ContactHistoryEntry {
final Uint8List key;
final int until; // epoch ms of the displacement
const ContactHistoryEntry(this.key, this.until);
}
class StoredContact {
final Uint8List key;
final bool verified;
final List<ContactHistoryEntry> history;
const StoredContact(this.key, this.verified, [this.history = const []]);
}
class MailRecord {
final String id; // hex of the 16-byte message id
final Uint8List envelope;
final int? receivedAt;
final String? recipient; // sent copies only
final int? sentAt;
const MailRecord(this.id, this.envelope,
{this.receivedAt, this.recipient, this.sentAt});
}
class ImportSummary {
int pinsAdded = 0, pinsConflicted = 0, contactsAdded = 0,
contactsConflicted = 0, mailAdded = 0, malformed = 0;
@override
String toString() =>
"$mailAdded messages, $contactsAdded contacts ($contactsConflicted conflicted), "
"$pinsAdded server keys ($pinsConflicted conflicted), $malformed malformed";
}
class SmolStore {
final Box _state;
final Box _mail;
SmolStore(this._state, this._mail);
/// [stateBox]/[mailBox] exist so tests can hold several isolated stores
/// in one process; production always uses the defaults.
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 --------------------------------------------------------------
Uint8List? seed() {
final raw = _load()["seed"];
return raw == null ? null : unhex(raw as String);
}
/// The active identity, or null before the user creates or restores one.
SmolIdentity? identity() {
final s = seed();
return s == null ? null : identityFromSeed(s);
}
void setIdentity(Uint8List newSeed) {
if (seed() != null) {
throw const SmolIdentityExistsException();
}
_update((state) => state..["seed"] = hex(newSeed));
}
// Rotation (§7): the old seed is retained, since mail sealed to a
// superseded key is readable with nothing else.
void rotateIdentity(Uint8List newSeed) {
final old = seed();
if (old == null) throw const SmolNoIdentityException();
_update((state) {
final retired = (state["retired"] as List? ?? [])
..add({"seed": hex(old), "at": DateTime.now().millisecondsSinceEpoch});
state["retired"] = retired;
state["seed"] = hex(newSeed);
return state;
});
}
/// §7: seeds rotated away from are retained, since mail sealed to a
/// superseded key is readable with nothing else.
List<SmolIdentity> identities() {
final s = seed();
if (s == null) return const [];
final retired = (_load()["retired"] as List? ?? const [])
.whereType<Map>()
.map((entry) => identityFromSeed(unhex(entry["seed"] as String)));
return [identityFromSeed(s), ...retired];
}
// --- 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);
}
void setAccount(SmolAddress address) {
_update((state) => state
..["account"] = {
"user": address.user,
"host": address.host,
"port": address.port,
});
}
Uint8List? serverPin(String host) {
final raw = ((_load()["servers"] as Map?) ?? {})[host];
return raw == null ? null : b32decode(raw as String);
}
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;
});
}
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))];
}
// --- 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;
}
return null;
}
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)!)];
}
// --- 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,
};
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?,
);
static String mailKey(String folder, String id) => "$folder/$id";
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 prefix = "$folder/";
final rows = <MailRecord>[];
for (final key in _mail.keys.cast<String>()) {
if (!key.startsWith(prefix)) continue;
final row = _mail.get(key);
if (row is Map) rows.add(_mapToRecord(row));
}
rows.sort((a, b) =>
(b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0));
return rows;
}
MailRecord? getMessage(String folder, String id) {
final row = _mail.get(mailKey(folder, id));
return row is Map ? _mapToRecord(row) : null;
}
Future<void> deleteMessage(String folder, String id) =>
_mail.delete(mailKey(folder, id));
// --- export / import: mail, contacts, pins — never the seed --------------------
/// The marker matches gsmol's web export, so backups move between the two
/// clients. Deliberately excludes the seed: it has its own reveal-and-copy
/// flow in settings, meant for a password manager, not a shareable file.
Map<String, dynamic> exportData() {
final state = _load();
final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>();
return {
"gsmolExport": 1,
"exportedAt": DateTime.now().millisecondsSinceEpoch,
"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"))
{
"id": row.id,
"receivedAt": row.receivedAt,
"envelope": base64Encode(row.envelope),
}
],
"sent": [
for (final row in listMessages("sent"))
{
"id": row.id,
"recipient": row.recipient,
"sentAt": row.sentAt,
"envelope": base64Encode(row.envelope),
}
],
};
}
/// 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 {
if (data["gsmolExport"] != 1) {
throw const SmolError("not a SmolMail export file");
}
final summary = ImportSummary();
_update((state) {
final servers = (state["servers"] as Map? ?? {}).cast<String, String>();
final incomingPins = data["servers"] is Map ? data["servers"] as Map : null;
if (data["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 = data["contacts"] is Map ? data["contacts"] as Map : null;
if (data["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 = data[folder] is List ? data[folder] as List : null;
if (data[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,
);
if (await storeIfNew(folder, record) != null) summary.mailAdded++;
} on Exception {
summary.malformed++;
} on TypeError {
summary.malformed++;
}
}
}
return summary;
}
// 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;
}
}
/// Remove every secret and every stored envelope; the UI must confirm first.
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++;
}
return count;
}
}
/// 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";
}

115
lib/smol/transport.dart Normal file
View file

@ -0,0 +1,115 @@
// 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);
}