fix(groups): separate swarm topic for encrypted groups (block old-code join)

An old-build (non-decrypting) peer could JOIN an encrypted group — it shares the
groupKey swarm topic old code understands — then, unable to read the encrypted
view, hit owner-recovery on a false "owner offline" reading and emit member
removals over the plaintext control channel, corrupting the group for updated
members (the EncTestv incident: iPhone on old build joined, left, and booted the
TCL; owner left with no members).

Fix: encrypted groups announce on a domain-separated blake2b topic
(groupSwarmTopic: blake2b("pearcal-enc-topic-v1:" + groupKey)) that old code
never derives, so it never connects to an encrypted group and can't corrupt it.
Legacy (unencrypted) groups keep the plain groupKey topic, so old + new still
interoperate. Routed the primary join path (joinGroup early + main), rekey,
member-leave topic, and the group re-join at 4387 through the helper; the seeder
(src/seed.js topicForGroupKey) matches so seeder + members meet.

Follow-ups (non-security; wrong topic there is a harmless leave-noop / minor
leave-delivery miss): removeBrokenGroup (~1352) and the pending-leave rejoin
(~6930) still derive the plain topic without the group record. Plus Fix 2
(authenticate the memberLeft/groupDeleted control channel; gate claim-ownership
on a readable view) tracked separately.

