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
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue