Component: src/persistence/object-storage/reEncryptionSweep.ts
Severity (assessment): LOW
CWE: CWE-693 (Protection Mechanism Failure)
Related: #109
The sweep decodes each body with an encryption option only, and re-encodes with compression + encryption only. decodeBody rejects a body carrying FLAG_INTEGRITY_HMAC when no integrityKey is supplied, so the sweep aborts on the first such object; there is no integrityKey field in ReEncryptOptions to supply one, and the re-encode would drop the tag even if there were.
Exploit walkthrough
Preconditions: a deployment that took the #116 advice (integrity: {mode:'hmac-sha256', integrityKey} on the durable-state store) and later needs to rotate or revoke a leaked master key. The operator runs reEncryptObjectStorage, which throws on the first integrity-tagged object with a message about a missing integrityKey — an option the API does not offer. The two security features are therefore mutually exclusive: the operator must either abandon tamper protection or leave a leaked master key in the retired list indefinitely, because dropping it would make the historical corpus undecryptable. The sweep fails before the put, so no data is damaged; the cost is that the revocation cannot be completed. (The pre-sweep verifyKeyringCompleteness sampler at lines 242-271 does not catch this either — it reads the flags byte directly and never calls decodeBody, so it reports success and the failure surfaces only once the rewrite loop starts.)
Evidence — src/persistence/object-storage/reEncryptionSweep.ts
src/persistence/object-storage/reEncryptionSweep.ts:339-356 — neither call mentions integrity:
const decoded = await decodeBody(framed, {
encryption: {
subKeyFor: async (v: number): Promise<Uint8Array | null> => { ... },
},
});
...
const rewritten = await encodeBody(decoded.payload, {
compression: decoded.compression,
encryption: { subKey: activeSubkey, keyVersion: activeVersion },
});
src/persistence/object-storage/BodyCodec.ts:226-230 — the decode that will throw:
if (!options.integrity?.integrityKey) {
throw new Error(
'BodyCodec: body carries FLAG_INTEGRITY_HMAC but no integrityKey was supplied for decoding.',
);
}
ReEncryptOptions (reEncryptionSweep.ts:87-155) has keyring, info, pidFromKey, onProgress, skip, progress, saveProgressEveryN, verifyKeyringCompleteness, sampleSize — and no integrity field. The sweep is the documented completion of rotation: reEncryptionSweep.ts:8-13 ("until the operator wants to drop the retired key ... That requires every historical body to be re-encrypted under the active key first").
Why the existing guard does not cover it
The sweep is otherwise carefully built — If-Match guards against a concurrent writer (line 366), isUsableSweepKey refuses a key whose extracted pid would give the wrong HKDF salt (lines 297-301, 427-439), progress is durable and resumable, and the keyring-completeness pre-check exists precisely to avoid a half-rewritten corpus. The integrity dimension was simply not carried into it when #116 landed after the sweep was written.
Suggested fix
Add an integrity?: IntegrityConfig (or integrityKey) to ReEncryptOptions, pass it to both decodeBody and encodeBody so tagged bodies survive the rewrite with their tag intact, and extend the pre-sweep completeness check to fail fast when a sampled body carries FLAG_INTEGRITY_HMAC and no key was supplied.
Relationship to existing issues
Adjacent to #109, but a distinct mechanism. Verified: grep -i integrity src/persistence/object-storage/reEncryptionSweep.ts returns nothing, decodeBody at :339-349 and encodeBody at :352-356 pass only encryption/compression, and BodyCodec.ts:226-230 throws 'body carries FLAG_INTEGRITY_HMAC but no integrityKey was supplied for decoding.' ReEncryptOptions has no integrity field to supply one. The pre-sweep verifyKeyringCompleteness sampler reads the flags/version bytes directly and never calls decodeBody, so it reports success and the failure only surfaces once the rewrite loop starts. #109 (CLOSED) is the neighbour — same file, same rotation feature — but it is about the crash-resume/progress-token gap and the operator dropping a retired key mid-sweep; it says nothing about integrity-tagged bodies. Nothing else in the corpus composes #116 with the rotation sweep. LOW: it is fail-closed operability (rotation blocked), not data loss, and needs both features enabled.
Verification status
Found in the second, independent whole-framework security re-audit of 2026-08-02 (v0.12.0) — a fresh pass run without reference to the first wave's findings, then triaged against the existing tracker and adjudicated by verifiers instructed to refute it.
Verifier note
Confirmed against the source. reEncryptionSweep.ts:339-349 builds the decode options with an encryption.subKeyFor resolver and nothing else, and :353-356 re-encodes with compression + encryption only; ReEncryptOptions (:87-155) exposes keyPrefix, keyring, info, pidFromKey, onProgress, skip, progress, saveProgressEveryN, verifyKeyringCompleteness, sampleSize — there is no integrity field and no way to supply an integrity key. BodyCodec.ts:222-230 throws 'body carries FLAG_INTEGRITY_HMAC but no integrityKey was supplied for decoding.' before any other manifest byte is trusted, so the sweep aborts on the first such object.
I checked the guards the finder says are absent. The pre-sweep verifyKeyringCompleteness sampler (:242-271) reads framed[4]/framed[5] directly and never calls decodeBody, so it passes and the failure surfaces only once the rewrite loop starts — as claimed. The active-version fast path (:325-333) skips already-current bodies before decode, so only retired-version bodies hit the throw — which is exactly the set a rotation exists to rewrite. Failure is fail-closed: decodeBody throws before backend.put at :363, so no corpus damage.
The composition is the documented one: docs/src/content/docs/persistence/object-storage/key-rotation.mdx shows reEncryptObjectStorage(backend, { keyPrefix: 'state/', keyring }), and 'state/' is the durable-state store's layout (ObjectStorageDurableStateStore.keyFor → <prefix><pid>/state.json), which is the only store that can write an integrity tag.
Two corrections applied. Integrity is available on ObjectStorageDurableStateStore only — ObjectStorageSnapshotStore.save (:87-96) has no integrity path — so the clash is scoped to durable-state sweeps, not snapshots. And there is no "#116 advice" to take: withIntegrity / hmac-sha256 appear nowhere under docs/src/content/docs/ (EN or DE), so the feature is reachable only through the builder API. LOW stands: fail-closed operability (revocation cannot be completed) requiring both opt-in features plus an actual rotation.
Correction applied: Two precision fixes. (1) The exploit says the operator "took the #116 advice" — there is no advice: a grep for withIntegrity / hmac-sha256 over docs/src/content/docs/ returns zero hits in both languages, so the integrity feature is API-only and undocumented. Reword to "a deployment that used the #116 API". (2) Integrity exists only on ObjectStorageDurableStateStore — ObjectStorageSnapshotStore.save (ObjectStorageSnapshotStore.ts:87-96) passes no integrity to encodeBody and its options type has no such field. So the collision is specifically keyPrefix: 'state/' sweeps, which is exactly the invocation the key-rotation page documents.
Component:
src/persistence/object-storage/reEncryptionSweep.tsSeverity (assessment): LOW
CWE: CWE-693 (Protection Mechanism Failure)
Related: #109
The sweep decodes each body with an
encryptionoption only, and re-encodes withcompression+encryptiononly.decodeBodyrejects a body carryingFLAG_INTEGRITY_HMACwhen nointegrityKeyis supplied, so the sweep aborts on the first such object; there is nointegrityKeyfield inReEncryptOptionsto supply one, and the re-encode would drop the tag even if there were.Exploit walkthrough
Preconditions: a deployment that took the #116 advice (
integrity: {mode:'hmac-sha256', integrityKey}on the durable-state store) and later needs to rotate or revoke a leaked master key. The operator runsreEncryptObjectStorage, which throws on the first integrity-tagged object with a message about a missingintegrityKey— an option the API does not offer. The two security features are therefore mutually exclusive: the operator must either abandon tamper protection or leave a leaked master key in theretiredlist indefinitely, because dropping it would make the historical corpus undecryptable. The sweep fails before theput, so no data is damaged; the cost is that the revocation cannot be completed. (The pre-sweepverifyKeyringCompletenesssampler at lines 242-271 does not catch this either — it reads the flags byte directly and never callsdecodeBody, so it reports success and the failure surfaces only once the rewrite loop starts.)Evidence —
src/persistence/object-storage/reEncryptionSweep.tssrc/persistence/object-storage/reEncryptionSweep.ts:339-356 — neither call mentions integrity:
src/persistence/object-storage/BodyCodec.ts:226-230 — the decode that will throw:
ReEncryptOptions(reEncryptionSweep.ts:87-155) haskeyring,info,pidFromKey,onProgress,skip,progress,saveProgressEveryN,verifyKeyringCompleteness,sampleSize— and no integrity field. The sweep is the documented completion of rotation: reEncryptionSweep.ts:8-13 ("until the operator wants to drop the retired key ... That requires every historical body to be re-encrypted under the active key first").Why the existing guard does not cover it
The sweep is otherwise carefully built —
If-Matchguards against a concurrent writer (line 366),isUsableSweepKeyrefuses a key whose extracted pid would give the wrong HKDF salt (lines 297-301, 427-439), progress is durable and resumable, and the keyring-completeness pre-check exists precisely to avoid a half-rewritten corpus. The integrity dimension was simply not carried into it when #116 landed after the sweep was written.Suggested fix
Add an
integrity?: IntegrityConfig(orintegrityKey) toReEncryptOptions, pass it to bothdecodeBodyandencodeBodyso tagged bodies survive the rewrite with their tag intact, and extend the pre-sweep completeness check to fail fast when a sampled body carriesFLAG_INTEGRITY_HMACand no key was supplied.Relationship to existing issues
Adjacent to #109, but a distinct mechanism. Verified:
grep -i integrity src/persistence/object-storage/reEncryptionSweep.tsreturns nothing, decodeBody at :339-349 and encodeBody at :352-356 pass only encryption/compression, and BodyCodec.ts:226-230 throws 'body carries FLAG_INTEGRITY_HMAC but no integrityKey was supplied for decoding.' ReEncryptOptions has no integrity field to supply one. The pre-sweep verifyKeyringCompleteness sampler reads the flags/version bytes directly and never calls decodeBody, so it reports success and the failure only surfaces once the rewrite loop starts. #109 (CLOSED) is the neighbour — same file, same rotation feature — but it is about the crash-resume/progress-token gap and the operator dropping a retired key mid-sweep; it says nothing about integrity-tagged bodies. Nothing else in the corpus composes #116 with the rotation sweep. LOW: it is fail-closed operability (rotation blocked), not data loss, and needs both features enabled.Verification status
Found in the second, independent whole-framework security re-audit of 2026-08-02 (
v0.12.0) — a fresh pass run without reference to the first wave's findings, then triaged against the existing tracker and adjudicated by verifiers instructed to refute it.Verifier note
Confirmed against the source.
reEncryptionSweep.ts:339-349builds the decode options with anencryption.subKeyForresolver and nothing else, and:353-356re-encodes withcompression+encryptiononly;ReEncryptOptions(:87-155) exposeskeyPrefix,keyring,info,pidFromKey,onProgress,skip,progress,saveProgressEveryN,verifyKeyringCompleteness,sampleSize— there is no integrity field and no way to supply an integrity key.BodyCodec.ts:222-230throws 'body carries FLAG_INTEGRITY_HMAC but no integrityKey was supplied for decoding.' before any other manifest byte is trusted, so the sweep aborts on the first such object.I checked the guards the finder says are absent. The pre-sweep
verifyKeyringCompletenesssampler (:242-271) readsframed[4]/framed[5]directly and never callsdecodeBody, so it passes and the failure surfaces only once the rewrite loop starts — as claimed. The active-version fast path (:325-333) skips already-current bodies before decode, so only retired-version bodies hit the throw — which is exactly the set a rotation exists to rewrite. Failure is fail-closed:decodeBodythrows beforebackend.putat :363, so no corpus damage.The composition is the documented one:
docs/src/content/docs/persistence/object-storage/key-rotation.mdxshowsreEncryptObjectStorage(backend, { keyPrefix: 'state/', keyring }), and'state/'is the durable-state store's layout (ObjectStorageDurableStateStore.keyFor→<prefix><pid>/state.json), which is the only store that can write an integrity tag.Two corrections applied. Integrity is available on
ObjectStorageDurableStateStoreonly —ObjectStorageSnapshotStore.save(:87-96) has no integrity path — so the clash is scoped to durable-state sweeps, not snapshots. And there is no "#116 advice" to take:withIntegrity/hmac-sha256appear nowhere underdocs/src/content/docs/(EN or DE), so the feature is reachable only through the builder API. LOW stands: fail-closed operability (revocation cannot be completed) requiring both opt-in features plus an actual rotation.Correction applied: Two precision fixes. (1) The exploit says the operator "took the #116 advice" — there is no advice: a grep for
withIntegrity/hmac-sha256overdocs/src/content/docs/returns zero hits in both languages, so the integrity feature is API-only and undocumented. Reword to "a deployment that used the #116 API". (2) Integrity exists only onObjectStorageDurableStateStore—ObjectStorageSnapshotStore.save(ObjectStorageSnapshotStore.ts:87-96) passes nointegritytoencodeBodyand its options type has no such field. So the collision is specificallykeyPrefix: 'state/'sweeps, which is exactly the invocation the key-rotation page documents.