Š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:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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ě',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user