feat: replace mail protocol with smolmail and rebuild the UI
This commit is contained in:
parent
cb24b68069
commit
e94e4158cc
93 changed files with 6387 additions and 3479 deletions
61
test/dbg_test.dart
Normal file
61
test/dbg_test.dart
Normal 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
121
test/e2e_test.dart
Normal 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
104
test/recall_flow_test.dart
Normal 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
306
test/smol_test.dart
Normal 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
140
test/store_test.dart
Normal 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
226
test/vectors.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue