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:
- 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.
- 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.
- 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
Summary
In
@cipherstash/stack, callingencrypt(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 valuenullinto 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/protectshort-circuits null at every operation.@cipherstash/stack's analogous code paths used to do the same.How to reproduce
The ported integration test
round-trips null valuesinpackages/stack/__tests__/searchable-json-pg.test.ts(added in #328) exercises this path directly and fails onexpect(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:encrypt.tsEncryptOperation.executeif (this.plaintext === null) return nullencrypt.tsEncryptOperationWithLockContext.executeif (plaintext === null) return nullbulk-encrypt.tsdecrypt.tsDecryptOperation.executeif (this.encryptedData === null) return nulldecrypt.tsDecryptOperationWithLockContext.executeif (encryptedData === null) return nullbulk-decrypt.tsencrypt-query.tsif (plaintext === null || plaintext === undefined) return { data: null }batch-encrypt-query.tsConfirmed 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 removednullfromEncrypted,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 nullruntime 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: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.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.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:
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 andEncryptedQueryResultto 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.tsto stack in #328 — theround-trips null valuestest 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.round-trips null valuesintegration test in test: port missing searchable JSON tests to stack package #328 passes on CI.