Files
sepot/app/test/crypto/identity_test.dart
T
Julius Joska 90a1390716 fix: reliable first-message send, QR routing, auto-lock recovery
Fixes reported by testers and found in an app review:

- First message right after install no longer fails. Before the app
  finished registering with the relay, the outgoing message could crash on
  a null delivery address. The profile field is now optional and the send
  path throws a clear "still connecting, try again shortly" error while
  registration completes in the background.
- The QR code and share link now appear only once the delivery address is
  ready. Previously a link generated before the first connection lacked
  routing, so the other side could not send the first message. The screen
  now shows a connecting state and reactively swaps in the QR when ready.
- Incomplete contact links are rejected on import instead of failing at
  first send, with a clear prompt to ask for a fresh link.
- Auto-lock no longer silences the app: after locking and unlocking,
  messaging is restarted (it previously stayed dead until app restart).
- Group messages are marked failed on a non-per-member send error instead
  of hanging in the "sending" state forever.
- Hardened the relay client against a rare duplicate connection when
  toggling Tor or locking mid-connect, and fromShareString now rejects a
  delivery address of the wrong length.
- Pin unifiedpush below 6.2.0 to drop the webcrypto dependency (its native
  framework lacks LC_ENCRYPTION_INFO and blocked the iOS upload).
- Skip Android release lint (network dependency check hangs on the build
  host and adds no value for a Flutter app).
2026-07-18 19:53:41 +02:00

161 lines
6.1 KiB
Dart

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('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 zafixovaná při zavedení fingerprintů — 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 — mnemonic kóduje serializovaný (clampnutý) privátní klíč,
// takže nestabilní serializace by rozbila obnovu účtu ze slov
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('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);
});
test('share link s routingem: aid+da přežijí roundtrip', () async {
final base = await LocalIdentity.create('Routovaná');
final withRouting = base.publicIdentity.withRouting(
accountId: 'deadbeef',
deliveryAddress: Uint8List.fromList(List.generate(32, (i) => i)),
);
final parsed =
PublicIdentity.fromShareString(withRouting.toShareString());
expect(parsed, isNotNull);
expect(parsed!.accountId, 'deadbeef');
expect(parsed.deliveryAddress,
Uint8List.fromList(List.generate(32, (i) => i)));
});
test('fromShareString: da špatné délky → null (ne vadný kontakt)', () async {
final base = await LocalIdentity.create('Krátká');
final bad = base.publicIdentity.withRouting(
accountId: 'deadbeef',
deliveryAddress: Uint8List.fromList(List.generate(8, (i) => i)),
);
expect(PublicIdentity.fromShareString(bad.toShareString()), isNull);
});
});
}