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.
This commit is contained in:
Julius Joska
2026-10-03 22:44:11 +02:00
parent 69687d0acf
commit 9a638545dd
6 changed files with 1164 additions and 12 deletions
+168
View File
@@ -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<String> members,
required List<String> 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<String> members;
/// Výsledná množina adminů (setříděné fingerprinty, podmnožina `members`).
final List<String> 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<int> 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<String> members,
required List<String> 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<String> _sortedUnique(List<String> xs) {
final s = xs.toSet().toList()..sort();
return s;
}
Uint8List _lp(List<int> 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<String> 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();
}
+24
View File
@@ -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();
@@ -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<Map<String, dynamic>>? roster;
Map<String, dynamic> 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<String, dynamic>();
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<String>().toList(),
admins: (m['admins'] as List).cast<String>().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<String, dynamic>(),
]
: 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<String>? members,
List<String>? 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<int> a, List<int> 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;
}
+130 -12
View File
@@ -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<String>? admins,
this.epoch = 0,
Set<String>? v2Members,
this.needsResync = false,
List<PendingGroupOp>? pendingOps,
this.rejectedOpAt,
this.provisional = false,
}) : createdAt = createdAt ?? DateTime.now(),
admins = admins ?? [createdBy],
v2Members = v2Members ?? <String>{},
pendingOps = pendingOps ?? <PendingGroupOp>[] {
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<String> 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<String> 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<PendingGroupOp> 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<String, dynamic> 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<String, dynamic> json) => Group(
id: json['id'] as String,
name: json['name'] as String,
memberIds:
(json['memberIds'] as List<dynamic>).cast<String>().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<String, dynamic> 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<dynamic>).cast<String>().toList(),
createdBy: json['createdBy'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
left: json['left'] as bool? ?? false,
admins: (json['admins'] as List<dynamic>?)?.cast<String>().toList(),
epoch: json['epoch'] as int? ?? 0,
v2Members: (json['v2'] as List<dynamic>?)?.cast<String>().toSet(),
needsResync: json['resync'] as bool? ?? false,
pendingOps: [
for (final p in (json['pending'] as List<dynamic>? ?? const []))
PendingGroupOp.fromJson((p as Map).cast<String, dynamic>()),
],
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<String, dynamic> wire;
int get epoch => wire['epoch'] as int? ?? 0;
Map<String, dynamic> toJson() => {'s': senderFp, 'op': wire};
factory PendingGroupOp.fromJson(Map<String, dynamic> json) => PendingGroupOp(
senderFp: json['s'] as String,
wire: (json['op'] as Map).cast<String, dynamic>(),
);
}
+427
View File
@@ -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<String>? 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<String>? members,
List<String>? 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<String, dynamic>);
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<String>? members, List<String>? 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<NotGroupAdminError>()));
});
test('poslední správce se nesmí degradovat ani odebrat', () {
final g = mkGroup();
expect(
() => buildGroupOp(
group: g, me: a, action: GroupAdminAction.demote, admins: []),
throwsA(isA<LastGroupAdminError>()));
expect(
() => buildGroupOp(
group: g,
me: a,
action: GroupAdminAction.remove,
members: [b, c],
admins: [],
),
throwsA(isA<LastGroupAdminError>()));
});
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<int> x, List<int> 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;
}
+131
View File
@@ -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<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);
});
});
}