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

61
test/dbg_test.dart Normal file
View file

@ -0,0 +1,61 @@
import "dart:io";
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/app_widget.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/smol/client.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
class StubClient extends SmolClient {
StubClient(super.store);
@override
Future<SmolAddress> recallAccount(String addressText) async {
final addr = parseAddress(addressText);
store.setAccount(addr);
return addr;
}
}
void main() {
late SmolStore store;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
final dir = await Directory.systemTemp.createTemp("dbg4");
Hive.init(dir.path);
store = await SmolStore.open(stateBox: "dbg-state", mailBox: "dbg-mail");
});
testWidgets("direct replace", (tester) async {
final seed = randomBytes(32);
await tester.pumpWidget(ProviderScope(
overrides: [
storeProvider.overrideWithValue(store),
clientProvider.overrideWithValue(StubClient(store)),
],
child: const AppWidget(),
));
await tester.pumpAndSettle();
await tester.tap(find.text("Restore From Seed"));
await tester.pumpAndSettle();
final fields = find.byType(TextField);
await tester.enterText(fields.at(0), hex(seed));
await tester.enterText(fields.at(1), "randogoth@smol.place");
await tester.tap(find.text("Restore"));
await tester.pumpAndSettle();
expect(store.account(), isNotNull, reason: "recall stub ran");
final element = tester.element(find.text("Back"));
final router = AutoRouter.of(element);
await router.replace(InboxRoute());
await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget);
});
}

121
test/e2e_test.dart Normal file
View file

@ -0,0 +1,121 @@
// End-to-end against a live reference server: register an address on a
// locally running smolmaild, send a sealed message to ourselves, fetch it back,
// 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`.
import "dart:io";
import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.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";
const host = "127.0.0.1";
const port = 1961;
Future<bool> serverUp() async {
try {
final probe = await Socket.connect(host, port,
timeout: const Duration(seconds: 2));
probe.destroy();
return true;
} catch (_) {
return false;
}
}
void main() {
test("register, send to self, fetch, unseal, drain", () async {
if (!await serverUp()) {
markTestSkipped("no smolmaild on $host:$port");
return;
}
final dir = await Directory.systemTemp.createTemp("smol-e2e");
Hive.init(dir.path);
final store = await SmolStore.open();
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 me = client.createIdentity();
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();
await client.registerAccount(address.short);
expect(store.account()?.user, user);
await client.send(address.short, "hello e2e", "sealed and signed");
await client.send(address.short, "second", "another sealed envelope");
final summary = await client.fetch();
expect(summary.stored, 2);
expect(summary.rejected, isEmpty);
final inbox = store.listMessages("inbox");
expect(inbox.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 = inbox.map((m) => client.describe(m).subject).toSet();
expect(subjects, {"hello e2e", "second"});
final hello = inbox.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));
// The server must be drained: everything that verified was acknowledged.
final again = await client.fetch();
expect(again.stored, 0);
// The sent copy is sealed to ourselves and readable (§5.6).
final sent = store.listMessages("sent");
expect(sent.length, 2);
expect(client.describe(sent.first).error, isNull);
// A restored seed can rebind the address without REGISTER; the address
// must resolve to this identity's key.
final recalled = await client.recallAccount(address.short);
expect(recalled.short, address.short);
expect(
() => client.recallAccount("nobody@$host"), throwsA(isA<SmolError>()));
// Restore on a second device: same seed, 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.setIdentity(me.seed);
expect(
secondDevice.recallAccount(address.short), throwsA(isA<SmolError>()));
restored.pinServer(host, learned.serverStatic);
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);
// Re-resolving our own address finds the same key and says so quietly.
final outcome = await client.refreshContact(address.short);
expect(outcome.warn, isFalse);
expect(outcome.message, contains("key unchanged"));
expect(store.contact(address.short)!.history, isEmpty);
}, timeout: const Timeout(Duration(minutes: 2)));
}

104
test/recall_flow_test.dart Normal file
View file

