Šepot — end-to-end encrypted messenger

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

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

Licensed under AGPL-3.0 with an app-store distribution exception (§7).
This commit is contained in:
Julius Joska
2026-07-11 17:06:39 +02:00
commit 8081d98c67
309 changed files with 44102 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/dek_wrap.dart';
/// S9: PIN musí být klíčem k DEK, ne hashem na porovnání. Testy běží
/// na OWASP floor parametrech (rychlejší, ale pořád reálné krypto).
void main() {
final rng = Random.secure();
Uint8List randomDek() =>
Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256)));
Future<String> wrapFloor(Uint8List dek, String pin) => DekWrap.wrap(
dek: dek,
pin: pin,
memoryKiB: DekWrap.floorMemoryKiB,
iterations: DekWrap.floorIterations,
);
test('wrap → unwrap se správným PINem vrátí původní DEK', () async {
final dek = randomDek();
final wrapped = await wrapFloor(dek, '123456');
final unwrapped = await DekWrap.unwrap(wrapped: wrapped, pin: '123456');
expect(unwrapped, dek);
});
test('wrap blob neobsahuje DEK ani PIN v čitelné podobě', () async {
final dek = randomDek();
final wrapped = await wrapFloor(dek, '123456');
expect(wrapped, startsWith('sepot-dek-v1:'));
expect(wrapped.contains('123456'), isFalse);
// ciphertext části nesmí být DEK samotný
expect(wrapped.contains(String.fromCharCodes(dek)), isFalse);
});
test('špatný PIN → WrongPinException (GCM tag nesedí)', () async {
final wrapped = await wrapFloor(randomDek(), '123456');
await expectLater(
DekWrap.unwrap(wrapped: wrapped, pin: '654321'),
throwsA(isA<WrongPinException>()),
);
});
test('zmanipulovaný ciphertext → WrongPinException', () async {
final wrapped = await wrapFloor(randomDek(), '123456');
final parts = wrapped.split(':');
// přepiš ciphertext (index 6) validním base64 jiného obsahu
parts[6] = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
final tampered = parts.join(':');
await expectLater(
DekWrap.unwrap(wrapped: tampered, pin: '123456'),
throwsA(isA<WrongPinException>()),
);
});
test('parametry pod OWASP floor se odmítnou při wrap i unwrap', () async {
// wrap: slabé parametry nesmí vůbec vzniknout
await expectLater(
DekWrap.wrap(dek: randomDek(), pin: '123456', memoryKiB: 1024),
throwsA(isA<StateError>()),
);
// unwrap: podvržený blob se slabými parametry nesmí ani začít derivovat
final weakBlob = [
'sepot-dek-v1', '1024', '1', '1',
'AAAAAAAAAAAAAAAAAAAAAA==', 'AAAAAAAAAAAAAAAA',
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 'AAAAAAAAAAAAAAAAAAAAAA==',
].join(':');
await expectLater(
DekWrap.unwrap(wrapped: weakBlob, pin: '123456'),
throwsA(isA<StateError>()),
);
});
test('neznámý formát/verze → FormatException', () async {
await expectLater(
DekWrap.unwrap(wrapped: 'garbage', pin: '123456'),
throwsA(isA<FormatException>()),
);
await expectLater(
DekWrap.unwrap(wrapped: 'sepot-dek-v99:a:b:c:d:e:f:g', pin: '123456'),
throwsA(isA<FormatException>()),
);
});
test('DEK jiné délky než 32 B se odmítne', () async {
await expectLater(
DekWrap.wrap(dek: Uint8List(16), pin: '123456'),
throwsA(isA<ArgumentError>()),
);
});
}
+136
View File
@@ -0,0 +1,136 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/mnemonic.dart';
import 'package:sepot/core/errors.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/models/identity.dart';
/// S1/S2 testy nad reálným libsignal backendem (nativní knihovna).
void main() {
setUpAll(() async {
await SepotCrypto.init();
});
group('S1: fingerprint a ID z klíče', () {
test('fingerprint má 52 znaků Crockford base32, SEP- zkratka 14', () async {
final identity = await LocalIdentity.create('Alice');
expect(identity.fingerprint.display, hasLength(52));
expect(identity.sepotId, startsWith('SEP-'));
expect(identity.sepotId, hasLength(14));
expect(identity.id, identity.fingerprint.display);
});
test('fingerprint je deterministický: stejný klíč → stejné ID', () async {
final identity = await LocalIdentity.create('A');
final again = SepotFingerprint.ofPublicKey(identity.identityPublicKey);
expect(again, identity.fingerprint);
});
test('KAT: známý pubkey → známý fingerprint (BLAKE2b-256 + Crockford)',
() {
// Pevný "pubkey" (33 B, type byte 0x05 + 32 B vzestupně) — nezávislý
// na libsignal, fixuje hash i base32 vrstvu proti tiché změně
final pubkey = Uint8List.fromList(
[0x05, ...List.generate(32, (i) => i)],
);
final fp = SepotFingerprint.ofPublicKey(pubkey);
expect(fp.display, hasLength(52));
// KAT hodnota vygenerovaná při zavedení S1 (2026-07-03) — pokud se
// změní, změnil se hash vstup/algoritmus a VŠECHNA ID uživatelů
expect(
fp.display,
'2TFMWYYW4MRZH3BR8QCWQZVVM20EPFEP40N91S6J9HXW1GGHATRG',
);
});
test('klíče: privátní 32 B, veřejný 33 B, derivace sedí', () {
final keys = SepotCrypto.generateIdentityKeys();
expect(keys.privateKey, hasLength(32));
expect(keys.publicKey, hasLength(33));
expect(SepotCrypto.derivePublicKey(keys.privateKey), keys.publicKey);
});
test('serializovaný privátní klíč je clamping-stabilní', () {
// deserialize→serialize musí být identita na už serializovaných
// bajtech — na tom stojí mnemonic roundtrip (MASTER-DESIGN §3, F-3)
for (var i = 0; i < 20; i++) {
final keys = SepotCrypto.generateIdentityKeys();
final roundtrip = SepotCrypto.derivePublicKey(keys.privateKey);
expect(roundtrip, keys.publicKey);
final mnemonicBack = SepotMnemonic.decode(
SepotMnemonic.encode(keys.privateKey),
);
expect(mnemonicBack, keys.privateKey);
expect(SepotCrypto.derivePublicKey(mnemonicBack), keys.publicKey);
}
});
});
group('S2: mnemonic recovery', () {
test('recover z mnemonic obnoví identický klíč i fingerprint', () async {
final original = await LocalIdentity.create('Alice');
final words = original.mnemonic;
expect(words, hasLength(24));
final recovered = await LocalIdentity.recover(
mnemonicWords: words,
displayName: 'Alice (obnoveno)',
);
expect(recovered.identityPrivateKey, original.identityPrivateKey);
expect(recovered.identityPublicKey, original.identityPublicKey);
expect(recovered.fingerprint, original.fingerprint);
});
test('překlep v mnemonic → MnemonicInvalidError, ne tichá jiná identita',
() async {
final original = await LocalIdentity.create('Alice');
final words = [...original.mnemonic]..[3] = 'abandon';
expect(
() => LocalIdentity.recover(
mnemonicWords: words,
displayName: 'x',
),
throwsA(isA<MnemonicInvalidError>()),
);
});
});
group('Serializace', () {
test('LocalIdentity JSON roundtrip (v2)', () async {
final identity = await LocalIdentity.create('Alice');
final back = LocalIdentity.fromJson(identity.toJson());
expect(back.identityPrivateKey, identity.identityPrivateKey);
expect(back.fingerprint, identity.fingerprint);
expect(back.displayName, 'Alice');
});
test('starý (před-libsignal) JSON → FormatException', () {
expect(
() => LocalIdentity.fromJson({
'id': 'uuid-v4-neco',
'displayName': 'Starý formát',
}),
throwsFormatException,
);
});
test('sepot://add/ share link roundtrip', () async {
final identity = await LocalIdentity.create('Čeněk Šťastný');
final link = identity.publicIdentity.toShareString();
expect(link, startsWith('sepot://add/'));
final parsed = PublicIdentity.fromShareString(link);
expect(parsed, isNotNull);
expect(parsed!.displayName, 'Čeněk Šťastný');
expect(parsed.fingerprint, identity.fingerprint);
expect(parsed.identityKey, identity.identityPublicKey);
});
test('fromShareString: odpad → null, ne výjimka', () {
expect(PublicIdentity.fromShareString('nesmysl'), isNull);
expect(PublicIdentity.fromShareString('sepot://add/%%%'), isNull);
expect(PublicIdentity.fromShareString(''), isNull);
});
});
}
+119
View File
@@ -0,0 +1,119 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/mnemonic.dart';
import 'package:sepot/core/errors.dart';
/// KAT vektory z oficiální BIP39 test suite (Trezor) pro 256bit entropii.
/// Review-crypto F-3: mnemonic je REVERZIBILNÍ ENKÓDOVÁNÍ klíče — known-answer
/// testy jsou povinné v CI napříč Dart i Rust.
const _katVectors = <String, String>{
'00000000000000000000000000000000'
'00000000000000000000000000000000':
'abandon abandon abandon abandon abandon abandon abandon abandon '
'abandon abandon abandon abandon abandon abandon abandon abandon '
'abandon abandon abandon abandon abandon abandon abandon art',
'7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f'
'7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f':
'legal winner thank year wave sausage worth useful legal winner '
'thank year wave sausage worth useful legal winner thank year '
'wave sausage worth title',
'80808080808080808080808080808080'
'80808080808080808080808080808080':
'letter advice cage absurd amount doctor acoustic avoid letter '
'advice cage absurd amount doctor acoustic avoid letter advice '
'cage absurd amount doctor acoustic bless',
'ffffffffffffffffffffffffffffffff'
'ffffffffffffffffffffffffffffffff':
'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo '
'zoo zoo zoo zoo zoo zoo zoo vote',
};
Uint8List _hexToBytes(String hex) {
final out = Uint8List(hex.length ~/ 2);
for (var i = 0; i < out.length; i++) {
out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
}
return out;
}
void main() {
group('SepotMnemonic KAT (oficiální BIP39 vektory)', () {
_katVectors.forEach((entropyHex, expectedMnemonic) {
test('entropy ${entropyHex.substring(0, 8)}… → známých 24 slov', () {
final words = SepotMnemonic.encode(_hexToBytes(entropyHex));
expect(words.join(' '), expectedMnemonic);
expect(words, hasLength(SepotMnemonic.wordCount));
});
test('mnemonic ${expectedMnemonic.substring(0, 12)}… → přesná entropie',
() {
final decoded = SepotMnemonic.decode(expectedMnemonic.split(' '));
expect(decoded, _hexToBytes(entropyHex));
});
});
});
group('SepotMnemonic roundtrip', () {
test('decode(encode(k)) == k pro 100 náhodných klíčů', () {
final rng = Random.secure();
for (var i = 0; i < 100; i++) {
final key = Uint8List.fromList(
List.generate(32, (_) => rng.nextInt(256)),
);
expect(SepotMnemonic.decode(SepotMnemonic.encode(key)), key);
}
});
test('normalizace: velká písmena a mezery navíc nevadí', () {
final key = Uint8List(32);
final words = SepotMnemonic.encode(key);
final messy = words
.map((w) => ' ${w.toUpperCase()} ')
.toList();
expect(SepotMnemonic.decode(messy), key);
});
});
group('SepotMnemonic chyby', () {
final valid = SepotMnemonic.encode(Uint8List(32));
test('špatný počet slov → MnemonicWordCountError', () {
expect(
() => SepotMnemonic.decode(valid.sublist(0, 23)),
throwsA(isA<MnemonicWordCountError>()),
);
});
test('neznámé slovo → MnemonicInvalidError', () {
final broken = [...valid]..[0] = 'xylofon';
expect(() => SepotMnemonic.decode(broken),
throwsA(isA<MnemonicInvalidError>()));
});
test('rozbitý checksum (prohozená slova) → MnemonicInvalidError', () {
// poslední slovo nese checksum — výměna prvních dvou RŮZNÝCH slov
// checksum rozbije
final words = SepotMnemonic.encode(
Uint8List.fromList(List.generate(32, (i) => i * 7 % 256)),
);
final swapped = [...words];
final j = swapped.indexWhere((w) => w != swapped[0]);
swapped[0] = words[j];
swapped[j] = words[0];
expect(() => SepotMnemonic.decode(swapped),
throwsA(isA<MnemonicInvalidError>()));
});
test('isValid odmítne nevalidní a přijme validní', () {
expect(SepotMnemonic.isValid(valid), isTrue);
expect(SepotMnemonic.isValid(valid.sublist(0, 12)), isFalse);
});
test('encode odmítne jinou délku než 32 B', () {
expect(() => SepotMnemonic.encode(Uint8List(16)), throwsArgumentError);
expect(() => SepotMnemonic.encode(Uint8List(64)), throwsArgumentError);
});
});
}