@prisma-next/extension-cipherstash 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -0
- package/dist/call-classes-CSvD7w8U.mjs +206 -0
- package/dist/call-classes-CSvD7w8U.mjs.map +1 -0
- package/dist/column-types.d.mts +33 -0
- package/dist/column-types.d.mts.map +1 -0
- package/dist/column-types.mjs +42 -0
- package/dist/column-types.mjs.map +1 -0
- package/dist/constants-BDxL9Pe3.d.mts +22 -0
- package/dist/constants-BDxL9Pe3.d.mts.map +1 -0
- package/dist/constants-B_2TNvUi.mjs +46 -0
- package/dist/constants-B_2TNvUi.mjs.map +1 -0
- package/dist/control.d.mts +7 -0
- package/dist/control.d.mts.map +1 -0
- package/dist/control.mjs +430 -0
- package/dist/control.mjs.map +1 -0
- package/dist/descriptor-meta-BgQfZTAF.mjs +129 -0
- package/dist/descriptor-meta-BgQfZTAF.mjs.map +1 -0
- package/dist/envelope-P9BxfJNr.mjs +271 -0
- package/dist/envelope-P9BxfJNr.mjs.map +1 -0
- package/dist/middleware.d.mts +13 -0
- package/dist/middleware.d.mts.map +1 -0
- package/dist/middleware.mjs +129 -0
- package/dist/middleware.mjs.map +1 -0
- package/dist/migration.d.mts +141 -0
- package/dist/migration.d.mts.map +1 -0
- package/dist/migration.mjs +2 -0
- package/dist/operation-types.d.mts +49 -0
- package/dist/operation-types.d.mts.map +1 -0
- package/dist/operation-types.mjs +1 -0
- package/dist/pack.d.mts +86 -0
- package/dist/pack.d.mts.map +1 -0
- package/dist/pack.mjs +2 -0
- package/dist/runtime.d.mts +207 -0
- package/dist/runtime.d.mts.map +1 -0
- package/dist/runtime.mjs +429 -0
- package/dist/runtime.mjs.map +1 -0
- package/dist/sdk-D5FTGyzp.d.mts +67 -0
- package/dist/sdk-D5FTGyzp.d.mts.map +1 -0
- package/package.json +69 -0
- package/src/contract/authoring.ts +62 -0
- package/src/contract/contract.d.ts +149 -0
- package/src/contract/contract.json +104 -0
- package/src/contract/contract.prisma +46 -0
- package/src/execution/abort.ts +143 -0
- package/src/execution/codec-runtime.ts +209 -0
- package/src/execution/decrypt-all.ts +217 -0
- package/src/execution/envelope.ts +263 -0
- package/src/execution/operators.ts +211 -0
- package/src/execution/parameterized.ts +71 -0
- package/src/execution/routing.ts +93 -0
- package/src/execution/sdk.ts +68 -0
- package/src/exports/column-types.ts +62 -0
- package/src/exports/contract-space-typing.ts +86 -0
- package/src/exports/control.ts +120 -0
- package/src/exports/middleware.ts +24 -0
- package/src/exports/migration.ts +43 -0
- package/src/exports/operation-types.ts +16 -0
- package/src/exports/pack.ts +13 -0
- package/src/exports/runtime.ts +110 -0
- package/src/extension-metadata/codec-metadata.ts +81 -0
- package/src/extension-metadata/constants.ts +70 -0
- package/src/extension-metadata/descriptor-meta.ts +76 -0
- package/src/middleware/bulk-encrypt.ts +192 -0
- package/src/migration/call-classes.ts +350 -0
- package/src/migration/cipherstash-codec.ts +157 -0
- package/src/migration/eql-bundle.ts +29 -0
- package/src/migration/eql-install.generated.ts +5751 -0
- package/src/types/operation-types.ts +81 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cipherstash storage codec runtime — wraps the `EncryptedString`
|
|
3
|
+
* envelope at the SQL codec boundary.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities are intentionally thin:
|
|
6
|
+
*
|
|
7
|
+
* - `decode(wire, ctx)` constructs a fresh envelope carrying the wire
|
|
8
|
+
* ciphertext + the cell's `(table, column)` from `ctx.column` + the
|
|
9
|
+
* SDK reference captured at codec construction time. The envelope's
|
|
10
|
+
* `decrypt({signal?})` later routes through the captured SDK; callers
|
|
11
|
+
* can also `await decryptAll(rows)` to coalesce decrypts across many
|
|
12
|
+
* envelopes into one bulk SDK call.
|
|
13
|
+
*
|
|
14
|
+
* - `encode(envelope, ctx)` extracts the ciphertext from the envelope's
|
|
15
|
+
* handle. The bulk-encrypt middleware populates the ciphertext slot
|
|
16
|
+
* before the codec runs; an envelope whose ciphertext
|
|
17
|
+
* slot is empty at encode time is a programmer error (the middleware
|
|
18
|
+
* was not registered, or this codec instance was used in a non-
|
|
19
|
+
* cipherstash context).
|
|
20
|
+
*
|
|
21
|
+
* The wire format wraps the SDK's JSON ciphertext payload in the
|
|
22
|
+
* Postgres composite literal `("...escaped JSON...")` because EQL
|
|
23
|
+
* defines `eql_v2_encrypted` as `CREATE TYPE eql_v2_encrypted AS (data
|
|
24
|
+
* jsonb)`, not as a domain over jsonb. The default `pg` driver encodes
|
|
25
|
+
* JS objects as JSON which Postgres then rejects when coercing into the
|
|
26
|
+
* composite. Mirrors the reference Drizzle integration at
|
|
27
|
+
* `reference/cipherstash/.../drizzle/src/pg/index.ts`.
|
|
28
|
+
*
|
|
29
|
+
* The codec captures the SDK at construction time, so multi-tenant
|
|
30
|
+
* deployments construct one extension instance per tenant — each with
|
|
31
|
+
* its own SDK — rather than sharing a module-singleton codec.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { JsonValue } from '@prisma-next/contract/types';
|
|
35
|
+
import { type AnyCodecDescriptor, CodecImpl } from '@prisma-next/framework-components/codec';
|
|
36
|
+
import type { Codec, SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast';
|
|
37
|
+
import { CIPHERSTASH_STRING_CODEC_ID } from '../extension-metadata/constants';
|
|
38
|
+
import { EncryptedString } from './envelope';
|
|
39
|
+
import type { CipherstashSdk } from './sdk';
|
|
40
|
+
|
|
41
|
+
const CIPHERSTASH_STRING_TARGET_TYPE = 'eql_v2_encrypted' as const;
|
|
42
|
+
// Cipherstash columns intentionally declare no codec traits.
|
|
43
|
+
//
|
|
44
|
+
// The framework's `equality` trait gates the built-in `eq` / `neq` /
|
|
45
|
+
// `in` / `notIn` comparison methods (see `COMPARISON_METHODS_META` in
|
|
46
|
+
// `packages/3-extensions/sql-orm-client/src/types.ts`). Those built-
|
|
47
|
+
// ins lower to standard SQL `=` / `!=` / `IN`, which is wrong for
|
|
48
|
+
// cipherstash columns because EQL ciphers contain randomized nonces
|
|
49
|
+
// and do not byte-equal under `=`. Declaring `equality` here would
|
|
50
|
+
// silently expose the wrong-SQL footgun; declaring `[]` makes
|
|
51
|
+
// `email.eq(...)` undefined at the column accessor and forces callers
|
|
52
|
+
// onto the cipherstash-namespaced operator surface
|
|
53
|
+
// (`email.cipherstashEq(...)` — see `./operators.ts`). The trait
|
|
54
|
+
// declaration is regression-pinned by `test/equality-trait-removal.test.ts`.
|
|
55
|
+
//
|
|
56
|
+
// The user-visible `EncryptedString({ equality: true })` flag in PSL
|
|
57
|
+
// / TS authoring is unrelated to this codec trait — it controls
|
|
58
|
+
// whether the codec lifecycle hook contributes a per-column search-
|
|
59
|
+
// config migration op for the column's `unique` index. The two
|
|
60
|
+
// `equality` concepts share only their name.
|
|
61
|
+
const CIPHERSTASH_STRING_TRAITS = [] as const;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Encode the SDK ciphertext payload as a Postgres composite literal
|
|
65
|
+
* `("...escaped JSON...")`. Embedded `"` are doubled per the composite
|
|
66
|
+
* text-format escape rules.
|
|
67
|
+
*/
|
|
68
|
+
function encodeEqlV2EncryptedWire(payload: unknown): string {
|
|
69
|
+
const json = JSON.stringify(payload);
|
|
70
|
+
if (json === undefined) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
'cipherstash codec: ciphertext payload is not JSON-serializable. ' +
|
|
73
|
+
'The CipherStash SDK must return a JSON-encodable bulk-encrypt result.',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const escaped = json.replaceAll('"', '""');
|
|
77
|
+
return `("${escaped}")`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Inverse of {@link encodeEqlV2EncryptedWire}. Postgres returns
|
|
82
|
+
* `eql_v2_encrypted` cells in composite text format; some pg clients
|
|
83
|
+
* pre-parse composite cells into `{ data: ... }` row objects. Both
|
|
84
|
+
* shapes — and `null`/`undefined` passthrough — are accepted.
|
|
85
|
+
*/
|
|
86
|
+
function decodeEqlV2EncryptedWire(wire: unknown): unknown {
|
|
87
|
+
if (wire === null || wire === undefined) return wire;
|
|
88
|
+
if (typeof wire === 'object') {
|
|
89
|
+
if ('data' in wire) {
|
|
90
|
+
return (wire as { data: unknown }).data;
|
|
91
|
+
}
|
|
92
|
+
return wire;
|
|
93
|
+
}
|
|
94
|
+
if (typeof wire !== 'string') {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`cipherstash codec: unexpected wire shape for eql_v2_encrypted: ${typeof wire}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const trimmed = wire.trim();
|
|
100
|
+
if (!trimmed.startsWith('(') || !trimmed.endsWith(')')) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`cipherstash codec: expected composite literal "(...)" but got: ${trimmed.slice(0, 40)}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const inner = trimmed.slice(1, -1);
|
|
106
|
+
const unquoted =
|
|
107
|
+
inner.startsWith('"') && inner.endsWith('"') ? inner.slice(1, -1).replaceAll('""', '"') : inner;
|
|
108
|
+
return JSON.parse(unquoted);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class CipherstashStringCodec
|
|
112
|
+
extends CodecImpl<
|
|
113
|
+
typeof CIPHERSTASH_STRING_CODEC_ID,
|
|
114
|
+
typeof CIPHERSTASH_STRING_TRAITS,
|
|
115
|
+
unknown,
|
|
116
|
+
EncryptedString
|
|
117
|
+
>
|
|
118
|
+
implements
|
|
119
|
+
Codec<
|
|
120
|
+
typeof CIPHERSTASH_STRING_CODEC_ID,
|
|
121
|
+
typeof CIPHERSTASH_STRING_TRAITS,
|
|
122
|
+
unknown,
|
|
123
|
+
EncryptedString
|
|
124
|
+
>
|
|
125
|
+
{
|
|
126
|
+
readonly sdk: CipherstashSdk | undefined;
|
|
127
|
+
|
|
128
|
+
constructor(descriptor: AnyCodecDescriptor, sdk: CipherstashSdk | undefined) {
|
|
129
|
+
super(descriptor);
|
|
130
|
+
this.sdk = sdk;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async encode(value: EncryptedString, _ctx: SqlCodecCallContext): Promise<unknown> {
|
|
134
|
+
const handle = value.expose();
|
|
135
|
+
if (handle.ciphertext === undefined) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
'cipherstash codec: envelope has no ciphertext at encode time. ' +
|
|
138
|
+
'Register the bulk-encrypt middleware in the runtime so envelopes are encrypted before encoding.',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return encodeEqlV2EncryptedWire(handle.ciphertext);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async decode(wire: unknown, ctx: SqlCodecCallContext): Promise<EncryptedString> {
|
|
145
|
+
if (this.sdk === undefined) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
'cipherstash codec: decode called on the metadata-only codec instance. ' +
|
|
148
|
+
'Construct a runtime descriptor via `createCipherstashRuntimeDescriptor({ sdk })` and use that instead.',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const column = ctx.column;
|
|
152
|
+
if (!column) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
'cipherstash codec: decode requires ctx.column to construct a routing-aware envelope. ' +
|
|
155
|
+
'The SQL runtime populates ctx.column for projected columns; aggregate/computed cells are not supported by this codec.',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return EncryptedString.fromInternal({
|
|
159
|
+
ciphertext: decodeEqlV2EncryptedWire(wire),
|
|
160
|
+
table: column.table,
|
|
161
|
+
column: column.name,
|
|
162
|
+
sdk: this.sdk,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
encodeJson(_value: EncryptedString): JsonValue {
|
|
167
|
+
return { $encryptedString: '<opaque>' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
decodeJson(_json: JsonValue): EncryptedString {
|
|
171
|
+
throw new Error(
|
|
172
|
+
'cipherstash codec: decodeJson is not supported; envelopes do not round-trip through JSON.',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Variance-erased descriptor placeholder used by `createCipherstashStringCodec`
|
|
179
|
+
* so legacy callers that need a bare `Codec` instance (e.g. extension control-
|
|
180
|
+
* plane wiring that built up a flat list of codecs) can keep constructing one
|
|
181
|
+
* directly. Production runtime descriptors should resolve the per-instance
|
|
182
|
+
* codec through `CipherstashStringDescriptor.factory(params)(ctx)`.
|
|
183
|
+
*/
|
|
184
|
+
const FALLBACK_DESCRIPTOR: AnyCodecDescriptor = {
|
|
185
|
+
codecId: CIPHERSTASH_STRING_CODEC_ID,
|
|
186
|
+
traits: CIPHERSTASH_STRING_TRAITS,
|
|
187
|
+
targetTypes: [CIPHERSTASH_STRING_TARGET_TYPE],
|
|
188
|
+
meta: {
|
|
189
|
+
db: { sql: { postgres: { nativeType: CIPHERSTASH_STRING_TARGET_TYPE } } },
|
|
190
|
+
},
|
|
191
|
+
paramsSchema: {
|
|
192
|
+
'~standard': {
|
|
193
|
+
version: 1,
|
|
194
|
+
vendor: 'cipherstash',
|
|
195
|
+
validate: (value: unknown) => ({ value }),
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
isParameterized: false,
|
|
199
|
+
renderOutputType: () => 'EncryptedString',
|
|
200
|
+
factory: () => () => {
|
|
201
|
+
throw new Error('cipherstash codec: fallback descriptor factory is not callable');
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export function createCipherstashStringCodec(sdk: CipherstashSdk): CipherstashStringCodec {
|
|
206
|
+
return new CipherstashStringCodec(FALLBACK_DESCRIPTOR, sdk);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export { CIPHERSTASH_STRING_CODEC_ID };
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `decryptAll` — read-side bulk-decrypt walker.
|
|
3
|
+
*
|
|
4
|
+
* Public utility users invoke after `findMany` (or any other read
|
|
5
|
+
* surface) to materialize the plaintext for every `EncryptedString`
|
|
6
|
+
* envelope reachable from the result set in a fixed number of bulk SDK
|
|
7
|
+
* round-trips:
|
|
8
|
+
*
|
|
9
|
+
* const rows = await db.select(...).from(User).execute();
|
|
10
|
+
* await decryptAll(rows);
|
|
11
|
+
* // every envelope's `decrypt()` now returns plaintext synchronously.
|
|
12
|
+
*
|
|
13
|
+
* Why a separate utility (rather than middleware that auto-decrypts on
|
|
14
|
+
* every read): the framework`s streaming-read path cannot bulk-amortize
|
|
15
|
+
* decryption across rows it`s yielding incrementally — by the time row
|
|
16
|
+
* N is yielded, rows 1..N-1 have already been delivered to the caller.
|
|
17
|
+
* The `decryptAll` shape lets the caller buffer the result set
|
|
18
|
+
* explicitly (with `await stream.toArray()`) and then opt into bulk
|
|
19
|
+
* decryption in one round-trip per `(table, column)` group. The runtime
|
|
20
|
+
* descriptor wrapper deliberately does NOT register an implicit-decrypt
|
|
21
|
+
* middleware for this reason.
|
|
22
|
+
*
|
|
23
|
+
* **Walker shape**.
|
|
24
|
+
*
|
|
25
|
+
* - Recursive on plain objects + plain arrays only. Date / Map / Set /
|
|
26
|
+
* typed arrays / Buffer / function / etc. are not recursed into:
|
|
27
|
+
* cipherstash envelopes are user data and would not normally embed
|
|
28
|
+
* inside these host containers; if a future caller needs to bulk-
|
|
29
|
+
* decrypt envelopes inside such a container they extract them into a
|
|
30
|
+
* plain row first. The narrow scope keeps the walker`s behavior
|
|
31
|
+
* trivially predictable and avoids the cycle / iterator / lazy-eval
|
|
32
|
+
* surface those exotic types bring.
|
|
33
|
+
* - Cycle-safe via a `WeakSet` of visited objects/arrays; the same
|
|
34
|
+
* envelope appearing in N positions is collected once.
|
|
35
|
+
* - Skips envelopes whose plaintext slot is already populated
|
|
36
|
+
* (write-side envelopes from `EncryptedString.from(plaintext)`, or
|
|
37
|
+
* read-side envelopes already materialized by a prior
|
|
38
|
+
* `decrypt()` / `decryptAll(...)`). The skip means a re-run is a
|
|
39
|
+
* no-op and a mixed write/read row tree only round-trips for the
|
|
40
|
+
* envelopes that need it.
|
|
41
|
+
*
|
|
42
|
+
* **Grouping**. Envelopes are grouped by `(sdk, table, column)` —
|
|
43
|
+
* routing key plus the envelope handle`s SDK reference. The SDK split
|
|
44
|
+
* preserves the per-tenant SDK isolation `runtime.ts`'s docblock spells
|
|
45
|
+
* out: each tenant constructs its own runtime descriptor with its own
|
|
46
|
+
* SDK so per-tenant key material never crosses runtimes. Envelopes from
|
|
47
|
+
* different tenants happening to share `(table, column)` therefore
|
|
48
|
+
* still receive separate `bulkDecrypt` calls.
|
|
49
|
+
*
|
|
50
|
+
* **Cancellation**. `opts.signal` is forwarded by identity to every
|
|
51
|
+
* `bulkDecrypt` call via `ifDefined` — the same shape the bulk-encrypt
|
|
52
|
+
* middleware and `EncryptedString.decrypt({ signal? })` use. The
|
|
53
|
+
* walker also races each SDK promise against `opts.signal` via
|
|
54
|
+
* `raceCipherstashAbort` so an abort surfaces `RUNTIME.ABORTED
|
|
55
|
+
* { phase: 'decrypt-all' }` promptly even when the SDK body itself
|
|
56
|
+
* ignores the signal. A pre-check before the first SDK round-trip
|
|
57
|
+
* short-circuits when the signal is already aborted at entry; the
|
|
58
|
+
* no-envelopes-reachable fast path returns immediately without
|
|
59
|
+
* observing the signal.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { ifDefined } from '@prisma-next/utils/defined';
|
|
63
|
+
import { checkCipherstashAborted, raceCipherstashAbort } from './abort';
|
|
64
|
+
import { EncryptedString, isHandleDecrypted, setHandlePlaintextCache } from './envelope';
|
|
65
|
+
import type { CipherstashRoutingKey, CipherstashSdk } from './sdk';
|
|
66
|
+
|
|
67
|
+
export interface DecryptAllOptions {
|
|
68
|
+
readonly signal?: AbortSignal;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface BulkDecryptTarget {
|
|
72
|
+
readonly envelope: EncryptedString;
|
|
73
|
+
readonly ciphertext: unknown;
|
|
74
|
+
readonly sdk: CipherstashSdk;
|
|
75
|
+
readonly routingKey: CipherstashRoutingKey;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Walk a result set and bulk-decrypt every `EncryptedString` envelope
|
|
80
|
+
* reachable from it. After the returned promise resolves, every touched
|
|
81
|
+
* envelope's `decrypt()` returns the cached plaintext synchronously
|
|
82
|
+
* without consulting the SDK.
|
|
83
|
+
*
|
|
84
|
+
* The walker is a no-op when no envelopes are reachable (returns
|
|
85
|
+
* without making any SDK call), so it is cheap to call defensively
|
|
86
|
+
* after queries that may or may not contain encrypted columns.
|
|
87
|
+
*/
|
|
88
|
+
export async function decryptAll(rows: unknown, opts?: DecryptAllOptions): Promise<void> {
|
|
89
|
+
const targets = collectTargets(rows);
|
|
90
|
+
if (targets.length === 0) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const groups = groupTargets(targets);
|
|
94
|
+
for (const group of groups.values()) {
|
|
95
|
+
const first = group[0];
|
|
96
|
+
if (!first) continue;
|
|
97
|
+
const ciphertexts = group.map((t) => t.ciphertext);
|
|
98
|
+
checkCipherstashAborted(opts?.signal, 'decrypt-all');
|
|
99
|
+
const plaintexts = await raceCipherstashAbort(
|
|
100
|
+
first.sdk.bulkDecrypt({
|
|
101
|
+
routingKey: first.routingKey,
|
|
102
|
+
ciphertexts,
|
|
103
|
+
...ifDefined('signal', opts?.signal),
|
|
104
|
+
}),
|
|
105
|
+
opts?.signal,
|
|
106
|
+
'decrypt-all',
|
|
107
|
+
);
|
|
108
|
+
if (plaintexts.length !== group.length) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`cipherstash decryptAll: SDK returned ${plaintexts.length} plaintexts ` +
|
|
111
|
+
`for routing key (${first.routingKey.table}, ${first.routingKey.column}) ` +
|
|
112
|
+
`but ${group.length} were requested.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
for (let i = 0; i < group.length; i++) {
|
|
116
|
+
const target = group[i];
|
|
117
|
+
const plaintext = plaintexts[i];
|
|
118
|
+
if (!target || plaintext === undefined) continue;
|
|
119
|
+
setHandlePlaintextCache(target.envelope, plaintext);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function collectTargets(root: unknown): BulkDecryptTarget[] {
|
|
125
|
+
const targets: BulkDecryptTarget[] = [];
|
|
126
|
+
const seenObjects = new WeakSet<object>();
|
|
127
|
+
const seenEnvelopes = new WeakSet<EncryptedString>();
|
|
128
|
+
visit(root, seenObjects, (envelope) => {
|
|
129
|
+
if (seenEnvelopes.has(envelope)) return;
|
|
130
|
+
seenEnvelopes.add(envelope);
|
|
131
|
+
if (isHandleDecrypted(envelope)) return;
|
|
132
|
+
const handle = envelope.expose();
|
|
133
|
+
if (handle.table === undefined || handle.column === undefined) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
'cipherstash decryptAll: envelope is missing (table, column) routing context. ' +
|
|
136
|
+
'Read-side envelopes constructed via codec.decode always carry routing context; ' +
|
|
137
|
+
'this typically means the envelope was constructed manually outside the codec path.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (handle.sdk === undefined) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
'cipherstash decryptAll: envelope is missing the SDK reference needed to decrypt. ' +
|
|
143
|
+
'Read-side envelopes constructed via codec.decode always carry an SDK reference; ' +
|
|
144
|
+
'this typically means the envelope was constructed manually outside the codec path.',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
targets.push({
|
|
148
|
+
envelope,
|
|
149
|
+
ciphertext: handle.ciphertext,
|
|
150
|
+
sdk: handle.sdk,
|
|
151
|
+
routingKey: { table: handle.table, column: handle.column },
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
return targets;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function visit(
|
|
158
|
+
value: unknown,
|
|
159
|
+
seen: WeakSet<object>,
|
|
160
|
+
found: (envelope: EncryptedString) => void,
|
|
161
|
+
): void {
|
|
162
|
+
if (value === null || value === undefined) return;
|
|
163
|
+
if (value instanceof EncryptedString) {
|
|
164
|
+
found(value);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (typeof value !== 'object') return;
|
|
168
|
+
if (seen.has(value)) return;
|
|
169
|
+
// Walker is intentionally scoped to plain arrays + plain objects.
|
|
170
|
+
// Date / Map / Set / typed arrays / Buffer / Error / class instances
|
|
171
|
+
// are passed over so the walker`s shape stays trivially predictable
|
|
172
|
+
// and immune to host-object iterator surprises.
|
|
173
|
+
if (Array.isArray(value)) {
|
|
174
|
+
seen.add(value);
|
|
175
|
+
for (const item of value) {
|
|
176
|
+
visit(item, seen, found);
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!isPlainObject(value)) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
seen.add(value);
|
|
184
|
+
for (const key of Object.keys(value)) {
|
|
185
|
+
visit((value as Record<string, unknown>)[key], seen, found);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isPlainObject(value: object): boolean {
|
|
190
|
+
const proto = Object.getPrototypeOf(value);
|
|
191
|
+
return proto === null || proto === Object.prototype;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function groupTargets(targets: ReadonlyArray<BulkDecryptTarget>): Map<string, BulkDecryptTarget[]> {
|
|
195
|
+
// Group by `(sdk identity, table, column)`. The SDK identity portion
|
|
196
|
+
// of the key uses a per-SDK index issued on first encounter so
|
|
197
|
+
// grouping never depends on object reference equality colliding
|
|
198
|
+
// accidentally (different SDK instances always partition into
|
|
199
|
+
// different groups even if their `(table, column)` matches).
|
|
200
|
+
const sdkIndex = new Map<CipherstashSdk, number>();
|
|
201
|
+
const groups = new Map<string, BulkDecryptTarget[]>();
|
|
202
|
+
for (const target of targets) {
|
|
203
|
+
let idx = sdkIndex.get(target.sdk);
|
|
204
|
+
if (idx === undefined) {
|
|
205
|
+
idx = sdkIndex.size;
|
|
206
|
+
sdkIndex.set(target.sdk, idx);
|
|
207
|
+
}
|
|
208
|
+
const id = `${idx}\u0000${target.routingKey.table}\u0000${target.routingKey.column}`;
|
|
209
|
+
let group = groups.get(id);
|
|
210
|
+
if (!group) {
|
|
211
|
+
group = [];
|
|
212
|
+
groups.set(id, group);
|
|
213
|
+
}
|
|
214
|
+
group.push(target);
|
|
215
|
+
}
|
|
216
|
+
return groups;
|
|
217
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `EncryptedString` envelope and its package-internal handle helpers.
|
|
3
|
+
*
|
|
4
|
+
* The envelope is the user-facing input/output type for cipherstash-
|
|
5
|
+
* backed columns. It wraps an `EncryptedStringHandle` (plaintext slot,
|
|
6
|
+
* ciphertext slot, routing key, SDK reference) holding the per-cell
|
|
7
|
+
* lifecycle state.
|
|
8
|
+
*
|
|
9
|
+
* ## Encapsulation pattern (Rust `secrecy` style)
|
|
10
|
+
*
|
|
11
|
+
* Storage is a `#private` instance field. The blessed read path is the
|
|
12
|
+
* explicit `expose()` method — same shape as the Rust `secrecy` crate's
|
|
13
|
+
* `SecretBox<T>::expose_secret`. Calling `expose()` is a deliberate
|
|
14
|
+
* opt-in: the caller is announcing "I want the wrapped state". The
|
|
15
|
+
* envelope does not — and is not meant to — make that *impossible*; it
|
|
16
|
+
* is meant to make accidental exposure (logger output, error envelopes,
|
|
17
|
+
* stringification, JSON serialization, primitive coercion) impossible
|
|
18
|
+
* unless the caller goes through `expose()`.
|
|
19
|
+
*
|
|
20
|
+
* Concretely the class overrides every coercion / serialization vector
|
|
21
|
+
* that would otherwise reveal the handle:
|
|
22
|
+
*
|
|
23
|
+
* - `toJSON()` — `JSON.stringify`
|
|
24
|
+
* - `toString()` — `String(envelope)`
|
|
25
|
+
* - `valueOf()` — legacy primitive coercion
|
|
26
|
+
* - `[Symbol.toPrimitive]()` — template literals, `+`
|
|
27
|
+
* - `[Symbol.for('nodejs.util.inspect.custom')]()` — `console.log`,
|
|
28
|
+
* Node REPL, debuggers
|
|
29
|
+
*
|
|
30
|
+
* All five return the same `[REDACTED]` placeholder. Without these
|
|
31
|
+
* overrides, modern Node runtimes surface `#private` fields in
|
|
32
|
+
* `util.inspect` output by default, which would silently re-expose
|
|
33
|
+
* the handle through `console.log(envelope)` (and any error message
|
|
34
|
+
* that interpolates an envelope).
|
|
35
|
+
*
|
|
36
|
+
* ## Lifecycle
|
|
37
|
+
*
|
|
38
|
+
* The handle has two flavours:
|
|
39
|
+
* - **Write side** — `EncryptedString.from(plaintext)` populates the
|
|
40
|
+
* `plaintext` slot and leaves `ciphertext` empty. The bulk-encrypt
|
|
41
|
+
* middleware populates `ciphertext` post-SDK and intentionally
|
|
42
|
+
* leaves the plaintext slot in place (zeroing JS strings is
|
|
43
|
+
* best-effort and GC-driven lifecycle is sufficient here). As a
|
|
44
|
+
* side effect a write-side envelope's `decrypt()` returns the
|
|
45
|
+
* original plaintext synchronously without an SDK round-trip.
|
|
46
|
+
* - **Read side** — `EncryptedString.fromInternal({...})` (called from
|
|
47
|
+
* the codec `decode` body) populates `ciphertext`, `(table, column)`
|
|
48
|
+
* from `SqlCodecCallContext.column`, and an `sdk` reference so
|
|
49
|
+
* `decrypt({signal?})` can issue the SDK's single-cell decrypt.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { ifDefined } from '@prisma-next/utils/defined';
|
|
53
|
+
import { checkCipherstashAborted, raceCipherstashAbort } from './abort';
|
|
54
|
+
import type { CipherstashSdk } from './sdk';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The mutable state of an `EncryptedString` — exposed by `expose()` for
|
|
58
|
+
* callers that explicitly opt in. Mutating these slots from outside the
|
|
59
|
+
* package is supported (we don't stop you) but unusual; the framework's
|
|
60
|
+
* own lifecycle mutators (`setHandleCiphertext`, `setHandleRoutingKey`,
|
|
61
|
+
* etc.) are the conventional path.
|
|
62
|
+
*/
|
|
63
|
+
export interface EncryptedStringHandle {
|
|
64
|
+
plaintext: string | undefined;
|
|
65
|
+
ciphertext: unknown;
|
|
66
|
+
table: string | undefined;
|
|
67
|
+
column: string | undefined;
|
|
68
|
+
sdk: CipherstashSdk | undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const REDACTED = '[REDACTED]';
|
|
72
|
+
|
|
73
|
+
export interface EncryptedStringFromInternalArgs {
|
|
74
|
+
readonly ciphertext: unknown;
|
|
75
|
+
readonly table: string;
|
|
76
|
+
readonly column: string;
|
|
77
|
+
readonly sdk: CipherstashSdk;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class EncryptedString {
|
|
81
|
+
readonly #handle: EncryptedStringHandle;
|
|
82
|
+
|
|
83
|
+
private constructor(handle: EncryptedStringHandle) {
|
|
84
|
+
this.#handle = handle;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Construct a write-side envelope from plaintext. Bulk-encrypt
|
|
89
|
+
* middleware populates the handle's ciphertext slot before the codec
|
|
90
|
+
* encodes the envelope to wire format.
|
|
91
|
+
*/
|
|
92
|
+
static from(plaintext: string): EncryptedString {
|
|
93
|
+
return new EncryptedString({
|
|
94
|
+
plaintext,
|
|
95
|
+
ciphertext: undefined,
|
|
96
|
+
table: undefined,
|
|
97
|
+
column: undefined,
|
|
98
|
+
sdk: undefined,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Construct a read-side envelope from a wire ciphertext + the column
|
|
104
|
+
* identity + the SDK used to decrypt the cell. Called from the codec
|
|
105
|
+
* `decode` body.
|
|
106
|
+
*/
|
|
107
|
+
static fromInternal(args: EncryptedStringFromInternalArgs): EncryptedString {
|
|
108
|
+
return new EncryptedString({
|
|
109
|
+
plaintext: undefined,
|
|
110
|
+
ciphertext: args.ciphertext,
|
|
111
|
+
table: args.table,
|
|
112
|
+
column: args.column,
|
|
113
|
+
sdk: args.sdk,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Explicitly retrieve the wrapped handle. Modelled on Rust `secrecy`'s
|
|
119
|
+
* `SecretBox<T>::expose_secret`: the handle is reachable, but you have
|
|
120
|
+
* to ask for it by name. Callers reach for `expose()` when they need
|
|
121
|
+
* to inspect or transport the ciphertext envelope, debug lifecycle
|
|
122
|
+
* state, or wire ad-hoc tooling around the SDK reference.
|
|
123
|
+
*
|
|
124
|
+
* Mutating the returned handle is supported but unusual — the
|
|
125
|
+
* framework's lifecycle mutators (`setHandleCiphertext`,
|
|
126
|
+
* `setHandleRoutingKey`, etc.) are the conventional path during
|
|
127
|
+
* encrypt / decrypt flow.
|
|
128
|
+
*/
|
|
129
|
+
expose(): EncryptedStringHandle {
|
|
130
|
+
return this.#handle;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Decrypt and return the plaintext.
|
|
135
|
+
*
|
|
136
|
+
* - If the handle's `plaintext` slot is already populated (write-side
|
|
137
|
+
* envelopes from `from(plaintext)`, or read-side envelopes already
|
|
138
|
+
* materialized by `decryptAll(...)` or a prior `decrypt()`), returns
|
|
139
|
+
* the cached plaintext synchronously without consulting the SDK.
|
|
140
|
+
* - Otherwise (read-side handle without a cached plaintext), invokes
|
|
141
|
+
* the SDK's single-cell `decrypt` with the handle's routing context.
|
|
142
|
+
* The caller-supplied `signal` is forwarded to the SDK by identity
|
|
143
|
+
* per the umbrella cancellation contract; the SDK promise is also
|
|
144
|
+
* raced against the signal so an abort surfaces a `RUNTIME.ABORTED
|
|
145
|
+
* { phase: 'decrypt' }` envelope promptly even if the SDK body
|
|
146
|
+
* ignores the signal. The cached-plaintext fast path returns
|
|
147
|
+
* synchronously without consulting the signal — no IO, no abort
|
|
148
|
+
* observation point.
|
|
149
|
+
*/
|
|
150
|
+
async decrypt(opts?: { signal?: AbortSignal }): Promise<string> {
|
|
151
|
+
if (this.#handle.plaintext !== undefined) {
|
|
152
|
+
return this.#handle.plaintext;
|
|
153
|
+
}
|
|
154
|
+
if (
|
|
155
|
+
!this.#handle.sdk ||
|
|
156
|
+
this.#handle.table === undefined ||
|
|
157
|
+
this.#handle.column === undefined
|
|
158
|
+
) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
'EncryptedString.decrypt(): envelope has no cached plaintext and no SDK binding. ' +
|
|
161
|
+
'This typically means the bulk-encrypt middleware did not run before the encode site.',
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
checkCipherstashAborted(opts?.signal, 'decrypt');
|
|
165
|
+
const plaintext = await raceCipherstashAbort(
|
|
166
|
+
this.#handle.sdk.decrypt({
|
|
167
|
+
ciphertext: this.#handle.ciphertext,
|
|
168
|
+
table: this.#handle.table,
|
|
169
|
+
column: this.#handle.column,
|
|
170
|
+
...ifDefined('signal', opts?.signal),
|
|
171
|
+
}),
|
|
172
|
+
opts?.signal,
|
|
173
|
+
'decrypt',
|
|
174
|
+
);
|
|
175
|
+
this.#handle.plaintext = plaintext;
|
|
176
|
+
return plaintext;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
toJSON(): string {
|
|
180
|
+
return REDACTED;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
toString(): string {
|
|
184
|
+
return REDACTED;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
valueOf(): string {
|
|
188
|
+
return REDACTED;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
[Symbol.toPrimitive](): string {
|
|
192
|
+
return REDACTED;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
[Symbol.for('nodejs.util.inspect.custom')](): string {
|
|
196
|
+
return REDACTED;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Populate the handle's ciphertext slot. Called by the bulk-encrypt
|
|
202
|
+
* middleware after the SDK returns the encrypted batch.
|
|
203
|
+
*
|
|
204
|
+
* The plaintext slot is intentionally retained — zeroing in JS is
|
|
205
|
+
* best-effort (strings are immutable) and the GC-driven lifecycle is
|
|
206
|
+
* sufficient.
|
|
207
|
+
*/
|
|
208
|
+
export function setHandleCiphertext(envelope: EncryptedString, ciphertext: unknown): void {
|
|
209
|
+
envelope.expose().ciphertext = ciphertext;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Populate the handle's plaintext slot with a freshly-decrypted value
|
|
214
|
+
* (read-side caching path used by `decryptAll` and by `decrypt()`'s own
|
|
215
|
+
* memoization).
|
|
216
|
+
*/
|
|
217
|
+
export function setHandlePlaintextCache(envelope: EncryptedString, plaintext: string): void {
|
|
218
|
+
envelope.expose().plaintext = plaintext;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Stamp the encrypt-side `(table, column)` routing context onto a
|
|
223
|
+
* write-side envelope's handle. Called by the bulk-encrypt middleware
|
|
224
|
+
* before grouping envelopes into per-routing-key bulk-encrypt batches.
|
|
225
|
+
*
|
|
226
|
+
* Idempotent for matching reassignments (re-stamping the same
|
|
227
|
+
* `(table, column)` is a no-op, which covers envelopes reconstructed
|
|
228
|
+
* via `fromInternal` on the read side and re-stamped on the way back
|
|
229
|
+
* in). Conflicting reassignments throw a descriptive error: an
|
|
230
|
+
* envelope reused across plans with a different routing context is a
|
|
231
|
+
* programming error — silently keeping the stale binding would lower
|
|
232
|
+
* to the wrong bulk-encrypt batch.
|
|
233
|
+
*/
|
|
234
|
+
export function setHandleRoutingKey(
|
|
235
|
+
envelope: EncryptedString,
|
|
236
|
+
table: string,
|
|
237
|
+
column: string,
|
|
238
|
+
): void {
|
|
239
|
+
const handle = envelope.expose();
|
|
240
|
+
if (handle.table === undefined) {
|
|
241
|
+
handle.table = table;
|
|
242
|
+
} else if (handle.table !== table) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`cipherstash envelope: routing-key table conflict — handle already bound to "${handle.table}", refusing to rebind to "${table}". Re-encode the value or construct a fresh envelope for the new routing target.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (handle.column === undefined) {
|
|
248
|
+
handle.column = column;
|
|
249
|
+
} else if (handle.column !== column) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`cipherstash envelope: routing-key column conflict on table "${handle.table}" — handle already bound to "${handle.column}", refusing to rebind to "${column}". Re-encode the value or construct a fresh envelope for the new routing target.`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* `true` when the handle already carries a usable plaintext (write-side
|
|
258
|
+
* construction or post-`decrypt` caching). Used by `decryptAll` to skip
|
|
259
|
+
* envelopes that don't need a round-trip.
|
|
260
|
+
*/
|
|
261
|
+
export function isHandleDecrypted(envelope: EncryptedString): boolean {
|
|
262
|
+
return envelope.expose().plaintext !== undefined;
|
|
263
|
+
}
|