@ -0,0 +1,104 @@
// Regression tests for the onboarding recall flows: restoring a seed and
// recalling the registered address must land the user in the inbox. The
// client's network operations are stubbed; what is under test is the UI and
// router flow itself.
import "dart:io";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/app_widget.dart";
import "package:smol_mail/smol/client.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/proto.dart";
import "package:smol_mail/smol/store.dart";
/// A [SmolClient] whose recall completes instantly, so the test exercises
/// the flow rather than the network.
class StubClient extends SmolClient {
StubClient(super.store);
@override
Future<SmolAddress> recallAccount(String addressText) async {
final addr = parseAddress(addressText);
store.setAccount(addr);
return addr;
}
}
void main() {
// Hive box opening is real file IO and must happen outside testWidgets'
// fake-async zone — including the per-test stores.
late final SmolStore storeA, storeB;
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
final dir = await Directory.systemTemp.createTemp("smol-recall-flow");
Hive.init(dir.path);
storeA = await SmolStore.open(
stateBox: "recall-a-state", mailBox: "recall-a-mail");
storeB = await SmolStore.open(
stateBox: "recall-b-state", mailBox: "recall-b-mail");
});
Widget app(SmolStore store) => ProviderScope(
overrides: [
storeProvider.overrideWithValue(store),
clientProvider.overrideWithValue(StubClient(store)),
],
child: const AppWidget(),
);
testWidgets("restore with an address recalls and lands in the inbox",
(tester) async {
final store = storeA;
final seed = randomBytes(32);
await tester.pumpWidget(app(store));
await tester.pumpAndSettle();
expect(find.text("Create Identity"), findsOneWidget);
await tester.tap(find.text("Restore From Seed"));
await tester.pumpAndSettle();
final fields = find.byType(TextField);
expect(fields, findsNWidgets(2));
await tester.enterText(fields.at(0), hex(seed));
await tester.enterText(fields.at(1), "randogoth@smol.place");
await tester.tap(find.text("Restore"));
await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget);
expect(store.seed(), seed);
expect(store.account()!.user, "randogoth");
});
testWidgets("restore without an address recalls from the register step",
(tester) async {
final store = storeB;
final seed = randomBytes(32);
await tester.pumpWidget(app(store));
await tester.pumpAndSettle();
await tester.tap(find.text("Restore From Seed"));
await tester.pumpAndSettle();
var fields = find.byType(TextField);
await tester.enterText(fields.at(0), hex(seed));
await tester.tap(find.text("Restore"));
await tester.pumpAndSettle();
// The register step: address, server key, optional invite token.
fields = find.byType(TextField);
expect(fields, findsNWidgets(3));
await tester.enterText(fields.at(0), "randogoth@smol.place");
await tester.enterText(fields.at(1), b32encode(randomBytes(32)));
await tester.tap(find.text("Already registered? Recall"));
await tester.pumpAndSettle();
expect(find.text("Inbox"), findsOneWidget);
expect(store.account()!.user, "randogoth");
});
}

306
test/smol_test.dart Normal file
View file

@ -0,0 +1,306 @@
// Unit tests: lib/smol must reproduce test/vectors.json byte for byte (the
// vectors are generated from the reference stack — PyNaCl, noiseprotocol — by
// ../gsmol/test/gen_vectors.py), plus protocol-level checks for frontmatter,
// addresses, rotation chains and response framing.
// Run: devbox run test
import "dart:convert";
import "dart:io";
import "dart:typed_data";
import "package:flutter_test/flutter_test.dart";
import "package:smol_mail/smol/crypto.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/noise.dart";
import "package:smol_mail/smol/proto.dart";
final vectors =
jsonDecode(File("test/vectors.json").readAsStringSync()) as Map<String, dynamic>;
Uint8List vhex(String text) => unhex(text);
String vstring(dynamic value) => value as String;
void main() {
test("hashes, HMAC/HKDF and AEAD match the reference vectors", () {
for (final v in vectors["sha256"] as List) {
final m = v as Map;
expect(hex(sha256(vhex(vstring(m["in"])))), vstring(m["out"]));
}
for (final v in vectors["sha512"] as List) {
final m = v as Map;
expect(hex(sha512(vhex(vstring(m["in"])))), vstring(m["out"]));
}
for (final v in vectors["hkdf"] as List) {
final m = v as Map;
expect(
hex(hkdfSha256(vhex(vstring(m["ikm"])), vhex(vstring(m["salt"])),
vhex(vstring(m["info"])), m["len"] as int)),
vstring(m["out"]));
}
for (final v in vectors["aead"] as List) {
final m = v as Map;
final key = vhex(vstring(m["key"]));
final nonce = vhex(vstring(m["nonce"]));
final aad = vhex(vstring(m["aad"]));
final sealed = aeadEncrypt(
key, nonce, vhex(vstring(m["plaintext"])), aad);
expect(hex(sealed), vstring(m["sealed"]), reason: "aead-seal ${m["name"]}");
expect(
hex(aeadDecrypt(key, nonce, vhex(vstring(m["sealed"])), aad)),
vstring(m["plaintext"]));
expect(
() => aeadDecrypt(
key, nonce, Uint8List.fromList(vhex(vstring(m["sealed"])).sublist(0, vhex(vstring(m["sealed"])).length - 1)), aad),
throwsA(isA<SmolError>()));
}
});
test("X25519 matches and rejects low-order points", () {
final byName = <String, Map>{};
for (final v in vectors["x25519"] as List) {
final m = v as Map;
byName[m["name"] as String] = m;
}
expect(hex(x25519Base(vhex(vstring(byName["alice"]!["priv"])))), byName["alice"]!["pub"]);
expect(hex(x25519Base(vhex(vstring(byName["bob"]!["priv"])))), byName["bob"]!["pub"]);
expect(
hex(x25519(vhex(vstring(byName["alice"]!["priv"])), vhex(vstring(byName["bob"]!["pub"])))),
byName["agree"]!["shared"]);
for (final v in vectors["x25519"] as List) {
final m = v as Map;
if ((m["name"] as String).startsWith("low-order")) {
expect(
() => x25519(vhex(vstring(byName["alice"]!["priv"])), vhex(vstring(m["peer"]))),
throwsA(isA<SmolError>()));
}
}
});
test("Ed25519 signs and verifies like the reference stack", () {
for (final v in vectors["ed25519"] as List) {
final m = v as Map;
final seed = vhex(vstring(m["seed"]));
expect(hex(ed25519PublicKey(seed)), vstring(m["pub"]), reason: "ed25519-pub ${m["name"]}");
final sig = ed25519Sign(seed, vhex(vstring(m["message"])));
if (m["valid"] as bool) {
expect(hex(sig), vstring(m["signature"]), reason: "ed25519-sign ${m["name"]}");
expect(ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])), sig), isTrue);
expect(
ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])),
vhex(vstring(m["signature"]))),
isTrue,
reason: "ed25519-verify-pynacl ${m["name"]}");
} else {
expect(
ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])),
vhex(vstring(m["signature"]))),
isFalse,
reason: "ed25519-reject ${m["name"]}");
}
}
});
test("§2 conversions between the identity key and X25519", () {
for (final v in vectors["ed_to_x25519"] as List) {
final m = v as Map;
expect(hex(ed25519SeedToX25519(vhex(vstring(m["seed"])))), vstring(m["x_priv"]));
expect(hex(ed25519ToX25519(ed25519PublicKey(vhex(vstring(m["seed"]))))), vstring(m["x_pub"]));
}
});
test("§5 envelope seals, ids and unseals byte for byte", () {
final v = vectors["envelope"] as Map;
final sender = identityFromSeed(vhex(vstring(v["sender_seed"])));
final recipient = identityFromSeed(vhex(vstring(v["recipient_seed"])));
Uint8List sealWith(bool pad) => seal(sender, recipient.publicKey,
vhex(vstring(v["body"])), v["time"] as int,
SealOptions(esk: vhex(vstring(v["esk"])), pad: pad));
expect(hex(sealWith(false)), vstring(v["envelope"]));
expect(hex(messageId(vhex(vstring(v["envelope"])))), vstring(v["id"]));
final opened = unseal([recipient], vhex(vstring(v["envelope"])));
expect(hex(opened.sender), hex(sender.publicKey));
expect(opened.time, v["time"] as int);
expect(hex(opened.body), vstring(v["body"]));
expect(
() => unseal([identityFromSeed(vhex(vstring(v["esk"])))],
vhex(vstring(v["envelope"]))),
throwsA(isA<SmolError>()));
// padding round-trips and is ignored by the receiver (§5.3)
final padded = sealWith(true);
expect((padded.length - envelopeHeader - 16) % padTo, 0);
expect(padded.length > vstring(v["envelope"]).length ~/ 2, isTrue);
expect(hex(unseal([recipient], padded).body), vstring(v["body"]));
});
test("Noise NX transcript matches the reference", () {
final v = vectors["noise"] as Map;
final nx = NxInitiator();
expect(hex(nx.writeMessage1(vhex(vstring(v["initiator_eph_priv"])))), vstring(v["message1"]));
final result = nx.readMessage2(vhex(vstring(v["message2"])));
expect(hex(result.serverStatic), vstring(v["server_static_pub"]));
expect(hex(result.handshakeHash), vstring(v["handshake_hash"]));
final initiatorFrames = (v["initiator_frames"] as List).cast<Map>();
final responderFrames = (v["responder_frames"] as List).cast<Map>();
for (var i = 0; i < initiatorFrames.length; i++) {
expect(hex(result.send.encrypt(vhex(vstring(initiatorFrames[i]["plaintext"])))),
vstring(initiatorFrames[i]["sealed"]),
reason: "noise-frame-i$i");
}
for (var i = 0; i < responderFrames.length; i++) {
expect(hex(result.recv.decrypt(vhex(vstring(responderFrames[i]["sealed"])))),
vstring(responderFrames[i]["plaintext"]),
reason: "noise-frame-r$i");
}
// The responder direction must also produce identical ciphertexts (AEAD is
// deterministic), so the recv cipher can be checked in both directions.
final mirrored = NxInitiator();
mirrored.writeMessage1(vhex(vstring(v["initiator_eph_priv"])));
final mirror = mirrored.readMessage2(vhex(vstring(v["message2"])));
expect(hex(mirror.recv.encrypt(vhex(vstring(responderFrames[0]["plaintext"])))),
vstring(responderFrames[0]["sealed"]));
});
test("frontmatter parses and fails closed (§5.5)", () {
const spec =
"---\nSubject: Re: the thing\nIn-Reply-To: 4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d\nX-Mood: cautiously optimistic\n---\nBody text starts here.";
final parsed = parseFrontmatter(spec);
expect(parsed.fields["Subject"], "Re: the thing");
expect(parsed.fields["In-Reply-To"], "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d");
expect(parsed.body, "Body text starts here.");
// a malformed line invalidates the whole block, which fails closed toward display
expect(parseFrontmatter("---\nno colon here\n---\nrest").body,
"---\nno colon here\n---\nrest");
expect(parseFrontmatter("---\nSubject: x\nno end").body,
"---\nSubject: x\nno end");
expect(parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields["A"], "1");
expect(buildFrontmatter(const {}, "---\nactual body"),
"---\n---\n---\nactual body");
expect(buildFrontmatter({"Subject": "hi"}, "there"), "---\nSubject: hi\n---\nthere");
expect(buildFrontmatter(const {}, "plain"), "plain");
expect(
parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["Subject"],
isNull);
});
test("addresses parse per §3 and round-trip through smol://", () {
final a = parseAddress("Alice@Example.ORG:1961");
expect(a.user, "alice");
expect(a.port, 1961);
expect(a.short, "alice@example.org"); // a default port is dropped
expect(parseAddress("bob@host").port, defaultPort);
final key = vhex(vstring((vectors["ed25519"] as List).cast<Map>().first["pub"]));
final parsed = parseAddress(parseAddress("bob@h").uri(key));
expect(parsed.identity, key);
expect(parsed.user, "bob");
expect(() => parseAddress("-bob@h"), throwsA(isA<SmolError>()));
// §3: fingerprints are the first 20 base32 characters in groups of four
final b32 = b32encode(key);
expect(
fingerprint(key),
[
b32.substring(0, 4), b32.substring(4, 8), b32.substring(8, 12),
b32.substring(12, 16), b32.substring(16, 20)
].join(" "));
for (final n in [1, 2, 5, 32, 52, 64]) {
final raw = randomBytes(n);
expect(b32decode(b32encode(raw)), raw, reason: "b32[$n]");
}
});
test("rotation chains validate, break and oversize per §7", () {
final old = identityFromSeed(randomBytes(32));
final mid = randomBytes(32);
final fresh = randomBytes(32);
final when = nowSeconds();
final chain = [
makeCert(old, mid, when),
makeCert(identityFromSeed(mid), fresh, when),
];
expect(walkChain(old.publicKey, ed25519PublicKey(fresh), chain), isTrue);
expect(walkChain(old.publicKey, old.publicKey, []), isTrue);
expect(
walkChain(old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)),
isFalse);
final forged = List<Uint8List>.from(chain);
forged[1] = makeCert(identityFromSeed(mid), randomBytes(32), when);
expect(
walkChain(old.publicKey, ed25519PublicKey(fresh), forged), isFalse);
expect(
walkChain(old.publicKey, ed25519PublicKey(fresh),
List.filled(17, chain[0])),
isFalse);
expect(chain[0].length, certLen);
});
test("response framing guards (§6.1)", () async {
Session scripted(Uint8List frame) {
// readNoise wants a u16 length prefix and at least 16 bytes of Noise
// message; the frame is padded up to that floor.
final message = Uint8List.fromList([
...frame,
...List.filled(frame.length < 16 ? 16 - frame.length : 0, 0),
]);
final session = Session(_ScriptedWire(concat([u16be(message.length), message])),
_PassthroughCipher(), _PassthroughCipher());
return session;
}
Future<void> refuses(String name, Uint8List frame, int op) async {
try {
await scripted(frame).call(op);
fail("$name: call resolved instead of throwing");
} on TestFailure {
rethrow;
} catch (_) {
// expected
}
}
// The shortest legal response is a type byte and a status byte.
await refuses("frame-too-short", concat([u32be(1), Uint8List.fromList([opFetch])]), opFetch);
// A response reuses the request's type byte; a mismatch means the session
// desynchronised, which must not be read as a status.
await refuses("frame-op-mismatch",
concat([u32be(2), Uint8List.fromList([opResolve, 0])]), opFetch);
// The same frame with the right echo still passes, so the guard is not
// simply rejecting everything.
final okSession =
scripted(concat([u32be(2), Uint8List.fromList([opFetch, 0])]));
expect((await okSession.call(opFetch)).status, 0);
});
}
/// Serves a scripted response and ignores sends, so framing can be tested
/// without a server.
class _ScriptedWire implements Wire {
final Uint8List _queue;
int _pos = 0;
_ScriptedWire(this._queue);
@override
void send(Uint8List bytes) {}
@override
void close() {}
@override
Future<Uint8List> readExact(int n) async {
if (_pos + n > _queue.length) {
throw const SmolError("script exhausted");
}
final out = Uint8List.fromList(_queue.sublist(_pos, _pos + n));
_pos += n;
return out;
}
}
class _PassthroughCipher implements SessionCipher {
@override
Uint8List encrypt(Uint8List plaintext) => Uint8List.fromList(plaintext);
@override
Uint8List decrypt(Uint8List sealed) => Uint8List.fromList(sealed);
}

