Šepot — end-to-end encrypted messenger

Initial public release. Anonymous on-device identity (no phone number,
e-mail, or password), Signal-protocol cryptography (X3DH + PQXDH, Double
Ratchet, sealed sender) via libsignal, a metadata-minimizing relay,
group chats, disappearing messages, encrypted local storage, optional
Tor transport, and a UI localized into Czech, English, German, Russian,
and Italian.

Monorepo: Flutter client (app/), Rust relay + wire types (rust/),
protobuf definitions (proto/), self-host deploy (deploy/).

Licensed under AGPL-3.0 with an app-store distribution exception (§7).
This commit is contained in:
Julius Joska
2026-07-11 17:06:39 +02:00
commit 8081d98c67
309 changed files with 44102 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/attachment_cipher.dart';
import 'package:sepot/core/storage/attachment_store.dart';
import 'package:sepot/models/attachment.dart';
import 'package:sepot/models/message.dart';
Uint8List randomBytes(int n) {
final rng = Random(42);
return Uint8List.fromList(List.generate(n, (_) => rng.nextInt(256)));
}
void main() {
group('AttachmentCipher', () {
test('roundtrip malý i velký payload', () async {
for (final size in [1, 4096, 1024 * 1024]) {
final plaintext = randomBytes(size);
final enc = await AttachmentCipher.encrypt(plaintext);
expect(enc.key.length, 32);
expect(enc.nonce.length, 12);
expect(enc.blob.length, size + AttachmentCipher.tagLength);
expect(enc.plaintextSize, size);
// ciphertext nesmí obsahovat plaintext (triviální sanity)
expect(enc.blob.sublist(0, size), isNot(equals(plaintext)));
final dec = await AttachmentCipher.decrypt(
blob: enc.blob,
key: enc.key,
nonce: enc.nonce,
expectedDigest: enc.digest,
);
expect(dec, equals(plaintext));
}
});
test('tamper blobu = AttachmentIntegrityException (digest)', () async {
final enc = await AttachmentCipher.encrypt(randomBytes(1024));
final tampered = Uint8List.fromList(enc.blob);
tampered[100] ^= 0xFF;
expect(
() => AttachmentCipher.decrypt(
blob: tampered,
key: enc.key,
nonce: enc.nonce,
expectedDigest: enc.digest,
),
throwsA(isA<AttachmentIntegrityException>()),
);
});
test('podvržený digest = AttachmentIntegrityException', () async {
final enc = await AttachmentCipher.encrypt(randomBytes(1024));
final wrongDigest = Uint8List.fromList(enc.digest);
wrongDigest[0] ^= 0x01;
expect(
() => AttachmentCipher.decrypt(
blob: enc.blob,
key: enc.key,
nonce: enc.nonce,
expectedDigest: wrongDigest,
),
throwsA(isA<AttachmentIntegrityException>()),
);
});
test('špatný klíč = AttachmentIntegrityException (GCM tag)', () async {
final enc = await AttachmentCipher.encrypt(randomBytes(1024));
final wrongKey = Uint8List.fromList(enc.key);
wrongKey[5] ^= 0x01;
expect(
() => AttachmentCipher.decrypt(
blob: enc.blob,
key: wrongKey,
nonce: enc.nonce,
expectedDigest: enc.digest,
),
throwsA(isA<AttachmentIntegrityException>()),
);
});
test('každá příloha má unikátní klíč i nonce', () async {
final a = await AttachmentCipher.encrypt(randomBytes(64));
final b = await AttachmentCipher.encrypt(randomBytes(64));
expect(a.key, isNot(equals(b.key)));
expect(a.nonce, isNot(equals(b.nonce)));
});
});
group('AttachmentStore', () {
late Directory tmp;
late AttachmentStore store;
setUp(() async {
tmp = await Directory.systemTemp.createTemp('sepot-att-');
store = AttachmentStore(dirOverride: tmp.path);
});
tearDown(() async {
if (await tmp.exists()) await tmp.delete(recursive: true);
});
test('save/load/exists/delete', () async {
const blobId = 'aabbccddeeff00112233445566778899';
final blob = randomBytes(512);
expect(await store.exists(blobId), isFalse);
expect(await store.load(blobId), isNull);
await store.save(blobId, blob);
expect(await store.exists(blobId), isTrue);
expect(await store.load(blobId), equals(blob));
await store.delete(blobId);
expect(await store.exists(blobId), isFalse);
});
test('neplatný blob_id (path traversal) se odmítne', () async {
for (final evil in [
'../../../etc/passwd',
'AABBCCDDEEFF00112233445566778899', // velká písmena
'aabbccddeeff001122334455667788', // krátké
'aabbccddeeff00112233445566778899aa', // dlouhé
'aabbccddeeff0011223344556677889g', // ne-hex
]) {
expect(
() => store.save(evil, randomBytes(8)),
throwsArgumentError,
reason: evil,
);
}
});
});
group('AttachmentMeta + Message serializace', () {
final meta = AttachmentMeta(
blobId: 'aabbccddeeff00112233445566778899',
keyB64: base64Encode(randomBytes(32)),
nonceB64: base64Encode(randomBytes(12)),
digestB64: base64Encode(randomBytes(32)),
size: 12345,
mime: 'image/jpeg',
width: 1600,
height: 900,
thumbB64: base64Encode(randomBytes(100)),
state: AttachmentTransferState.ready,
);
test('wire roundtrip nenese lokální stav', () {
final wire = meta.toWire();
expect(wire.containsKey('state'), isFalse);
final back = AttachmentMeta.fromWire(wire);
expect(back.blobId, meta.blobId);
expect(back.keyB64, meta.keyB64);
expect(back.size, meta.size);
expect(back.state, AttachmentTransferState.pending);
});
test('lokální JSON roundtrip drží stav', () {
final back = AttachmentMeta.fromJson(meta.toJson());
expect(back.state, AttachmentTransferState.ready);
expect(back.thumbB64, meta.thumbB64);
});
test('nesmyslná velikost z drátu se odmítne', () {
final wire = meta.toWire()..['s'] = -1;
expect(() => AttachmentMeta.fromWire(wire), throwsFormatException);
});
test('Message s přílohou přežije JSON roundtrip', () {
final msg = Message.attachment(
senderId: 'me',
recipientId: 'you',
attachment: meta,
caption: 'popisek',
);
expect(msg.type, MessageType.image);
final back = Message.fromJson(
jsonDecode(jsonEncode(msg.toJson())) as Map<String, dynamic>);
expect(back.attachment, isNotNull);
expect(back.attachment!.blobId, meta.blobId);
expect(back.attachment!.state, AttachmentTransferState.ready);
expect(back.content, 'popisek');
expect(back.type, MessageType.image);
});
});
}
+31
View File
@@ -0,0 +1,31 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:flutter_test/flutter_test.dart';
// Ověřuje jádro zálohy: HKDF z privátního klíče + AES-GCM roundtrip a že
// cizí klíč (jiná identita) zálohu nedešifruje. (BackupService.import/export
// volá storage = native; tady testujeme krypto vrstvu izolovaně.)
Future<SecretKey> derive(Uint8List pk) => Hkdf(hmac: Hmac.sha256(), outputLength: 32)
.deriveKey(secretKey: SecretKey(pk), nonce: utf8.encode('sepot-backup-salt-v1'), info: utf8.encode('sepot-backup-v1'));
void main() {
test('záloha: AES-GCM roundtrip klíčem odvozeným z identity', () async {
final pk = Uint8List.fromList(List.generate(32, (i) => i));
final key = await derive(pk);
final nonce = Uint8List.fromList(List.filled(12, 7));
final msg = utf8.encode('{"v":1,"conversations":[]}');
final box = await AesGcm.with256bits().encrypt(msg, secretKey: key, nonce: nonce);
final plain = await AesGcm.with256bits().decrypt(box, secretKey: key);
expect(utf8.decode(plain), '{"v":1,"conversations":[]}');
});
test('cizí identita zálohu nedešifruje', () async {
final key = await derive(Uint8List.fromList(List.filled(32, 1)));
final other = await derive(Uint8List.fromList(List.filled(32, 2)));
final box = await AesGcm.with256bits().encrypt(utf8.encode('tajne'),
secretKey: key, nonce: Uint8List.fromList(List.filled(12, 3)));
expect(() => AesGcm.with256bits().decrypt(box, secretKey: other),
throwsA(isA<SecretBoxAuthenticationError>()));
});
}
@@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/models/conversation.dart';
void main() {
test('mute/archive přežijí JSON roundtrip', () {
final c = Conversation(
contactId: 'X',
muted: true,
mutedUntil: DateTime.parse('2026-07-09T10:00:00.000'),
archived: true,
);
final back =
Conversation.fromJson(jsonRoundtrip(c.toJson()));
expect(back.muted, isTrue);
expect(back.mutedUntil, DateTime.parse('2026-07-09T10:00:00.000'));
expect(back.archived, isTrue);
});
test('legacy JSON bez nových klíčů projde s defaulty', () {
final legacy = {
'contactId': 'Y',
'messages': <dynamic>[],
'lastActivity': '2026-07-08T10:00:00.000',
'disappearAfterSeconds': null,
};
final c = Conversation.fromJson(legacy);
expect(c.muted, isFalse);
expect(c.archived, isFalse);
expect(c.mutedUntil, isNull);
expect(c.isMuted, isFalse);
});
test('isMuted: dočasné ztlumení vyprší', () {
final past = Conversation(
contactId: 'Z',
muted: true,
mutedUntil: DateTime.now().subtract(const Duration(hours: 1)));
expect(past.isMuted, isFalse); // vypršelo
final future = Conversation(
contactId: 'Z',
muted: true,
mutedUntil: DateTime.now().add(const Duration(hours: 1)));
expect(future.isMuted, isTrue);
final forever = Conversation(contactId: 'Z', muted: true);
expect(forever.isMuted, isTrue);
});
}
Map<String, dynamic> jsonRoundtrip(Map<String, dynamic> m) =>
Map<String, dynamic>.from(m);
+98
View File
@@ -0,0 +1,98 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/dek_wrap.dart';
/// S9: PIN musí být klíčem k DEK, ne hashem na porovnání. Testy běží
/// na OWASP floor parametrech (rychlejší, ale pořád reálné krypto).
void main() {
final rng = Random.secure();
Uint8List randomDek() =>
Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
Future<String> wrapFloor(Uint8List dek, String pin) => DekWrap.wrap(
dek: dek,
pin: pin,
memoryKiB: DekWrap.floorMemoryKiB,
iterations: DekWrap.floorIterations,
);
test('wrap → unwrap se správným PINem vrátí původní DEK', () async {
final dek = randomDek();
final wrapped = await wrapFloor(dek, '123456');
final unwrapped = await DekWrap.unwrap(wrapped: wrapped, pin: '123456');
expect(unwrapped, dek);
});
test('wrap blob neobsahuje DEK ani PIN v čitelné podobě', () async {
final dek = randomDek();
final wrapped = await wrapFloor(dek, '123456');
expect(wrapped, startsWith('sepot-dek-v1:'));
expect(wrapped.contains('123456'), isFalse);
// ciphertext části nesmí být DEK samotný
expect(wrapped.contains(String.fromCharCodes(dek)), isFalse);
});
test('špatný PIN → WrongPinException (GCM tag nesedí)', () async {
final wrapped = await wrapFloor(randomDek(), '123456');
await expectLater(
DekWrap.unwrap(wrapped: wrapped, pin: '654321'),
throwsA(isA<WrongPinException>()),
);
});
test('zmanipulovaný ciphertext → WrongPinException', () async {
final wrapped = await wrapFloor(randomDek(), '123456');
final parts = wrapped.split(':');
// přepiš ciphertext (index 6) validním base64 jiného obsahu
parts[6] = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
final tampered = parts.join(':');
await expectLater(
DekWrap.unwrap(wrapped: tampered, pin: '123456'),
throwsA(isA<WrongPinException>()),
);
});
test('parametry pod OWASP floor se odmítnou při wrap i unwrap', () async {
// wrap: slabé parametry nesmí vůbec vzniknout
await expectLater(
DekWrap.wrap(dek: randomDek(), pin: '123456', memoryKiB: 1024),
throwsA(isA<StateError>()),
);
// unwrap: podvržený blob se slabými parametry nesmí ani začít derivovat
final weakBlob = [
'sepot-dek-v1', '1024', '1', '1',
'AAAAAAAAAAAAAAAAAAAAAA==', 'AAAAAAAAAAAAAAAA',
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 'AAAAAAAAAAAAAAAAAAAAAA==',
].join(':');
await expectLater(
DekWrap.unwrap(wrapped: weakBlob, pin: '123456'),
throwsA(isA<StateError>()),
);
});
test('neznámý formát/verze → FormatException', () async {
await expectLater(
DekWrap.unwrap(wrapped: 'garbage', pin: '123456'),
throwsA(isA<FormatException>()),
);
await expectLater(
DekWrap.unwrap(wrapped: 'sepot-dek-v99:a:b:c:d:e:f:g', pin: '123456'),
throwsA(isA<FormatException>()),
);
});
test('DEK jiné délky než 32 B se odmítne', () async {
await expectLater(
DekWrap.wrap(dek: Uint8List(16), pin: '123456'),
throwsA(isA<ArgumentError>()),
);
});
}
+136
View File
@@ -0,0 +1,136 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/mnemonic.dart';
import 'package:sepot/core/errors.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/models/identity.dart';
/// S1/S2 testy nad reálným libsignal backendem (nativní knihovna).
void main() {
setUpAll(() async {
await SepotCrypto.init();
});
group('S1: fingerprint a ID z klíče', () {
test('fingerprint má 52 znaků Crockford base32, SEP- zkratka 14', () async {
final identity = await LocalIdentity.create('Alice');
expect(identity.fingerprint.display, hasLength(52));
expect(identity.sepotId, startsWith('SEP-'));
expect(identity.sepotId, hasLength(14));
expect(identity.id, identity.fingerprint.display);
});
test('fingerprint je deterministický: stejný klíč → stejné ID', () async {
final identity = await LocalIdentity.create('A');
final again = SepotFingerprint.ofPublicKey(identity.identityPublicKey);
expect(again, identity.fingerprint);
});
test('KAT: známý pubkey → známý fingerprint (BLAKE2b-256 + Crockford)',
() {
// Pevný "pubkey" (33 B, type byte 0x05 + 32 B vzestupně) — nezávislý
// na libsignal, fixuje hash i base32 vrstvu proti tiché změně
final pubkey = Uint8List.fromList(
[0x05, ...List.generate(32, (i) => i)],
);
final fp = SepotFingerprint.ofPublicKey(pubkey);
expect(fp.display, hasLength(52));
// KAT hodnota vygenerovaná při zavedení S1 (2026-07-03) — pokud se
// změní, změnil se hash vstup/algoritmus a VŠECHNA ID uživatelů
expect(
fp.display,
'2TFMWYYW4MRZH3BR8QCWQZVVM20EPFEP40N91S6J9HXW1GGHATRG',
);
});
test('klíče: privátní 32 B, veřejný 33 B, derivace sedí', () {
final keys = SepotCrypto.generateIdentityKeys();
expect(keys.privateKey, hasLength(32));
expect(keys.publicKey, hasLength(33));
expect(SepotCrypto.derivePublicKey(keys.privateKey), keys.publicKey);
});
test('serializovaný privátní klíč je clamping-stabilní', () {
// deserialize→serialize musí být identita na už serializovaných
// bajtech — na tom stojí mnemonic roundtrip (MASTER-DESIGN §3, F-3)
for (var i = 0; i < 20; i++) {
final keys = SepotCrypto.generateIdentityKeys();
final roundtrip = SepotCrypto.derivePublicKey(keys.privateKey);
expect(roundtrip, keys.publicKey);
final mnemonicBack = SepotMnemonic.decode(
SepotMnemonic.encode(keys.privateKey),
);
expect(mnemonicBack, keys.privateKey);
expect(SepotCrypto.derivePublicKey(mnemonicBack), keys.publicKey);
}
});
});
group('S2: mnemonic recovery', () {
test('recover z mnemonic obnoví identický klíč i fingerprint', () async {
final original = await LocalIdentity.create('Alice');
final words = original.mnemonic;
expect(words, hasLength(24));
final recovered = await LocalIdentity.recover(
mnemonicWords: words,
displayName: 'Alice (obnoveno)',
);
expect(recovered.identityPrivateKey, original.identityPrivateKey);
expect(recovered.identityPublicKey, original.identityPublicKey);
expect(recovered.fingerprint, original.fingerprint);
});
test('překlep v mnemonic → MnemonicInvalidError, ne tichá jiná identita',
() async {
final original = await LocalIdentity.create('Alice');
final words = [...original.mnemonic]..[3] = 'abandon';
expect(
() => LocalIdentity.recover(
mnemonicWords: words,
displayName: 'x',
),
throwsA(isA<MnemonicInvalidError>()),
);
});
});
group('Serializace', () {
test('LocalIdentity JSON roundtrip (v2)', () async {
final identity = await LocalIdentity.create('Alice');
final back = LocalIdentity.fromJson(identity.toJson());
expect(back.identityPrivateKey, identity.identityPrivateKey);
expect(back.fingerprint, identity.fingerprint);
expect(back.displayName, 'Alice');
});
test('starý (před-libsignal) JSON → FormatException', () {
expect(
() => LocalIdentity.fromJson({
'id': 'uuid-v4-neco',
'displayName': 'Starý formát',
}),
throwsFormatException,
);
});
test('sepot://add/ share link roundtrip', () async {
final identity = await LocalIdentity.create('Čeněk Šťastný');
final link = identity.publicIdentity.toShareString();
expect(link, startsWith('sepot://add/'));
final parsed = PublicIdentity.fromShareString(link);
expect(parsed, isNotNull);
expect(parsed!.displayName, 'Čeněk Šťastný');
expect(parsed.fingerprint, identity.fingerprint);
expect(parsed.identityKey, identity.identityPublicKey);
});
test('fromShareString: odpad → null, ne výjimka', () {
expect(PublicIdentity.fromShareString('nesmysl'), isNull);
expect(PublicIdentity.fromShareString('sepot://add/%%%'), isNull);
expect(PublicIdentity.fromShareString(''), isNull);
});
});
}
+119
View File
@@ -0,0 +1,119 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/mnemonic.dart';
import 'package:sepot/core/errors.dart';
/// KAT vektory z oficiální BIP39 test suite (Trezor) pro 256bit entropii.
/// Review-crypto F-3: mnemonic je REVERZIBILNÍ ENKÓDOVÁNÍ klíče — known-answer
/// testy jsou povinné v CI napříč Dart i Rust.
const _katVectors = <String, String>{
'00000000000000000000000000000000'
'00000000000000000000000000000000':
'abandon abandon abandon abandon abandon abandon abandon abandon '
'abandon abandon abandon abandon abandon abandon abandon abandon '
'abandon abandon abandon abandon abandon abandon abandon art',
'7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f'
'7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f':
'legal winner thank year wave sausage worth useful legal winner '
'thank year wave sausage worth useful legal winner thank year '
'wave sausage worth title',
'80808080808080808080808080808080'
'80808080808080808080808080808080':
'letter advice cage absurd amount doctor acoustic avoid letter '
'advice cage absurd amount doctor acoustic avoid letter advice '
'cage absurd amount doctor acoustic bless',
'ffffffffffffffffffffffffffffffff'
'ffffffffffffffffffffffffffffffff':
'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo '
'zoo zoo zoo zoo zoo zoo zoo vote',
};
Uint8List _hexToBytes(String hex) {
final out = Uint8List(hex.length ~/ 2);
for (var i = 0; i < out.length; i++) {
out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
}
return out;
}
void main() {
group('SepotMnemonic KAT (oficiální BIP39 vektory)', () {
_katVectors.forEach((entropyHex, expectedMnemonic) {
test('entropy ${entropyHex.substring(0, 8)}… → známých 24 slov', () {
final words = SepotMnemonic.encode(_hexToBytes(entropyHex));
expect(words.join(' '), expectedMnemonic);
expect(words, hasLength(SepotMnemonic.wordCount));
});
test('mnemonic ${expectedMnemonic.substring(0, 12)}… → přesná entropie',
() {
final decoded = SepotMnemonic.decode(expectedMnemonic.split(' '));
expect(decoded, _hexToBytes(entropyHex));
});
});
});
group('SepotMnemonic roundtrip', () {
test('decode(encode(k)) == k pro 100 náhodných klíčů', () {
final rng = Random.secure();
for (var i = 0; i < 100; i++) {
final key = Uint8List.fromList(
List.generate(32, (_) => rng.nextInt(256)),
);
expect(SepotMnemonic.decode(SepotMnemonic.encode(key)), key);
}
});
test('normalizace: velká písmena a mezery navíc nevadí', () {
final key = Uint8List(32);
final words = SepotMnemonic.encode(key);
final messy = words
.map((w) => ' ${w.toUpperCase()} ')
.toList();
expect(SepotMnemonic.decode(messy), key);
});
});
group('SepotMnemonic chyby', () {
final valid = SepotMnemonic.encode(Uint8List(32));
test('špatný počet slov → MnemonicWordCountError', () {
expect(
() => SepotMnemonic.decode(valid.sublist(0, 23)),
throwsA(isA<MnemonicWordCountError>()),
);
});
test('neznámé slovo → MnemonicInvalidError', () {
final broken = [...valid]..[0] = 'xylofon';
expect(() => SepotMnemonic.decode(broken),
throwsA(isA<MnemonicInvalidError>()));
});
test('rozbitý checksum (prohozená slova) → MnemonicInvalidError', () {
// poslední slovo nese checksum — výměna prvních dvou RŮZNÝCH slov
// checksum rozbije
final words = SepotMnemonic.encode(
Uint8List.fromList(List.generate(32, (i) => i * 7 % 256)),
);
final swapped = [...words];
final j = swapped.indexWhere((w) => w != swapped[0]);
swapped[0] = words[j];
swapped[j] = words[0];
expect(() => SepotMnemonic.decode(swapped),
throwsA(isA<MnemonicInvalidError>()));
});
test('isValid odmítne nevalidní a přijme validní', () {
expect(SepotMnemonic.isValid(valid), isTrue);
expect(SepotMnemonic.isValid(valid.sublist(0, 12)), isFalse);
});
test('encode odmítne jinou délku než 32 B', () {
expect(() => SepotMnemonic.encode(Uint8List(16)), throwsArgumentError);
expect(() => SepotMnemonic.encode(Uint8List(64)), throwsArgumentError);
});
});
}
+259
View File
@@ -0,0 +1,259 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/services/messaging_service.dart';
import 'package:sepot/core/storage/crypto_db.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/models/contact.dart';
import 'package:sepot/models/group.dart';
import 'package:sepot/models/identity.dart';
/// Bezpečnostní pravidla skupin (GROUPS-V1.md §Bezpečnostní pravidla).
/// Útočné scénáře: podvržený roster, cizí odesílatel, přepis identity
/// klíče, pozvánka bez příjemce, roster přes strop.
void main() {
late Directory tmp;
late CryptoDb db;
late SecureStorageService storage;
late MessagingService service;
late LocalIdentity ja;
final rng = Random.secure();
Uint8List randomDek() =>
Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
setUpAll(() async => SepotCrypto.init());
setUp(() async {
tmp = Directory.systemTemp.createTempSync('sepot_groups_sec');
db = CryptoDb.open(path: '${tmp.path}/app.db', dek: randomDek());
storage = SecureStorageService(dbPath: '${tmp.path}/app.db')
..debugAttachDb(db);
service = MessagingService(storage: storage);
ja = await LocalIdentity.create('Já');
service.debugSetIdentity(ja);
});
tearDown(() {
db.close();
tmp.deleteSync(recursive: true);
});
/// Roster entry pro danou identitu (poctivá: f == fingerprint(ik)).
Map<String, dynamic> entry(LocalIdentity id, {bool routing = true}) => {
'f': id.fingerprint.display,
'n': id.displayName,
'ik': base64Encode(id.publicIdentity.identityKey),
if (routing) 'aid': 'a' * 32,
if (routing) 'da': base64Encode(Uint8List(32)),
};
Map<String, dynamic> pozvanka(
String gid,
String gname,
List<Map<String, dynamic>> roster,
) =>
{'v': 1, 't': 'ginv', 'gid': gid, 'gname': gname, 'roster': roster};
test('poctivá pozvánka založí skupinu a neznámé členy jako kontakty',
() async {
final alice = await LocalIdentity.create('Alice');
await service.debugHandleGroupInvite(
alice.fingerprint.display,
pozvanka('g1', 'Rodina', [entry(alice), entry(ja)]),
);
final g = await storage.loadGroup('g1');
expect(g, isNotNull);
expect(g!.name, 'Rodina');
expect(g.memberIds.toSet(),
{alice.fingerprint.display, ja.fingerprint.display});
final kontakty = await storage.loadContacts();
expect(kontakty.map((c) => c.id), contains(alice.fingerprint.display),
reason: 'člen z rosteru se založí jako neověřený kontakt');
expect(kontakty.single.verified, isFalse);
});
test('PODVRŽENÝ ROSTER: fingerprint nesedící ke svému identity klíči '
'se zahodí (anti-spoof)', () async {
final alice = await LocalIdentity.create('Alice');
final mallory = await LocalIdentity.create('Mallory');
// Mallory tvrdí, že klíč patří Aliciinu fingerprintu.
final falesny = entry(mallory)..['f'] = alice.fingerprint.display;
await service.debugHandleGroupInvite(
mallory.fingerprint.display,
pozvanka('g1', 'Podvrh', [falesny, entry(mallory), entry(ja)]),
);
final g = await storage.loadGroup('g1');
expect(g, isNotNull, reason: 'zbytek pozvánky je platný');
expect(g!.memberIds, isNot(contains(alice.fingerprint.display)),
reason: 'záznam s nesedícím fingerprintem se musí zahodit');
final kontakty = await storage.loadContacts();
expect(kontakty.map((c) => c.id), isNot(contains(alice.fingerprint.display)),
reason: 'podvržený člen se nesmí založit jako kontakt');
});
test('pozvánka, ve které odesílatel není členem, se zahodí', () async {
final alice = await LocalIdentity.create('Alice');
final mallory = await LocalIdentity.create('Mallory');
await service.debugHandleGroupInvite(
mallory.fingerprint.display, // odesílatel chybí v rosteru
pozvanka('g1', 'Cizí', [entry(alice), entry(ja)]),
);
expect(await storage.loadGroup('g1'), isNull);
});
test('pozvánka, ve které nejsem členem, se zahodí', () async {
final alice = await LocalIdentity.create('Alice');
final bob = await LocalIdentity.create('Bob');
await service.debugHandleGroupInvite(
alice.fingerprint.display,
pozvanka('g1', 'Bez mě', [entry(alice), entry(bob)]),
);
expect(await storage.loadGroup('g1'), isNull);
});
test('roster přes strop 128 členů se zahodí', () async {
final alice = await LocalIdentity.create('Alice');
final roster = [entry(alice), entry(ja)];
for (var i = 0; i < Group.maxMembers; i++) {
roster.add(entry(await LocalIdentity.create('Člen $i')));
}
await service.debugHandleGroupInvite(
alice.fingerprint.display,
pozvanka('g1', 'Přeplněná', roster),
);
expect(await storage.loadGroup('g1'), isNull);
});
test('roster NEPŘEPÍŠE identity klíč existujícího kontaktu (TOFU)',
() async {
final alice = await LocalIdentity.create('Alice');
final mallory = await LocalIdentity.create('Mallory');
// Alici už znám a mám ověřený její pravý klíč.
await storage.saveContact(Contact(
displayName: 'Alice',
publicIdentity: alice.publicIdentity,
addedAt: DateTime(2026, 7, 1),
verified: true,
));
// Mallory pošle roster, kde Aliciin záznam nese Malloryin klíč pod
// Malloryiným fingerprintem — Alice tam je poctivě, ale útočník
// zkusí přejmenovat/přetlačit. Klíč Alice musí zůstat původní.
final rosterAlice = entry(alice);
await service.debugHandleGroupInvite(
mallory.fingerprint.display,
pozvanka('g1', 'Skupina', [rosterAlice, entry(mallory), entry(ja)]),
);
final kontakty = await storage.loadContacts();
final ulozena = kontakty.firstWhere((c) => c.id == alice.fingerprint.display);
expect(ulozena.publicIdentity.identityKey,
equals(alice.publicIdentity.identityKey));
expect(ulozena.verified, isTrue,
reason: 'pozvánka nesmí shodit ověření existujícího kontaktu');
});
test('MEMBERSHIP GUARD: obsah od nečlena → drop (null)', () async {
final alice = await LocalIdentity.create('Alice');
final mallory = await LocalIdentity.create('Mallory');
await storage.saveGroup(Group(
id: 'g1',
name: 'Uzavřená',
memberIds: [ja.fingerprint.display, alice.fingerprint.display],
createdBy: ja.fingerprint.display,
));
expect(await service.debugGroupForIncoming('g1', alice.fingerprint.display),
isNotNull);
expect(
await service.debugGroupForIncoming('g1', mallory.fingerprint.display),
isNull,
reason: 'nečlen nesmí psát do skupiny');
expect(await service.debugGroupForIncoming('neznama', alice.fingerprint.display),
isNull);
});
test('MEMBERSHIP GUARD: do opuštěné skupiny nedorazí nic', () async {
final alice = await LocalIdentity.create('Alice');
await storage.saveGroup(Group(
id: 'g1',
name: 'Opuštěná',
memberIds: [alice.fingerprint.display],
createdBy: alice.fingerprint.display,
left: true,
));
expect(await service.debugGroupForIncoming('g1', alice.fingerprint.display),
isNull);
});
test('aktualizaci rosteru smí poslat jen stávající člen', () async {
final alice = await LocalIdentity.create('Alice');
final mallory = await LocalIdentity.create('Mallory');
await storage.saveGroup(Group(
id: 'g1',
name: 'Původní',
memberIds: [ja.fingerprint.display, alice.fingerprint.display],
createdBy: ja.fingerprint.display,
));
// Mallory (nečlen) se zkusí přidat vlastní pozvánkou.
await service.debugHandleGroupInvite(
mallory.fingerprint.display,
pozvanka('g1', 'Únos', [entry(mallory), entry(alice), entry(ja)]),
);
final g = await storage.loadGroup('g1');
expect(g!.memberIds, isNot(contains(mallory.fingerprint.display)));
expect(g.name, 'Původní', reason: 'nečlen nesmí ani přejmenovat');
});
test('člen smí rozšířit roster (trust model v1: každý smí přidávat)',
() async {
final alice = await LocalIdentity.create('Alice');
final bob = await LocalIdentity.create('Bob');
await storage.saveGroup(Group(
id: 'g1',
name: 'Skupina',
memberIds: [ja.fingerprint.display, alice.fingerprint.display],
createdBy: ja.fingerprint.display,
));
await service.debugHandleGroupInvite(
alice.fingerprint.display,
pozvanka('g1', 'Skupina', [entry(alice), entry(bob), entry(ja)]),
);
final g = await storage.loadGroup('g1');
expect(g!.memberIds, contains(bob.fingerprint.display));
});
test('pozvánka s prázdným jménem nebo přerostlým gid se zahodí', () async {
final alice = await LocalIdentity.create('Alice');
await service.debugHandleGroupInvite(
alice.fingerprint.display,
pozvanka('g1', '', [entry(alice), entry(ja)]),
);
expect(await storage.loadGroup('g1'), isNull);
final dlouheGid = 'g' * 65;
await service.debugHandleGroupInvite(
alice.fingerprint.display,
pozvanka(dlouheGid, 'Dlouhé', [entry(alice), entry(ja)]),
);
expect(await storage.loadGroup(dlouheGid), isNull);
});
}
+27
View File
@@ -0,0 +1,27 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/mnemonic.dart';
// Obnova účtu stojí na reverzibilitě mnemonicu: 24 slov -> týž privátní
// klíč. Plnou identitu (LocalIdentity.create/recover) nejde testovat v čistém
// Dart VM — libsignal je native. Tady ověřujeme to podstatné bez native.
void main() {
test('mnemonic round-trip je stabilní (základ obnovy účtu)', () {
final key = Uint8List.fromList(List.generate(32, (i) => (i * 7 + 3) & 0xff));
final words = SepotMnemonic.encode(key);
expect(words.length, 24);
expect(SepotMnemonic.isValid(words), isTrue);
final decoded = SepotMnemonic.decode(words);
// deserialize -> serialize stabilní: obnova vždy vrátí identická slova/klíč
expect(SepotMnemonic.encode(decoded), words);
expect(SepotMnemonic.decode(SepotMnemonic.encode(decoded)), decoded);
});
test('neplatná fráze se pozná', () {
final words = SepotMnemonic.encode(Uint8List.fromList(List.filled(32, 0x11)));
expect(SepotMnemonic.isValid(words.sublist(0, 23)), isFalse); // málo slov
final broken = [...words]..[0] = 'zzzzzz';
expect(SepotMnemonic.isValid(broken), isFalse); // neznámé slovo
});
}
+358
View File
@@ -0,0 +1,358 @@
@Tags(['relay'])
library;
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:libsignal/libsignal.dart' show IdentityKeyPair, ProtocolAddress;
import 'package:sepot/core/crypto/attachment_cipher.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/network/relay_client.dart';
import 'package:sepot/core/storage/crypto_db.dart';
import 'package:sepot/core/storage/persistent_stores.dart';
import 'package:sepot/models/attachment.dart';
/// E2E přes REÁLNÝ lokální relay (S3+S6): produkční cesta bez UI.
/// Spuštění: relay musí běžet (SEPOT_RELAY, default http://127.0.0.1:8470).
/// flutter test test/integration --tags relay
void main() {
final relayUrl =
Platform.environment['SEPOT_RELAY'] ?? 'http://127.0.0.1:8470';
SepotStores dbStores(CryptoDb db, IdentityKeyPair identity, int regId) =>
SepotStores(
session: DbSessionStore(db),
identity: DbIdentityKeyStore(
db,
identityKeyPair: identity,
registrationId: regId,
),
preKey: DbPreKeyStore(db),
signedPreKey: DbSignedPreKeyStore(db),
kyberPreKey: DbKyberPreKeyStore(db),
);
late Directory tmp;
setUpAll(() async {
await SepotCrypto.init();
});
setUp(() => tmp = Directory.systemTemp.createTempSync('sepot_e2e'));
tearDown(() => tmp.deleteSync(recursive: true));
test('offline fronta → doručení → restart příjemce → ratchet pokračuje',
() async {
final dek = RelayClient.randomBytes(32);
final nowMs = DateTime.now().millisecondsSinceEpoch;
// ── Alice (in-memory stores stačí — restart testujeme u Boba) ──
final aliceKeys = SepotCrypto.generateIdentityKeys();
final alice = SepotCrypto.fromPrivateKey(
privateKey: aliceKeys.privateKey,
accountId: 'unbound',
registrationId: 1001,
);
final aliceRelay = RelayClient(baseUrl: relayUrl, crypto: alice);
final aliceDa = RelayClient.randomBytes(32);
await aliceRelay.register(
bundle: await alice.generatePreKeyBundle(
preKeyId: 1,
signedPreKeyId: 1,
kyberPreKeyId: 1,
timestampMs: nowMs,
),
oneTimePreKeys: await alice.generateOneTimePreKeys(startId: 2, count: 3),
deliveryAddress: aliceDa,
);
// ── Bob (persistent stores — bude restartovat) ──
final bobKeys = SepotCrypto.generateIdentityKeys();
final bobDbPath = '${tmp.path}/bob.db';
var bobDb = CryptoDb.open(path: bobDbPath, dek: dek);
var bob = SepotCrypto.fromPrivateKey(
privateKey: bobKeys.privateKey,
accountId: 'unbound',
registrationId: 1002,
storesFactory: (id) => dbStores(bobDb, id, 1002),
);
var bobRelay = RelayClient(baseUrl: relayUrl, crypto: bob);
final bobDa = RelayClient.randomBytes(32);
final bobReg = await bobRelay.register(
bundle: await bob.generatePreKeyBundle(
preKeyId: 1,
signedPreKeyId: 1,
kyberPreKeyId: 1,
timestampMs: nowMs,
),
oneTimePreKeys: await bob.generateOneTimePreKeys(startId: 2, count: 3),
deliveryAddress: bobDa,
);
// ── Alice: bundle Boba + OVĚŘENÍ identity klíče (anti-MITM) ──
final bundle = await aliceRelay.fetchBundle(bobDa);
expect(bundle.identityKey, bobKeys.publicKey,
reason: 'Bundle z relaye musí nést Bobův skutečný identity klíč');
final bobAddr = ProtocolAddress(name: bobReg.accountId, deviceId: 1);
await alice.processRemoteBundle(bobAddr, bundle);
final (cert, trustRoot) = await aliceRelay.fetchSenderCertificate();
// ── Zpráva 1: Bob OFFLINE → fronta ──
final sealed1 = await alice.sealedEncrypt(
recipient: bobAddr,
plaintext: utf8.encode('první — do fronty'),
senderCertificate: cert,
);
aliceRelay.start();
await aliceRelay.stateStream
.firstWhere((s) => s == RelayConnectionState.connected)
.timeout(const Duration(seconds: 5));
final r1 = await aliceRelay.sendEnvelope(
dstAddress: bobDa,
payload: sealed1,
);
expect(r1.status.name, 'STATUS_STORED_OFFLINE');
// ── Bob se připojí, dostane z fronty, dešifruje, ACKne ──
final delivered1F = bobRelay.deliveries.first;
bobRelay.start();
final delivered1 =
await delivered1F.timeout(const Duration(seconds: 5));
final opened1 = await bob.sealedDecrypt(
ciphertext: Uint8List.fromList(delivered1.payload),
trustRoot: trustRoot,
timestampMs: nowMs,
);
expect(utf8.decode(opened1.plaintext), 'první — do fronty');
expect(opened1.senderName, alice.address.name(),
reason: 'odesílatele zná jen příjemce (sealed sender)');
expect(opened1.senderIdentityKey, aliceKeys.publicKey,
reason: 'identity klíč odesílatele ověřen certifikátem');
bobRelay.ack(delivered1.msgUuid);
// Bob odpoví (uzavře ratchet oběma směry) — Alice je online
final (bobCert, _) = await bobRelay.fetchSenderCertificate();
final aliceDeliverF = aliceRelay.deliveries.first;
final sealedReply = await bob.sealedEncrypt(
recipient: ProtocolAddress(name: alice.address.name(), deviceId: 1),
plaintext: utf8.encode('odpověď'),
senderCertificate: bobCert,
);
await bobRelay.sendEnvelope(dstAddress: aliceDa, payload: sealedReply);
final aliceGot =
await aliceDeliverF.timeout(const Duration(seconds: 5));
final aliceOpened = await alice.sealedDecrypt(
ciphertext: Uint8List.fromList(aliceGot.payload),
trustRoot: trustRoot,
timestampMs: nowMs,
);
expect(utf8.decode(aliceOpened.plaintext), 'odpověď');
aliceRelay.ack(aliceGot.msgUuid);
// ── RESTART BOBA: zavřít vše, nové instance ze stejného DB souboru ──
await bobRelay.dispose();
bobDb.close();
bobDb = CryptoDb.open(path: bobDbPath, dek: dek);
bob = SepotCrypto.fromPrivateKey(
privateKey: bobKeys.privateKey,
accountId: bobReg.accountId,
registrationId: 1002,
storesFactory: (id) => dbStores(bobDb, id, 1002),
);
bobRelay = RelayClient(baseUrl: relayUrl, crypto: bob);
bobRelay.restoreSession(
accountId: bobReg.accountId,
deviceUuid: bobReg.deviceUuid,
);
// ── Zpráva 2: ratchet MUSÍ pokračovat ze stavu na disku ──
final delivered2F = bobRelay.deliveries.first;
bobRelay.start();
await bobRelay.stateStream
.firstWhere((s) => s == RelayConnectionState.connected)
.timeout(const Duration(seconds: 5));
final sealed2 = await alice.sealedEncrypt(
recipient: bobAddr,
plaintext: utf8.encode('po restartu Boba'),
senderCertificate: cert,
);
final r2 = await aliceRelay.sendEnvelope(
dstAddress: bobDa,
payload: sealed2,
);
expect(r2.status.name, 'STATUS_DELIVERED_ONLINE');
final delivered2 =
await delivered2F.timeout(const Duration(seconds: 5));
final opened2 = await bob.sealedDecrypt(
ciphertext: Uint8List.fromList(delivered2.payload),
trustRoot: trustRoot,
timestampMs: nowMs,
);
expect(utf8.decode(opened2.plaintext), 'po restartu Boba');
bobRelay.ack(delivered2.msgUuid);
await aliceRelay.dispose();
await bobRelay.dispose();
bobDb.close();
}, timeout: const Timeout(Duration(minutes: 2)));
test('příloha: šifrovaný blob přes relay + meta E2E zprávou (§4.6)',
() async {
final nowMs = DateTime.now().millisecondsSinceEpoch;
Future<(SepotCrypto, RelayClient, Uint8List, RelayRegistration)> spawn(
int regId) async {
final keys = SepotCrypto.generateIdentityKeys();
final crypto = SepotCrypto.fromPrivateKey(
privateKey: keys.privateKey,
accountId: 'unbound',
registrationId: regId,
);
final relay = RelayClient(baseUrl: relayUrl, crypto: crypto);
final da = RelayClient.randomBytes(32);
final reg = await relay.register(
bundle: await crypto.generatePreKeyBundle(
preKeyId: 1,
signedPreKeyId: 1,
kyberPreKeyId: 1,
timestampMs: nowMs,
),
oneTimePreKeys: await crypto.generateOneTimePreKeys(startId: 2, count: 3),
deliveryAddress: da,
);
return (crypto, relay, da, reg);
}
final (alice, aliceRelay, _, _) = await spawn(2001);
final (bob, bobRelay, bobDa, bobReg) = await spawn(2002);
// ── Alice: „fotka" → per-file šifra → upload blobu na relay ──
final original = Uint8List.fromList(
List.generate(200 * 1024, (i) => (i * 31 + 7) & 0xFF));
final encrypted = await AttachmentCipher.encrypt(original);
final blobId = await aliceRelay.uploadAttachment(encrypted.blob);
expect(blobId, matches(RegExp(r'^[0-9a-f]{32}$')));
// ── meta jde E2E zprávou (relay ji nikdy nevidí) ──
final meta = AttachmentMeta(
blobId: blobId,
keyB64: base64Encode(encrypted.key),
nonceB64: base64Encode(encrypted.nonce),
digestB64: base64Encode(encrypted.digest),
size: encrypted.plaintextSize,
mime: 'image/jpeg',
width: 640,
height: 480,
);
final content = jsonEncode({
'v': 1,
't': 'att',
'id': 'test-att-1',
'att': meta.toWire(),
});
final bundle = await aliceRelay.fetchBundle(bobDa);
final bobAddr = ProtocolAddress(name: bobReg.accountId, deviceId: 1);
await alice.processRemoteBundle(bobAddr, bundle);
final (cert, trustRoot) = await aliceRelay.fetchSenderCertificate();
aliceRelay.start();
await aliceRelay.stateStream
.firstWhere((s) => s == RelayConnectionState.connected)
.timeout(const Duration(seconds: 5));
final deliveredF = bobRelay.deliveries.first;
bobRelay.start();
final sealed = await alice.sealedEncrypt(
recipient: bobAddr,
plaintext: utf8.encode(content),
senderCertificate: cert,
);
await aliceRelay.sendEnvelope(dstAddress: bobDa, payload: sealed);
// ── Bob: dešifruje zprávu → stáhne blob (bez auth) → digest → data ──
final delivered = await deliveredF.timeout(const Duration(seconds: 5));
final opened = await bob.sealedDecrypt(
ciphertext: Uint8List.fromList(delivered.payload),
trustRoot: trustRoot,
timestampMs: nowMs,
);
final received =
jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>;
expect(received['t'], 'att');
final gotMeta =
AttachmentMeta.fromWire(received['att'] as Map<String, dynamic>);
expect(gotMeta.blobId, blobId);
final blob = await bobRelay.downloadAttachment(gotMeta.blobId);
final plaintext = await AttachmentCipher.decrypt(
blob: blob,
key: gotMeta.key,
nonce: gotMeta.nonce,
expectedDigest: gotMeta.digest,
);
expect(plaintext, equals(original));
bobRelay.ack(delivered.msgUuid);
// ── negativní: neexistující blob = 404 ──
await expectLater(
bobRelay.downloadAttachment('00112233445566778899aabbccddeeff'),
throwsA(isA<RelayException>()
.having((e) => e.statusCode, 'statusCode', 404)),
);
await aliceRelay.dispose();
await bobRelay.dispose();
}, timeout: const Timeout(Duration(minutes: 2)));
test('smazání účtu: DELETE /v1/identity → bundle 404, druhé smazání 401',
() async {
final nowMs = DateTime.now().millisecondsSinceEpoch;
final keys = SepotCrypto.generateIdentityKeys();
final crypto = SepotCrypto.fromPrivateKey(
privateKey: keys.privateKey,
accountId: 'unbound',
registrationId: 1003,
);
final relay = RelayClient(baseUrl: relayUrl, crypto: crypto);
final da = RelayClient.randomBytes(32);
await relay.register(
bundle: await crypto.generatePreKeyBundle(
preKeyId: 1,
signedPreKeyId: 1,
kyberPreKeyId: 1,
timestampMs: nowMs,
),
oneTimePreKeys: await crypto.generateOneTimePreKeys(startId: 2, count: 3),
deliveryAddress: da,
);
// účet existuje — bundle jde stáhnout
await relay.fetchBundle(da);
await relay.deleteIdentity();
// po smazání je adresa neznámá
await expectLater(
relay.fetchBundle(da),
throwsA(isA<RelayException>()
.having((e) => e.statusCode, 'statusCode', 404)),
);
// druhé smazání: zařízení už neexistuje → 401 (idempotence auth vrstvy)
await expectLater(
relay.deleteIdentity(),
throwsA(isA<RelayException>()
.having((e) => e.statusCode, 'statusCode', 401)),
);
await relay.dispose();
}, timeout: const Timeout(Duration(minutes: 2)));
}
+91
View File
@@ -0,0 +1,91 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/features/settings/language_settings_tile.dart';
import 'package:sepot/l10n/generated/app_localizations.dart';
/// Všech 5 podporovaných jazyků se musí načíst a mít neprázdné texty.
/// Chybějící překlad = gen-l10n doplní text šablony (cs) — to hlídá
/// porovnání ARB souborů níže, ne runtime.
void main() {
const locales = [
Locale('cs'),
Locale('en'),
Locale('de'),
Locale('ru'),
Locale('it'),
];
test('podporované locale sedí s MaterialApp seznamem', () {
expect(
AppLocalizations.supportedLocales.toSet(),
locales.toSet(),
);
});
for (final locale in locales) {
test('AppLocalizations se načte pro $locale', () async {
final l10n = await AppLocalizations.delegate.load(locale);
expect(l10n.appTitle, 'Šepot');
expect(l10n.settingsLanguage, isNotEmpty);
expect(l10n.languageSystem, isNotEmpty);
});
}
// Wiring delegátů ve widget tree: skutečný widget se musí vykreslit
// v jazyce vynuceného locale (ne jen delegate.load v izolaci).
const tileTitles = {
'cs': 'Jazyk aplikace',
'en': 'App language',
'de': 'App-Sprache',
'ru': 'Язык приложения',
'it': "Lingua dell'app",
};
for (final entry in tileTitles.entries) {
testWidgets('LanguageSettingsTile se vykreslí v ${entry.key}',
(tester) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
locale: Locale(entry.key),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: LanguageSettingsTile()),
),
),
);
await tester.pumpAndSettle();
expect(find.text(entry.value), findsOneWidget);
});
}
test('žádný jazyk nemá chybějící klíče proti šabloně (cs)', () {
final dir = Directory('lib/l10n');
final template = json.decode(
File('${dir.path}/app_cs.arb').readAsStringSync(),
) as Map<String, dynamic>;
final templateKeys =
template.keys.where((k) => !k.startsWith('@')).toSet();
for (final code in ['en', 'de', 'ru', 'it']) {
final other = json.decode(
File('${dir.path}/app_$code.arb').readAsStringSync(),
) as Map<String, dynamic>;
final keys = other.keys.where((k) => !k.startsWith('@')).toSet();
expect(
templateKeys.difference(keys),
isEmpty,
reason: 'app_$code.arb nemá všechny klíče šablony',
);
expect(
keys.difference(templateKeys),
isEmpty,
reason: 'app_$code.arb má klíče navíc oproti šabloně',
);
}
});
}
+35
View File
@@ -0,0 +1,35 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/models/message.dart';
void main() {
test('reakce/edit/delete přežijí JSON roundtrip', () {
final m = Message.text(senderId: 'me', recipientId: 'you', content: 'ahoj')
.copyWith(
reactions: {'FP1': '👍', 'FP2': '❤️'},
editedAt: DateTime.parse('2026-07-06T20:00:00.000'),
);
final back =
Message.fromJson(jsonDecode(jsonEncode(m.toJson())) as Map<String, dynamic>);
expect(back.reactions, {'FP1': '👍', 'FP2': '❤️'});
expect(back.editedAt, isNotNull);
expect(back.isDeleted, isFalse);
});
test('smazaná zpráva: isDeleted + prázdný obsah', () {
final m = Message.text(senderId: 'me', recipientId: 'you', content: 'x')
.copyWith(isDeleted: true, content: '', reactions: const {});
final back =
Message.fromJson(jsonDecode(jsonEncode(m.toJson())) as Map<String, dynamic>);
expect(back.isDeleted, isTrue);
expect(back.content, '');
});
test('reply nese replyToId', () {
final m = Message.text(
senderId: 'me', recipientId: 'you', content: 'odpoved', replyToId: 'abc');
final back =
Message.fromJson(jsonDecode(jsonEncode(m.toJson())) as Map<String, dynamic>);
expect(back.replyToId, 'abc');
});
}
@@ -0,0 +1,61 @@
@Tags(['tor'])
library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/constants/app_constants.dart';
import 'package:sepot/core/network/relay_transport.dart';
/// Živý smoke [TorTransport] proti REÁLNÉMU onion relayi přes Tor.
/// Vyžaduje běžící SOCKS proxy na 127.0.0.1:9050 (Orbot / lokální tor) —
/// jinak se testy SAMY skipnou, aby `flutter test` na strojích bez toru
/// neselhal.
/// flutter test test/network/tor_transport_live_test.dart --tags tor
void main() {
const socksPort = 9050;
Future<bool> socksUp() async {
try {
final s = await Socket.connect('127.0.0.1', socksPort,
timeout: const Duration(seconds: 1));
s.destroy();
return true;
} catch (_) {
return false;
}
}
test('REST /v1/server-info přes onion → 200 + trust_root', () async {
if (!await socksUp()) {
markTestSkipped('Tor SOCKS 127.0.0.1:$socksPort neběží');
return;
}
final t = TorTransport(socksPort: socksPort);
final resp = await t
.httpClient()
.get(Uri.parse('${AppConstants.relayOnion}/v1/server-info'))
.timeout(const Duration(seconds: 60));
expect(resp.statusCode, 200);
final body = jsonDecode(resp.body) as Map<String, dynamic>;
expect(body['trust_root'], isNotNull);
expect(body['capabilities'], contains('sealed-sender'));
t.close();
}, timeout: const Timeout(Duration(seconds: 90)));
test('WS /v1/ws přes onion → upgrade projde (obousměrný tunel)', () async {
if (!await socksUp()) {
markTestSkipped('Tor SOCKS 127.0.0.1:$socksPort neběží');
return;
}
final t = TorTransport(socksPort: socksPort);
final wsUrl = AppConstants.relayOnion.replaceFirst('http', 'ws');
final ch = t.connectWs(Uri.parse('$wsUrl/v1/ws'));
// Dokončení `ready` = HTTP Upgrade (101) prošel skrz Tor tunel k onion.
await ch.ready.timeout(const Duration(seconds: 60));
expect(ch.closeCode, isNull); // spojení stojí
await ch.sink.close();
t.close();
}, timeout: const Timeout(Duration(seconds: 90)));
}
+86
View File
@@ -0,0 +1,86 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/network/relay_transport.dart';
import 'package:socks5_proxy/socks_server.dart';
/// Deterministické ověření [TorTransport]: REST i WS musí projít lokálním
/// SOCKS5 serverem k lokálnímu cílovému serveru. Žádná síť ven, žádný
/// libsignal — jen transportní vrstva. Doplňuje živý smoke proti onion
/// (`tor_transport_live_test.dart`, tag `tor`).
void main() {
group('TorTransport přes lokální SOCKS5 proxy', () {
late SocksServer proxy;
late int proxyPort;
late HttpServer target;
late int targetPort;
setUp(() async {
// SOCKS5 proxy, která každý požadavek prostě forwarduje na cíl.
proxy = SocksServer();
proxy.connections.listen((c) async {
try {
await c.forward();
} catch (_) {
// klient zavřel dřív — pro test nepodstatné
}
}).onError((_) {});
final proxySock = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
proxyPort = proxySock.port;
await proxy.addServerSocket(proxySock);
// Cílový server: echo HTTP + echo WebSocket.
target = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
targetPort = target.port;
target.listen((req) async {
if (WebSocketTransformer.isUpgradeRequest(req)) {
final ws = await WebSocketTransformer.upgrade(req);
ws.listen((data) => ws.add(data)); // echo zpět
} else {
req.response
..statusCode = 200
..write('ozvěna:${req.uri.path}');
await req.response.close();
}
});
});
tearDown(() async {
await target.close(force: true);
await proxy.stop();
});
test('REST GET projde přes proxy k cíli', () async {
final t = TorTransport(socksHost: '127.0.0.1', socksPort: proxyPort);
final resp = await t
.httpClient()
.get(Uri.parse('http://127.0.0.1:$targetPort/ahoj'))
.timeout(const Duration(seconds: 10));
expect(resp.statusCode, 200);
expect(resp.body, 'ozvěna:/ahoj');
t.close();
});
test('WebSocket se otevře přes proxy a echuje', () async {
final t = TorTransport(socksHost: '127.0.0.1', socksPort: proxyPort);
final ch = t.connectWs(Uri.parse('ws://127.0.0.1:$targetPort/v1/ws'));
await ch.ready.timeout(const Duration(seconds: 10));
ch.sink.add('ping-přes-tor');
final echo = await ch.stream.first.timeout(const Duration(seconds: 10));
expect(echo, 'ping-přes-tor');
await ch.sink.close();
t.close();
});
test('DirectTransport míří na cíl přímo (kontrolní vzorek)', () async {
final t = DirectTransport();
final resp = await t
.httpClient()
.get(Uri.parse('http://127.0.0.1:$targetPort/primo'))
.timeout(const Duration(seconds: 10));
expect(resp.statusCode, 200);
expect(resp.body, 'ozvěna:/primo');
t.close();
});
});
}
+74
View File
@@ -0,0 +1,74 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/safety_number.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
void main() {
setUpAll(() async {
await SepotCrypto.init();
});
Uint8List fixedKey(int seed) {
// deterministický „privátní klíč" → veřejný přes libsignal derivaci
final priv = Uint8List.fromList(List.generate(32, (i) => (i * 7 + seed) & 0xFF));
return SepotCrypto.derivePublicKey(priv);
}
test('obě strany vidí stejné číslo (symetrie)', () {
final a = fixedKey(1);
final b = fixedKey(2);
final fromA = SafetyNumber.compute(localIdentityKey: a, remoteIdentityKey: b);
final fromB = SafetyNumber.compute(localIdentityKey: b, remoteIdentityKey: a);
expect(fromA.display, fromB.display);
});
test('60 číslic, 12 bloků po 5', () {
final n = SafetyNumber.compute(
localIdentityKey: fixedKey(1),
remoteIdentityKey: fixedKey(2),
);
expect(n.display, matches(RegExp(r'^\d{60}$')));
expect(n.groups, hasLength(12));
expect(n.groups.every((g) => g.length == 5), isTrue);
});
test('jiný pár klíčů = jiné číslo', () {
final ab = SafetyNumber.compute(
localIdentityKey: fixedKey(1), remoteIdentityKey: fixedKey(2));
final ac = SafetyNumber.compute(
localIdentityKey: fixedKey(1), remoteIdentityKey: fixedKey(3));
expect(ab.display, isNot(ac.display));
});
test('QR compare: protistrany si odpovídají, cizí ne', () {
final a = fixedKey(1);
final b = fixedKey(2);
final mine = SafetyNumber.compute(localIdentityKey: a, remoteIdentityKey: b);
final theirs = SafetyNumber.compute(localIdentityKey: b, remoteIdentityKey: a);
final stranger = SafetyNumber.compute(
localIdentityKey: a, remoteIdentityKey: fixedKey(9));
expect(SafetyNumber.compare(mine.scannable, theirs.scannable), isTrue);
expect(SafetyNumber.compare(mine.scannable, stranger.scannable), isFalse);
// vadný vstup nesmí spadnout
expect(
SafetyNumber.compare(mine.scannable, Uint8List.fromList([1, 2, 3])),
isFalse,
);
});
test('KAT: zafixovaný výstup — změna = breaking change pro uživatele', () {
final n = SafetyNumber.compute(
localIdentityKey: fixedKey(1),
remoteIdentityKey: fixedKey(2),
);
// Vygenerováno libsignal 5.0.9 (iterations=5200, version=2,
// identifier=BLAKE2b-256(pubkey)). Pokud tenhle test spadne po
// upgradu, VŠEM uživatelům se změní safety numbers → stop ship.
expect(
n.display,
'289143504470406742912626343937385761080203728630218051783783',
);
});
}
+122
View File
@@ -0,0 +1,122 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/storage/crypto_db.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/models/message.dart';
/// S9: aplikační data (konverzace, zprávy, nastavení) žijí v SQLCipher DB,
/// ne v plain Hive. Testuje se DB vrstva i service nad ní (debugAttachDb —
/// OS keystore na Linux VM není).
void main() {
late Directory tmp;
late CryptoDb db;
late SecureStorageService storage;
final rng = Random.secure();
Uint8List randomDek() =>
Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
setUp(() {
tmp = Directory.systemTemp.createTempSync('sepot_s9_test');
db = CryptoDb.open(path: '${tmp.path}/app.db', dek: randomDek());
storage = SecureStorageService(dbPath: '${tmp.path}/app.db')
..debugAttachDb(db);
});
tearDown(() {
db.close();
tmp.deleteSync(recursive: true);
});
test('zamčený storage nedá data — StateError, žádný tichý fallback', () {
final locked = SecureStorageService(dbPath: '${tmp.path}/locked.db');
expect(() => locked.db, throwsStateError);
expect(locked.isUnlocked, isFalse);
});
test('zprávy uložené přes addMessage přežijí reopen DB', () async {
final msg = Message.text(
senderId: 'alice',
recipientId: 'bob',
content: 'Tajná zpráva',
);
await storage.addMessage('bob', msg);
// reopen — data musí přijít z disku, ne z paměti
final path = '${tmp.path}/reopen.db';
final dek = randomDek();
final db1 = CryptoDb.open(path: path, dek: dek);
final s1 = SecureStorageService(dbPath: path)..debugAttachDb(db1);
await s1.addMessage('bob', msg);
db1.close();
final db2 = CryptoDb.open(path: path, dek: dek);
final s2 = SecureStorageService(dbPath: path)..debugAttachDb(db2);
final conv = await s2.loadConversation('bob');
expect(conv, isNotNull);
expect(conv!.messages.single.content, 'Tajná zpráva');
db2.close();
});
test('DB soubor neobsahuje plaintext obsahu zpráv', () async {
await storage.addMessage(
'bob',
Message.text(
senderId: 'alice',
recipientId: 'bob',
content: 'NAJDI-MELESS-TOKEN',
),
);
// WAL checkpoint, ať je obsah v hlavním souboru
db.raw.execute('PRAGMA wal_checkpoint(TRUNCATE);');
final bytes = File('${tmp.path}/app.db').readAsBytesSync();
expect(
String.fromCharCodes(bytes).contains('NAJDI-MELESS-TOKEN'),
isFalse,
reason: 'Obsah zprávy je na disku čitelný = at-rest šifrování neběží!',
);
});
test('konverzace se řadí podle poslední aktivity', () async {
await storage.saveConversation(
Conversation(
contactId: 'stary',
lastActivity: DateTime(2026, 1, 1),
),
);
await storage.saveConversation(
Conversation(
contactId: 'novy',
lastActivity: DateTime(2026, 7, 1),
),
);
final list = await storage.loadConversations();
expect(list.map((c) => c.contactId).toList(), ['novy', 'stary']);
});
test('smazání kontaktu smaže i jeho konverzaci (atomicky)', () async {
db.contactPut('bob', '{"id":"bob"}');
db.conversationPut('bob', '{"contactId":"bob","messages":[],'
'"lastActivity":"2026-07-03T00:00:00.000"}');
db.contactDelete('bob');
expect(db.contactsJsonAll(), isEmpty);
expect(db.conversationJson('bob'), isNull);
});
test('nastavení: JSON roundtrip přes app_settings', () async {
await storage.setSetting('disappearing_seconds', 86400);
await storage.setSetting('theme', 'dark');
expect(await storage.getSetting<int>('disappearing_seconds'), 86400);
expect(await storage.getSetting<String>('theme'), 'dark');
expect(await storage.getSetting<String>('neexistuje'), isNull);
});
}
+132
View File
@@ -0,0 +1,132 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/storage/crypto_db.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/models/group.dart';
import 'package:sepot/models/message.dart';
/// Skupiny v1 (GROUPS-V1.md) — perzistence a model. Bezpečnostní pravidla
/// příjmové cesty (roster anti-spoof, membership guard) mají vlastní test
/// v `test/groups_security_test.dart`, protože nepotřebují DB.
void main() {
late Directory tmp;
late CryptoDb db;
late SecureStorageService storage;
final rng = Random.secure();
Uint8List randomDek() =>
Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
setUp(() {
tmp = Directory.systemTemp.createTempSync('sepot_groups_test');
db = CryptoDb.open(path: '${tmp.path}/app.db', dek: randomDek());
storage = SecureStorageService(dbPath: '${tmp.path}/app.db')
..debugAttachDb(db);
});
tearDown(() {
db.close();
tmp.deleteSync(recursive: true);
});
Group vzorovaSkupina({String id = 'g-1', List<String>? members}) => Group(
id: id,
name: 'Rodina',
memberIds: members ?? ['ME', 'ALICE', 'BOB'],
createdBy: 'ME',
);
test('skupina přežije uložení a načtení (JSON roundtrip přes DB)', () async {
final g = vzorovaSkupina();
await storage.saveGroup(g);
final nactena = await storage.loadGroup('g-1');
expect(nactena, isNotNull);
expect(nactena!.name, 'Rodina');
expect(nactena.memberIds, ['ME', 'ALICE', 'BOB']);
expect(nactena.createdBy, 'ME');
expect(nactena.left, isFalse);
expect(nactena.createdAt.toIso8601String(), g.createdAt.toIso8601String());
});
test('loadGroup vrátí null pro id, které patří 1:1 kontaktu', () async {
await storage.saveGroup(vzorovaSkupina());
expect(await storage.loadGroup('ALICE'), isNull,
reason: 'rozlišení skupina vs kontakt stojí na tomhle null');
});
test('loadGroups vrátí všechny skupiny včetně opuštěných', () async {
await storage.saveGroup(vzorovaSkupina(id: 'g-1'));
final opustena = vzorovaSkupina(id: 'g-2')
..left = true
..memberIds = ['ALICE', 'BOB'];
await storage.saveGroup(opustena);
final vsechny = await storage.loadGroups();
expect(vsechny.map((g) => g.id).toSet(), {'g-1', 'g-2'});
expect(vsechny.firstWhere((g) => g.id == 'g-2').left, isTrue,
reason: 'opuštěná skupina zůstává kvůli historii');
});
test('uložení stejného id skupinu přepíše, nezdvojí', () async {
await storage.saveGroup(vzorovaSkupina());
final zmenena = vzorovaSkupina()
..name = 'Rodina a přátelé'
..memberIds = ['ME', 'ALICE', 'BOB', 'CILKA'];
await storage.saveGroup(zmenena);
final vsechny = await storage.loadGroups();
expect(vsechny.length, 1);
expect(vsechny.single.name, 'Rodina a přátelé');
expect(vsechny.single.memberIds.length, 4);
});
test('deleteGroup skupinu odstraní', () async {
await storage.saveGroup(vzorovaSkupina());
await storage.deleteGroup('g-1');
expect(await storage.loadGroup('g-1'), isNull);
expect(await storage.loadGroups(), isEmpty);
});
test('skupinová konverzace žije pod contact_id = gid', () async {
final g = vzorovaSkupina();
await storage.saveGroup(g);
await storage.saveConversation(Conversation(
contactId: g.id,
messages: [
Message.text(senderId: 'ALICE', recipientId: g.id, content: 'ahoj'),
],
));
final conv = await storage.loadConversation(g.id);
expect(conv, isNotNull);
expect(conv!.messages.single.content, 'ahoj');
expect(conv.messages.single.senderId, 'ALICE',
reason: 'autor zprávy = fingerprint člena, ne gid');
});
test('Group.isMember rozhoduje o membership guardu', () {
final g = vzorovaSkupina();
expect(g.isMember('ALICE'), isTrue);
expect(g.isMember('MALLORY'), isFalse);
});
test('strop členů je 128 (MASTER-DESIGN otázka 9)', () {
expect(Group.maxMembers, 128);
});
test('fromJson snese chybějící "left" (starší záznam)', () {
final g = Group.fromJson({
'id': 'g-old',
'name': 'Staré',
'memberIds': ['ME'],
'createdBy': 'ME',
'createdAt': DateTime(2026, 7, 1).toIso8601String(),
});
expect(g.left, isFalse);
});
}
@@ -0,0 +1,168 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:libsignal/libsignal.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/storage/crypto_db.dart';
import 'package:sepot/core/storage/persistent_stores.dart';
import 'package:sqlite3/sqlite3.dart';
/// S3: krypto stavy musí přežít restart procesu — ratchet pokračuje
/// z disku, jinak by každý restart appky rozbil všechny konverzace.
void main() {
late Directory tmp;
final rng = Random.secure();
Uint8List randomDek() =>
Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
SepotStores dbStores(CryptoDb db, IdentityKeyPair identity, int regId) {
return SepotStores(
session: DbSessionStore(db),
identity: DbIdentityKeyStore(
db,
identityKeyPair: identity,
registrationId: regId,
),
preKey: DbPreKeyStore(db),
signedPreKey: DbSignedPreKeyStore(db),
kyberPreKey: DbKyberPreKeyStore(db),
);
}
setUpAll(() async {
await SepotCrypto.init();
});
setUp(() {
tmp = Directory.systemTemp.createTempSync('sepot_s3_test');
});
tearDown(() {
tmp.deleteSync(recursive: true);
});
test('DB soubor na disku není plaintext SQLite (SQLCipher aktivní)', () {
final path = '${tmp.path}/crypto.db';
final db = CryptoDb.open(path: path, dek: randomDek());
db.kvPut('test', Uint8List.fromList([1, 2, 3]));
db.close();
final header = File(path).openSync().readSync(16);
expect(
String.fromCharCodes(header),
isNot(startsWith('SQLite format 3')),
reason: 'Plaintext hlavička = DB se NEšifruje!',
);
});
test('špatný DEK → výjimka, žádný tichý přístup', () {
final path = '${tmp.path}/crypto.db';
CryptoDb.open(path: path, dek: randomDek()).close();
expect(
() => CryptoDb.open(path: path, dek: randomDek()),
throwsA(isA<SqliteException>()),
);
});
test('ratchet přežije restart: handshake → restart Boba → konverzace jede',
() async {
final path = '${tmp.path}/bob.db';
final dek = randomDek();
final bobKeys = SepotCrypto.generateIdentityKeys();
final aliceAddr = ProtocolAddress(name: 'alice', deviceId: 1);
final bobAddr = ProtocolAddress(name: 'bob', deviceId: 1);
// --- Bob, první běh "aplikace" ---
final bobDb1 = CryptoDb.open(path: path, dek: dek);
final bob1 = SepotCrypto.fromPrivateKey(
privateKey: bobKeys.privateKey,
accountId: 'bob',
registrationId: 2,
storesFactory: (identity) => dbStores(bobDb1, identity, 2),
);
final bobBundle = await bob1.generatePreKeyBundle(
preKeyId: 1,
signedPreKeyId: 1,
kyberPreKeyId: 1,
timestampMs: DateTime.now().millisecondsSinceEpoch,
);
// --- Alice (in-memory, druhá strana) ---
final alice = SepotCrypto.generate(accountId: 'alice', registrationId: 1);
// X3DH + obousměrný handshake
await alice.processRemoteBundle(bobAddr, bobBundle);
final msg1 = await alice.encryptTo(bobAddr, 'ahoj Bobe'.codeUnits);
final plain1 = await bob1.decryptFrom(aliceAddr, msg1);
expect(String.fromCharCodes(plain1), 'ahoj Bobe');
final msg2 = await bob1.encryptTo(aliceAddr, 'ahoj Alice'.codeUnits);
final plain2 = await alice.decryptFrom(bobAddr, msg2);
expect(String.fromCharCodes(plain2), 'ahoj Alice');
// OTP prekey se po použití maže (forward secrecy první zprávy)
expect(await DbPreKeyStore(bobDb1).getAllPreKeyIds(), isEmpty);
// --- „Zabití aplikace" ---
bobDb1.close();
// --- Bob, druhý běh: NIC v paměti, vše z disku ---
final bobDb2 = CryptoDb.open(path: path, dek: dek);
final bob2 = SepotCrypto.fromPrivateKey(
privateKey: bobKeys.privateKey,
accountId: 'bob',
registrationId: 2,
storesFactory: (identity) => dbStores(bobDb2, identity, 2),
);
// Ratchet pokračuje oběma směry
final msg3 = await alice.encryptTo(bobAddr, 'po restartu'.codeUnits);
final plain3 = await bob2.decryptFrom(aliceAddr, msg3);
expect(String.fromCharCodes(plain3), 'po restartu');
final msg4 = await bob2.encryptTo(aliceAddr, 'jedu dál'.codeUnits);
final plain4 = await alice.decryptFrom(bobAddr, msg4);
expect(String.fromCharCodes(plain4), 'jedu dál');
bobDb2.close();
});
test('TOFU: změna identity protistrany se detekuje (saveIdentity=true)',
() async {
final db = CryptoDb.open(path: '${tmp.path}/tofu.db', dek: randomDek());
final keys = SepotCrypto.generateIdentityKeys();
final identity = IdentityKeyPair.fromKeys(
privateKey: PrivateKey.deserialize(bytes: keys.privateKey.toList()),
publicKey: PrivateKey.deserialize(bytes: keys.privateKey.toList())
.getPublicKey(),
);
final store = DbIdentityKeyStore(
db,
identityKeyPair: identity,
registrationId: 7,
);
final addr = ProtocolAddress(name: 'mallory', deviceId: 1);
final firstKey = PrivateKey.generate().getPublicKey();
final secondKey = PrivateKey.generate().getPublicKey();
// první kontakt: nová identita, TOFU věří
expect(await store.saveIdentity(addr, firstKey), isTrue);
expect(
await store.isTrustedIdentity(addr, firstKey, Direction.sending),
isTrue,
);
// stejný klíč znovu: beze změny
expect(await store.saveIdentity(addr, firstKey), isFalse);
// JINÝ klíč: nedůvěryhodný, dokud ho saveIdentity nepřepíše
expect(
await store.isTrustedIdentity(addr, secondKey, Direction.sending),
isFalse,
);
db.close();
});
}
+8
View File
@@ -0,0 +1,8 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
test('Šepot app základní test', () {
// Základní test — app se inicializuje
expect(1 + 1, 2);
});
}