Skip to content

[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

Description

@pathosDev

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.

  1. Read is not needed. The key layout is deterministic: <prefix><persistenceId>/state.json.
  2. 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.
  3. PUT it over state.json.
  4. 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.

Evidence — src/persistence/object-storage/BodyCodec.ts:247-293

The branch that reads its own answer off the wire:

src/persistence/object-storage/BodyCodec.ts:247-293
  const maxOut = options.maxOutputBytes ?? DEFAULT_MAX_DECOMPRESSED_BYTES;
  let payload: Uint8Array;
  let keyVersion: number | undefined;
  if (encrypted) {
    if (!options.encryption) {
      throw new Error('BodyCodec: body is encrypted but no subKey/resolver was supplied for decoding.');
    }
    let offset = 5;
    if (versioned) {
      if (bodyForRest.length < 6) {
        throw new Error('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) {
      throw new Error('BodyCodec: encrypted body is shorter than the manifest IV requires.');
    }
    const iv = bodyForRest.subarray(offset, offset + IV_LENGTH);
    const ciphertext = 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.
    const enc = options.encryption as
      | { readonly subKey: Uint8Array }
      | { readonly subKeyFor: SubKeyResolver };
    let subKey: Uint8Array | null;
    if ('subKeyFor' in enc) {
      subKey = await enc.subKeyFor(keyVersion ?? 0);
      if (!subKey) {
        throw new Error(
          `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;
    }

    const compressedPlaintext = await aesGcmDecrypt(subKey, iv, ciphertext);
    payload = await compressorFor(compression).decompress(compressedPlaintext, maxOut);
  } else {
    const compressedSlice = bodyForRest.subarray(5);
    payload = await compressorFor(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.

Evidence — src/persistence/durable-state-stores/ObjectStorageDurableStateStore.ts:103-119

The caller has the encryption config in hand and passes it purely as a capability, never as a requirement:

src/persistence/durable-state-stores/ObjectStorageDurableStateStore.ts:103-119
    const subKeyFor = resolveDecryptSubkey(encryption, persistenceId);
    const decodeOptions: import('../object-storage/BodyCodec.js').DecodeOptions = {
      ...(subKeyFor ? { encryption: { subKeyFor } } : {}),
      ...(integrity.mode === 'hmac-sha256'
        ? {
            integrity: {
              integrityKey: integrity.integrityKey,
              requireIntegrity: this.requireIntegrity,
            },
          }
        : {}),
      maxOutputBytes: this.maxDecompressedBytes,
    };
    let decoded: import('../object-storage/BodyCodec.js').DecodedBody;
    try {
      decoded = await decodeBody(fetched.value.body, decodeOptions);
    } catch (e) {

DecodedBody.encrypted comes back and is discarded — nothing compares it to encryption.mode.

Evidence — src/persistence/snapshot-stores/ObjectStorageSnapshotStore.ts:181-188

The snapshot store has no integrity plumbing at all (#613), so it has nothing even incidentally in the way:

src/persistence/snapshot-stores/ObjectStorageSnapshotStore.ts:181-188
    const encryption = options?.encryption
      ?? resolveEncryption(this.encryption, persistenceId, { mode: 'none' });
    const subKeyFor = resolveDecryptSubkey(encryption, persistenceId);
    const decoded = await decodeBody(fetched.value.body, {
      ...(subKeyFor ? { encryption: { subKeyFor } } : {}),
      maxOutputBytes: this.maxDecompressedBytes,
    });
    const json = utf8Decoder.decode(decoded.payload);

Evidence — src/persistence/PersistenceOptions.ts:102-106

The docblock that tells an encrypted deployment it does not need integrity:

src/persistence/PersistenceOptions.ts:102-106
/**
 * Body integrity directive (#116).  Protects unencrypted bodies
 * against tamper-in-place at the object-storage layer; encrypted
 * bodies are already protected by AES-GCM's auth tag.
 *

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

Suggested fix

  • 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.
  • Longer term, the manifest belongs inside the authenticated data: bind flags, keyVersion and 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

  • 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.

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