// 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; 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())); } }); test("X25519 matches and rejects low-order points", () { final byName = {}; 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())); } } }); 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())); // 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(); final responderFrames = (v["responder_frames"] as List).cast(); 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().first["pub"])); final parsed = parseAddress(parseAddress("bob@h").uri(key)); expect(parsed.identity, key); expect(parsed.user, "bob"); expect(() => parseAddress("-bob@h"), throwsA(isA())); // §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.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 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 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); }