Š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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user