Repository navigation
Runtime null guards stripped from stack encryption operations cause silent ciphertext for null input #494
Description
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 ciphertextThe 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:
- Defense in depth lost. TypeScript types are erased at runtime. Callers reaching an operation through
null as anycasts, dynamic model field walking, or JS interop can still have null reach the FFI call, where protect-ffi treats JSONnullas a validJsPlaintextand produces a real ciphertext. - Asymmetry with the model layer.
packages/stack/src/encryption/helpers/model-helpers.tsstill has null/undefined guards at lines 175, 231, 528, 578 — soencryptModel({ x: null }, …)correctly skips the null field. The lower-levelencrypt(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. - 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. -
bulkDecryptsymmetric. -
encryptQuery(null, opts)returns{ data: null }; same forundefined. - Batch encryptQuery preserves null/undefined positions.
- Re-enabled
round-trips null valuesintegration test in test: port missing searchable JSON tests to stack package #328 passes on CI.
Activity