140
test/store_test.dart Normal file
View file

@ -0,0 +1,140 @@
// Store-level behavior: contact key history (§8), import binding, and the
// export/import backup file.
import "dart:convert";
import "dart:io";
import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.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";
// Each store gets its own boxes; Hive is a per-process singleton, so without
// this the "exporting" and "importing" stores would be the same store.
Future<SmolStore> freshStore(String tag) =>
SmolStore.open(stateBox: "test-$tag-state", mailBox: "test-$tag-mail");
void main() {
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
final dir = await Directory.systemTemp.createTemp("smol-store-test");
Hive.init(dir.path);
});
test("contact history keeps displaced keys, not re-saves", () async {
final store = await freshStore("history");
final a = randomBytes(32), b = randomBytes(32), c = randomBytes(32);
store.saveContact("alice@example.org", a, true);
expect(store.contact("alice@example.org")!.history, isEmpty);
store.saveContact("alice@example.org", b, false);
final rotated = store.contact("alice@example.org")!;
expect(rotated.key, b);
expect(rotated.history.length, 1);
expect(rotated.history.first.key, a);
expect(rotated.history.first.until, greaterThan(0));
// Re-saving the same key is not a rotation and must not add an entry.
store.saveContact("alice@example.org", b, true);
expect(store.contact("alice@example.org")!.history.length, 1);
// A second displacement appends, oldest first.
store.saveContact("alice@example.org", c, false);
final history = store.contact("alice@example.org")!.history;
expect(history.length, 2);
expect(history[0].key, a);
expect(history[1].key, b);
expect(store.allContacts().single.$2.history.length, 2);
});
test("importContact binds a smol:// address to the key it carries", () async {
final store = await freshStore("import-contact");
final client = SmolClient(store);
final identity = identityFromSeed(randomBytes(32));
client.importContact(
"smol://bob@example.org/${b32encode(identity.publicKey)}");
final saved = store.contact("bob@example.org")!;
expect(saved.verified, isTrue);
expect(saved.key, identity.publicKey);
});
test("export never contains the seed and round-trips through import",
() async {
final a = await freshStore("export");
final b = await freshStore("round-trip");
a.setIdentity(randomBytes(32));
a.pinServer("example.org", randomBytes(32));
a.saveContact("alice@example.org", randomBytes(32), true);
a.saveContact("alice@example.org", randomBytes(32), false); // history grows
await a.storeMessage(
"inbox", MailRecord("aa", randomBytes(64), receivedAt: 5));
await a.storeMessage("sent",
MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6));
final data = a.exportData();
expect(data["gsmolExport"], 1); // gsmol-compatible marker
expect(jsonEncode(data).contains(hex(a.seed()!)), isFalse);
final summary = await b.importData(data);
expect(summary.mailAdded, 2);
expect(summary.pinsAdded, 1);
expect(summary.contactsAdded, 1);
expect(b.serverPin("example.org"), a.serverPin("example.org"));
expect(b.contact("alice@example.org")!.history.length, 1);
expect(b.getMessage("inbox", "aa"), isNotNull);
expect(b.getMessage("sent", "bb"), isNotNull);
expect(b.seed(), isNull); // never the seed
});
test("import never overwrites a differing trust binding", () async {
final store = await freshStore("conflict");
final mine = randomBytes(32), other = randomBytes(32);
store.pinServer("example.org", mine);
store.saveContact("alice@example.org", mine, true);
final summary = await store.importData({
"gsmolExport": 1,
"servers": {"example.org": b32encode(other)},
"contacts": {
"alice@example.org": {"key": b32encode(other), "verified": true},
"bob@example.org": {"key": b32encode(randomBytes(32)), "verified": false},
},
});
expect(summary.pinsConflicted, 1);
expect(summary.pinsAdded, 0);
expect(summary.contactsConflicted, 1);
expect(summary.contactsAdded, 1);
expect(store.serverPin("example.org"), mine);
expect(store.contact("alice@example.org")!.key, mine);
expect(store.contact("bob@example.org"), isNotNull);
});
test("import skips malformed entries and rejects wrong files", () async {
final store = await freshStore("malformed");
final summary = await store.importData({
"gsmolExport": 1,
"servers": {"bad": "notbase32!", "short": b32encode(randomBytes(8))},
"contacts": {
"x": {"key": "nope"},
"y": "not a record",
},
"inbox": [
{"id": "ok", "envelope": base64Encode(randomBytes(64)), "receivedAt": 1},
{"id": "bad", "envelope": "!!!not base64!!!"},
"not a record",
],
"sent": "not a list",
});
expect(summary.malformed, 7); // 2 pins, 2 contacts, 2 mail, 1 sent
expect(summary.pinsAdded, 0);
expect(summary.mailAdded, 1);
expect(store.getMessage("inbox", "ok"), isNotNull);
expect(store.getMessage("inbox", "bad"), isNull);
expect(() => store.importData({"nope": 1}), throwsA(isA<SmolError>()));
});
}

226
test/vectors.json Normal file
View file

@ -0,0 +1,226 @@
{
"sha256": [
{
"in": "",
"out": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"in": "616263",
"out": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
},
{
"in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"out": "fdeab9acf3710362bd2658cdc9a29e8f9c757fcf9811603a8c447cd1d9151108"
},
{
"in": "736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c",
"out": "58a0adce483c517ae1b37025dbe3b534880972be4d9d10b78959a13fb8b13462"
}
],
"sha512": [
{
"in": "",
"out": "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"
},
{
"in": "616263",
"out": "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"
},
{
"in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"out": "ee4320ebaf3fdb4f2c832b137200c08e235e0fa7bbd0eb1740c7063ba8a0d151da77e003398e1714a955d475b05e3e950b639503b452ec185de4229bc4873949"
}
],
"hkdf": [
{
"name": "rfc5869-1",
"ikm": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
"salt": "000102030405060708090a0b0c",
"info": "f0f1f2f3f4f5f6f7f8f9",
"len": 42,
"out": "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"
},
{
"name": "seal-shaped",
"ikm": "61677265656d656e7420736861726564",
"salt": "65706b726563697069656e74",
"info": "736d6f6c6d61696c2f31207365616c",
"len": 32,
"out": "b9f729e45d7bab89598edbce35f3f5bb91d6f403a5ff85943a838defbfcd7a0c"
}
],
"aead": [
{
"name": "empty",
"key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d",
"nonce": "3edd69829394f0807df7b01d",
"aad": "",
"plaintext": "",
"sealed": "941b286ea0ee86bb66236fc3c16b2e48"
},
{
"name": "short",
"key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d",
"nonce": "3edd69829394f0807df7b01d",
"aad": "50515253c0c1c2c3c4c5c6c7",
"plaintext": "68656c6c6f",
"sealed": "7dbede6dd11ffa046f102dedea535912be561e3c29"
},
{
"name": "multiline",
"key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d",
"nonce": "3edd69829394f0807df7b01d",
"aad": "50515253c0c1c2c3c4c5c6c7",
"plaintext": "4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a206966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73637265656e20776f756c642062652069742e",
"sealed": "59bad668dbf00561dd86add05afe35b269f9997d57e46cee0e42fd69693c3df16b681f3905bbea4135cb379f4a0c730b5dbb3ba06d1231ce396660a16f8cadb8b422d745b932df3c1eed2df9636750551a0333b3d06877582f172f3ec2d437061143699bfc7258aa98e319f23a1f31f88fd15a24a97b46fd2e05c266d31ee96f5e24"
}
],
"x25519": [
{
"name": "alice",
"priv": "c63095403fea13cb2c43380599e669ebfb41ab184b04df28a143472e4283aa33",
"pub": "712e68cefc13d0e1778226b7f7aaeb59042225e7a4def3816344da256e031664"
},
{
"name": "bob",
"priv": "33485a5b40b70730b3c3e468a8262543e5a4d9dc98de06399de66a4d579e02a7",
"pub": "b8540c30e8fb348aed0abc8189cf8025ce09a9c5d74e0681c4e50f8d281da252"
},
{
"name": "agree",
"shared": "e6a3b1d2ae10c29b51519a71255af4bd20deee697c6ced1a44eadff428439e13"
},
{
"name": "low-order-0",
"peer": "0000000000000000000000000000000000000000000000000000000000000000",
"peer_rejected": true,
"raised": true
},
{
"name": "low-order-1",
"peer": "0100000000000000000000000000000000000000000000000000000000000000",
"peer_rejected": true,
"raised": true
}
],
"ed25519": [
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0",
"message": "",
"signature": "b20543ac2e0ba66c1b437de2afda7ca8ca6f9feb2af3e3e7ac3183e388d1786c9c027bfe549639140d0e45e7e850999b3fb992c7c003946c1c5aaeb09892cc0c",
"valid": true
},
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0",
"message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000",
"signature": "f7e74a0540e05843b52efb7dee4f3622ab8a88333142f6dd1d5386612f7f0f1410bd0b1f1ff09dea9419922b756920a4c1fb31a95eac3cc55a410d0d6f1dab03",
"valid": true
},
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0",
"message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"signature": "e55b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209",
"valid": true
},
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0",
"message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"signature": "e45b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209",
"valid": false
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e",
"message": "",
"signature": "2dfb4b1e65dfd4bf991ef50be88acddf2d59fe158daf2879bec5641ab80b1e0c542b9ea2bd9a6bc76f2020b76b14dc80ae9f68c62ea58dbd8f5b1990ff069e08",
"valid": true
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e",
"message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000",
"signature": "f6d398cd25fec0ba882af13b85c6addcc2f546181fba84f8925e6e196b759897337a2291f4b73c40a062b0a2283ef096ded790154af58e9d4bd39c32b3db2f05",
"valid": true
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e",
"message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"signature": "24f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e",
"valid": true
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e",
"message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
"signature": "25f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e",
"valid": false
}
],
"ed_to_x25519": [
{
"name": "vector-seed-a",
"seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb",
"x_priv": "30dc8ba3a49892d4b8a626cd371fd43bff4e5651c2a45f1be16e89d84fa89e68",
"x_pub": "77bc3dae84c9b4085862725a21e0a366c09563d6da8f29c408ac2b6928937910"
},
{
"name": "vector-seed-b",
"seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d",
"x_priv": "b03deb6f9f4ab25d15471a355ff4ee23ea98f7d24695c500ed80b6af4b7b9963",
"x_pub": "253d4b5b14df341a5dde486ddd588e292444118ed8eed1fe0738823b82e4243d"
}
],
"envelope": {
"sender_seed": "89c16df9e4352e706abe701928c230d8bd169cd31633bf4589c2d410cb3ae8cc",
"recipient_seed": "6f845ccc435252d39dd08e964d219258e92c3d6c54af0376fa45d5e55eec0aaf",
"esk": "c31fee505964c44b711cf354b431f9716c644a3dbf8c1d29c5e3cedf884d0a48",
"body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a",
"time": 1730000000,
"envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab",
"id": "b05d15a2ab5293164eda564de02a6901",
"unpadded_plaintext_len": 143
},
"noise": {
"initiator_eph_priv": "af5f15993914cfd4e308856810d483ab25a383bfb2d708a0e15f80275f1fe6c2",
"responder_eph_priv": "1c21927bb3e579efb69c937a2bf2e299ca1da9c83a62fb7f8ea1a375eb6f1c80",
"server_static_priv": "c7b206a746c9b167da412a6b1a235869e316e9f08dbbc8478430b2998130f79e",
"server_static_pub": "fa111eca8e853320f8e076674143fd4d1cd181bddeda7d9950a65922b9eafc32",
"message1": "b8b73e6f132dab5659270e6496d2c575ae7daf45a0961ae8e19405a12ed1cc2e",
"message2": "0b03ec78fd19cf7b8480881f33c6b4ca1094fac03c87860095c87c6737349c28256e498747bcf2018420d145c378e6605e63b217f92925ae5771dbe387b94cf9c995f7a3a8314fe2e671ca8fa1463e0642c1d7974f326737cadf630b8aac8ed9",
"handshake_hash": "a039471479680a596a8a61b8827afb01853274b03de2870095edb9b7dd4e2c72",
"assert_hash_equal": true,
"initiator_frames": [
{
"plaintext": "70696e67",
"sealed": "f1885096d9b9383b0bc38b08dff77c005d69f0f0"
},
{
"plaintext": "7365636f6e64206672616d6520746f20746573742074686520636f756e746572",
"sealed": "4a6481a2f7d0c909d9b321bc097e09a1929aeaf8647751f64d65b5e1f92abe960796dbf1c619bef48845aea257f2c73c"
}
],
"responder_frames": [
{
"plaintext": "706f6e67",
"sealed": "a7dda7fe3ef32435b3e72fda49714e9977318f0b"
},
{
"plaintext": "787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878",
"sealed": "9aa3af13c00a8a0cd81167b48a5443541e0c862297638e4b7e5c71196657bc565aad46c2d147b23f752ac48c29b69699209e252d3e840c13fc466dd0445515dec2d289902c7177a237f9256afe9508a45b64ea12cdb597a8c38b6ce0c43891ec772c21ca360980094268686642eae8e01ccf89650a427297dbab052aabfeb08adbb2087ae303dd168ceb3beffa63c8d3ccd1c5b2687c2a9c0d43eaab237fde648bf8b0f347b4952ff6da2212360b4a2a5b1316e9e776d82961c498e51f35a58c999f080ac1c12d7ac08f6ddd7addb70c37ea6bef42ed2c498362f4fd75a222068035a7f372c4f57e613427193ffd8ed40a2d0765ba62cbfd6cb5103165dc15be7ad2db723a4edc73cefe9f7fe5ff78a8ea06ecbc7c5135e5d810a1bd4e47f0cd4a1b6e8dd88b85236dd4b41296e00b7054139ee4fd4faa5876e01d62"
}
]
}
}

View file

@ -1,19 +1,35 @@
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
import "dart:io";
import "package:flash_mail/presentation/app_widget.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:flutter_test/flutter_test.dart";
import "package:hive_flutter/hive_flutter.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/app_widget.dart";
import "package:smol_mail/smol/store.dart";
late final SmolStore store;
void main() {
testWidgets("Counter increments smoke test", (WidgetTester tester) async {
await tester.pumpWidget(const AppWidget());
// Hive's box opening is real file IO, which never completes inside a
// testWidgets fake-async zone — so it happens here, outside one.
setUpAll(() async {
TestWidgetsFlutterBinding.ensureInitialized();
final dir = await Directory.systemTemp.createTemp("smol-widget-test");
Hive.init(dir.path);
store = await SmolStore.open();
});
expect(find.text("0"), findsOneWidget);
expect(find.text("1"), findsNothing);
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text("0"), findsNothing);
expect(find.text("1"), findsOneWidget);
testWidgets("onboarding invites to create or restore an identity",
(tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [storeProvider.overrideWithValue(store)],
child: const AppWidget(),
),
);
await tester.pumpAndSettle();
expect(find.text("Create Identity"), findsOneWidget);
expect(find.text("Restore From Seed"), findsOneWidget);
});
}