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:
co-authored by
Claude Opus 5
parent
7e9070fb60
commit
290d5f8fa1
+29
-1
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user