Timing: no real encrypted groups exist yet (only test ones), so changing the
encrypted-topic derivation now costs nothing. Blocks the v* release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013vW5J1ScY4KDeyRPAfsjTP
This commit is contained in:
Your Name
2026-07-15 13:09:18 -05:00
co-authored by Claude Opus 4.8
parent e6a4baa0b5
commit 9583d61ec9
3 changed files with 52 additions and 7 deletions
+20 -5
View File
@@ -1543,7 +1543,7 @@ async function _joinGroupImpl (group) {
// every member stuck in ready() before ever announcing the topic, so no one
// serves anyone. Idempotent with the later swarm.join (same topic).
try {
const earlyTopic = b4a.from(group.groupKey.slice(0, 64).padEnd(64, '0'), 'hex')
const earlyTopic = groupSwarmTopic(group.groupKey, group.encryptionKey)
swarm.join(earlyTopic, { server: true, client: true })
} catch (e) {
console.warn('[group] early swarm.join failed:', group.id, e?.message)
@@ -1716,7 +1716,7 @@ async function _joinGroupImpl (group) {
// Always use group.groupKey as swarm topic so both sides match
// (owner updates groupKey to realKey before this point)
const topicKey = group.groupKey
const topic = b4a.from(topicKey.slice(0, 64).padEnd(64, '0'), 'hex')
const topic = groupSwarmTopic(topicKey, group.encryptionKey)
swarm.join(topic, { server: true, client: true })
console.log('Joined group swarm:', group.id, 'topic:', topicKey.slice(0,16))
@@ -2801,6 +2801,21 @@ function _hex32 () {
const b = b4a.alloc(32); sodium.randombytes_buf(b); return b4a.toString(b, 'hex')
}
// Swarm topic for a group. ENCRYPTED groups (proposal 2026-07-15) use a
// domain-separated blake2b topic so OLD-code peers — which join on groupKey and
// can't decrypt — never even connect to an encrypted group, and so can't
// trigger owner-recovery on a false "owner offline" reading or emit member
// removals over the plaintext control channel (the EncTestv incident). Legacy
// (unencrypted) groups keep the plain groupKey topic so old + new interoperate.
function groupSwarmTopic (groupKey, encryptionKey) {
if (encryptionKey) {
const out = b4a.alloc(32)
sodium.crypto_generichash(out, b4a.concat([b4a.from('pearcal-enc-topic-v1:'), b4a.from(groupKey, 'hex')]))
return out
}
return b4a.from(groupKey.slice(0, 64).padEnd(64, '0'), 'hex')
}
function _clearPairSession () {
if (!_pairSession) return
if (_pairSession.expiryTimer) clearTimeout(_pairSession.expiryTimer)
@@ -3681,7 +3696,7 @@ async function purgeMigratedGroup (oldGroupId, opts = {}) {
// 2. Leave the swarm topic for this group.
if (swarm && oldGroup.groupKey) {
try {
const topic = b4a.from(oldGroup.groupKey.slice(0, 64).padEnd(64, '0'), 'hex')
const topic = groupSwarmTopic(oldGroup.groupKey, oldGroup.encryptionKey)
await swarm.leave(topic).catch(() => {})
} catch (e) { console.warn('[PURGE] swarm leave:', e.message) }
}
@@ -4141,7 +4156,7 @@ async function syncMemberLeft (groupId, memberId) {
pendingMemberLeaves.add(key)
// Persist including groupKey and topicHex so we can rejoin swarm after restart to deliver
const group = await getGroup(groupId).catch(() => null)
const topicHex = group?.groupKey ? group.groupKey.slice(0, 64).padEnd(64, '0') : null
const topicHex = group?.groupKey ? b4a.toString(groupSwarmTopic(group.groupKey, group.encryptionKey), 'hex') : null
await db.put('pendingLeave:' + groupId + ':' + memberId, { groupId, memberId, groupKey: group?.groupKey, ts: Date.now() }).catch(() => {})
if (topicHex) await db.put('pendingLeaveKey:' + groupId, { topicHex }).catch(() => {})
for (const ch of activeChannels) {
@@ -4384,7 +4399,7 @@ async function resyncGroup (groupId) {
const group = await getGroup(groupId).catch(() => null)
if (swarm && group?.groupKey) {
try {
const topic = b4a.from(group.groupKey.slice(0, 64).padEnd(64, '0'), 'hex')
const topic = groupSwarmTopic(group.groupKey, group.encryptionKey)
await swarm.leave(topic).catch(() => {})
swarm.join(topic, { server: true, client: true })
await swarm.flush().catch(() => {})
+6 -2
View File
@@ -75,9 +75,13 @@ const mounted = new Map() // groupId -> { core, writerCores: Map<hex,core>, top
const WRITER_ANNOUNCE_PROTOCOL = 'pearcal/writer-announce'
const WRITER_ANNOUNCE_ID = Buffer.from('pearcal-writer-announce-v1')
// Same topic derivation as bare.js joinGroup (groupKey → 32-byte topic).
// Topic for an enrolled group. Seeded groups are ENCRYPTED, so this must match
// bare.js groupSwarmTopic()'s encrypted branch (domain-separated blake2b) — old
// code joins the plain groupKey topic and never meets the seeder or members.
function topicForGroupKey (groupKey) {
return b4a.from(groupKey.slice(0, 64).padEnd(64, '0'), 'hex')
const out = b4a.alloc(32)
sodium.crypto_generichash(out, b4a.concat([b4a.from('pearcal-enc-topic-v1:'), b4a.from(groupKey, 'hex')]))
return out
}
// Date.now() is unavailable in some bare sandboxes at module init; guard it.
+26
View File
@@ -0,0 +1,26 @@
// Encrypted groups use a domain-separated swarm topic so OLD-code peers (which
// join on the plain groupKey topic) never connect to an encrypted group and
// can't corrupt its membership (the EncTestv incident). seed.topicForGroupKey
// is bare.js groupSwarmTopic()'s encrypted branch; assert it differs from the
// legacy topic. (proposal 2026-07-15; bugfix/encrypted-group-topic-separation)
const test = require('node:test')
const assert = require('node:assert/strict')
const b4a = require('b4a')
const { topicForGroupKey } = require('../src/seed.js')
const groupKey = 'a'.repeat(64)
const legacyTopic = b4a.toString(b4a.from(groupKey.slice(0, 64).padEnd(64, '0'), 'hex'), 'hex')
test('encrypted topic is a 32-byte value distinct from the legacy groupKey topic', () => {
const enc = topicForGroupKey(groupKey)
assert.equal(enc.length, 32)
assert.notEqual(b4a.toString(enc, 'hex'), legacyTopic, 'old-code peers must not share the topic')
})
test('encrypted topic derivation is deterministic', () => {
assert.equal(b4a.toString(topicForGroupKey(groupKey), 'hex'), b4a.toString(topicForGroupKey(groupKey), 'hex'))
})
test('different groupKeys yield different encrypted topics', () => {
assert.notEqual(b4a.toString(topicForGroupKey('a'.repeat(64)), 'hex'), b4a.toString(topicForGroupKey('b'.repeat(64)), 'hex'))
})