From 836877d42437c36c73a64f61edd86dcc0de60732 Mon Sep 17 00:00:00 2001 From: nate Date: Thu, 20 Aug 2026 07:40:34 -0700 Subject: [PATCH 1/2] =?UTF-8?q?Manifests=20gain=20a=20monotonic=20publish?= =?UTF-8?q?=20counter=20=E2=80=94=20stale=20reads=20can't=20roll=20back=20?= =?UTF-8?q?the=20roster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roster and key changes bumped nothing wire-visible (charter height moves only on ownership transfers), so a re-served OLD manifest — a server cache, a racy second device — was accepted whole: the removed member silently reappeared and the group key rolled back to the retired one, with consumers then encrypting group-wide payloads under it. There was nothing on the wire to arbitrate with. - Group.publishCounter: wire-visible, serialized only when > 0, so counter-less groups (and every consumer that never bumps) stay byte-identical — the roles-field compat pattern, golden-pinned. - GroupService.bumpedForPublish: bump once per publish EVENT, before the per-member fan-out, and adopt the result locally. - decryptManifest(minPublishCounter:): a manifest below the caller's persisted high-water mark is refused under EVERY charter policy — this is caller-provided arbitration like expectedGroupId, not charter trust. Equality passes (concurrent same-counter publishers stay readable; the vector is rollback, not writer arbitration). Consumers adopt with the minCharterHeight pattern: persist the highest accepted counter per group, pass it on decrypt, bump on publish. 74 tests, analyze clean. --- lib/src/group.dart | 19 ++++++++++ lib/src/group_service.dart | 15 ++++++++ test/group_model_test.dart | 23 ++++++++++++ test/group_service_test.dart | 68 ++++++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+) diff --git a/lib/src/group.dart b/lib/src/group.dart index dfaa2c0..8f371d6 100644 --- a/lib/src/group.dart +++ b/lib/src/group.dart @@ -226,6 +226,19 @@ class Group { /// state. Local-only: never included in the manifest. Defaults to 0. final int manifestUpdatedAt; + /// Monotonic publish counter, WIRE-VISIBLE — the arbitration a stale + /// manifest read otherwise lacks. Charter `height` only advances on + /// ownership transfers, so a re-served OLD manifest (server cache, a racy + /// second device) silently rolled back roster changes AND the group-key + /// rotation that rode them. Publishers bump this once per publish event + /// ([GroupService.bumpedForPublish]); consumers persist a per-group + /// high-water mark and pass it to `decryptManifest` as + /// `minPublishCounter` — exactly the `minCharterHeight` pattern. + /// + /// Serialized only when > 0, so counter-less groups (and every peer that + /// never bumps) stay byte-identical on the wire. Defaults to 0. + final int publishCounter; + /// Signed ownership charter (delegation chain). Null for groups created /// before charters existed — such groups have no enforceable ownership proof /// and gain one only by being recreated. Carried in the manifest (so every @@ -256,6 +269,7 @@ class Group { required this.groupKey, required this.createdAt, this.manifestUpdatedAt = 0, + this.publishCounter = 0, this.charter, this.extra = const {}, }); @@ -273,6 +287,7 @@ class Group { List? members, Uint8List? groupKey, int? manifestUpdatedAt, + int? publishCounter, List? charter, bool clearCharter = false, Map? extra, @@ -285,6 +300,7 @@ class Group { groupKey: groupKey ?? this.groupKey, createdAt: createdAt, manifestUpdatedAt: manifestUpdatedAt ?? this.manifestUpdatedAt, + publishCounter: publishCounter ?? this.publishCounter, charter: clearCharter ? null : (charter ?? this.charter), extra: extra ?? this.extra, ); @@ -327,6 +343,7 @@ class Group { 'members': members.map((m) => m.toManifestJson()).toList(), 'groupKey': base64.encode(groupKey), 'createdAt': createdAt, + if (publishCounter > 0) 'publishCounter': publishCounter, if (charter != null) 'charter': charter, }; for (final e in extra.entries) { @@ -352,6 +369,7 @@ class Group { 'groupKey', 'createdAt', 'manifestUpdatedAt', + 'publishCounter', 'charter', }; @@ -367,6 +385,7 @@ class Group { groupKey: Uint8List.fromList(base64.decode(j['groupKey'] as String)), createdAt: _asInt(j['createdAt'])!, manifestUpdatedAt: _asInt(j['manifestUpdatedAt']) ?? 0, + publishCounter: _asInt(j['publishCounter']) ?? 0, charter: rawCharter is List ? rawCharter.cast() : null, extra: { for (final e in j.entries) diff --git a/lib/src/group_service.dart b/lib/src/group_service.dart index 634f928..763e170 100644 --- a/lib/src/group_service.dart +++ b/lib/src/group_service.dart @@ -242,6 +242,14 @@ class GroupService { return transferOwnership(group, newOwnerUid); } + /// The group as it should be PUBLISHED: the wire-visible publish counter + /// bumped once. Call once per publish EVENT (before the per-member + /// manifest fan-out, so every member's copy carries the same counter) and + /// adopt the returned group locally — the next publish bumps from there. + /// Consumers that never call this stay byte-identical on the wire. + static Group bumpedForPublish(Group group) => + group.copyWith(publishCounter: group.publishCounter + 1); + /// Encrypt the group manifest for [memberPublicKey] using /// DH(ownerSecretKey, memberPublicKey). static String encryptManifestFor({ @@ -292,6 +300,7 @@ class GroupService { required Uint8List ownerPublicKey, required String expectedGroupId, int minCharterHeight = 0, + int minPublishCounter = 0, CharterPolicy charterPolicy = CharterPolicy.strict, }) { PrecalculatedBox? box; @@ -305,6 +314,12 @@ class GroupService { if (group.groupId != expectedGroupId) return null; if (group.groupKey.length != 32) return null; + // Freshness floor, caller-provided knowledge like [expectedGroupId]: + // a manifest older than what this device already accepted is a stale + // read (server cache, a racy second device) — accepting it would roll + // back roster changes and the key rotation that rode them. Strict + // under EVERY policy: this is arbitration, not charter trust. + if (group.publishCounter < minPublishCounter) return null; // Drop roster entries that fail the uid↔key binding rather than // rejecting the whole manifest. Dropping is both safer and more // available: an unbound entry is simply never trusted (nothing is ever diff --git a/test/group_model_test.dart b/test/group_model_test.dart index 0f64a8f..1f4476d 100644 --- a/test/group_model_test.dart +++ b/test/group_model_test.dart @@ -269,6 +269,29 @@ void main() { expect(jsonEncode(g.toManifestJson()), expected); }); + test('publishCounter: omitted at 0 (golden compat), published when bumped, ' + 'round-trips', () { + final g = Group( + groupId: 'g1', + name: 'Fam', + ownerUid: 'u1', + members: const [GroupMember(uid: 'u1', publicKeyB64: 'cHVi')], + groupKey: Uint8List(32), + createdAt: 7, + ); + // Counter-less groups (and every consumer that never bumps) stay + // byte-identical on the wire — same pattern as the roles field. + expect(g.toManifestJson().containsKey('publishCounter'), isFalse); + + final bumped = GroupService.bumpedForPublish(g); + expect(bumped.publishCounter, 1); + final j = bumped.toManifestJson(); + expect(j['publishCounter'], 1); + final rt = Group.fromJson(Map.from(j)); + expect(rt.publishCounter, 1); + expect(GroupService.bumpedForPublish(rt).publishCounter, 2); + }); + test('Group role helpers: owner via ownerUid, co-owner via role', () { final g = Group( groupId: 'g1', diff --git a/test/group_service_test.dart b/test/group_service_test.dart index 1b8eb20..cc1cb97 100644 --- a/test/group_service_test.dart +++ b/test/group_service_test.dart @@ -139,6 +139,74 @@ void main() { }); group('manifest boundary', () { + test('a STALE manifest (publish counter below the high-water mark) is ' + 'rejected — the roster/key rollback vector', () { + final owner = generateIdentity(sodium); + final member = generateIdentity(sodium); + final evicted = generateIdentity(sodium); + var g = GroupService.createGroup( + sodium: sodium, + name: 'Fresh', + identity: owner, + signingKeyDomain: signingDomain); + g = GroupService.addMember(g, memberFor(member)); + g = GroupService.addMember(g, memberFor(evicted)); + + // Publish 1: the pre-removal state (the copy a cache might re-serve). + final v1 = GroupService.bumpedForPublish(g); + final staleBlob = GroupService.encryptManifestFor( + sodium: sodium, + group: v1, + ownerIdentity: owner, + memberPublicKey: member.keyPair.publicKey, + ); + + // Publish 2: the member is removed and the group key ROTATES. + final v2 = GroupService.bumpedForPublish(GroupService.removeMember( + sodium: sodium, group: v1, memberUid: evicted.uid)); + final freshBlob = GroupService.encryptManifestFor( + sodium: sodium, + group: v2, + ownerIdentity: owner, + memberPublicKey: member.keyPair.publicKey, + ); + final fresh = GroupService.decryptManifest( + sodium: sodium, + blob: freshBlob, + myIdentity: member, + ownerPublicKey: owner.keyPair.publicKey, + expectedGroupId: g.groupId, + )!; + expect(fresh.publishCounter, 2); + // The consumer persists 2 as its high-water mark; the re-served OLD + // manifest must now be refused — accepting it silently restored the + // removed member AND rolled the group key back to the retired one. + expect( + GroupService.decryptManifest( + sodium: sodium, + blob: staleBlob, + myIdentity: member, + ownerPublicKey: owner.keyPair.publicKey, + expectedGroupId: g.groupId, + minPublishCounter: fresh.publishCounter, + ), + isNull, + ); + // At its own mark (or below) it still decrypts — equality is not + // staleness; concurrent same-counter publishers stay readable. + expect( + GroupService.decryptManifest( + sodium: sodium, + blob: freshBlob, + myIdentity: member, + ownerPublicKey: owner.keyPair.publicKey, + expectedGroupId: g.groupId, + minPublishCounter: fresh.publishCounter, + ), + isNotNull, + ); + }); + test('encrypt→decrypt round-trips between owner and member', () { final owner = generateIdentity(sodium); final member = generateIdentity(sodium); From d86d8d21689514d4f6b856351340d0f80ffc2a3c Mon Sep 17 00:00:00 2001 From: nate Date: Thu, 20 Aug 2026 08:05:00 -0700 Subject: [PATCH 2/2] Review fixes: SPEC/README describe the counter; 0.5.0 housekeeping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SPEC.md now matches the wire it defines: publishCounter in the Group shape (wire-visible only when > 0), a decryptManifest check-table row for the minPublishCounter floor (with its rationale and its every-policy enforcement), and the threat-model gap rewritten from 'manifests carry no freshness field' to the opt-in posture — with the residuals stated plainly: an unadopted fleet keeps the old exposure, and the counter arbitrates rollback, not divergence (equal-counter concurrent publishes remain last-write-wins). - README's Known gaps bullet updated the same way; CHANGELOG 0.5.0 entry; version bump; README test count (74 on this branch; the second merge with the transfer-safety PR reconciles to 77). --- CHANGELOG.md | 13 +++++++++++++ README.md | 16 +++++++++------- SPEC.md | 26 ++++++++++++++++++-------- pubspec.yaml | 2 +- 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25942cb..c32c26f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.5.0 + +- **Manifests gain a monotonic `publishCounter`** — the freshness arbitration + a stale manifest read lacked. Wire-visible only when > 0 (unbumped fleets + stay byte-identical, golden-pinned); publishers bump once per publish event + via `GroupService.bumpedForPublish`; `decryptManifest` gains + `minPublishCounter` and refuses a manifest below the caller's persisted + high-water mark under EVERY charter policy. Closes the replayed + pre-rotation manifest restoring a removed member and rolling the group key + back to one they still hold; the counter arbitrates rollback, not + divergence (equal-counter concurrent publishes remain last-write-wins). + SPEC.md's manifest shape, check table, and threat model updated to match. + ## 0.4.0 Adopter-portability release (the seams a consuming app needs to swap its own diff --git a/README.md b/README.md index a60e05f..94d6b9c 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ flutter pub get flutter test ``` -Expect `All tests passed!` — 72 tests across three files: +Expect `All tests passed!` — 74 tests across three files: - **`group_model_test.dart`** (17) — `Group`/`GroupMember` JSON round-trips, the manifest-vs-local-storage field split, the unknown-field passthrough @@ -259,12 +259,14 @@ derivation vectors now are) is tracked work. Stated plainly rather than left for a reader to find: -- **Manifests carry no freshness field.** Nothing in `toManifestJson()` is - monotonic, so a replayed pre-rotation manifest — validly signed by the owner, - no forgery needed — is indistinguishable from a current one and can put - members back on a key a removed member still holds. Closing this properly - means adding a key epoch to the manifest, which is a wire change. Until then - a transport that can replay must carry its own sequencing. +- **Manifest freshness is opt-in.** The manifest carries a monotonic + `publishCounter` (omitted at zero — unbumped fleets stay byte-identical); + publishers bump via `GroupService.bumpedForPublish`, consumers persist a + per-group high-water mark and pass `minPublishCounter` to + `decryptManifest`. A replayed pre-rotation manifest is then refused. Until + a fleet adopts both halves it keeps the old exposure, and the counter + arbitrates rollback, not divergence (equal-counter concurrent publishes + remain last-write-wins). - **The charter golden vector is not independently generated** (see above). - **No roster-consistency proof**: a malicious owner can in principle show different members different rosters. See the threat model. diff --git a/SPEC.md b/SPEC.md index 385936d..3f37e23 100644 --- a/SPEC.md +++ b/SPEC.md @@ -11,7 +11,9 @@ charter key, learned from peers), `displayName`, plus local-only `localDisplayNa `avatarEmoji`, `avatarPhotoPath`, `avatarPlain`. **Group**: `groupId`, `name`, `ownerUid`, `members[]`, `groupKey` (32-byte -symmetric), `createdAt`, `manifestUpdatedAt` (local-only), `charter`. +symmetric), `createdAt`, `manifestUpdatedAt` (local-only), `publishCounter` +(wire-visible, serialized only when > 0 — see *Manifest freshness* below), +`charter`. Two JSON shapes: - `toManifestJson()` — server/peers. Excludes local-only fields; `groupKey` is @@ -59,6 +61,7 @@ peer-authored — so it validates rather than merely decrypting, and returns nul | charter tip uid `==` manifest `ownerUid` | divergence is the usurpation attack, not a reason to relax | | charter tip box key `==` the sending owner's key | only the charter's owner may publish | | `height >= minCharterHeight` (caller high-water mark) | rejects a replayed older chain | +| `publishCounter >= minPublishCounter` (caller high-water mark) | rejects a replayed older MANIFEST: charter height only moves on ownership transfers, so without this a re-served pre-rotation manifest silently restores a removed member and rolls the group key back to one they still hold. Enforced under **every** charter policy — it is caller-provided arbitration like the `groupId` pin, not charter trust | | `groupKey` is 32 bytes; every roster entry's `uid == uidForBoxPublicKey(its key)` | see *Roster binding* | When a group has **no** charter there is nothing to enforce and only the @@ -193,13 +196,20 @@ Two invariants that live in *calling* code, not here: - **Rotation only helps if manifests are re-published.** `removeMember` mints a fresh key, but members learn it from a manifest — an app that rotates without re-publishing leaves everyone on the old key. -- **Manifests carry no freshness field.** Nothing in `toManifestJson()` is - monotonic, so a replayed pre-rotation manifest is indistinguishable from a - current one and would put members back on a key a removed member holds. A - transport that can reorder or replay must carry its own epoch/version outside - the manifest, or an app must treat the charter `height` plus its own sequencing - as the freshness signal. Closing this inside the manifest is a wire change and - is not yet done. +- **Manifest freshness is opt-in.** The manifest now carries a monotonic + `publishCounter` (inside the encrypted, owner-authenticated box — a server + can't forge or strip it without breaking the box; omitted at zero so + unbumped fleets stay byte-identical). Publishers bump once per publish + event via `GroupService.bumpedForPublish` and adopt the result; consumers + persist their highest accepted counter per group and pass it to + `decryptManifest` as `minPublishCounter` (the `minCharterHeight` pattern). + The replayed pre-rotation manifest is then refused instead of putting + members back on a key a removed member holds. Residuals, stated plainly: + an UNADOPTED fleet keeps the old exposure (nothing protects until the + publisher bumps and readers pass a floor), and the counter arbitrates + **rollback, not divergence** — two devices bumping independently from the + same base produce equal counters with different contents, where + last-write-wins remains the (pre-existing) semantics. ## Out of scope (deliberately app-local) diff --git a/pubspec.yaml b/pubspec.yaml index a8021ae..ec29033 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: groups description: "End-to-end-encrypted group membership, key rotation, manifests, and a signed ownership charter." -version: 0.4.0 +version: 0.5.0 publish_to: 'none' environment: