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
+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();