Skip to content

Runtime null guards stripped from stack encryption operations cause silent ciphertext for null input #494

@coderdan

Description

@coderdan

Summary

In @cipherstash/stack, calling encrypt(null) (or any of the bulk / decrypt / encryptQuery / batch encryptQuery operations on a null element) does not preserve null. Instead, protect-ffi encrypts the JSON value null into a real SteVec ciphertext like { k: 'sv', v: 2, i: { … }, … }. Decrypt and bulk paths exhibit the analogous gap — null ciphertext or null array elements are not short-circuited.

@cipherstash/protect short-circuits null at every operation. @cipherstash/stack's analogous code paths used to do the same.

How to reproduce

import { Encryption } from '@cipherstash/stack'
import { encryptedColumn, encryptedTable } from '@cipherstash/stack/schema'

const t = encryptedTable('demo', {
  metadata: encryptedColumn('metadata').searchableJson(),
})
const client = await Encryption({ schemas: [t] })

const result = await client.encrypt(null as any, { column: t.metadata, table: t })
// Expected: result.data === null
// Actual:   result.data === { k: 'sv', v: 2, i: { …(2) }, …(1) }  ← a real SteVec ciphertext

The ported integration test round-trips null values in packages/stack/__tests__/searchable-json-pg.test.ts (added in #328) exercises this path directly and fails on expect(encrypted.data).toBeNull().

Where the gap is

All six operations in packages/stack/src/encryption/operations/ are missing the null short-circuits that exist in @cipherstash/protect:

Operation Stack Protect
encrypt.ts EncryptOperation.execute no guard if (this.plaintext === null) return null
encrypt.ts EncryptOperationWithLockContext.execute no guard if (plaintext === null) return null
bulk-encrypt.ts no per-element filter filter-null + position-stable merge
decrypt.ts DecryptOperation.execute no guard if (this.encryptedData === null) return null
decrypt.ts DecryptOperationWithLockContext.execute no guard if (encryptedData === null) return null
bulk-decrypt.ts no per-element filter filter-null + position-stable merge
encrypt-query.ts no guard if (plaintext === null || plaintext === undefined) return { data: null }
batch-encrypt-query.ts no per-element filter per-element filter, position-stable

Confirmed with grep -rn "=== null" packages/stack/src/encryption/operations/: zero hits in stack, nine hits in protect (encrypt.ts:56, encrypt.ts:131, bulk-encrypt.ts:57, bulk-decrypt.ts:48, decrypt.ts:41, decrypt.ts:100, encrypt-query.ts:55, encrypt-query.ts:123, batch-encrypt-query — filter helper).

How it happened

Commit 5b7288b feat(stack): remove null from Encrypted type (Feb 24 2026, CJ Brewer) was a type-level tightening: it removed null from Encrypted, EncryptedValue, EncryptPayload, and the various bulk payload/result types so that single-value encrypt/decrypt would reject null at compile time.

Alongside the type changes, the matching if (value === null) return null runtime guards were deleted from every operation. The likely reasoning was "the type now disallows null, so the guard is unreachable". That's only true at compile time:

  1. Defense in depth lost. TypeScript types are erased at runtime. Callers reaching an operation through null as any casts, dynamic model field walking, or JS interop can still have null reach the FFI call, where protect-ffi treats JSON null as a valid JsPlaintext and produces a real ciphertext.
  2. Asymmetry with the model layer. packages/stack/src/encryption/helpers/model-helpers.ts still has null/undefined guards at lines 175, 231, 528, 578 — so encryptModel({ x: null }, …) correctly skips the null field. The lower-level encrypt(null, …) does not. Internal inconsistency: walking a model treats null as absent, but calling encrypt directly treats null as the literal JSON value null, please encrypt it.
  3. Decrypt-side blast radius. A legacy or manually-NULLed DB row no longer short-circuits to null on decrypt — ffiDecrypt(null) would throw or misbehave.

Severity

Not a security vulnerability — keys and protocol behavior are unaffected. But it is a correctness bug at the SDK boundary:

  • Apps that pass null through to bulk encrypt (the common case for batch processing mixed-nullable arrays) will produce ciphertexts where they expected DB NULL.
  • Apps reading those rows back will receive plaintext null (the inner JSON null), not the database NULL marker — round-trip semantics are surprising.
  • Apps with legacy NULL rows in the DB calling decrypt on them get an error from protect-ffi instead of a null result.

Fix

PR #493 restores the runtime guards mirroring @cipherstash/protect's pattern, widens the internal operation field/return types to compile, and widens the public bulk types and EncryptedQueryResult to admit null in element positions. Encryption.encrypt()'s public signature stays narrow (JsPlaintext); the runtime guard is defense in depth for casts and interop.

Discovered while porting packages/protect/__tests__/searchable-json-pg.test.ts to stack in #328 — the round-trips null values test exercises the path and turned the regression up immediately.

Acceptance

  • encrypt(null as any, opts) returns { data: null } (no FFI call, no ciphertext).
  • decrypt(null, …) returns { data: null }.
  • bulkEncrypt([{ plaintext: 'a' }, { plaintext: null }, { plaintext: 'b' }], opts) returns [{ data: <ct> }, { data: null }, { data: <ct> }] — positions preserved.
  • bulkDecrypt symmetric.
  • encryptQuery(null, opts) returns { data: null }; same for undefined.
  • Batch encryptQuery preserves null/undefined positions.
  • Re-enabled round-trips null values integration test in test: port missing searchable JSON tests to stack package #328 passes on CI.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions