diff --git a/app/lib/core/crypto/group_admin.dart b/app/lib/core/crypto/group_admin.dart new file mode 100644 index 0000000..792d535 --- /dev/null +++ b/app/lib/core/crypto/group_admin.dart @@ -0,0 +1,168 @@ +/// Admin-signed membership pro skupiny. +/// +/// Původní skupiny nemají role: roster update přijímají od kteréhokoli člena. V2 přidává +/// admin/member role a KAŽDOU změnu členství/role/jména podepisuje admin svým +/// identity klíčem (XEdDSA). Ostatní klienti podpis ověří proti identity klíči +/// admina z rosteru — server tu roli nehraje (sealed sender, hloupý relay). +/// +/// Podpis pokrývá VÝSLEDNÝ STAV (setříděné množiny členů a adminů + jméno + +/// epocha) zřetězený na hash předchozího stavu (`prev`) — admin autorizuje +/// konkrétní výsledek navázaný na známou historii, ne jen delta. Tím padá +/// nejednoznačnost i fork: kdo nesedí na `prev`, ví, že se rozešel a musí +/// resync. +/// +/// Kanonická serializace je záměrně délkově-prefixovaná: přesun bajtů mezi poli +/// (fingerprint ↔ jméno apod.) MUSÍ změnit podepisované bajty, jinak by šlo +/// zaměnit význam podpisu. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:libsignal/libsignal.dart' show PublicKey; +import 'package:pointycastle/digests/sha256.dart'; + +/// Typ admin operace. Pořadí (index) je součást podpisu — NIKDY nepřehazovat, +/// jen přidávat na konec (jinak se rozbijí staré podpisy). +/// +/// `sync` není změna: správce jím podepisuje svůj aktuální stav jako odpověď +/// na žádost o resync (mezera v epoše / rozchod historie u příjemce). +enum GroupAdminAction { add, remove, promote, demote, rename, sync } + +/// Doménové separátory — jiný kontext = jiné podepisované bajty. +const String _opDomain = 'sepot-group-adm-v1'; +const String _stateDomain = 'sepot-group-state-v1'; + +/// Jedna admin operace = autorizovaný přechod skupiny do výsledného stavu. +class GroupAdminOp { + GroupAdminOp({ + required this.gid, + required this.epoch, + required this.action, + required this.actor, + required List members, + required List admins, + required this.name, + required this.prev, + }) : members = _sortedUnique(members), + admins = _sortedUnique(admins); + + /// UUID skupiny (`gid` na drátu). + final String gid; + + /// Epocha, kterou tato operace ustavuje (monotónní, prev + 1). + final int epoch; + + final GroupAdminAction action; + + /// Fingerprint podepisujícího admina. MUSÍ se rovnat sealed-sender + /// fingerprintu odesílatele (anti-spoof). + final String actor; + + /// Výsledný roster (setříděné fingerprinty, včetně nezměněných členů). + final List members; + + /// Výsledná množina adminů (setříděné fingerprinty, podmnožina `members`). + final List admins; + + /// Výsledné jméno skupiny. + final String name; + + /// Hash předchozího stavu (`stateHash` epochy `epoch-1`); prázdné pro + /// založení (epoch 0). + final Uint8List prev; + + /// Podepisované bajty — kanonické, délkově-prefixované, deterministické. + Uint8List canonicalBytes() { + final b = BytesBuilder(copy: false); + b.add(_lpStr(_opDomain)); + b.add(_lpStr(gid)); + b.add(_u64be(epoch)); + b.addByte(action.index); + b.add(_lpStr(actor)); + b.add(_lpList(members)); + b.add(_lpList(admins)); + b.add(_lpStr(name)); + b.add(_lp(prev)); + return b.toBytes(); + } + + /// Hash tohoto výsledného stavu — vstupuje jako `prev` do NÁSLEDUJÍCÍ + /// operace (řetěz proti forku). + Uint8List stateHash() => hashState( + gid: gid, + epoch: epoch, + members: members, + admins: admins, + name: name, + ); + + /// XEdDSA podpis operace daným signerem (typicky `SepotCrypto.sign`). + Uint8List signWith(Uint8List Function(List message) signer) => + signer(canonicalBytes()); + + /// Ověří podpis proti identity klíči admina (33 B serializovaný pub klíč). + /// Volající NAVÍC musí ověřit, že `actor` je v aktuální množině adminů a že + /// `actor` == sealed-sender fingerprint — to zde nevidíme. + bool verify(Uint8List signature, Uint8List actorIdentityKey) { + final pk = PublicKey.deserialize(bytes: actorIdentityKey.toList()); + return pk.verify( + message: canonicalBytes().toList(), + signature: signature.toList(), + ); + } +} + +/// Hash stavu skupiny (setříděné množiny) — sdílený mezi op.stateHash a +/// výpočtem `prev` u příjemce. +Uint8List hashState({ + required String gid, + required int epoch, + required List members, + required List admins, + required String name, +}) { + final b = BytesBuilder(copy: false); + b.add(_lpStr(_stateDomain)); + b.add(_lpStr(gid)); + b.add(_u64be(epoch)); + b.add(_lpList(_sortedUnique(members))); + b.add(_lpList(_sortedUnique(admins))); + b.add(_lpStr(name)); + return Uint8List.fromList(SHA256Digest().process(b.toBytes())); +} + +// ── Kanonické kódování ──────────────────────────────────────────────────── + +List _sortedUnique(List xs) { + final s = xs.toSet().toList()..sort(); + return s; +} + +Uint8List _lp(List bytes) { + final b = BytesBuilder(copy: false); + b.add(_u32be(bytes.length)); + b.add(bytes); + return b.toBytes(); +} + +Uint8List _lpStr(String s) => _lp(utf8.encode(s)); + +Uint8List _lpList(List items) { + final b = BytesBuilder(copy: false); + b.add(_u32be(items.length)); + for (final item in items) { + b.add(_lpStr(item)); + } + return b.toBytes(); +} + +Uint8List _u32be(int n) { + final d = ByteData(4)..setUint32(0, n, Endian.big); + return d.buffer.asUint8List(); +} + +Uint8List _u64be(int n) { + final d = ByteData(8)..setUint64(0, n, Endian.big); + return d.buffer.asUint8List(); +} diff --git a/app/lib/core/errors.dart b/app/lib/core/errors.dart index 2acdfb8..f377ab9 100644 --- a/app/lib/core/errors.dart +++ b/app/lib/core/errors.dart @@ -56,6 +56,30 @@ class AttachmentTooLargeError extends SepotUserError { String toString() => 'attachment too large (max $maxBytes bytes)'; } +/// Příloha pro přeposlání už v telefonu není (smazaná, mizící, nestažená). +class AttachmentUnavailableError extends SepotUserError { + const AttachmentUnavailableError(); + + @override + String toString() => 'attachment not available locally'; +} + +/// Změnu skupiny smí udělat jen správce. +class NotGroupAdminError extends SepotUserError { + const NotGroupAdminError(); + + @override + String toString() => 'only a group admin can do this'; +} + +/// Poslední správce se nesmí odebrat ani zbavit role. +class LastGroupAdminError extends SepotUserError { + const LastGroupAdminError(); + + @override + String toString() => 'the last group admin cannot step down'; +} + /// Skupina bez jediného dalšího člena nedává smysl. class GroupNeedsMemberError extends SepotUserError { const GroupNeedsMemberError(); diff --git a/app/lib/core/services/group_admin_policy.dart b/app/lib/core/services/group_admin_policy.dart new file mode 100644 index 0000000..3e9e595 --- /dev/null +++ b/app/lib/core/services/group_admin_policy.dart @@ -0,0 +1,284 @@ +/// Pravidla správců skupin oddělená od sítě a +/// úložiště: drátová podoba `gop`, ověření příchozí operace v pořadí 1–6 +/// ze specifikace, tvorba odchozí operace s invarianty a podržení operací +/// z budoucích epoch. +/// +/// Ověření podpisu se předává jako funkce — produkce volá libsignal +/// (`GroupAdminOp.verify`), testy pravidel tak běží i bez nativní knihovny. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sepot/core/crypto/group_admin.dart'; +import 'package:sepot/core/errors.dart'; +import 'package:sepot/models/group.dart'; + +/// Ověří podpis [sig] operace [op] identity klíčem [identityKey]. +typedef GroupOpSignatureCheck = bool Function( + GroupAdminOp op, Uint8List sig, Uint8List identityKey); + +bool _libsignalVerify(GroupAdminOp op, Uint8List sig, Uint8List ik) { + try { + return op.verify(sig, ik); + } catch (_) { + // Nedeserializovatelný klíč/podpis z drátu = neplatný podpis, ne pád + // příjmové cesty. + return false; + } +} + +/// Výsledek ověření příchozí operace. +enum GopVerdict { + /// Platná a navazuje — aplikovat. + apply, + + /// `by` nesedí se sealed-sender autorem (krok 1). + spoof, + + /// Porušené invarianty nebo cizí skupina — nikdy nepůjde aplikovat. + invalid, + + /// Podepsal někdo, kdo u mě není správce (krok 2). + notAdmin, + + /// Podpis nesedí nebo neznám identity klíč podepisujícího (krok 3). + badSignature, + + /// Epocha přeskakuje — chybí mi mezistav (krok 4, `>`). + gap, + + /// Epochu už mám nebo jsem dál (krok 4, `<=`). + stale, + + /// Navazuje na jiný předchozí stav než můj (krok 5). + fork, +} + +/// Drátová podoba `gop`: pole tabulky ze specifikace. Samostatně jako +/// `t: 'gop'`, v kompatibilním režimu vnořená pod klíčem `gop` v legacy +/// `ginv`/`gmeta` (v1 klient neznámé pole ignoruje a použije stín). +class GroupOpMessage { + GroupOpMessage({required this.op, required this.sig, this.roster}); + + final GroupAdminOp op; + final Uint8List sig; + + /// Roster entry `[{f,n,ik,aid,da,v}]` — u přidání nových členů a u + /// pozvánky, ať nový člen zná klíče a routing ostatních. + final List>? roster; + + Map toWire() => { + 'gid': op.gid, + 'epoch': op.epoch, + 'op': op.action.name, + 'by': op.actor, + 'members': op.members, + 'admins': op.admins, + 'gname': op.name, + 'prev': base64Encode(op.prev), + 'sig': base64Encode(sig), + 'roster': ?roster, + }; + + /// Null = poškozená nebo neznámá podoba (neznámý typ operace z novější + /// verze se nedá ověřit, protože jeho index je součást podpisu). + static GroupOpMessage? fromWire(Object? raw) { + if (raw is! Map) return null; + try { + final m = raw.cast(); + final action = GroupAdminAction.values + .where((a) => a.name == m['op']) + .firstOrNull; + final epoch = m['epoch']; + if (action == null || epoch is! int || epoch < 0) return null; + final rosterRaw = m['roster']; + return GroupOpMessage( + op: GroupAdminOp( + gid: m['gid'] as String, + epoch: epoch, + action: action, + actor: m['by'] as String, + members: (m['members'] as List).cast().toList(), + admins: (m['admins'] as List).cast().toList(), + name: m['gname'] as String, + prev: base64Decode(m['prev'] as String), + ), + sig: base64Decode(m['sig'] as String), + roster: rosterRaw is List + ? [ + for (final e in rosterRaw) + if (e is Map) e.cast(), + ] + : null, + ); + } on Object { + // Špatné typy polí (TypeError), vadné base64 (FormatException). + return null; + } + } +} + +/// Invarianty stavu, které musí držet každá operace bez ohledu na autora. +bool _stateValid(GroupAdminOp op) => + op.gid.isNotEmpty && + op.gid.length <= 64 && + op.name.trim().isNotEmpty && + op.members.length <= Group.maxMembers && + op.admins.isNotEmpty && + op.admins.every(op.members.contains); + +/// Ověří příchozí operaci proti lokálnímu stavu [local] v pořadí kroků 1–6 +/// (očíslované v těle funkce). +/// +/// [actorIdentityKey] = identity klíč `by` z mých kontaktů (TOFU — nikdy +/// z téže zprávy, jinak by si útočník klíč přinesl sám). +GopVerdict checkGroupOp({ + required Group local, + required GroupOpMessage msg, + required String senderFp, + required String me, + required Uint8List? actorIdentityKey, + GroupOpSignatureCheck verify = _libsignalVerify, +}) { + final op = msg.op; + // 1. Autor = sealed sender. + if (op.actor != senderFp) return GopVerdict.spoof; + if (op.gid != local.id || !_stateValid(op)) return GopVerdict.invalid; + + // 2. Oprávnění. U odhadnutých správců (viz Group.provisional) stačí, že + // podepisující je člen a v podepsaném stavu správcem. + final provisional = local.isProvisionalFor(me); + final authorized = provisional + ? local.isMember(op.actor) && op.admins.contains(op.actor) + : local.isAdmin(op.actor); + if (!authorized) return GopVerdict.notAdmin; + + // 3. Podpis. + if (actorIdentityKey == null || !verify(op, msg.sig, actorIdentityKey)) { + return GopVerdict.badSignature; + } + + // 4. Epocha. Stav od správce (sync) i kotva odhadnuté skupiny nemají na + // co navázat — rozhoduje jen to, že posouvají dopředu. Ostrá nerovnost + // u sync brání přeskakování mezi dvěma stejně starými větvemi. + if (op.action == GroupAdminAction.sync) { + return op.epoch > local.epoch ? GopVerdict.apply : GopVerdict.stale; + } + if (provisional) { + return op.epoch >= local.epoch ? GopVerdict.apply : GopVerdict.stale; + } + if (op.epoch <= local.epoch) return GopVerdict.stale; + if (op.epoch > local.epoch + 1) return GopVerdict.gap; + + // 5. Řetěz: `prev` musí být hash mého aktuálního stavu. + final mine = hashState( + gid: local.id, + epoch: local.epoch, + members: local.memberIds, + admins: local.admins, + name: local.name, + ); + if (!_bytesEqual(op.prev, mine)) return GopVerdict.fork; + + // 6. Aplikovat (volající přes applyGroupOp). + return GopVerdict.apply; +} + +/// Ověří operaci, kterou mě někdo přidává do skupiny, kterou ještě neznám. +/// Není s čím řetězit — důvěra je stejná jako u v1 pozvánky (kdo mě zve, +/// je člen), navíc musí být podepsaná správcem uvedeným v rosteru. +GopVerdict checkJoinOp({ + required GroupOpMessage msg, + required String senderFp, + required String me, + required Uint8List? actorIdentityKey, + GroupOpSignatureCheck verify = _libsignalVerify, +}) { + final op = msg.op; + if (op.actor != senderFp) return GopVerdict.spoof; + if (!_stateValid(op) || !op.members.contains(me)) return GopVerdict.invalid; + if (!op.admins.contains(op.actor)) return GopVerdict.notAdmin; + if (actorIdentityKey == null || !verify(op, msg.sig, actorIdentityKey)) { + return GopVerdict.badSignature; + } + return GopVerdict.apply; +} + +/// Přepíše lokální stav výsledkem ověřené operace. Když ve výsledku +/// nejsem, správce mě odebral — skupina se chová jako opuštěná. +void applyGroupOp(Group group, GroupAdminOp op, String me) { + group + ..memberIds = [...op.members] + ..admins = [...op.admins] + ..name = op.name + ..epoch = op.epoch + ..provisional = false; + if (!op.members.contains(me)) group.left = true; +} + +/// Hash aktuálního stavu skupiny (vstupuje jako `prev` další operace). +Uint8List currentStateHash(Group g) => hashState( + gid: g.id, + epoch: g.epoch, + members: g.memberIds, + admins: g.admins, + name: g.name, + ); + +/// Postaví odchozí operaci s kontrolou oprávnění a invariantů. Hází +/// [NotGroupAdminError] / [LastGroupAdminError] dřív, než cokoli odejde. +GroupAdminOp buildGroupOp({ + required Group group, + required String me, + required GroupAdminAction action, + List? members, + List? admins, + String? name, +}) { + if (!group.isAdmin(me)) throw const NotGroupAdminError(); + final newMembers = members ?? group.memberIds; + final newAdmins = admins ?? group.admins; + if (newMembers.toSet().length > Group.maxMembers) { + throw const GroupTooLargeError(Group.maxMembers); + } + // Poslední správce se nesmí odebrat ani zbavit role — skupina by zůstala + // bez nikoho, kdo smí podepsat další změnu. + if (newAdmins.isEmpty) throw const LastGroupAdminError(); + if (!newAdmins.every(newMembers.contains)) { + throw StateError('group admins must be members'); + } + final isSync = action == GroupAdminAction.sync; + return GroupAdminOp( + gid: group.id, + epoch: isSync ? group.epoch : group.epoch + 1, + action: action, + actor: me, + members: newMembers, + admins: newAdmins, + name: name ?? group.name, + // Sync nic neřetězí, jen potvrzuje stav — viz checkGroupOp krok 4. + prev: isSync ? Uint8List(0) : currentStateHash(group), + ); +} + +/// Podrží operaci na později. Při přetečení se zahodí ty z nejvzdálenější +/// budoucnosti — podvržené operace s absurdní epochou tak nevytlačí +/// skutečně chybějící mezistav. +void holdGroupOp(Group group, PendingGroupOp pending) { + final sig = pending.wire['sig']; + if (group.pendingOps.any((p) => p.wire['sig'] == sig)) return; + group.pendingOps = [...group.pendingOps, pending] + ..sort((a, b) => a.epoch.compareTo(b.epoch)); + if (group.pendingOps.length > Group.maxPendingOps) { + group.pendingOps = group.pendingOps.sublist(0, Group.maxPendingOps); + } +} + +bool _bytesEqual(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} diff --git a/app/lib/models/group.dart b/app/lib/models/group.dart index d3cca85..bfab39d 100644 --- a/app/lib/models/group.dart +++ b/app/lib/models/group.dart @@ -1,8 +1,11 @@ -/// Skupina — párový fanout MVP. +/// Skupina — párový fanout + správci. /// /// Drží jen jméno a fingerprinty členů; routing a identity klíče členů /// žijí v tabulce contacts (člen = běžný kontakt založený z rosteru). /// Konverzace skupiny je řádek v conversations s contact_id = [id]. +/// +/// Ukládá se jako JSON blob (`groups.json`), takže nová pole v2 nepotřebují +/// migraci schématu — chybějící klíče doplní [Group.fromJson] lokálně. class Group { Group({ required this.id, @@ -11,7 +14,19 @@ class Group { required this.createdBy, DateTime? createdAt, this.left = false, - }) : createdAt = createdAt ?? DateTime.now(); + List? admins, + this.epoch = 0, + Set? v2Members, + this.needsResync = false, + List? pendingOps, + this.rejectedOpAt, + this.provisional = false, + }) : createdAt = createdAt ?? DateTime.now(), + admins = admins ?? [createdBy], + v2Members = v2Members ?? {}, + pendingOps = pendingOps ?? [] { + normalizeAdmins(); + } /// UUID v4 skupiny (= `gid` na drátu, = contact_id konverzace). final String id; @@ -26,15 +41,76 @@ class Group { final DateTime createdAt; - /// Opustil jsem skupinu — historie zůstává, vstup je mrtvý a příchozí - /// zprávy skupiny se zahazují. + /// Opustil jsem skupinu (nebo mě správce odebral) — historie zůstává, + /// vstup je mrtvý a příchozí zprávy skupiny se zahazují. bool left; + /// Správci — podmnožina [memberIds], u živé skupiny nikdy prázdná. + List admins; + + /// Epocha posledního autorizovaného stavu (0 = založení / migrace z v1). + int epoch; + + /// Členové, kteří inzerovali podporu správců (`gv: 2`). Jen přibývá: + /// kdyby šlo capability odebrat, mohl by kdokoli skupinu vrátit do + /// kompatibilního režimu a obejít správce. + Set v2Members; + + /// Přišla operace, kterou nešlo navázat (mezera v epoše / rozchod + /// historie) — čeká se na stav od správce. + bool needsResync; + + /// Podržené operace z budoucích epoch; zkusí se znovu po každém posunu. + List pendingOps; + + /// Kdy naposledy přišla změna skupiny od někoho, kdo k ní nemá právo. + DateTime? rejectedOpAt; + + /// Správce jen odhadnutý, ne ověřený: skupina převzatá z verze bez + /// správců nebo pozvánka od v1 klienta. `createdBy` tu znamená „od koho + /// jsem pozvánku dostal“, ne skutečného zakladatele — člen přidaný + /// ne-zakladatelem by jinak natrvalo odmítal pravého správce. První + /// podepsaný stav od člena, který je v něm správcem, se proto přijme + /// jako kotva (důvěra stejná jako u pozvánky samotné). + bool provisional; + /// Strop členů (doporučení: 128). static const maxMembers = 128; + /// Víc podržených operací nedává smysl — mezeru stejně zacelí stav + /// od správce; strop brání nafouknutí záznamu podvrženými zprávami. + static const maxPendingOps = 16; + bool isMember(String fingerprint) => memberIds.contains(fingerprint); + bool isAdmin(String fingerprint) => admins.contains(fingerprint); + + /// Zakladatel si správce neodhaduje — `createdBy == me` je jistota. + bool isProvisionalFor(String me) => provisional && createdBy != me; + + /// Vynucená skupina: všichni členové kromě [me] inzerují v2, takže + /// nikdo nepotřebuje legacy `ginv`/`gmeta` od ne-správce. Dokud je ve + /// skupině byť jediný v1 klient, řídí se starým pravidlem „kterýkoli + /// člen smí měnit roster" a v2 ho musí tolerovat, jinak by se stav rozešel. + bool isEnforced(String me) => + memberIds.every((f) => f == me || v2Members.contains(f)); + + /// Drží invariant `admins ⊆ members`, neprázdné. Když správce zmizí + /// mimo podepsaný řetěz (v1 `gleave`, migrace skupiny, kterou zakladatel + /// už opustil), nastoupí deterministicky nejmenší fingerprint — každý + /// klient tak dojde ke stejnému výsledku bez síťové domluvy. + void normalizeAdmins() { + final kept = { + for (final a in admins) + if (memberIds.contains(a)) a, + }.toList() + ..sort(); + if (kept.isEmpty && memberIds.isNotEmpty) { + kept.add(([...memberIds]..sort()).first); + } + admins = kept; + } + Map toJson() => { 'id': id, 'name': name, @@ -42,15 +118,57 @@ class Group { 'createdBy': createdBy, 'createdAt': createdAt.toIso8601String(), 'left': left, + 'admins': admins, + 'epoch': epoch, + 'v2': v2Members.toList()..sort(), + if (needsResync) 'resync': true, + if (pendingOps.isNotEmpty) + 'pending': [for (final p in pendingOps) p.toJson()], + 'rejectedAt': ?rejectedOpAt?.toIso8601String(), + if (provisional) 'prov': true, }; - factory Group.fromJson(Map json) => Group( - id: json['id'] as String, - name: json['name'] as String, - memberIds: - (json['memberIds'] as List).cast().toList(), - createdBy: json['createdBy'] as String, - createdAt: DateTime.parse(json['createdAt'] as String), - left: json['left'] as bool? ?? false, + /// Skupina uložená verzí bez správců nemá `admins`/`epoch` — dopočítá se + /// `admins = [createdBy]`, `epoch = 0`. + /// Migrovaná skupina je [provisional] — viz tam proč. + factory Group.fromJson(Map json) { + final rejected = json['rejectedAt'] as String?; + final migrated = !json.containsKey('admins'); + return Group( + id: json['id'] as String, + name: json['name'] as String, + memberIds: (json['memberIds'] as List).cast().toList(), + createdBy: json['createdBy'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + left: json['left'] as bool? ?? false, + admins: (json['admins'] as List?)?.cast().toList(), + epoch: json['epoch'] as int? ?? 0, + v2Members: (json['v2'] as List?)?.cast().toSet(), + needsResync: json['resync'] as bool? ?? false, + pendingOps: [ + for (final p in (json['pending'] as List? ?? const [])) + PendingGroupOp.fromJson((p as Map).cast()), + ], + rejectedOpAt: rejected == null ? null : DateTime.parse(rejected), + provisional: migrated || (json['prov'] as bool? ?? false), + ); + } +} + +/// Podržená správcovská operace: drátová podoba + sealed-sender autor, aby +/// šla později ověřit úplně stejně, jako kdyby právě přišla. +class PendingGroupOp { + const PendingGroupOp({required this.senderFp, required this.wire}); + + final String senderFp; + final Map wire; + + int get epoch => wire['epoch'] as int? ?? 0; + + Map toJson() => {'s': senderFp, 'op': wire}; + + factory PendingGroupOp.fromJson(Map json) => PendingGroupOp( + senderFp: json['s'] as String, + wire: (json['op'] as Map).cast(), ); } diff --git a/app/test/group_admin_policy_test.dart b/app/test/group_admin_policy_test.dart new file mode 100644 index 0000000..cccde8f --- /dev/null +++ b/app/test/group_admin_policy_test.dart @@ -0,0 +1,427 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sepot/core/crypto/group_admin.dart'; +import 'package:sepot/core/errors.dart'; +import 'package:sepot/core/services/group_admin_policy.dart'; +import 'package:sepot/models/group.dart'; + +/// Pravidla správců skupin bez sítě a bez nativní +/// libsignal: migrace starých skupin, invarianty, ověření příchozí operace +/// v pořadí 1–6 a drátová podoba `gop`. Podpis tu zastupuje fake — skutečný +/// XEdDSA pokrývá group_admin_test.dart a groups_admin_flow_test.dart. +void main() { + const a = 'fpA'; // zakladatel, správce + const b = 'fpB'; + const c = 'fpC'; + const d = 'fpD'; + final ikA = Uint8List.fromList(List.filled(33, 1)); + final goodSig = Uint8List.fromList([7, 7, 7]); + + /// Fake podpis: platný je jen `goodSig` a jen s klíčem správce A. + bool fakeVerify(GroupAdminOp op, Uint8List sig, Uint8List ik) => + _eq(sig, goodSig) && _eq(ik, ikA); + + Group mkGroup({int epoch = 0, List? admins, bool provisional = false}) => + Group( + id: 'g1', + name: 'Chata', + memberIds: [a, b, c], + createdBy: a, + admins: admins, + epoch: epoch, + provisional: provisional, + ); + + GroupOpMessage msgFor( + Group local, { + GroupAdminAction action = GroupAdminAction.add, + String actor = a, + int? epoch, + List? members, + List? admins, + String? name, + Uint8List? prev, + Uint8List? sig, + }) => + GroupOpMessage( + op: GroupAdminOp( + gid: local.id, + epoch: epoch ?? local.epoch + 1, + action: action, + actor: actor, + members: members ?? [...local.memberIds, d], + admins: admins ?? local.admins, + name: name ?? local.name, + prev: prev ?? currentStateHash(local), + ), + sig: sig ?? goodSig, + ); + + GopVerdict check(Group local, GroupOpMessage msg, + {String sender = a, Uint8List? ik}) => + checkGroupOp( + local: local, + msg: msg, + senderFp: sender, + me: b, + actorIdentityKey: ik ?? ikA, + verify: fakeVerify, + ); + + group('migrace a model', () { + test('skupina uložená bez správců: admins=[createdBy], epoch=0', () { + final stara = { + 'id': 'g1', + 'name': 'Chata', + 'memberIds': [a, b, c], + 'createdBy': a, + 'createdAt': '2026-07-10T10:00:00.000', + 'left': false, + }; + final g = Group.fromJson(stara); + expect(g.admins, [a]); + expect(g.epoch, 0); + expect(g.v2Members, isEmpty); + expect(g.pendingOps, isEmpty); + expect(g.provisional, isTrue, + reason: 'createdBy u v1 může být jen ten, kdo mě pozval'); + expect(g.isProvisionalFor(a), isFalse, + reason: 'zakladatel si správce neodhaduje'); + expect(g.isProvisionalFor(b), isTrue); + }); + + test('migrace skupiny, kterou zakladatel opustil: nastoupí nejmenší ' + 'fingerprint (deterministicky u všech)', () { + final g = Group.fromJson({ + 'id': 'g1', + 'name': 'Chata', + 'memberIds': [c, b], + 'createdBy': a, + 'createdAt': '2026-07-10T10:00:00.000', + }); + expect(g.admins, [b]); + }); + + test('JSON round-trip zachová všechna v2 pole', () { + final g = mkGroup(epoch: 3, admins: [a, b]) + ..v2Members.addAll([b, c]) + ..needsResync = true + ..rejectedOpAt = DateTime(2026, 9, 30, 12) + ..pendingOps = [ + PendingGroupOp(senderFp: a, wire: {'epoch': 5, 'sig': 'x'}), + ]; + final back = Group.fromJson( + jsonDecode(jsonEncode(g.toJson())) as Map); + expect(back.admins, [a, b]); + expect(back.epoch, 3); + expect(back.v2Members, {b, c}); + expect(back.needsResync, isTrue); + expect(back.rejectedOpAt, DateTime(2026, 9, 30, 12)); + expect(back.pendingOps.single.senderFp, a); + expect(back.pendingOps.single.epoch, 5); + expect(back.provisional, isFalse); + }); + + test('invariant: správci jsou podmnožina členů', () { + final g = mkGroup(admins: [a, 'cizi']); + expect(g.admins, [a]); + g.memberIds = [b, c]; + g.normalizeAdmins(); + expect(g.admins, [b], reason: 'nikdy nezůstane bez správce'); + }); + }); + + group('kompatibilní režim', () { + test('dokud je ve skupině v1 člen, skupina není vynucená', () { + final g = mkGroup()..v2Members.addAll([a]); + expect(g.isEnforced(b), isFalse, reason: 'c neinzeruje v2'); + g.v2Members.add(c); + expect(g.isEnforced(b), isTrue, + reason: 'všichni kromě mě inzerují v2 (já v2 jsem)'); + }); + }); + + group('ověření příchozí operace (pořadí 1–6)', () { + test('poctivá navazující operace → apply', () { + final g = mkGroup(); + expect(check(g, msgFor(g)), GopVerdict.apply); + }); + + test('1. SPOOF: by ≠ sealed-sender autor', () { + final g = mkGroup(); + expect(check(g, msgFor(g), sender: c), GopVerdict.spoof); + }); + + test('2. NE-SPRÁVCE: podepsal člen bez role', () { + final g = mkGroup(); + expect(check(g, msgFor(g, actor: c), sender: c), GopVerdict.notAdmin); + }); + + test('3. ŠPATNÝ PODPIS i neznámý klíč podepisujícího', () { + final g = mkGroup(); + expect(check(g, msgFor(g, sig: Uint8List.fromList([1]))), + GopVerdict.badSignature); + expect(check(g, msgFor(g), ik: Uint8List.fromList(List.filled(33, 9))), + GopVerdict.badSignature); + expect( + checkGroupOp( + local: g, + msg: msgFor(g), + senderFp: a, + me: b, + actorIdentityKey: null, + verify: fakeVerify, + ), + GopVerdict.badSignature); + }); + + test('4a. MEZERA v epoše → gap', () { + final g = mkGroup(epoch: 2); + expect(check(g, msgFor(g, epoch: 4)), GopVerdict.gap); + }); + + test('4b. ZASTARALÁ epocha (stejná i starší) → stale', () { + final g = mkGroup(epoch: 2); + expect(check(g, msgFor(g, epoch: 2)), GopVerdict.stale); + expect(check(g, msgFor(g, epoch: 1)), GopVerdict.stale); + }); + + test('5. FORK: prev nesedí s mým stavem', () { + final g = mkGroup(epoch: 2); + final jinaHistorie = hashState( + gid: 'g1', epoch: 2, members: [a, b], admins: [a], name: 'Chata'); + expect(check(g, msgFor(g, prev: jinaHistorie)), GopVerdict.fork); + }); + + test('invarianty příchozího stavu: prázdní správci, správce mimo členy, ' + 'cizí skupina', () { + final g = mkGroup(); + expect(check(g, msgFor(g, admins: [])), GopVerdict.invalid); + expect(check(g, msgFor(g, members: [a, b], admins: [a, c])), + GopVerdict.invalid); + final cizi = GroupOpMessage( + op: GroupAdminOp( + gid: 'g2', + epoch: 1, + action: GroupAdminAction.add, + actor: a, + members: [a, b], + admins: [a], + name: 'X', + prev: currentStateHash(g), + ), + sig: goodSig, + ); + expect(check(g, cizi), GopVerdict.invalid); + }); + + test('sync od správce: jen ostře novější epocha', () { + final g = mkGroup(epoch: 3); + GroupOpMessage sync(int epoch) => msgFor(g, + action: GroupAdminAction.sync, epoch: epoch, prev: Uint8List(0)); + expect(check(g, sync(5)), GopVerdict.apply); + expect(check(g, sync(3)), GopVerdict.stale, + reason: 'stejně stará větev nesmí přepínat stav tam a zpět'); + }); + + test('odhadnutý správce: první podepsaný stav od člena-správce je kotva', + () { + // B dostal pozvánku od C (v1), takže si myslí, že správce je C. + final g = Group( + id: 'g1', + name: 'Chata', + memberIds: [a, b, c], + createdBy: c, + provisional: true, + ); + expect(g.admins, [c]); + final kotva = msgFor(g, epoch: 7, prev: Uint8List(0), admins: [a]); + expect(check(g, kotva), GopVerdict.apply, + reason: 'prev ani epochu nejde ověřit, správce je v podpisu'); + expect(check(g, msgFor(g, actor: d, admins: [d]), sender: d), + GopVerdict.notAdmin, reason: 'nečlen kotvu dát nesmí'); + }); + }); + + group('pozvánka s podpisem (join)', () { + GroupOpMessage join({List? members, List? admins}) => + GroupOpMessage( + op: GroupAdminOp( + gid: 'g9', + epoch: 0, + action: GroupAdminAction.add, + actor: a, + members: members ?? [a, b], + admins: admins ?? [a], + name: 'Nová', + prev: Uint8List(0), + ), + sig: goodSig, + ); + + GopVerdict checkJoin(GroupOpMessage m, {String sender = a}) => checkJoinOp( + msg: m, + senderFp: sender, + me: b, + actorIdentityKey: ikA, + verify: fakeVerify, + ); + + test('platná pozvánka', () => expect(checkJoin(join()), GopVerdict.apply)); + test('bez mě → invalid', + () => expect(checkJoin(join(members: [a, c])), GopVerdict.invalid)); + test('podepsal ne-správce → notAdmin', + () => expect(checkJoin(join(admins: [b])), GopVerdict.notAdmin)); + test('spoof → spoof', + () => expect(checkJoin(join(), sender: c), GopVerdict.spoof)); + }); + + group('tvorba operace (odesílatel)', () { + test('epocha +1, prev = hash mého stavu', () { + final g = mkGroup(epoch: 4); + final op = buildGroupOp( + group: g, me: a, action: GroupAdminAction.rename, name: 'Jinak'); + expect(op.epoch, 5); + expect(op.prev, currentStateHash(g)); + expect(op.name, 'Jinak'); + }); + + test('ne-správce operaci nepostaví', () { + expect( + () => buildGroupOp( + group: mkGroup(), me: b, action: GroupAdminAction.rename), + throwsA(isA())); + }); + + test('poslední správce se nesmí degradovat ani odebrat', () { + final g = mkGroup(); + expect( + () => buildGroupOp( + group: g, me: a, action: GroupAdminAction.demote, admins: []), + throwsA(isA())); + expect( + () => buildGroupOp( + group: g, + me: a, + action: GroupAdminAction.remove, + members: [b, c], + admins: [], + ), + throwsA(isA())); + }); + + test('správce se smí degradovat, když zůstane jiný', () { + final op = buildGroupOp( + group: mkGroup(admins: [a, b]), + me: a, + action: GroupAdminAction.demote, + admins: [b], + ); + expect(op.admins, [b]); + }); + + test('sync nečísluje epochu ani neřetězí', () { + final op = buildGroupOp( + group: mkGroup(epoch: 6), me: a, action: GroupAdminAction.sync); + expect(op.epoch, 6); + expect(op.prev, isEmpty); + }); + + test('aplikace výsledku bez mě = odebrán', () { + final g = mkGroup(); + applyGroupOp( + g, + buildGroupOp( + group: g, + me: a, + action: GroupAdminAction.remove, + members: [a, c], + ), + b, + ); + expect(g.left, isTrue); + expect(g.epoch, 1); + }); + }); + + group('drátová podoba gop', () { + test('round-trip toWire → JSON → fromWire', () { + final g = mkGroup(epoch: 2); + final msg = GroupOpMessage( + op: buildGroupOp( + group: g, + me: a, + action: GroupAdminAction.promote, + admins: [a, c], + ), + sig: goodSig, + roster: [ + {'f': c, 'n': 'Cyril', 'ik': 'AA==', 'v': 2}, + ], + ); + final wire = jsonDecode(jsonEncode(msg.toWire())); + expect(wire['op'], 'promote'); + expect(wire['by'], a); + final back = GroupOpMessage.fromWire(wire)!; + expect(back.op.canonicalBytes(), msg.op.canonicalBytes(), + reason: 'po cestě drátem se musí podepsané bajty shodovat'); + expect(back.sig, goodSig); + expect(back.roster!.single['n'], 'Cyril'); + }); + + test('poškozená podoba → null, ne výjimka', () { + expect(GroupOpMessage.fromWire(null), isNull); + expect(GroupOpMessage.fromWire({'op': 'add'}), isNull); + expect( + GroupOpMessage.fromWire({ + 'gid': 'g1', + 'epoch': 1, + 'op': 'nova-operace-z-budoucna', + 'by': a, + 'members': [a], + 'admins': [a], + 'gname': 'X', + 'prev': '', + 'sig': '', + }), + isNull); + expect( + GroupOpMessage.fromWire({ + 'gid': 'g1', + 'epoch': 1, + 'op': 'add', + 'by': a, + 'members': [a], + 'admins': [a], + 'gname': 'X', + 'prev': '%%%', + 'sig': '', + }), + isNull); + }); + }); + + group('podržené operace', () { + test('seřazené podle epochy, bez duplicit, přetečení zahodí nejvzdálenější', + () { + final g = mkGroup(); + for (var e = Group.maxPendingOps + 5; e >= 2; e--) { + holdGroupOp(g, PendingGroupOp(senderFp: a, wire: {'epoch': e, 'sig': '$e'})); + } + holdGroupOp(g, PendingGroupOp(senderFp: a, wire: {'epoch': 2, 'sig': '2'})); + expect(g.pendingOps.length, Group.maxPendingOps); + expect(g.pendingOps.first.epoch, 2); + expect(g.pendingOps.last.epoch, Group.maxPendingOps + 1); + }); + }); +} + +bool _eq(List x, List y) { + if (x.length != y.length) return false; + for (var i = 0; i < x.length; i++) { + if (x[i] != y[i]) return false; + } + return true; +} diff --git a/app/test/group_admin_test.dart b/app/test/group_admin_test.dart new file mode 100644 index 0000000..49f11b4 --- /dev/null +++ b/app/test/group_admin_test.dart @@ -0,0 +1,131 @@ +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? members, + List? 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); + }); + }); +}