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