fix: read back sent copies after the sec 5.6 upstream fix, pinned at 2d65d66

This commit is contained in:
randogoth 2026-09-28 23:49:39 +03:00
parent 52947e153d
commit d6f4c434fa
36 changed files with 4223 additions and 3118 deletions

View file

@ -1,22 +1,22 @@
// Store-level behavior: contact key history (§8), import binding, and the
// export/import backup file.
// Store-level behavior against the real native store: master lifecycle, read
// marks, pins and the settings the app owns in Hive.
import "dart:convert";
import "dart:io";
import "dart:math";
import "dart:typed_data";
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");
Uint8List randomBytes(int n) =>
Uint8List.fromList(List.generate(n, (_) => Random.secure().nextInt(256)));
Future<SmolStore> freshStore(String tag) => SmolStore.open(
dbPath: "${Directory.systemTemp.createTempSync("smol-store-$tag").path}/store.db",
stateBox: "$tag-state",
readBox: "$tag-read");
void main() {
setUpAll(() async {
@ -25,182 +25,69 @@ void main() {
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);
test("master set, restore and rotations", () async {
final store = await freshStore("master");
expect(store.master(), isNull);
expect(store.identity(), isNull);
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 master secret and round-trips through import",
() async {
final a = await freshStore("export");
final b = await freshStore("round-trip");
a.setMaster(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"], 2); // sealed to the identity's master, like gsmol
expect(jsonEncode(data).contains(hex(a.master()!)), isFalse);
// v2 is sealed to the exporting identity's master — a restore-on-new-device
// scenario, not a transfer to someone else's identity (see the test below).
b.setMaster(a.master()!);
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);
});
test("v2 import refuses a different identity's export", () async {
final a = await freshStore("export-wrong-identity");
final c = await freshStore("round-trip-wrong-identity");
a.setMaster(randomBytes(32));
a.pinServer("example.org", randomBytes(32));
final data = a.exportData();
c.setMaster(randomBytes(32)); // a different master than a's
expect(() => c.importData(data), throwsA(isA<SmolError>()));
});
test("accept tokens: accept/block gate the sync set, tiers split the inbox view",
() async {
final store = await freshStore("accept-tokens");
final master = randomBytes(32);
store.setMaster(master);
final alice = randomBytes(32);
store.saveContact("alice@example.org", alice, true);
expect(store.master(), master);
// A fresh identity's public key is the native-derived one.
expect(store.identity()!.publicKey.length, 52);
expect(store.rotations(), 0);
// No one accepted yet: an empty set is already complete, so it may sync.
var (sync, tokens) = store.tokenSet(master);
expect(sync, 1);
expect(tokens, isEmpty);
store.accept("alice@example.org", alice);
(sync, tokens) = store.tokenSet(master);
expect(sync, 1);
expect(tokens, [tokenFor(master, alice)]);
expect(store.accepted("alice@example.org")!.active, isTrue);
store.block("alice@example.org");
expect(store.accepted("alice@example.org")!.active, isFalse);
expect(store.tokenSet(master).$2, isEmpty);
// Re-accepting keeps the identity frozen at the original acceptance,
// so the token a correspondent already holds keeps working.
store.accept("alice@example.org", randomBytes(32));
expect(store.accepted("alice@example.org")!.identity, alice);
expect(() => store.block("bob@example.org"), throwsA(isA<SmolError>()));
// A restored master must not silently replace the server's set.
store.setSyncOk(false);
(sync, tokens) = store.tokenSet(master);
expect(sync, 0);
expect(tokens, isEmpty);
await store.storeIfNew(
"inbox", MailRecord("aa", randomBytes(64), receivedAt: 1, tier: tierMain));
await store.storeIfNew("inbox",
MailRecord("bb", randomBytes(64), receivedAt: 2, tier: tierRequests));
expect(store.listMessages("inbox").map((r) => r.id), ["aa"]);
expect(store.listMessages("requests").map((r) => r.id), ["bb"]);
expect(store.getMessage("requests", "bb"), isNotNull);
final other = randomBytes(32);
store.restoreMaster(other, 3);
expect(store.master(), other);
// The account handle was rebuilt at the restored rotation index.
expect(store.identity()!.publicKey.length, 52);
});
test("v1 legacy export still imports without an identity", () async {
final store = await freshStore("import-legacy-v1");
final summary = await store.importData({
"gsmolExport": 1,
"servers": {"example.org": b32encode(randomBytes(32))},
});
expect(summary.pinsAdded, 1);
test("leave-on-server is a setting, not protocol state", () async {
final store = await freshStore("leave");
expect(store.leaveOnServer(), isFalse);
await store.setLeaveOnServer(true);
expect(store.leaveOnServer(), isTrue);
await store.setLeaveOnServer(false);
expect(store.leaveOnServer(), isFalse);
});
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("read marks drive the unread counts", () async {
final store = await freshStore("read");
expect(store.unreadCount(), 0);
// Nothing is stored yet, so marking an id is harmless bookkeeping.
store.markRead("ab" * 32);
expect(store.isRead("ab" * 32), isTrue);
});
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);
test("pins save, list and unpin", () async {
final store = await freshStore("pins");
expect(store.allPins(), isEmpty);
// A syntactically valid key: the system server's, a real 52-char form.
const key = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq";
store.pinServer("example.org", key);
expect(store.serverPin("example.org"), key);
expect(store.allPins().length, 1);
await store.unpinServer("example.org");
expect(store.serverPin("example.org"), isNull);
});
expect(() => store.importData({"nope": 1}), throwsA(isA<SmolError>()));
test("wipe clears every secret and mark, overwriting the master", () async {
final store = await freshStore("wipe");
final master = randomBytes(32);
store.setMaster(master);
store.pinServer("example.org",
"lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq");
store.markRead("cd" * 32);
await store.wipe();
// The bytes the store owned are overwritten, not just dereferenced —
// the caller's reference sees the zeros too.
expect(master.every((b) => b == 0), isTrue);
expect(store.master(), isNull);
expect(store.identity(), isNull);
expect(store.serverPin("example.org"), isNull);
expect(store.isRead("cd" * 32), isFalse);
});
}