Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
## 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.
- **Ownership transfers are retry-safe.** `transferOwnershipWithCharter` had
a publish-retry trap: a repeat call with the already-updated group minted a
self-link or a link not signed by the previous owner — permanently invalid
Expand Down
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,21 +204,24 @@ flutter pub get
flutter test
```

Expect `All tests passed!` — 75 tests across three files:
Expect `All tests passed!` — 77 tests across three files:

- **`group_model_test.dart`** (17) — `Group`/`GroupMember` JSON round-trips,
- **`group_model_test.dart`** (18) — `Group`/`GroupMember` JSON round-trips,
the manifest-vs-local-storage field split, the unknown-field passthrough
rules (wire-visible `Group.extra` vs local-only `GroupMember.extra`;
recognized keys can never be shadowed), and the `GroupRole` golden test
recognized keys can never be shadowed), the `publishCounter`
omitted-at-zero/round-trip golden, and the `GroupRole` golden test
(a pure-member roster serializes byte-for-byte unchanged, proving the
role-only-when-non-default backward-compat rule holds).
- **`group_service_test.dart`** (24) — the full lifecycle against the REAL
- **`group_service_test.dart`** (28) — the full lifecycle against the REAL
crypto end to end (create → add → remove/rotate → manifest round-trip →
transfer → roles → sealed keys → group-key blobs), **plus the adversarial
boundary cases**: a roster entry whose uid doesn't match its box key, a
manifest naming a different group, a member-published manifest usurping
ownership, a replayed pre-transfer charter, and tampered / truncated /
garbage / wrong-recipient blobs — all of which must be refused, not thrown on.
ownership, a replayed pre-transfer charter, a stale manifest below the
publish-counter high-water mark, retry-idempotent/self-validating
ownership transfers, and tampered / truncated / garbage / wrong-recipient
blobs — all of which must be refused, not thrown on.
- **`ownership_charter_test.dart`** (31) — the **golden-vector parity anchor**
(a fixed, pre-signed chain that must validate to owner uid `90ad2339…` here
and in the server-side verifier; if it goes red, canonical-JSON bytes,
Expand Down Expand Up @@ -259,12 +262,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.
Expand Down
26 changes: 18 additions & 8 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
19 changes: 19 additions & 0 deletions lib/src/group.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -256,6 +269,7 @@ class Group {
required this.groupKey,
required this.createdAt,
this.manifestUpdatedAt = 0,
this.publishCounter = 0,
this.charter,
this.extra = const {},
});
Expand All @@ -273,6 +287,7 @@ class Group {
List<GroupMember>? members,
Uint8List? groupKey,
int? manifestUpdatedAt,
int? publishCounter,
List<Object?>? charter,
bool clearCharter = false,
Map<String, Object?>? extra,
Expand All @@ -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,
);
Expand Down Expand Up @@ -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) {
Expand All @@ -352,6 +369,7 @@ class Group {
'groupKey',
'createdAt',
'manifestUpdatedAt',
'publishCounter',
'charter',
};

Expand All @@ -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<Object?>() : null,
extra: {
for (final e in j.entries)
Expand Down
15 changes: 15 additions & 0 deletions lib/src/group_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,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({
Expand Down Expand Up @@ -318,6 +326,7 @@ class GroupService {
required Uint8List ownerPublicKey,
required String expectedGroupId,
int minCharterHeight = 0,
int minPublishCounter = 0,
CharterPolicy charterPolicy = CharterPolicy.strict,
}) {
PrecalculatedBox? box;
Expand All @@ -331,6 +340,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
Expand Down
23 changes: 23 additions & 0 deletions test/group_model_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic>.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',
Expand Down
68 changes: 68 additions & 0 deletions test/group_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading