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:
co-authored by
Claude Opus 4.8
parent
e6a4baa0b5
commit
9583d61ec9
+20
-5
@@ -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
@@ -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.
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
Reference in New Issue
Block a user