fix(groups): set the encrypted latch on groups that predate it

Closes #142, found while answering #138.

The latch (#124) is the one thing that makes a damaged encrypted group
distinguishable from a legacy unencrypted one. On disk the two are otherwise
identical - a group record with no encryptionKey - and that ambiguity is what
stops a keyless device from working out that it is the broken one.

But the latch is only ever written as a side effect of putGroupRecord, and it
shipped on 2026-07-23, after groups that were already keyed. Those records sit
at `encryptionKey` present, `encrypted` false: plainly encrypted, not saying
so. Verified on this box - both keyed groups here (joined 2026-07-21 and
07-22) are in exactly that state right now.

That is the one state the latch exists to prevent. Lose the key from such a
record through one of the #123 paths and nothing distinguishes it from a
legacy group: classifyKeylessGroup drops from 'certain' to a soft 'likely' at
best, so the repair banner never fires with confidence, and the group reopens
unencrypted on the raw groupKey topic. #138's probe shows what that means in
practice - every event op, group record and avatar readable by anyone
replicating it.

Holding a key is proof, so the repair needs no heuristic and no network: one
idempotent pass at boot over local group records, writing through
putGroupRecord so the latch is applied by the same guard as every other write.
Deliberately NOT gated on the personal base the way
backfillMissingGroupEncryptionKeys is - a single-device install has no personal
base and needs this most.

The same pass now also warns about the inverse (`encrypted` set, no key), which
is a device that has definitively lost it. Nothing to repair locally - that
needs a fresh invite or a keyed sibling - but it was previously silent.

Verified end to end against the real worklet under plain Node: create a group
(latched), strip `encrypted` from the stored record so it looks like a
pre-2026-07-23 join, reboot, latch is back. 266 unit tests pass, 11 new,
including that the backfill's answer is the guard's own (so the two cannot
drift) and that the latch is what moves classifyKeylessGroup from a guess to
'certain'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEd2k9F1HgR3JHdB8spury
This commit is contained in:
Your Name
2026-07-26 14:40:46 -05:00
co-authored by Claude Opus 5
parent 7e9070fb60
commit 290d5f8fa1
3 changed files with 149 additions and 1 deletions
+29 -1
View File
@@ -17,7 +17,10 @@ const {
} = require('./lib/eventTombstone.js')
const { planEventWrite, personalAppendValue } = require('./lib/eventMove.js')
const { SEEDER_PAIR_SCAN_TIMEOUT_MS } = require('./lib/seederPairTiming.js')
const { resolveGroupEncryptionKey, resolveGroupEncryptedFlag, classifyKeylessGroup, resolvedPeerCount } = require('./lib/groupRecord.js')
const {
resolveGroupEncryptionKey, resolveGroupEncryptedFlag, classifyKeylessGroup, resolvedPeerCount,
needsEncryptedLatchBackfill, isEncryptedButKeyless,
} = require('./lib/groupRecord.js')
const { raceAppend, APPEND_TIMEOUT_MS } = require('./lib/appendTimeout.js')
const { shouldSwallowFault, parseConflictLog } = require('./lib/conflictSeatbelt.js')
const { writerRewindStatus } = require('./lib/rewindGuard.js')
@@ -8130,6 +8133,31 @@ async function _doInit (dir, attempt = 0) {
}
} catch (e) { console.warn('[REKEY] startup purge error:', e.message) }
// TODO #142: set the `encrypted` latch on every group that already holds a
// key. The latch (#124) is only ever written as a side effect of
// putGroupRecord, and it shipped after groups that were already keyed, so
// those records say `encrypted: false` while plainly being encrypted - the
// one state the latch exists to prevent. Lose the key from such a record via
// one of the #123 paths and it is indistinguishable from a legacy
// unencrypted group, so keylessGroupStatus can no longer call it damaged and
// the group silently reopens in the clear.
//
// Local-only, idempotent, one pass. Deliberately NOT gated on the personal
// base like backfillMissingGroupEncryptionKeys: a single-device install has
// no personal base and needs this most. Writing through putGroupRecord means
// the latch is applied by the same guard as every other write.
for (const g of groups) {
if (needsEncryptedLatchBackfill(g)) {
await putGroupRecord(g.id, g, 'latch-backfill').catch(() => {})
console.log('[GROUPS] set encrypted latch on keyed group:', g.id)
} else if (isEncryptedButKeyless(g)) {
// Nothing to repair locally - recovery needs a fresh invite carrying
// `enc=` or a keyed sibling. Say so loudly; this is a device that will
// otherwise sit on the wrong swarm topic forever (TODO #123).
console.warn('[GROUPS] group is encrypted but this device holds NO key:', g.id, '- needs a fresh invite to recover')
}
}
// Startup dedup: clean up same-name duplicate members left over from
// reinstall/wipe rejoins that occurred before the dedup logic was deployed.
// Also adds stale entries to removedMembers so the cleanup propagates via Autobase.
+28
View File
@@ -110,9 +110,37 @@ function resolvedPeerCount (members, selfId) {
return n
}
// TODO #142 - is this record one the latch should already have been set on?
//
// The latch is only ever set as a side effect of a group-record write, and it
// shipped (2026-07-23) after groups that already held keys, so those records sit
// at `encryptionKey` present + `encrypted` false: plainly encrypted, but not
// saying so. That is the exact state the latch exists to prevent. Lose the key
// from such a record via one of the #123 paths and nothing distinguishes it from
// a legacy unencrypted group - classifyKeylessGroup drops from 'certain' to a
// soft 'likely' at best, and the device reopens the group in the clear.
//
// Holding a key is proof, so this needs no heuristic and no network: it is a
// one-pass boot repair over local records.
function needsEncryptedLatchBackfill (record) {
return !!(record && record.encryptionKey && !record.encrypted)
}
// The other half of the same sweep's value: a record that claims encrypted and
// holds NO key is a device that has definitively lost it (TODO #123/#124). There
// is nothing to repair locally - recovery needs a fresh invite or a sibling - so
// the sweep only reports these, but reporting them at all is new. Deliberately
// not folded into the function above: one returns work to do, this one returns a
// thing to say.
function isEncryptedButKeyless (record) {
return !!(record && record.encrypted && !record.encryptionKey)
}
module.exports = {
resolveGroupEncryptionKey,
resolveGroupEncryptedFlag,
classifyKeylessGroup,
resolvedPeerCount,
needsEncryptedLatchBackfill,
isEncryptedButKeyless,
}
+92
View File
@@ -0,0 +1,92 @@
// TODO #142 - the `encrypted` latch has to be set on groups that predate it,
// because the moment it matters is AFTER the key is gone and by then the record
// cannot prove anything about itself. Pure decisions in src/lib/groupRecord.js.
// (bugfix/encrypted-latch-backfill)
const test = require('node:test')
const assert = require('node:assert/strict')
const {
needsEncryptedLatchBackfill,
isEncryptedButKeyless,
resolveGroupEncryptedFlag,
classifyKeylessGroup,
} = require('../src/lib/groupRecord.js')
const KEY = 'a'.repeat(64)
// ── needsEncryptedLatchBackfill ───────────────────────────────────────────
test('a keyed group with no latch needs the backfill', () => {
// Exactly the state found on this box: joined before 2026-07-23, holds a key,
// record still says encrypted:false.
assert.equal(needsEncryptedLatchBackfill({ encryptionKey: KEY, encrypted: false }), true)
assert.equal(needsEncryptedLatchBackfill({ encryptionKey: KEY }), true)
})
test('a keyed group that already has the latch is left alone', () => {
assert.equal(needsEncryptedLatchBackfill({ encryptionKey: KEY, encrypted: true }), false)
})
test('a legacy unencrypted group is never latched', () => {
// The whole point: a group that legitimately has no key must stay unlatched,
// or every legacy group would start claiming to be damaged.
assert.equal(needsEncryptedLatchBackfill({ encrypted: false }), false)
assert.equal(needsEncryptedLatchBackfill({}), false)
})
test('a keyless record that already claims encrypted is not backfill work', () => {
// It is already latched; there is no key to prove anything with.
assert.equal(needsEncryptedLatchBackfill({ encrypted: true }), false)
})
test('tolerates null and undefined records', () => {
assert.equal(needsEncryptedLatchBackfill(null), false)
assert.equal(needsEncryptedLatchBackfill(undefined), false)
})
// ── isEncryptedButKeyless ─────────────────────────────────────────────────
test('latched with no key is the definitively broken device', () => {
assert.equal(isEncryptedButKeyless({ encrypted: true }), true)
assert.equal(isEncryptedButKeyless({ encrypted: true, encryptionKey: '' }), true)
})
test('holding the key is not broken, latch or no latch', () => {
assert.equal(isEncryptedButKeyless({ encrypted: true, encryptionKey: KEY }), false)
assert.equal(isEncryptedButKeyless({ encryptionKey: KEY }), false)
})
test('a legacy group is not broken', () => {
assert.equal(isEncryptedButKeyless({}), false)
assert.equal(isEncryptedButKeyless(null), false)
})
// ── the two are mutually exclusive ────────────────────────────────────────
test('no record is ever both backfill work and definitively broken', () => {
const records = [
{ encryptionKey: KEY, encrypted: false },
{ encryptionKey: KEY, encrypted: true },
{ encrypted: true },
{ encrypted: false },
{},
null,
]
for (const r of records) {
assert.ok(!(needsEncryptedLatchBackfill(r) && isEncryptedButKeyless(r)),
'both true for ' + JSON.stringify(r))
}
})
// ── the backfill agrees with the guard that normally sets the latch ───────
test('backfilling produces exactly what putGroupRecord would have written', () => {
// The sweep re-writes the record through putGroupRecord, so the latch it ends
// up with must be the guard's own answer - not a second opinion that could
// drift from it.
const rec = { encryptionKey: KEY, encrypted: false }
assert.equal(needsEncryptedLatchBackfill(rec), true)
assert.equal(resolveGroupEncryptedFlag({
priorEncrypted: rec.encrypted, priorKey: rec.encryptionKey,
incomingEncrypted: rec.encrypted, incomingKey: rec.encryptionKey,
}), true)
})
// ── why it matters: the latch is what makes the diagnosis certain ─────────
test('after the key is lost, the latch is the difference between certain and a guess', () => {
const joinedAt = Date.now() - 48 * 60 * 60 * 1000
const common = { encryptionKey: null, joinedAt, peerCount: 0, now: Date.now(), staleAfterMs: 24 * 60 * 60 * 1000 }
const latched = classifyKeylessGroup({ ...common, encrypted: true })
const unlatched = classifyKeylessGroup({ ...common, encrypted: false })
assert.equal(latched.certainty, 'certain')
assert.notEqual(unlatched.certainty, 'certain')
})