fix: read back sent copies after the sec 5.6 upstream fix, pinned at 2d65d66

This commit is contained in:
randogoth 2026-09-28 23:49:39 +03:00
parent 52947e153d
commit d6f4c434fa
36 changed files with 4223 additions and 3118 deletions

View file

@ -1,26 +1,32 @@
// End-to-end against a live reference server: register an address on a
// locally running smolmaild, send sealed messages to ourselves, fetch them
// back, exercise the accept-token round trip (§5.8) between the requests and
// main tiers, and check the server is drained afterwards. Skips when nothing
// listens on 127.0.0.1:1961, so `devbox run test` does not depend on a server.
// End-to-end against a live server: register an address, send sealed mail to
// ourselves, fetch it back, exercise the accept-token round trip (§5.8)
// between the requests and main tiers, restore onto a second store, and
// check the server is drained afterwards. Skips when nothing listens on
// 127.0.0.1:1961, so `devbox run test` does not depend on a server.
//
// To run it: (cd ../smolmail && uv run smolmaild.py keygen --key server.key &&
// uv run smolmaild.py serve --key server.key --db mail.db)
// then `devbox run test`.
// The server key is pinned directly: on this machine the bunshin.service
// static key is a documented, operator-supplied value — exactly the trusted
// channel SPEC.md §4 asks a pin to come from.
import "dart:io";
import "dart:math";
import "dart:typed_data";
import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart";
import "package:smol_mail/smol/address.dart";
import "package:smol_mail/smol/client.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/store.dart";
import "package:smol_mail/smol/ui.dart";
const host = "127.0.0.1";
const port = 1961;
const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq";
Uint8List randomBytes(int n) =>
Uint8List.fromList(List.generate(n, (_) => Random.secure().nextInt(256)));
Future<bool> serverUp() async {
try {
@ -33,35 +39,31 @@ Future<bool> serverUp() async {
}
}
Future<SmolStore> freshStore(String tag, String dir) => SmolStore.open(
dbPath: "$dir/$tag.db", stateBox: "$tag-state", readBox: "$tag-read");
void main() {
test("register, send to self, fetch, unseal, drain", () async {
if (!await serverUp()) {
markTestSkipped("no smolmaild on $host:$port");
markTestSkipped("no server on $host:$port");
return;
}
final dir = await Directory.systemTemp.createTemp("smol-e2e");
Hive.init(dir.path);
final store = await SmolStore.open();
final store = await freshStore("main", dir.path);
final client = SmolClient(store);
// First contact is trust-on-first-use: learn the key the handshake reveals,
// then pin it — the flow a user with an operator-supplied key skips.
final warnings = <String>[];
client.onWarning = warnings.add;
final master = client.createIdentity();
await client.createIdentity();
final me = client.identity!;
final user = "e2e${hex(randomBytes(4))}";
final address = parseAddress("$user@$host");
final learned = await client.connect(address, requirePin: false);
// §8: the first, unpinned session must be announced as unverified.
expect(warnings, isNotEmpty);
expect(warnings.single, contains("not pinned"));
store.pinServer(host, learned.serverStatic);
learned.session.wire.close();
store.pinServer(host, serverKey);
await client.registerAccount(address.short);
expect(store.account()?.user, user);
expect(client.accountAddress()!.short, address.short);
await client.send(address.short, "hello e2e", "sealed and signed");
await client.send(address.short, "second", "another sealed envelope");
@ -75,33 +77,32 @@ void main() {
expect(store.listMessages("inbox"), isEmpty);
final requests = store.listMessages("requests");
expect(requests.length, 2);
// receivedAt has second granularity, so the order of the two is not
// guaranteed; assert on the pair, then open the one we care about.
final subjects = requests.map((m) => client.describe(m).subject).toSet();
expect(subjects, {"hello e2e", "second"});
final hello = requests.firstWhere(
(m) => client.describe(m).subject == "hello e2e");
final hello = requests
.firstWhere((m) => client.describe(m).subject == "hello e2e");
final opened = client.describe(hello);
expect(opened.error, isNull);
expect(opened.body, "sealed and signed\n");
expect(hex(opened.sender!), hex(me.publicKey));
// fumi's describe splits the trailing newline into the frontmatter
// parse, so the body arrives trimmed.
expect(opened.body, "sealed and signed");
expect(opened.sender, me.publicKey);
// The server must be drained: everything that verified was acknowledged.
final again = await client.fetch();
expect(again.stored, 0);
// Accept ourselves as a correspondent (§5.8): the change is pushed to the
// server right away. This next message carries our own Accept field, but
// no MAC yet — we cannot know our own token before receiving and parsing
// a message that carries it — so it still lands in requests.
// Accept ourselves as a correspondent (§5.8): the change is pushed to
// the server right away. This next message carries our own Accept field,
// but no MAC yet, so it still lands in requests.
await client.acceptContact(address.short);
await client.send(address.short, "third", "still unsolicited");
expect((await client.fetch()).stored, 1);
expect(store.listMessages("requests").length, 3);
expect(store.listMessages("inbox"), isEmpty);
// Having now learned our own token from that message's Accept field, the
// next one carries a matching MAC and reaches the main tier.
// Having now learned our own token, the next one carries a matching MAC
// and reaches the main tier.
await client.send(address.short, "fourth", "now accepted");
expect((await client.fetch()).stored, 1);
final mainTier = store.listMessages("inbox");
@ -123,18 +124,17 @@ void main() {
// Restore on a second device: same master, fresh store, no pin. Unpinned
// recall is refused; re-registering a taken name is refused; recall with
// the operator-supplied key then binds the account without REGISTER.
final restored = await SmolStore.open(
stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail");
final secondDevice = SmolClient(restored);
restored.setMaster(master);
expect(
final secondStore = await freshStore("second", dir.path);
final secondDevice = SmolClient(secondStore);
secondStore.restoreMaster(store.master()!, 0);
await expectLater(
secondDevice.recallAccount(address.short), throwsA(isA<SmolError>()));
restored.pinServer(host, learned.serverStatic);
secondStore.pinServer(host, serverKey);
await expectLater(
secondDevice.registerAccount(address.short), throwsA(isA<SmolError>()));
final bound = await secondDevice.recallAccount(address.short);
expect(bound.short, address.short);
expect(restored.account()!.user, user);
expect(secondDevice.accountAddress()!.user, user);
// Re-resolving our own address finds the same key and says so quietly.
final outcome = await client.refreshContact(address.short);
@ -146,24 +146,21 @@ void main() {
test("leave mail on server keeps mail until deleted, with dedupe on refetch",
() async {
if (!await serverUp()) {
markTestSkipped("no smolmaild on $host:$port");
markTestSkipped("no server on $host:$port");
return;
}
final dir = await Directory.systemTemp.createTemp("smol-e2e-keep");
Hive.init(dir.path);
final store =
await SmolStore.open(stateBox: "e2e-keep-state", mailBox: "e2e-keep-mail");
final store = await freshStore("keep", dir.path);
final client = SmolClient(store);
client.createIdentity();
await client.createIdentity();
final user = "e2ekeep${hex(randomBytes(4))}";
final address = parseAddress("$user@$host");
final learned = await client.connect(address, requirePin: false);
store.pinServer(host, learned.serverStatic);
learned.session.wire.close();
store.pinServer(host, serverKey);
await client.registerAccount(address.short);
store.setLeaveOnServer(true);
await store.setLeaveOnServer(true);
await client.send(address.short, "kept", "stays on the server until deleted");
final first = await client.fetch();
@ -172,19 +169,17 @@ void main() {
expect(record.keptOnServer, isTrue);
expect(client.describe(record).subject, "kept");
// Re-fetching from scratch must not duplicate it locally (storeIfNew's
// Re-paging from scratch must not duplicate it locally (the seen-id
// dedupe), even though the server still has it (nothing was deleted).
store.setCursor(0, Uint8List(idLen));
final second = await client.fetch();
final second = await client.fetch(reset: true);
expect(second.stored, 0);
expect(store.listMessages("requests").length, 1);
// Deleting removes it from the server too: a further full re-page after
// deletion must come back empty rather than resurrecting it.
// Deleting removes it locally and from the server too: a further full
// re-page after deletion comes back empty rather than resurrecting it.
await client.deleteMessage("requests", record);
expect(store.listMessages("requests"), isEmpty);
store.setCursor(0, Uint8List(idLen));
final third = await client.fetch();
final third = await client.fetch(reset: true);
expect(third.stored, 0);
expect(store.listMessages("requests"), isEmpty);
}, timeout: const Timeout(Duration(minutes: 2)));