317 lines
13 KiB
Dart
317 lines
13 KiB
Dart
// 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 inReplyTo =
|
|
"4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5";
|
|
final spec =
|
|
"---\nSubject: Re: the thing\nIn-Reply-To: $inReplyTo\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"], inReplyTo);
|
|
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");
|
|
// keys compare case-insensitively; the first occurrence wins (§5.5)
|
|
expect(parseFrontmatter("---\nSubject: x\nsubject: y\n---\ntext").fields["subject"], "x");
|
|
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: never two separators in a row
|
|
expect(() => parseAddress("a..b@h"), throwsA(isA<SmolError>()));
|
|
expect(parseAddress("a.b_c@h").user, "a.b_c");
|
|
// §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", () {
|
|
const username = "alice";
|
|
final old = identityFromSeed(randomBytes(32));
|
|
final mid = randomBytes(32);
|
|
final fresh = randomBytes(32);
|
|
final when = nowSeconds();
|
|
final chain = [
|
|
makeCert(username, old, mid, when),
|
|
makeCert(username, identityFromSeed(mid), fresh, when),
|
|
];
|
|
expect(walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain), isTrue);
|
|
expect(walkChain(username, old.publicKey, old.publicKey, []), isTrue);
|
|
expect(
|
|
walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)),
|
|
isFalse);
|
|
final forged = List<Uint8List>.from(chain);
|
|
forged[1] = makeCert(username, identityFromSeed(mid), randomBytes(32), when);
|
|
expect(
|
|
walkChain(username, old.publicKey, ed25519PublicKey(fresh), forged), isFalse);
|
|
expect(
|
|
walkChain(username, old.publicKey, ed25519PublicKey(fresh),
|
|
List.filled(17, chain[0])),
|
|
isFalse);
|
|
// a chain signed for a different username must not validate (§7)
|
|
expect(
|
|
walkChain("bob", old.publicKey, ed25519PublicKey(fresh), chain), 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);
|
|
}
|