You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[Security] decodeBody never checks that the caller expected encryption and there is no requireEncryption option, so object-store write access is enough to replace an encrypted body with a plaintext one the actor recovers into #965
Component:src/persistence/object-storage/BodyCodec.ts Severity (assessment): HIGH CWE: CWE-757 (Selection of Less-Secure Algorithm During Negotiation — "Algorithm Downgrade")
decodeBody reads the encryption decision out of the stored body's own flags byte and never compares it against what the caller configured. A store wired for client-aes256-gcm therefore decodes a body whose flags say "not encrypted" as plaintext, silently: the whole if (encrypted) branch — subkey resolution, IV, AES-GCM tag verification — is skipped, and the payload is handed back as recovered state. There is no requireEncryption decode option and no field on ObjectStorageDurableStateStoreOptions that could express one. The manifest is the only thing that says what the body is, and outside the AES-GCM tag nothing authenticates the manifest, so the party who can write the object also picks the algorithm.
Exploit walkthrough
Attacker position: object-store write access — a leaked S3 key with s3:PutObject on the state prefix, a misconfigured bucket policy, a compromised sidecar, or anyone with write access to the directory behind FilesystemObjectStorageBackend. Exactly the party at-rest encryption exists to defend against.
Read is not needed. The key layout is deterministic: <prefix><persistenceId>/state.json.
Build a body: the four magic bytes ATS1, then a single flags byte of 0x00 (compression none, FLAG_ENCRYPTED clear, FLAG_INTEGRITY_HMAC clear), then the payload JSON — {"revision":N,"state":{…},"timestamp":…} with whatever state the attacker wants. No key material is involved; the body is 5 bytes of header and plain JSON.
PUT it over state.json.
The next load() — an actor restart, a rebalanced shard, a cold start — returns the attacker's state. DurableStateActor adopts it as its recovered state and the revision in the body becomes the CAS baseline the store caches, so subsequent writes proceed from the forged revision without conflict.
The result is stronger than tampering with an encrypted blob, which AES-GCM would reject: the attacker does not have to break the encryption, only to declare that there was none.
The branch that reads its own answer off the wire:
src/persistence/object-storage/BodyCodec.ts:247-293constmaxOut=options.maxOutputBytes??DEFAULT_MAX_DECOMPRESSED_BYTES;letpayload: Uint8Array;letkeyVersion: number|undefined;if(encrypted){if(!options.encryption){thrownewError('BodyCodec: body is encrypted but no subKey/resolver was supplied for decoding.');}letoffset=5;if(versioned){if(bodyForRest.length<6){thrownewError('BodyCodec: encrypted body claims key-versioned but is shorter than the version byte requires.');}keyVersion=bodyForRest[5]!;offset=6;}if(bodyForRest.length<offset+IV_LENGTH){thrownewError('BodyCodec: encrypted body is shorter than the manifest IV requires.');}constiv=bodyForRest.subarray(offset,offset+IV_LENGTH);constciphertext=bodyForRest.subarray(offset+IV_LENGTH);// Resolve the subkey: prefer the resolver path (versioned), fall// back to the legacy single-subkey field. An unversioned body// dispatched against a resolver is treated as version 0 — that's// the implicit version the legacy single-key shape always carried.constenc=options.encryptionas|{readonlysubKey: Uint8Array}|{readonlysubKeyFor: SubKeyResolver};letsubKey: Uint8Array|null;if('subKeyFor'inenc){subKey=awaitenc.subKeyFor(keyVersion??0);if(!subKey){thrownewError(`BodyCodec: no master key registered for version ${keyVersion??0} — `+`add it to the keyring's \`retired\` list to decrypt historical blobs.`,);}}else{subKey=enc.subKey;}constcompressedPlaintext=awaitaesGcmDecrypt(subKey,iv,ciphertext);payload=awaitcompressorFor(compression).decompress(compressedPlaintext,maxOut);}else{constcompressedSlice=bodyForRest.subarray(5);payload=awaitcompressorFor(compression).decompress(compressedSlice,maxOut);}
The check that exists is one-directional — "the body says encrypted but you gave me no key" is an error; "you gave me a key but the body says plaintext" is not.
FLAG_ENCRYPTED is not covered by anything. It sits in the header at offset 4, before the IV; the AES-GCM tag covers only the ciphertext, and the HMAC (when present) covers the header — but a forged body simply does not set the HMAC flag.
The ETag CAS does not help.load populates etagCache from whatever the backend returns; the attacker's write produces a fresh, valid ETag.
Server-side encryption modes (sse-s3, sse-kms) do not help either. They protect the object from the storage operator, not from a principal with PutObject.
Add requireEncryption?: boolean to DecodeOptions, and have decodeBody throw when it is set and FLAG_ENCRYPTED is clear — mirroring the existing requireIntegrity branch exactly.
Better: derive it rather than making it another opt-in. When the caller supplies options.encryption, that is the statement that the body must be encrypted; a plaintext body should be refused unless the caller explicitly opts into mixed reading for a migration window (allowPlaintext: true).
Both object-storage stores pass their resolved encryption.mode down, so a store configured client-aes256-gcm refuses a plaintext body for that persistenceId — including through the per-call PersistenceOptions.encryption path.
Fix the docblock in PersistenceOptions.ts:102-106: AES-GCM protects the ciphertext, not the manifest byte that decides whether there is any.
decodeBody refuses a body without FLAG_ENCRYPTED when the caller required encryption, with an error naming the downgrade.
ObjectStorageDurableStateStore and ObjectStorageSnapshotStore require encryption for any persistenceId whose resolved config is client-aes256-gcm.
A test writes a ATS1 + 0x00 + plaintext body straight into the backend and asserts load() throws rather than returning the forged state — for both stores.
A migration escape hatch exists and is documented for buckets that legitimately mix pre-encryption and post-encryption bodies.
The IntegrityConfig docblock no longer tells encrypted deployments that AES-GCM already covers them.
docs/.../persistence/ (EN + DE) documents the threat model: who can write to the bucket, and what at-rest encryption does and does not protect against.
Adjacent issues: #579 (clearing FLAG_INTEGRITY_HMAC downgrades the HMAC — the same manifest-is-unauthenticated shape, one flag over), #612 (nothing binds key/revision into the AAD, so an authentic older body replays), #613 (the snapshot store has no integrity support at all, which is why it has no incidental guard here), #739 (re-encryption cannot process integrity-tagged bodies).
Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: reproduced by execution. A real ObjectStorageDurableStateStore configured with { mode: 'client-aes256-gcm', masterKey } over an in-memory backend, with an "attacker" writing raw bytes straight into the bucket:
=== what the store actually wrote ===
bytes : 140
magic : ATS1
flags byte : 0x05 (bit2 FLAG_ENCRYPTED = true)
ciphertext head : 8b 6a 41 1f 2b 15 17 a0
load() honest body: {"persistenceId":"account-1","revision":1,"state":{"owner":"alice","balanceCents":1000,"role":"user"},…}
=== after the attacker overwrote state.json with flags 0x00 + plaintext ===
forged bytes : 111 flags 0x00, no key material used
load() RETURNED : {"persistenceId":"account-1","revision":1,"state":{"owner":"alice","balanceCents":999999999,"role":"admin"},…}
store threw? : no — the encrypted-at-rest store accepted a plaintext body
=== decodeBody() directly, with an encryption resolver supplied ===
decoded.encrypted : false (the caller asked for encryption and got false)
=== does requireIntegrity cover it? ===
requireIntegrity=true : rejected -> BodyCodec: body has no integrity tag but requireIntegrity=true was set.
requireIntegrity=false: ACCEPTED the plaintext body (this is the default)
=== sanity: an encrypted body still needs the key ===
encrypted body without a key: rejected -> BodyCodec: body is encrypted but no subKey/resolver was supplied for decoding.
The last two blocks are the honest boundary of the finding: requireIntegrity: true closes it, and the inverse direction (encrypted body, no key) is correctly refused. What is missing is the symmetric check.
Part of the production-readiness review batch — tracked in #913.
Component:
src/persistence/object-storage/BodyCodec.tsSeverity (assessment): HIGH
CWE: CWE-757 (Selection of Less-Secure Algorithm During Negotiation — "Algorithm Downgrade")
decodeBodyreads the encryption decision out of the stored body's ownflagsbyte and never compares it against what the caller configured. A store wired forclient-aes256-gcmtherefore decodes a body whoseflagssay "not encrypted" as plaintext, silently: the wholeif (encrypted)branch — subkey resolution, IV, AES-GCM tag verification — is skipped, and the payload is handed back as recovered state. There is norequireEncryptiondecode option and no field onObjectStorageDurableStateStoreOptionsthat could express one. The manifest is the only thing that says what the body is, and outside the AES-GCM tag nothing authenticates the manifest, so the party who can write the object also picks the algorithm.Exploit walkthrough
Attacker position: object-store write access — a leaked S3 key with
s3:PutObjecton the state prefix, a misconfigured bucket policy, a compromised sidecar, or anyone with write access to the directory behindFilesystemObjectStorageBackend. Exactly the party at-rest encryption exists to defend against.<prefix><persistenceId>/state.json.ATS1, then a singleflagsbyte of0x00(compressionnone,FLAG_ENCRYPTEDclear,FLAG_INTEGRITY_HMACclear), then the payload JSON —{"revision":N,"state":{…},"timestamp":…}with whatever state the attacker wants. No key material is involved; the body is 5 bytes of header and plain JSON.PUTit overstate.json.load()— an actor restart, a rebalanced shard, a cold start — returns the attacker's state.DurableStateActoradopts it as its recovered state and therevisionin the body becomes the CAS baseline the store caches, so subsequent writes proceed from the forged revision without conflict.The result is stronger than tampering with an encrypted blob, which AES-GCM would reject: the attacker does not have to break the encryption, only to declare that there was none.
Evidence —
src/persistence/object-storage/BodyCodec.ts:247-293The branch that reads its own answer off the wire:
The check that exists is one-directional — "the body says encrypted but you gave me no key" is an error; "you gave me a key but the body says plaintext" is not.
Evidence —
src/persistence/durable-state-stores/ObjectStorageDurableStateStore.ts:103-119The caller has the encryption config in hand and passes it purely as a capability, never as a requirement:
DecodedBody.encryptedcomes back and is discarded — nothing compares it toencryption.mode.Evidence —
src/persistence/snapshot-stores/ObjectStorageSnapshotStore.ts:181-188The snapshot store has no integrity plumbing at all (#613), so it has nothing even incidentally in the way:
Evidence —
src/persistence/PersistenceOptions.ts:102-106The docblock that tells an encrypted deployment it does not need integrity:
An AES-GCM tag protects a body that claims to be AES-GCM. It says nothing about a body that claims not to be, which is the whole attack.
Why the existing guard does not cover it
requireIntegrityis a different control that happens to overlap. Set totrueand with an HMAC key configured, it does reject this body — a plaintext forgery carries no tag either. But it defaults tofalse(ObjectStorageDurableStateStore.ts:84), it is [Security] HMAC integrity tag is strippable — clearing FLAG_INTEGRITY_HMAC downgrades to no verification becauserequireIntegritydefaults to false andwithIntegrity()does not set it #579's subject rather than this one, and — decisively — the documentation above tells an encrypted deployment that integrity is for unencrypted bodies. The deployments most exposed here are exactly the ones the docs steer away from the accidental mitigation.FLAG_ENCRYPTEDis not covered by anything. It sits in the header at offset 4, before the IV; the AES-GCM tag covers only the ciphertext, and the HMAC (when present) covers the header — but a forged body simply does not set the HMAC flag.loadpopulatesetagCachefrom whatever the backend returns; the attacker's write produces a fresh, valid ETag.sse-s3,sse-kms) do not help either. They protect the object from the storage operator, not from a principal withPutObject.Suggested fix
requireEncryption?: booleantoDecodeOptions, and havedecodeBodythrow when it is set andFLAG_ENCRYPTEDis clear — mirroring the existingrequireIntegritybranch exactly.options.encryption, that is the statement that the body must be encrypted; a plaintext body should be refused unless the caller explicitly opts into mixed reading for a migration window (allowPlaintext: true).encryption.modedown, so a store configuredclient-aes256-gcmrefuses a plaintext body for thatpersistenceId— including through the per-callPersistenceOptions.encryptionpath.PersistenceOptions.ts:102-106: AES-GCM protects the ciphertext, not the manifest byte that decides whether there is any.flags,keyVersionand the storage key into the AEAD AAD (this is the same seam [Security] Nothing binds the storage key or revision into the AEAD AAD or the HMAC input, so replaying an authentic older body rolls DurableState back undetectably even with AES-GCM + HMAC both enabled #612 needs for the key/revision binding, and one change can serve both).Acceptance criteria
decodeBodyrefuses a body withoutFLAG_ENCRYPTEDwhen the caller required encryption, with an error naming the downgrade.ObjectStorageDurableStateStoreandObjectStorageSnapshotStorerequire encryption for anypersistenceIdwhose resolved config isclient-aes256-gcm.ATS1+0x00+ plaintext body straight into the backend and assertsload()throws rather than returning the forged state — for both stores.IntegrityConfigdocblock no longer tells encrypted deployments that AES-GCM already covers them.docs/.../persistence/(EN + DE) documents the threat model: who can write to the bucket, and what at-rest encryption does and does not protect against.Adjacent issues: #579 (clearing
FLAG_INTEGRITY_HMACdowngrades the HMAC — the same manifest-is-unauthenticated shape, one flag over), #612 (nothing binds key/revision into the AAD, so an authentic older body replays), #613 (the snapshot store has no integrity support at all, which is why it has no incidental guard here), #739 (re-encryption cannot process integrity-tagged bodies).Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (
v0.13.0) and re-verified before filing: reproduced by execution. A realObjectStorageDurableStateStoreconfigured with{ mode: 'client-aes256-gcm', masterKey }over an in-memory backend, with an "attacker" writing raw bytes straight into the bucket:The last two blocks are the honest boundary of the finding:
requireIntegrity: truecloses it, and the inverse direction (encrypted body, no key) is correctly refused. What is missing is the symmetric check.Part of the production-readiness review batch — tracked in #913.