Skip to content

Commit d25d100

Browse files
authored
feat(stack): port the model helpers to wasm-inline — encryptModel/decryptModel + bulk forms (#767)
* feat(stack): port the model helpers to wasm-inline — encryptModel/decryptModel + bulk forms `@cipherstash/stack/wasm-inline` exposed no model operations, so edge code hand-rolled the per-field bulkEncrypt mapping for every read and write. The failure mode of that mapping is the quiet one #742 describes: add a column to the schema, forget to extend the mapping, and the field silently persists in PLAINTEXT. The model helpers close that gap by construction. The traversal is SHARED, not ported: the pure walk (field matching by JS property name, dotted-path nesting, null bookkeeping, nested placement, numeric validation, the property→DB-name column map) moves out of packages/stack's model-helpers.ts into a new bundle-safe encryption/helpers/model-traversal.ts with no runtime protect-ffi imports, and both entries consume it — the native model helpers pair it with the NAPI batch calls exactly as before, the WASM entry pairs it with the /wasm-inline FFI batch calls. The two entries can no longer drift on which fields get encrypted. The new surface follows this entry's local conventions (#741 precedent): - Every method returns the `{ data } | { failure }` WasmResult; arguments are plain models plus a v3 table — no `{ id, … }` envelopes. - One ZeroKMS round trip per call, however many fields/models it covers, with assertBatchLength guarding the positional pairing. - decryptModel/bulkDecryptModels ride decryptBulkFallible: one bad field doesn't mask the rest, and the single failure names EVERY failed field by its model path (`[model 1] profile.ssn`), with per-item codes. - Date columns round-trip Date → Date: encrypt serializes to ISO-8601 (WasmPlaintext carries no Date across the wasm serde — a raw Date would arrive as `{}`), decrypt rebuilds via the table's cast_as, mirroring the native v3 client's rowReconstructor. - No .withLockContext(): identity-bound encryption on the edge is configured at client construction via config.authStrategy. 13 new mocked unit tests pin the FFI payload shapes, single-crossing batching, passthrough/null preservation, the property→DB-name mapping, dotted-path nesting, Date round-trip, per-field failure labelling, and the short-response refusal; the gated Deno e2e gains live round-trip coverage for all four methods. skills/stash-encryption drops its "not available on the WASM entry" caveat in favour of the real contract. Closes #742 * fix(stack): address code-review findings on the wasm-inline model helpers Recall-mode review (#767) surfaced a cluster of defects in the SHARED model traversal that this PR newly ships on the wasm surface, plus wasm-specific gaps. Fixed at the right depth — mostly by rewriting the traversal to be non-mutating and unambiguous, so the native entry benefits too. Shared traversal (packages/stack/src/encryption/helpers/model-traversal.ts): The walk used to shallow-copy the model and `delete` matched fields out of the copy, which mutated the caller's nested objects (a nested-column decrypt wrote decrypted PLAINTEXT back into the caller's "encrypted" input), and re-`split` the dotted key to find the delete target (crash / plaintext leak on a literal flat dotted key, and `Object.prototype` reach via `__proto__.x`). Rewritten to build a FRESH passthrough tree that omits operation fields and keeps nulls in place: - caller model is never mutated (encrypt and decrypt); - a literal flat dotted key is normalized to its column path — no crash, no surviving plaintext; - a `__proto__`-shaped key is refused by the setNestedValue guard (no prototype pollution) instead of an unguarded delete-walk; - a dotted null key no longer materializes a phantom nested object (nulls ride in the tree; the rebuild stopped re-applying a separate null map); - an already-encrypted field is passed through, not re-encrypted (was silent double-encryption for a types.Json column); - a non-object / null model element is rejected loudly (was silent char-explosion / {}); - prefix recursion is `.`-boundary correct; - Date/class instances pass through by reference (a plain-object clone would rebuild a Date as {}), preserving the exact behaviour the 108 live native model tests assert. WASM entry (packages/stack/src/wasm-inline.ts): - model ops validate the table against the client's schemas and precompute per-table date paths once at construction (was table.build() per call, and a foreign table silently accepted) — mirrors the native typed client; - a null / empty models batch returns { data: [] } like the native helpers; - Date is normalized to RFC 3339 at EVERY encrypt/query crossing via one shared toWasmFfiPlaintext (was only the model path; a Date to bulkEncrypt crossed the serde as {}), and an invalid Date is rejected per field; - decrypt guards new Date(): an unparseable date value is returned as-is rather than a silent Invalid Date; - docblocks corrected (native-parity overclaims, the fabricated "no { id } envelopes" difference). Tests / docs: - 11 new hardening unit tests (no mutation, literal dotted key, double-encrypt passthrough, non-object element, null argument, unknown table, invalid Date, __proto__ refusal, value-level Date normalization); - the bulk-model e2e now asserts the payloads are actually encrypted (a passthrough no-op would have passed before); - skill: DB-named-row Date caveat + accurate per-field failure labels; - changeset: adds the `stash` patch for the skill change. Deferred: the V3EncryptedModel/V3DecryptedModel type-level lie for dotted-path columns (they type the nested column as passthrough) — a recursive mapped-type change to the shared v3 types affecting the native client too; runtime is correct. Tracked for a follow-up. Full suite: 1045 pass; test:types clean; code:check clean. * fix(stack): address PR review on wasm-inline model helpers Review feedback on #767: - Test: assert undefined-preservation with toStrictEqual, not toEqual — toEqual treats an undefined property as absent, so the createdOn case would pass even if the field were dropped (CodeRabbit). - Changeset + skill: qualify the one-round-trip claim — non-empty batches make one ZeroKMS round trip; null/empty or no-schema-field batches short-circuit and make zero calls (CodeRabbit). - Traversal: store passthrough fields via defineProperty so a field literally named __proto__ is kept verbatim instead of being swallowed by the prototype setter; mirrors the setNestedValue guard. Fidelity only — never reaches global Object.prototype (review note). - Tests: cover encryptModel/bulkEncryptModels against an unregistered table (both call requireTable), and the __proto__ passthrough case (review note). - Document setNestedValue's object-intermediate limitation for array-index paths (review note).
1 parent d6bc9e9 commit d25d100

7 files changed

Lines changed: 1421 additions & 361 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@cipherstash/stack': minor
3+
'stash': patch
4+
---
5+
6+
`@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.
7+
8+
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.

e2e/wasm/roundtrip.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,5 +221,72 @@ Deno.test({
221221
)
222222
// Round-trips at the ORIGINAL indices, with the null hole preserved.
223223
assertEquals(bulkDecrypted.data, [plaintext, null, second])
224+
225+
// 7. (#742) The model helpers — the surface that walks a model against
226+
// its schema, so edge code never hand-rolls the field mapping whose
227+
// failure mode is a column silently persisted in plaintext. Each call
228+
// is one ZeroKMS round trip regardless of field or model count.
229+
const rowA = { id: 'row-a', email: plaintext, note: null }
230+
const modelResult = await client.encryptModel(rowA, users)
231+
assertEquals(
232+
modelResult.failure,
233+
undefined,
234+
`encryptModel() failed: ${modelResult.failure?.message}`,
235+
)
236+
const encryptedRow = modelResult.data
237+
assertEquals(encryptedRow.id, 'row-a', 'passthrough field was altered')
238+
assertEquals(encryptedRow.note, null, 'null field was altered')
239+
assertEquals(
240+
isEncrypted(encryptedRow.email),
241+
true,
242+
'schema field was not encrypted by encryptModel',
243+
)
244+
245+
const modelBack = await client.decryptModel(encryptedRow, users)
246+
assertEquals(
247+
modelBack.failure,
248+
undefined,
249+
`decryptModel() failed: ${modelBack.failure?.message}`,
250+
)
251+
assertEquals(modelBack.data, rowA, 'model round-trip mismatch')
252+
253+
const rowB = { id: 'row-b', email: second, note: 'kept' }
254+
const bulkModels = await client.bulkEncryptModels([rowA, rowB], users)
255+
assertEquals(
256+
bulkModels.failure,
257+
undefined,
258+
`bulkEncryptModels() failed: ${bulkModels.failure?.message}`,
259+
)
260+
assertEquals(bulkModels.data.length, 2, 'bulkEncryptModels misaligned')
261+
// Assert the payloads are actually ENCRYPTED — otherwise a passthrough
262+
// no-op regression (the plaintext-persistence failure this surface exists
263+
// to prevent) would still round-trip and pass every other assertion.
264+
for (const [i, row] of bulkModels.data.entries()) {
265+
assertEquals(
266+
isEncrypted(row.email),
267+
true,
268+
`bulkEncryptModels[${i}].email is not an encrypted payload`,
269+
)
270+
assertEquals(
271+
row.note,
272+
i === 0 ? null : 'kept',
273+
'passthrough field altered',
274+
)
275+
}
276+
277+
const bulkModelsBack = await client.bulkDecryptModels(
278+
bulkModels.data,
279+
users,
280+
)
281+
assertEquals(
282+
bulkModelsBack.failure,
283+
undefined,
284+
`bulkDecryptModels() failed: ${bulkModelsBack.failure?.message}`,
285+
)
286+
assertEquals(
287+
bulkModelsBack.data,
288+
[rowA, rowB],
289+
'bulk model round-trip mismatch',
290+
)
224291
},
225292
})

0 commit comments

Comments
 (0)