orm-blind-indexer 1.0.0
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/LICENSE +21 -0
- package/README.md +636 -0
- package/dist/index.d.mts +364 -0
- package/dist/index.d.ts +364 -0
- package/dist/index.js +510 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +475 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +66 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import * as _prisma_client from '@prisma/client';
|
|
2
|
+
import * as _prisma_client_runtime_library from '@prisma/client/runtime/library';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @file types/index.ts
|
|
6
|
+
* @description Core TypeScript interfaces and types for the orm-blind-indexer package.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Configuration for a single encrypted field within a model.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* // For the "email" field on the User model:
|
|
14
|
+
* {
|
|
15
|
+
* indexFieldName: "email_bidx",
|
|
16
|
+
* caseSensitive: false
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
interface FieldConfig {
|
|
21
|
+
/**
|
|
22
|
+
* The name of the database column that stores the HMAC-SHA256 blind index.
|
|
23
|
+
* This column must exist in your Prisma schema (e.g., `email_bidx String @unique`).
|
|
24
|
+
*/
|
|
25
|
+
indexFieldName: string;
|
|
26
|
+
/**
|
|
27
|
+
* When `false` (the default), the plaintext is lowercased before hashing.
|
|
28
|
+
* This ensures case-insensitive lookups: searching for "Test@Test.com"
|
|
29
|
+
* will match a record where "test@test.com" was stored.
|
|
30
|
+
*
|
|
31
|
+
* When `true`, the plaintext is hashed as-is, so the case must match exactly.
|
|
32
|
+
*
|
|
33
|
+
* @default false
|
|
34
|
+
*/
|
|
35
|
+
caseSensitive?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Optional byte length to truncate the HMAC-SHA256 digest to before encoding.
|
|
38
|
+
* Useful for frequency-analysis defense (k-anonymity / CipherSweet spec).
|
|
39
|
+
* Minimum: 8 bytes, Maximum: 32 bytes.
|
|
40
|
+
* Defaults to 32 (full SHA-256 digest length).
|
|
41
|
+
*
|
|
42
|
+
* @default undefined (full 32 bytes)
|
|
43
|
+
*/
|
|
44
|
+
truncateBytes?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Per-model field configuration map.
|
|
48
|
+
* Maps field names (as they appear in the Prisma schema) to their FieldConfig.
|
|
49
|
+
*/
|
|
50
|
+
type ModelFieldConfig = Record<string, FieldConfig>;
|
|
51
|
+
/**
|
|
52
|
+
* Full field map across all models.
|
|
53
|
+
* Maps Prisma model names (PascalCase, e.g., "User") to their field configurations.
|
|
54
|
+
*/
|
|
55
|
+
type FieldsConfig = Record<string, ModelFieldConfig>;
|
|
56
|
+
/**
|
|
57
|
+
* Root configuration object for the `withBlindIndexer` Prisma extension.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* const config: BlindIndexConfig = {
|
|
62
|
+
* encryptionKey: process.env.ENCRYPTION_KEY!, // 32-byte base64 or hex string
|
|
63
|
+
* indexKey: process.env.INDEX_KEY!, // HMAC salt (any length, min 32 bytes recommended)
|
|
64
|
+
* fields: {
|
|
65
|
+
* User: {
|
|
66
|
+
* email: {
|
|
67
|
+
* indexFieldName: "email_bidx",
|
|
68
|
+
* caseSensitive: false,
|
|
69
|
+
* },
|
|
70
|
+
* },
|
|
71
|
+
* },
|
|
72
|
+
* };
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
interface BlindIndexConfig {
|
|
76
|
+
/**
|
|
77
|
+
* The AES-256-GCM encryption key.
|
|
78
|
+
*
|
|
79
|
+
* Must decode to exactly **32 bytes** (256 bits).
|
|
80
|
+
* Accepted formats:
|
|
81
|
+
* - Base64-encoded 32-byte key (44 characters with padding)
|
|
82
|
+
* - Hex-encoded 32-byte key (64 hex characters)
|
|
83
|
+
*
|
|
84
|
+
* Generate with: `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"`
|
|
85
|
+
*/
|
|
86
|
+
encryptionKey: string;
|
|
87
|
+
/**
|
|
88
|
+
* The HMAC-SHA256 index key (salt).
|
|
89
|
+
*
|
|
90
|
+
* Used as the secret for the HMAC function. Should be a high-entropy
|
|
91
|
+
* random value, **different** from the encryptionKey.
|
|
92
|
+
* Recommended minimum: 32 bytes of entropy.
|
|
93
|
+
*
|
|
94
|
+
* Generate with: `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"`
|
|
95
|
+
*/
|
|
96
|
+
indexKey: string;
|
|
97
|
+
/**
|
|
98
|
+
* Map of model names to their encrypted fields configuration.
|
|
99
|
+
*/
|
|
100
|
+
fields: FieldsConfig;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Represents the parsed components of an AES-256-GCM ciphertext string.
|
|
104
|
+
* The on-disk format is `base64(iv):base64(authTag):base64(ciphertext)`.
|
|
105
|
+
*/
|
|
106
|
+
interface ParsedCiphertext {
|
|
107
|
+
/** 12-byte Initialization Vector, stored as base64. */
|
|
108
|
+
iv: Buffer;
|
|
109
|
+
/** 16-byte GCM Authentication Tag, stored as base64. */
|
|
110
|
+
authTag: Buffer;
|
|
111
|
+
/** The actual encrypted bytes, stored as base64. */
|
|
112
|
+
ciphertext: Buffer;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Result of a successful encryption operation.
|
|
116
|
+
*/
|
|
117
|
+
interface EncryptionResult {
|
|
118
|
+
/**
|
|
119
|
+
* The serialized ciphertext string in the format:
|
|
120
|
+
* `base64(iv):base64(authTag):base64(ciphertext)`
|
|
121
|
+
*/
|
|
122
|
+
encrypted: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Creates a Prisma Client Extension that transparently implements blind indexing
|
|
127
|
+
* for the configured fields.
|
|
128
|
+
*
|
|
129
|
+
* **Usage:**
|
|
130
|
+
* ```ts
|
|
131
|
+
* import { PrismaClient } from '@prisma/client';
|
|
132
|
+
* import { withBlindIndexer } from 'orm-blind-indexer';
|
|
133
|
+
*
|
|
134
|
+
* const prisma = new PrismaClient().$extends(
|
|
135
|
+
* withBlindIndexer({
|
|
136
|
+
* encryptionKey: process.env.ENCRYPTION_KEY!,
|
|
137
|
+
* indexKey: process.env.INDEX_KEY!,
|
|
138
|
+
* fields: {
|
|
139
|
+
* User: {
|
|
140
|
+
* email: {
|
|
141
|
+
* indexFieldName: 'email_bidx',
|
|
142
|
+
* caseSensitive: false,
|
|
143
|
+
* },
|
|
144
|
+
* },
|
|
145
|
+
* },
|
|
146
|
+
* })
|
|
147
|
+
* );
|
|
148
|
+
* ```
|
|
149
|
+
*
|
|
150
|
+
* @param config - The blind indexer configuration object.
|
|
151
|
+
* @returns A Prisma Client Extension object to pass to `.$extends()`.
|
|
152
|
+
* @throws {Error} At initialization time if the keys are invalid.
|
|
153
|
+
*/
|
|
154
|
+
declare function withBlindIndexer(config: BlindIndexConfig): (client: any) => _prisma_client.PrismaClientExtends<_prisma_client_runtime_library.InternalArgs<{}, {}, {}, {}>>;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @file crypto/encryptor.ts
|
|
158
|
+
* @description AES-256-GCM authenticated encryption and decryption.
|
|
159
|
+
*
|
|
160
|
+
* ## Why AES-256-GCM?
|
|
161
|
+
*
|
|
162
|
+
* AES-256-GCM (Galois/Counter Mode) is an authenticated encryption with
|
|
163
|
+
* associated data (AEAD) cipher. It provides:
|
|
164
|
+
*
|
|
165
|
+
* 1. **Confidentiality**: The ciphertext reveals nothing about the plaintext.
|
|
166
|
+
* 2. **Integrity & Authenticity**: The 16-byte authentication tag (Auth Tag)
|
|
167
|
+
* cryptographically proves the ciphertext has not been tampered with.
|
|
168
|
+
* Decryption fails with an error if the tag doesn't verify.
|
|
169
|
+
* 3. **No padding oracle**: GCM is a stream-cipher mode — no PKCS#7 padding
|
|
170
|
+
* means no padding oracle attacks.
|
|
171
|
+
*
|
|
172
|
+
* ## On-disk format
|
|
173
|
+
*
|
|
174
|
+
* We serialize the IV, Auth Tag, and ciphertext as three colon-separated
|
|
175
|
+
* base64 segments: `base64(iv):base64(authTag):base64(ciphertext)`
|
|
176
|
+
*
|
|
177
|
+
* This is a self-contained format: everything needed to decrypt the value
|
|
178
|
+
* lives in the value itself. The key is stored separately (environment variable).
|
|
179
|
+
*
|
|
180
|
+
* ## IV Randomness
|
|
181
|
+
*
|
|
182
|
+
* A fresh 12-byte random IV is generated for **every** encryption call using
|
|
183
|
+
* `crypto.randomBytes()`. This is critical: reusing an IV with the same key
|
|
184
|
+
* in GCM mode completely breaks confidentiality (two-time pad attack).
|
|
185
|
+
* With a 12-byte IV, the probability of a collision is negligible for
|
|
186
|
+
* reasonable database sizes (birthday bound: ~2^48 operations for 2^-32 risk).
|
|
187
|
+
*
|
|
188
|
+
* ## Auth Tag Length
|
|
189
|
+
*
|
|
190
|
+
* We use the full 16-byte (128-bit) authentication tag, which is the
|
|
191
|
+
* maximum and recommended length. Shorter tags reduce forgery resistance.
|
|
192
|
+
*/
|
|
193
|
+
/**
|
|
194
|
+
* Encrypts a UTF-8 plaintext string using AES-256-GCM.
|
|
195
|
+
*
|
|
196
|
+
* @param plaintext - The plaintext string to encrypt (e.g., an email address).
|
|
197
|
+
* @param key - A 32-byte Buffer containing the AES-256 encryption key.
|
|
198
|
+
* @returns A serialized ciphertext string: `base64(iv):base64(authTag):base64(ciphertext)`.
|
|
199
|
+
* @throws {Error} If encryption fails (e.g., invalid key length).
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* ```ts
|
|
203
|
+
* const encrypted = encrypt('user@example.com', encryptionKeyBuffer);
|
|
204
|
+
* // => "ABC123...:XYZ789...:cipher..."
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
declare function encrypt(plaintext: string, key: Buffer): string;
|
|
208
|
+
/**
|
|
209
|
+
* Decrypts a serialized AES-256-GCM ciphertext string back to UTF-8 plaintext.
|
|
210
|
+
*
|
|
211
|
+
* The decryption process automatically verifies the GCM authentication tag.
|
|
212
|
+
* If the ciphertext or key has been tampered with, decryption will throw an error
|
|
213
|
+
* ("Unsupported state or unable to authenticate data") before any plaintext is returned.
|
|
214
|
+
*
|
|
215
|
+
* @param cipherText - The serialized ciphertext string from `encrypt()`.
|
|
216
|
+
* @param key - The same 32-byte Buffer used during encryption.
|
|
217
|
+
* @returns The original UTF-8 plaintext string.
|
|
218
|
+
* @throws {Error} If the ciphertext format is invalid, the key is wrong,
|
|
219
|
+
* or the authentication tag verification fails (indicating tampering).
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```ts
|
|
223
|
+
* const plaintext = decrypt(encryptedValue, encryptionKeyBuffer);
|
|
224
|
+
* // => "user@example.com"
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
declare function decrypt(cipherText: string, key: Buffer): string;
|
|
228
|
+
/**
|
|
229
|
+
* Checks if a string appears to be in the encrypted format produced by `encrypt()`.
|
|
230
|
+
* Used to avoid double-encrypting values that are already encrypted.
|
|
231
|
+
*
|
|
232
|
+
* This is a heuristic check based on format, not cryptographic verification.
|
|
233
|
+
*
|
|
234
|
+
* @param value - The string to check.
|
|
235
|
+
* @returns `true` if the string matches the `base64:base64:base64` pattern.
|
|
236
|
+
*/
|
|
237
|
+
declare function isEncryptedFormat(value: string): boolean;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* @file crypto/hasher.ts
|
|
241
|
+
* @description HMAC-SHA256 blind index generation.
|
|
242
|
+
*
|
|
243
|
+
* ## What is a Blind Index?
|
|
244
|
+
*
|
|
245
|
+
* A blind index solves the problem of querying encrypted data. Because AES-256-GCM
|
|
246
|
+
* uses a random IV on every encryption, the same plaintext produces a different
|
|
247
|
+
* ciphertext each time. This makes exact-match database queries impossible on
|
|
248
|
+
* the encrypted column.
|
|
249
|
+
*
|
|
250
|
+
* A blind index is a **deterministic** hash of the plaintext that:
|
|
251
|
+
* - Always produces the same output for the same input (queryable)
|
|
252
|
+
* - Reveals no information about the plaintext without the key (private)
|
|
253
|
+
* - Is stored in a separate `_bidx` column alongside the encrypted data
|
|
254
|
+
*
|
|
255
|
+
* ## Why HMAC-SHA256 (not plain SHA256)?
|
|
256
|
+
*
|
|
257
|
+
* Plain SHA256 is a one-way function, but it has no secret. An attacker who
|
|
258
|
+
* gains read access to your database could mount a **dictionary attack**:
|
|
259
|
+
* they hash every common email address and look for matches.
|
|
260
|
+
*
|
|
261
|
+
* HMAC-SHA256 incorporates a **secret key** (the `indexKey`). Without knowing
|
|
262
|
+
* the key, an attacker cannot compute the hash, so the blind index leaks no
|
|
263
|
+
* information about the plaintext even if the database is fully compromised.
|
|
264
|
+
*
|
|
265
|
+
* ## Case Sensitivity
|
|
266
|
+
*
|
|
267
|
+
* Email addresses and usernames are often compared case-insensitively.
|
|
268
|
+
* When `caseSensitive: false` (the default), we lowercase the input before
|
|
269
|
+
* hashing. This ensures that "Test@Test.com" and "test@test.com" produce the
|
|
270
|
+
* same blind index, enabling case-insensitive equality searches.
|
|
271
|
+
*
|
|
272
|
+
* **Important**: The case normalization must be applied consistently on both
|
|
273
|
+
* write (when storing) and read (when querying). The extension handles this.
|
|
274
|
+
*/
|
|
275
|
+
/**
|
|
276
|
+
* Generates a deterministic HMAC-SHA256 blind index for a given plaintext.
|
|
277
|
+
*
|
|
278
|
+
* The output is a base64-encoded 32-byte HMAC digest. Base64 is used (rather
|
|
279
|
+
* than hex) for compactness: 44 characters vs 64 characters for the same value.
|
|
280
|
+
*
|
|
281
|
+
* @param text - The plaintext value to hash (e.g., an email address).
|
|
282
|
+
* @param indexKey - A secret Buffer used as the HMAC key. Must be kept private.
|
|
283
|
+
* @param caseSensitive - When `false`, `text` is lowercased before hashing.
|
|
284
|
+
* Defaults to `false` for case-insensitive equality searches.
|
|
285
|
+
* @param truncateBytes - Optional byte length (8 to 32) to truncate the HMAC
|
|
286
|
+
* digest before base64 encoding. Useful for frequency-analysis defense.
|
|
287
|
+
* @returns A base64-encoded HMAC-SHA256 digest string.
|
|
288
|
+
*
|
|
289
|
+
* @example
|
|
290
|
+
* ```ts
|
|
291
|
+
* const indexKey = Buffer.from(process.env.INDEX_KEY!, 'base64');
|
|
292
|
+
*
|
|
293
|
+
* // Case-insensitive (default): both produce the same hash
|
|
294
|
+
* generateBlindIndex('Test@Test.com', indexKey, false);
|
|
295
|
+
* generateBlindIndex('test@test.com', indexKey, false);
|
|
296
|
+
*
|
|
297
|
+
* // Case-sensitive: these produce different hashes
|
|
298
|
+
* generateBlindIndex('Test@Test.com', indexKey, true);
|
|
299
|
+
* generateBlindIndex('test@test.com', indexKey, true);
|
|
300
|
+
*
|
|
301
|
+
* // Truncated blind index (e.g., 16 bytes for frequency defense)
|
|
302
|
+
* generateBlindIndex('user@example.com', indexKey, false, 16);
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
declare function generateBlindIndex(text: string, indexKey: Buffer, caseSensitive?: boolean, truncateBytes?: number): string;
|
|
306
|
+
/**
|
|
307
|
+
* Generates blind indexes for an array of plaintext values.
|
|
308
|
+
* Useful for `{ in: [...] }` and `{ notIn: [...] }` Prisma query operators.
|
|
309
|
+
*
|
|
310
|
+
* @param values - An array of plaintext strings to hash.
|
|
311
|
+
* @param indexKey - The secret HMAC key Buffer.
|
|
312
|
+
* @param caseSensitive - When `false`, each value is lowercased before hashing.
|
|
313
|
+
* @param truncateBytes - Optional byte length (8 to 32) to truncate each digest.
|
|
314
|
+
* @returns An array of base64-encoded HMAC-SHA256 digests, in the same order as `values`.
|
|
315
|
+
*/
|
|
316
|
+
declare function generateBlindIndexBatch(values: string[], indexKey: Buffer, caseSensitive?: boolean, truncateBytes?: number): string[];
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* @file crypto/utils.ts
|
|
320
|
+
* @description Key validation and parsing utilities for cryptographic operations.
|
|
321
|
+
*
|
|
322
|
+
* Security Design Notes:
|
|
323
|
+
* - AES-256 requires a 256-bit (32-byte) key. Any deviation is a hard error.
|
|
324
|
+
* - Keys may be provided as base64 or hex strings for safe environment variable storage.
|
|
325
|
+
* - We validate at extension initialization time, not at query time, to fail fast.
|
|
326
|
+
*/
|
|
327
|
+
/**
|
|
328
|
+
* Parses a key string (base64 or hex) into a Buffer and validates its byte length.
|
|
329
|
+
*
|
|
330
|
+
* The function tries hex first (unambiguous format), then base64.
|
|
331
|
+
*
|
|
332
|
+
* @param keyString - The raw key string from environment variables or config.
|
|
333
|
+
* @param expectedBytes - The required byte length of the decoded key.
|
|
334
|
+
* @param keyName - A descriptive name for the key, used in error messages.
|
|
335
|
+
* @returns A `Buffer` of exactly `expectedBytes` bytes.
|
|
336
|
+
* @throws {Error} If the key cannot be parsed or has an incorrect length.
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```ts
|
|
340
|
+
* const key = parseAndValidateKey(process.env.ENCRYPTION_KEY!, 32, 'encryptionKey');
|
|
341
|
+
* ```
|
|
342
|
+
*/
|
|
343
|
+
declare function parseAndValidateKey(keyString: string, expectedBytes: number, keyName: string): Buffer;
|
|
344
|
+
/**
|
|
345
|
+
* Validates an encryption key for AES-256 (requires exactly 32 bytes / 256 bits).
|
|
346
|
+
*
|
|
347
|
+
* @param keyString - The raw encryption key string from config.
|
|
348
|
+
* @returns A `Buffer` of exactly 32 bytes suitable for AES-256-GCM.
|
|
349
|
+
* @throws {Error} If the key is invalid or not exactly 32 bytes.
|
|
350
|
+
*/
|
|
351
|
+
declare function validateEncryptionKey(keyString: string): Buffer;
|
|
352
|
+
/**
|
|
353
|
+
* Parses the index key (HMAC salt) from a string.
|
|
354
|
+
* Unlike the encryption key, the HMAC key has no strict length requirement in the
|
|
355
|
+
* cryptographic spec (SHA-256 accepts any length), but we enforce a minimum of 16 bytes
|
|
356
|
+
* for security best-practice guidance.
|
|
357
|
+
*
|
|
358
|
+
* @param keyString - The raw index key string from config.
|
|
359
|
+
* @returns A `Buffer` usable as an HMAC key.
|
|
360
|
+
* @throws {Error} If the key cannot be parsed or is shorter than 16 bytes.
|
|
361
|
+
*/
|
|
362
|
+
declare function validateIndexKey(keyString: string): Buffer;
|
|
363
|
+
|
|
364
|
+
export { type BlindIndexConfig, type EncryptionResult, type FieldConfig, type FieldsConfig, type ModelFieldConfig, type ParsedCiphertext, decrypt, encrypt, generateBlindIndex, generateBlindIndexBatch, isEncryptedFormat, parseAndValidateKey, validateEncryptionKey, validateIndexKey, withBlindIndexer };
|