8081d98c67
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).
359 lines
13 KiB
Dart
359 lines
13 KiB
Dart
@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)));
|
|
}
|