Files
sepot/app/test/group_admin_test.dart
T
Julius Joska 9a638545dd feat(app): admin-signed group membership
Until now any member could change a group's roster or name. Groups now
have admins, and every change to membership, roles or the name is an
operation signed by an admin's identity key (XEdDSA). Recipients verify
the signature against the admin's key from their own contacts, never a
key carried in the same message; the relay plays no part.

- Operations form a hash chain with an epoch; gaps or forks are held back
  and resolved by a signed state from an admin.
- Groups created by older versions migrate with the creator as admin; if
  the creator has left, the smallest fingerprint takes over
  deterministically on every device.
- Mixed groups with older clients keep working in a compatibility mode
  until every member advertises admin support; that capability can only
  be added, never withdrawn.
- The last admin cannot step down or be removed.

The verification rules live in a network-free policy module with its own
tests.
2026-10-03 22:44:11 +02:00

132 lines
4.5 KiB
Dart

import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:libsignal/libsignal.dart' show PrivateKey;
import 'package:sepot/core/crypto/group_admin.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
/// Krypto jádro admin-signed skupin. Testuje kanonickou
/// serializaci (determinismus + nezáměnnost polí) a XEdDSA sign/verify.
///
/// Serializační testy jsou pure-Dart a běží všude. Sign/verify potřebuje
/// nativní libsignal (`liblibsignal_frb.so`) — v host `flutter test` bez ní
/// se skupina PŘESKOČÍ (stejně jako ostatní krypto testy; ostré ověření běží
/// na zařízení/CI s nativní knihovnou).
void main() {
GroupAdminOp op({
String gid = 'g-1',
int epoch = 1,
GroupAdminAction action = GroupAdminAction.add,
String actor = 'fpA',
List<String>? members,
List<String>? admins,
String name = 'Parta',
Uint8List? prev,
}) =>
GroupAdminOp(
gid: gid,
epoch: epoch,
action: action,
actor: actor,
members: members ?? ['fpB', 'fpA', 'fpC'],
admins: admins ?? ['fpA'],
name: name,
prev: prev ?? Uint8List(0),
);
group('kanonická serializace', () {
test('je deterministická a nezávislá na pořadí členů', () {
final a = op(members: ['fpB', 'fpA', 'fpC']).canonicalBytes();
final b = op(members: ['fpC', 'fpB', 'fpA']).canonicalBytes();
expect(a, b, reason: 'setřídění rosteru → stejné bajty');
});
test('duplicitní člen roster neovlivní', () {
final a = op(members: ['fpA', 'fpB', 'fpC']).canonicalBytes();
final b = op(members: ['fpA', 'fpB', 'fpB', 'fpC']).canonicalBytes();
expect(a, b);
});
test('přesun bajtů mezi poli změní podepisované bajty (délkový prefix)', () {
// actor|name = "ab"|"c" vs "a"|"bc" — bez prefixu by konkatenace splynula
final a = op(actor: 'ab', name: 'c').canonicalBytes();
final b = op(actor: 'a', name: 'bc').canonicalBytes();
expect(a, isNot(b));
});
test('typ operace je součást podpisu', () {
final a = op(action: GroupAdminAction.add).canonicalBytes();
final b = op(action: GroupAdminAction.remove).canonicalBytes();
expect(a, isNot(b));
});
test('epocha, gid, admini a jméno mění bajty', () {
final base = op().canonicalBytes();
expect(op(epoch: 2).canonicalBytes(), isNot(base));
expect(op(gid: 'g-2').canonicalBytes(), isNot(base));
expect(op(admins: ['fpA', 'fpB']).canonicalBytes(), isNot(base));
expect(op(name: 'Jiná').canonicalBytes(), isNot(base));
});
});
group('stateHash řetěz', () {
test('různý stav → různý hash, stejný stav → stejný hash', () {
final h1 = op(epoch: 1).stateHash();
final h1b = op(epoch: 1).stateHash();
final h2 = op(epoch: 2).stateHash();
expect(h1, h1b);
expect(h1, isNot(h2));
expect(h1.length, 32);
});
});
group('XEdDSA sign/verify', () {
var signalOk = false;
setUpAll(() async {
try {
await SepotCrypto.init();
signalOk = true;
} catch (_) {
signalOk = false; // nativní libsignal nedostupná (host bez .so)
}
});
bool ensureSignal() {
if (!signalOk) {
markTestSkipped('nativní libsignal nedostupná v host testu');
}
return signalOk;
}
test('podpis admina se ověří jeho identity klíčem', () {
if (!ensureSignal()) return;
final priv = PrivateKey.generate();
final pub = Uint8List.fromList(priv.getPublicKey().serialize());
final o = op();
final sig = o.signWith((m) => priv.sign(message: m));
expect(o.verify(sig, pub), isTrue);
});
test('pozměněná operace se stejným podpisem NEprojde', () {
if (!ensureSignal()) return;
final priv = PrivateKey.generate();
final pub = Uint8List.fromList(priv.getPublicKey().serialize());
final sig = op(name: 'Parta').signWith((m) => priv.sign(message: m));
// Útočník přepíše jméno, ale podpis nechá starý.
final tampered = op(name: 'Ovládnuto');
expect(tampered.verify(sig, pub), isFalse);
});
test('podpis jiného klíče neprojde (cizí admin)', () {
if (!ensureSignal()) return;
final signer = PrivateKey.generate();
final impostor = Uint8List.fromList(
PrivateKey.generate().getPublicKey().serialize(),
);
final o = op();
final sig = o.signWith((m) => signer.sign(message: m));
expect(o.verify(sig, impostor), isFalse);
});
});
}