Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/wasm-inline-model-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@cipherstash/stack': minor
'stash': patch
---

`@cipherstash/stack/wasm-inline` now has the model helpers: `encryptModel` / `decryptModel` and `bulkEncryptModels` / `bulkDecryptModels` (#742). They run the same schema traversal as the native entry (shared code, so the two entries cannot drift on which fields get encrypted): declared columns are encrypted — matched by JS property name, nested fields via the column's dotted path — everything else passes through, and `null`/`undefined` fields are preserved without reaching ZeroKMS. A call that encrypts (or decrypts) at least one field is one ZeroKMS round trip regardless of how many fields or models it covers; a `null`/empty batch, or one whose models carry no schema fields, returns without contacting ZeroKMS at all. `types.Date`/`types.Timestamp` columns round-trip `Date` → `Date` (ISO strings on the wire), and failures follow this entry's `{ data } | { failure }` Result contract, with decrypt failures naming every failing field by its model path. Edge code no longer needs the hand-written `bulkEncrypt` field mapping whose failure mode was a schema column silently persisted in plaintext.

The shared model traversal is also hardened: it no longer mutates the caller's model (previously a nested-column decrypt wrote decrypted plaintext back into the caller's input, and encrypt overwrote it with ciphertext); a literal flat dotted key, a `__proto__`-shaped key, or a non-object model element is handled safely instead of crashing, leaking plaintext, or reaching `Object.prototype`; an already-encrypted field is passed through rather than re-encrypted; and an invalid `Date` is rejected per field. On the WASM entry, model ops now validate the table against the client's schemas, `Date` values are normalized at every encrypt/query crossing (not just the model path), and a `null`/empty model batch returns `{ data: [] }`. The skills update ships in the `stash` tarball, hence the `stash` patch.
67 changes: 67 additions & 0 deletions e2e/wasm/roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,5 +221,72 @@ Deno.test({
)
// Round-trips at the ORIGINAL indices, with the null hole preserved.
assertEquals(bulkDecrypted.data, [plaintext, null, second])

// 7. (#742) The model helpers — the surface that walks a model against
// its schema, so edge code never hand-rolls the field mapping whose
// failure mode is a column silently persisted in plaintext. Each call
// is one ZeroKMS round trip regardless of field or model count.
const rowA = { id: 'row-a', email: plaintext, note: null }
const modelResult = await client.encryptModel(rowA, users)
assertEquals(
modelResult.failure,
undefined,
`encryptModel() failed: ${modelResult.failure?.message}`,
)
const encryptedRow = modelResult.data
assertEquals(encryptedRow.id, 'row-a', 'passthrough field was altered')
assertEquals(encryptedRow.note, null, 'null field was altered')
assertEquals(
isEncrypted(encryptedRow.email),
true,
'schema field was not encrypted by encryptModel',
)

const modelBack = await client.decryptModel(encryptedRow, users)
assertEquals(
modelBack.failure,
undefined,
`decryptModel() failed: ${modelBack.failure?.message}`,
)
assertEquals(modelBack.data, rowA, 'model round-trip mismatch')

const rowB = { id: 'row-b', email: second, note: 'kept' }
const bulkModels = await client.bulkEncryptModels([rowA, rowB], users)
assertEquals(
bulkModels.failure,
undefined,
`bulkEncryptModels() failed: ${bulkModels.failure?.message}`,
)
assertEquals(bulkModels.data.length, 2, 'bulkEncryptModels misaligned')
// Assert the payloads are actually ENCRYPTED — otherwise a passthrough
// no-op regression (the plaintext-persistence failure this surface exists
// to prevent) would still round-trip and pass every other assertion.
for (const [i, row] of bulkModels.data.entries()) {
assertEquals(
isEncrypted(row.email),
true,
`bulkEncryptModels[${i}].email is not an encrypted payload`,
)
assertEquals(
row.note,
i === 0 ? null : 'kept',
'passthrough field altered',
)
}

const bulkModelsBack = await client.bulkDecryptModels(
bulkModels.data,
users,
)
assertEquals(
bulkModelsBack.failure,
undefined,
`bulkDecryptModels() failed: ${bulkModelsBack.failure?.message}`,
)
assertEquals(
bulkModelsBack.data,
[rowA, rowB],
'bulk model round-trip mismatch',
)
},
})
Loading
Loading