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] Ten of eleven snapshot stores and nine of ten durable-state stores bind PersistenceOptions and never read it, so an actor encryption() setting is a silent no-op and state is written to disk in plaintext #960
Component:src/persistence/relational/RelationalSnapshotStore.ts, src/persistence/relational/RelationalDurableStateStore.ts Severity (assessment): HIGH CWE: CWE-311 (missing encryption of sensitive data)
PersistentActor.encryption() and DurableStateActor.encryption() are the framework's per-actor at-rest encryption controls: an actor overrides the hook, the hook is folded into a PersistenceOptions and handed to the store on every write. Exactly one of the eleven shipped snapshot stores and one of the ten durable-state stores actually read that argument — the object-storage pair. Everywhere else the parameter is named _options and discarded, so an actor that declares encryption() and is backed by Postgres, MariaDB, SQL Server, libSQL, D1, SQLite, MongoDB, Cassandra or DynamoDB writes its state to disk in plaintext, with no throw, no warning and no log line. The docs make the gap worse rather than better: they name in-memory and SQLite as the stores that ignore the setting, which reads as an assurance that the production backends honour it.
Exploit walkthrough
No attacker interaction is required — the failure is a silent no-op on a security control, and the exposure is whatever the store's own threat model is.
An operator handling regulated data sets protected encryption() { return { algorithm: 'aes-gcm', keyId: 'k1' }; } on a DurableStateActor, following docs/.../persistence/durable-state.mdx.
The store is PostgresDurableStateStore (or any of the other eight). DurableStateActor.persist builds the PersistenceOptions and passes it to store.upsert(persistenceId, expected, wire, options).
RelationalDurableStateStore.upsert binds the argument to _options and never reads it. The row is written by encodePayload(state, this.serializer) — JSON text.
Nothing anywhere reports that the requested encryption did not happen. A SELECT payload FROM durable_state returns the cleartext, as does any database backup, any replica, any logical-decoding stream and any operator with read access to the table.
The same path applies to PersistentActor.encryption() → SnapshotStore.save(...) → RelationalSnapshotStore.save, where the payload is the actor's full state at snapshot time.
The hook that is being discarded, and the promise it makes on the durable-state side — note that this one carries no caveat at all:
src/persistence/DurableStateActor.ts:63-68/** * Per-actor encryption — overrides the plugin default. Used on both * the write path (encrypt) and the read path (decrypt). Default * `undefined` defers to the plugin. */protectedencryption(): EncryptionConfig|undefined{returnundefined;}
PersistentActor's twin does carry one, but it is a JSDoc aside rather than anything the runtime enforces:
src/persistence/PersistentActor.ts:135-141/** * Per-actor encryption — overrides the plugin default for THIS actor's * snapshots. Honoured by stores that encrypt at rest (object-storage); * other stores ignore it. Used on both the write path (encrypt) and * the read path (derive subkey from master to decrypt). */encryption(): EncryptionConfig|undefined{returnundefined;}
And the documentation actively points the wrong way — it names only the two stores nobody runs in production:
The German mirror repeats it verbatim at docs/src/content/docs/de/persistence/durable-state.mdx:245-247 ("ignoriert von Stores, die das nicht tun (In-Memory, SQLite)"). A reader on Postgres concludes their state is encrypted. It is not: RelationalDurableStateStore serves Postgres, MariaDB, SQL Server, libSQL and D1, and reads _options on neither the write nor the read path (load at line 124 discards it too).
Full survey of the current tree — stores that read options?.encryption / options?.compression at all:
There is no guard — the interfaces sanction the silence explicitly, which is the actual defect:
src/persistence/SnapshotStore.ts:11-22/** * Persist a snapshot at `seq` — typically the seq of the latest event * applied. Optional `options` carry per-call preferences from the * caller (e.g. compression/encryption set on the actor). Stores that * cannot honour them silently ignore the field. */save<S=unknown>(persistenceId: string,seq: number,state: S,options?: PersistenceOptions,): Promise<Snapshot<S>>;
DurableStateStore.upsert carries the identical sentence (src/persistence/DurableStateStore.ts:32-45). So a store that ignores the field is not violating its contract — it is following one that was written for compression, where dropping the request costs disk, and then applied unchanged to encryption, where dropping the request costs confidentiality. The two travel in the same bag and share the same "silently ignore" clause. The nearest thing to an actual check anywhere is the object-storage store's requireIntegrity flag, which governs HMAC only and only inside the one store that already implements everything.
The consequence is that a security control is a suggestion: it is honoured or not depending on which store the plugin registry happened to wire, and nothing at any layer — type system, runtime, log, startup check — tells the operator which they got.
Suggested fix
Make an unimplementable request fail instead of no-op:
Alternatively, and better long-term: move encryption/compression out of the per-store implementations into a decorator (EncryptingSnapshotStore(inner, keyRing)) that wraps any store, so the capability is universal rather than object-storage-only. PayloadCodec is already the single funnel every store's payload passes through, which makes the seam cheap.
Either way, correct docs/.../persistence/durable-state.mdx and its German mirror to list what actually honours the hook, and say so on the object-storage encryption page too.
Acceptance criteria
Setting encryption() on an actor backed by a store that cannot encrypt fails loudly at the first write, naming the store.
Every shipped store declares its encryption/compression capability truthfully, and a test asserts the declaration matches behaviour.
docs/.../persistence/durable-state.mdx + German mirror name the stores that honour the hooks, not two examples of the ones that do not.
A regression test writes with encryption() set against a relational store and asserts the outcome is an error, not a plaintext row.
Reference issues: #613 is the same defect class on the third field of the same bag — ObjectStorageSnapshotStore silently drops PersistenceOptions.integrity, so the #116 HMAC cannot be enabled for event-sourced actors at all. It is scoped to one store and one field; this issue is the encryption/compression pair across every store that is not object storage, and the two want one fix (a capability declaration that makes an unhonourable request an error). #782 is the adjacent leak in the other direction (CachedSnapshotStore writes decrypted state into an external cache, undoing the encryption of a store that did honour it) — same control, opposite failure. #612 concerns what the object-storage encryption does not bind into its AAD, i.e. a weakness in the one implementation that exists. #223 (per-entity envelope encryption with KEK rotation) is the feature this gap makes premature.
Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (v0.13.0) and re-verified before filing: confirmed by reading, with the survey table above produced by grepping every store in the current tree for a read of options?.encryption / options?.compression — exactly one snapshot store and one durable-state store match, and both are the object-storage pair. Not reproduced end-to-end: proving "the bytes on disk are plaintext" needs a live Postgres, and the parameter being bound to _options and never referenced in the method body is not ambiguous.
Part of the production-readiness review batch — tracked in #913.
Component:
src/persistence/relational/RelationalSnapshotStore.ts,src/persistence/relational/RelationalDurableStateStore.tsSeverity (assessment): HIGH
CWE: CWE-311 (missing encryption of sensitive data)
PersistentActor.encryption()andDurableStateActor.encryption()are the framework's per-actor at-rest encryption controls: an actor overrides the hook, the hook is folded into aPersistenceOptionsand handed to the store on every write. Exactly one of the eleven shipped snapshot stores and one of the ten durable-state stores actually read that argument — the object-storage pair. Everywhere else the parameter is named_optionsand discarded, so an actor that declaresencryption()and is backed by Postgres, MariaDB, SQL Server, libSQL, D1, SQLite, MongoDB, Cassandra or DynamoDB writes its state to disk in plaintext, with no throw, no warning and no log line. The docs make the gap worse rather than better: they name in-memory and SQLite as the stores that ignore the setting, which reads as an assurance that the production backends honour it.Exploit walkthrough
No attacker interaction is required — the failure is a silent no-op on a security control, and the exposure is whatever the store's own threat model is.
protected encryption() { return { algorithm: 'aes-gcm', keyId: 'k1' }; }on aDurableStateActor, followingdocs/.../persistence/durable-state.mdx.PostgresDurableStateStore(or any of the other eight).DurableStateActor.persistbuilds thePersistenceOptionsand passes it tostore.upsert(persistenceId, expected, wire, options).RelationalDurableStateStore.upsertbinds the argument to_optionsand never reads it. The row is written byencodePayload(state, this.serializer)— JSON text.SELECT payload FROM durable_statereturns the cleartext, as does any database backup, any replica, any logical-decoding stream and any operator with read access to the table.The same path applies to
PersistentActor.encryption()→SnapshotStore.save(...)→RelationalSnapshotStore.save, where the payload is the actor's full state at snapshot time.Evidence —
src/persistence/relational/RelationalSnapshotStore.ts:72-85Evidence —
src/persistence/relational/RelationalDurableStateStore.ts:76-81The hook that is being discarded, and the promise it makes on the durable-state side — note that this one carries no caveat at all:
PersistentActor's twin does carry one, but it is a JSDoc aside rather than anything the runtime enforces:And the documentation actively points the wrong way — it names only the two stores nobody runs in production:
The German mirror repeats it verbatim at
docs/src/content/docs/de/persistence/durable-state.mdx:245-247("ignoriert von Stores, die das nicht tun (In-Memory, SQLite)"). A reader on Postgres concludes their state is encrypted. It is not:RelationalDurableStateStoreserves Postgres, MariaDB, SQL Server, libSQL and D1, and reads_optionson neither the write nor the read path (loadat line 124 discards it too).Full survey of the current tree — stores that read
options?.encryption/options?.compressionat all:PersistenceOptionsObjectStorageSnapshotStoreObjectStorageDurableStateStoreWhy the existing guard does not cover it
There is no guard — the interfaces sanction the silence explicitly, which is the actual defect:
DurableStateStore.upsertcarries the identical sentence (src/persistence/DurableStateStore.ts:32-45). So a store that ignores the field is not violating its contract — it is following one that was written forcompression, where dropping the request costs disk, and then applied unchanged toencryption, where dropping the request costs confidentiality. The two travel in the same bag and share the same "silently ignore" clause. The nearest thing to an actual check anywhere is the object-storage store'srequireIntegrityflag, which governs HMAC only and only inside the one store that already implements everything.The consequence is that a security control is a suggestion: it is honoured or not depending on which store the plugin registry happened to wire, and nothing at any layer — type system, runtime, log, startup check — tells the operator which they got.
Suggested fix
Make an unimplementable request fail instead of no-op:
compressionmay keep its "silently ignore" clause;encryption(andintegrity, see [Security] ObjectStorageSnapshotStore has no integrity support at all and silently dropsPersistenceOptions.integrity, so snapshot state — which drives actor recovery — is unauthenticated with no opt-in available #613) must not travel under it. GiveSnapshotStore/DurableStateStorea capability declaration (readonly supportsEncryption?: boolean,supportsCompression?: boolean) and have the base classes throw at write time whenoptions.encryptionis set and the store does not declare support. A store that silently drops an encryption request is the one case where a hard failure is unambiguously right — the alternative is plaintext the operator believes is ciphertext.EncryptingSnapshotStore(inner, keyRing)) that wraps any store, so the capability is universal rather than object-storage-only.PayloadCodecis already the single funnel every store's payload passes through, which makes the seam cheap.docs/.../persistence/durable-state.mdxand its German mirror to list what actually honours the hook, and say so on the object-storage encryption page too.Acceptance criteria
encryption()on an actor backed by a store that cannot encrypt fails loudly at the first write, naming the store.docs/.../persistence/durable-state.mdx+ German mirror name the stores that honour the hooks, not two examples of the ones that do not.encryption()set against a relational store and asserts the outcome is an error, not a plaintext row.Reference issues: #613 is the same defect class on the third field of the same bag —
ObjectStorageSnapshotStoresilently dropsPersistenceOptions.integrity, so the #116 HMAC cannot be enabled for event-sourced actors at all. It is scoped to one store and one field; this issue is the encryption/compression pair across every store that is not object storage, and the two want one fix (a capability declaration that makes an unhonourable request an error). #782 is the adjacent leak in the other direction (CachedSnapshotStorewrites decrypted state into an external cache, undoing the encryption of a store that did honour it) — same control, opposite failure. #612 concerns what the object-storage encryption does not bind into its AAD, i.e. a weakness in the one implementation that exists. #223 (per-entity envelope encryption with KEK rotation) is the feature this gap makes premature.Verification status
Found in the ten-lens production-readiness review of 2026-08-05 (
v0.13.0) and re-verified before filing: confirmed by reading, with the survey table above produced by grepping every store in the current tree for a read ofoptions?.encryption/options?.compression— exactly one snapshot store and one durable-state store match, and both are the object-storage pair. Not reproduced end-to-end: proving "the bytes on disk are plaintext" needs a live Postgres, and the parameter being bound to_optionsand never referenced in the method body is not ambiguous.Part of the production-readiness review batch — tracked in #913.