Skip to content

[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

Description

@pathosDev

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.

  1. An operator handling regulated data sets protected encryption() { return { algorithm: 'aes-gcm', keyId: 'k1' }; } on a DurableStateActor, following docs/.../persistence/durable-state.mdx.
  2. 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).
  3. RelationalDurableStateStore.upsert binds the argument to _options and never reads it. The row is written by encodePayload(state, this.serializer) — JSON text.
  4. 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.

Evidence — src/persistence/relational/RelationalSnapshotStore.ts:72-85

src/persistence/relational/RelationalSnapshotStore.ts:72-85
  async save<S>(persistenceId: string, seq: number, state: S, _options?: PersistenceOptions): Promise<Snapshot<S>> {
    const pool = await this.ensureOpen();
    const now = Date.now();
    try {
      await pool.query(this.statements.upsert, [persistenceId, seq, encodePayload(state, this.serializer), now]);
      if (this.keepN > 0) {
        const { sql, params } = this.statements.prune;
        await pool.query(sql, params(persistenceId, this.keepN));
      }
      return { persistenceId, sequenceNr: seq, state, timestamp: now };
    } catch (e) {
      this.fail('save', e);
    }
  }

Evidence — src/persistence/relational/RelationalDurableStateStore.ts:76-81

src/persistence/relational/RelationalDurableStateStore.ts:76-81
  async upsert<S>(
    persistenceId: string,
    expectedRevision: number,
    state: S,
    _options?: PersistenceOptions,
  ): Promise<DurableStateRecord<S>> {

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.
   */
  protected encryption(): EncryptionConfig | undefined { return undefined; }

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 { return undefined; }

And the documentation actively points the wrong way — it names only the two stores nobody runs in production:

docs/src/content/docs/persistence/durable-state.mdx:238-242
Honored by stores that implement them (object-storage with
encryption, etc.); ignored by stores that don't (in-memory,
SQLite).  See
[Object storage encryption](/persistence/object-storage/encryption/)
for the durable-state encryption story.

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:

honours PersistenceOptions ignores it
snapshot stores ObjectStorageSnapshotStore relational (Postgres, MariaDB, MsSQL, libSQL, D1), SQLite, Cassandra, Mongo, DynamoDB, InMemory
durable-state stores ObjectStorageDurableStateStore relational (Postgres, MariaDB, MsSQL, libSQL, D1), SQLite, Mongo, DynamoDB, InMemory

Why the existing guard does not cover it

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:

  • Split the bag. compression may keep its "silently ignore" clause; encryption (and integrity, see [Security] ObjectStorageSnapshotStore has no integrity support at all and silently drops PersistenceOptions.integrity, so snapshot state — which drives actor recovery — is unauthenticated with no opt-in available #613) must not travel under it. Give SnapshotStore / DurableStateStore a capability declaration (readonly supportsEncryption?: boolean, supportsCompression?: boolean) and have the base classes throw at write time when options.encryption is 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.
  • 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 bagObjectStorageSnapshotStore 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: highTop priority — high impact, plan nextproduction-goalBlocks or defines the path to production readinesssecuritySecurity-relevant — see severity label for impact tierseverity: highSignificant impact, exploitable in standard threat model

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions