@lazyapps/encryption 0.0.0-init.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/README.md ADDED
@@ -0,0 +1,394 @@
1
+ # @lazyapps/encryption
2
+
3
+ Field-level encryption for event-sourced applications. Encrypts sensitive
4
+ fields in events before storage and decrypts them on read, with envelope
5
+ encryption (DEK/KEK separation), pluggable key stores, and built-in support
6
+ for crypto-shredding (GDPR "right to be forgotten").
7
+
8
+ ## Quick Start
9
+
10
+ ```javascript
11
+ import {
12
+ createEncryption,
13
+ defineEncryptionSchema,
14
+ inMemoryKeyStore,
15
+ } from '@lazyapps/encryption';
16
+ import { start } from '@lazyapps/bootstrap';
17
+
18
+ const schema = defineEncryptionSchema({
19
+ CUSTOMER_CREATED: {
20
+ 'payload.name': { context: 'personal', subjectField: 'aggregateId' },
21
+ 'payload.email': { context: 'personal', subjectField: 'aggregateId' },
22
+ },
23
+ });
24
+
25
+ const encryption = createEncryption({
26
+ schema,
27
+ keyStore: inMemoryKeyStore({
28
+ personal: crypto.randomBytes(32),
29
+ }),
30
+ contexts: {
31
+ personal: { roles: ['admin', 'support', 'self'] },
32
+ },
33
+ });
34
+
35
+ start({
36
+ encryption,
37
+ commands: { /* ... */ },
38
+ });
39
+ ```
40
+
41
+ Bootstrap automatically wires encryption into the event store (encrypt on
42
+ write, decrypt on replay) and event bus (encrypt before publish). No changes
43
+ to aggregates, read model projections, or command handlers are needed.
44
+
45
+ ## How It Works
46
+
47
+ ### Envelope Encryption
48
+
49
+ Each sensitive field is encrypted with its own **Data Encryption Key (DEK)**,
50
+ unique per subject (e.g., per customer). DEKs are wrapped (encrypted) by a
51
+ **Key Encryption Key (KEK)** managed by the key store. This two-layer design
52
+ means:
53
+
54
+ - Rotating a KEK does not require re-encrypting all data
55
+ - Deleting a subject's DEKs renders their data permanently unreadable
56
+ (crypto-shredding)
57
+ - Different encryption contexts can have different KEKs with different access
58
+ policies
59
+
60
+ ### Encrypted Field Format
61
+
62
+ Encrypted fields in events are replaced with an envelope object:
63
+
64
+ ```javascript
65
+ {
66
+ __encrypted: true,
67
+ alg: 'aes-256-gcm',
68
+ iv: '<base64>',
69
+ data: '<base64>',
70
+ tag: '<base64>',
71
+ ctx: 'personal', // encryption context name
72
+ kid: '<subjectId>', // which subject's DEK was used
73
+ kv: 1 // DEK version
74
+ }
75
+ ```
76
+
77
+ **Type coercion note**: All values are converted to strings before encryption
78
+ (`String(value)`). After decryption, fields always contain strings regardless of
79
+ their original type. For example, the number `42` becomes the string `"42"`, and
80
+ the boolean `true` becomes `"true"`. Applications must handle type coercion when
81
+ consuming decrypted values.
82
+
83
+ ## Key Store Tiers
84
+
85
+ The `keyStore` parameter controls where KEKs live and how DEKs are
86
+ wrapped/unwrapped. Three tiers are available, each suited to different
87
+ deployment scenarios.
88
+
89
+ ### Tier 1: In-Memory (Development / Testing)
90
+
91
+ KEKs are provided directly as buffers. DEKs are stored in a local Map.
92
+ Suitable for unit tests and single-process development.
93
+
94
+ ```javascript
95
+ import { inMemoryKeyStore } from '@lazyapps/encryption';
96
+
97
+ const keyStore = inMemoryKeyStore({
98
+ personal: crypto.randomBytes(32),
99
+ financial: crypto.randomBytes(32),
100
+ });
101
+ ```
102
+
103
+ ### Tier 2: MongoDB (Self-Hosted Production)
104
+
105
+ KEKs are derived from a root secret via HMAC-SHA256. DEKs are stored in a
106
+ MongoDB collection. Suitable for deployments where a dedicated KMS is not
107
+ available.
108
+
109
+ ```javascript
110
+ import { mongoKeyStore } from '@lazyapps/encryption';
111
+
112
+ const keyStore = mongoKeyStore({
113
+ url: process.env.MONGO_URL,
114
+ rootSecret: process.env.ENCRYPTION_ROOT_SECRET,
115
+ database: 'encryption-keys',
116
+ dekCollection: 'deks',
117
+ });
118
+ ```
119
+
120
+ **Security note**: The root secret is a single point of compromise. Any service
121
+ with the root secret can derive all KEKs. Use environment variables or Docker
122
+ secrets to provide it, and consider Tier 3 for production deployments with
123
+ compliance requirements.
124
+
125
+ ### Tier 3: Vault / OpenBao (Production)
126
+
127
+ KEKs live inside HashiCorp Vault (or OpenBao, the Apache 2.0 fork). All key
128
+ wrapping and unwrapping happens inside Vault via the transit secrets engine.
129
+ Services never see KEKs — they authenticate with AppRole credentials and Vault
130
+ policies control which encryption contexts each service can access.
131
+
132
+ ```javascript
133
+ import { vaultKeyStore, appRole } from '@lazyapps/encryption';
134
+
135
+ const keyStore = vaultKeyStore({
136
+ vaultUrl: process.env.VAULT_ADDR,
137
+ authMethod: appRole({
138
+ roleId: process.env.VAULT_ROLE_ID,
139
+ secretId: process.env.VAULT_SECRET_ID,
140
+ }),
141
+ });
142
+ ```
143
+
144
+ Optionally, DEK metadata can be persisted to MongoDB instead of in-memory
145
+ storage using the `dekBackend` option:
146
+
147
+ ```javascript
148
+ const keyStore = vaultKeyStore({
149
+ vaultUrl: process.env.VAULT_ADDR,
150
+ authMethod: appRole({ roleId, secretId }),
151
+ dekBackend: {
152
+ url: process.env.MONGO_URL,
153
+ database: 'encryption-keys',
154
+ collection: 'deks',
155
+ },
156
+ });
157
+ ```
158
+
159
+ You can also authenticate with a token directly (useful for dev mode):
160
+
161
+ ```javascript
162
+ const keyStore = vaultKeyStore({
163
+ vaultUrl: 'http://localhost:8200',
164
+ token: 'dev-root-token',
165
+ });
166
+ ```
167
+
168
+ ## Schema Definition
169
+
170
+ The encryption schema declares which event fields to encrypt, which encryption
171
+ context protects them, and which event field identifies the subject (the entity
172
+ whose data is being protected).
173
+
174
+ ```javascript
175
+ import { defineEncryptionSchema } from '@lazyapps/encryption';
176
+
177
+ const schema = defineEncryptionSchema({
178
+ CUSTOMER_CREATED: {
179
+ 'payload.name': { context: 'personal', subjectField: 'aggregateId' },
180
+ 'payload.location': { context: 'personal', subjectField: 'aggregateId' },
181
+ },
182
+ ORDER_CREATED: {
183
+ 'payload.text': {
184
+ context: 'order-details',
185
+ subjectField: 'payload.customerId',
186
+ },
187
+ },
188
+ });
189
+ ```
190
+
191
+ - **Field paths** use dot notation (e.g., `'payload.name'`)
192
+ - **`context`**: Names the encryption context (must match a key in the key
193
+ store and the `contexts` configuration)
194
+ - **`subjectField`**: Dot-notation path to the field in the event that
195
+ identifies the data subject. The DEK is keyed by this value.
196
+
197
+ ## Encryption Contexts
198
+
199
+ Contexts define access control — which roles can decrypt fields protected by
200
+ each context:
201
+
202
+ ```javascript
203
+ const contexts = {
204
+ personal: { roles: ['admin', 'support', 'self'] },
205
+ financial: { roles: ['admin', 'finance'] },
206
+ 'order-details': { roles: ['admin', 'support', 'sales'] },
207
+ };
208
+ ```
209
+
210
+ The `'self'` role is special: it grants access when the requesting identity
211
+ matches the data subject.
212
+
213
+ ## Bootstrap Integration
214
+
215
+ Pass the `encryption` promise to `start()`. Bootstrap handles wiring:
216
+
217
+ ```javascript
218
+ const encryption = createEncryption({ schema, keyStore, contexts });
219
+
220
+ // Command processor: wraps eventStore (encrypt) and eventBus (encrypt)
221
+ start({
222
+ encryption,
223
+ commands: { eventStore: mongodb(...), eventBus: rabbitMq(...), ... },
224
+ });
225
+
226
+ // Read model: wraps storage (encrypt on write) and creates projection decryptor
227
+ start({
228
+ encryption,
229
+ readModels: {
230
+ role: 'customer-service',
231
+ storage: mongodb(...),
232
+ ...
233
+ },
234
+ });
235
+ ```
236
+
237
+ ### What Bootstrap Does
238
+
239
+ For command processors (`commands` config):
240
+ - **Event store**: Events are encrypted before `addEvent` and decrypted during
241
+ `replay` (so aggregate projections see plaintext)
242
+ - **Event bus**: Events are encrypted before `publishEvent` (if not already
243
+ encrypted by the event store wrapper)
244
+
245
+ For read models (`readModels` config):
246
+ - **Projection decryptor**: Created via `createProjectionDecryptor(role)`,
247
+ decrypts events before projection handlers see them. Falls back to
248
+ `'[deleted]'` if DEKs are unavailable (crypto-shredded)
249
+ - **Storage**: If `readModelEncryption` is configured, wraps storage operations
250
+ to encrypt sensitive fields on write (insertOne, updateOne, etc.)
251
+
252
+ ## Read Model Encryption
253
+
254
+ To encrypt fields stored in read model collections (not just event fields),
255
+ provide a `readModelEncryption` configuration:
256
+
257
+ ```javascript
258
+ const encryption = createEncryption({
259
+ schema,
260
+ keyStore,
261
+ contexts,
262
+ readModelEncryption: {
263
+ customers: {
264
+ name: { context: 'personal', subjectField: 'customerId' },
265
+ location: { context: 'personal', subjectField: 'customerId' },
266
+ },
267
+ orderSummaries: {
268
+ customerName: { context: 'personal', subjectField: 'customerId' },
269
+ },
270
+ },
271
+ });
272
+ ```
273
+
274
+ The storage wrapper intercepts `insertOne`, `updateOne`, `updateMany`,
275
+ `findOneAndUpdate`, `findOneAndReplace`, and `bulkWrite` calls, encrypting the
276
+ specified fields before they reach the database.
277
+
278
+ ## Crypto-Shredding (Forget Subject)
279
+
280
+ To make a subject's data permanently unreadable, delete their DEKs:
281
+
282
+ ```javascript
283
+ // Via the encryption module directly
284
+ encryption.then((enc) => enc.forgetSubject(subjectId));
285
+ ```
286
+
287
+ After DEK deletion:
288
+ - Encrypted events in the event store become undecryptable
289
+ - Projection handlers receive fallback values (`'[deleted]'` by default)
290
+ - Read model queries return fallback values for encrypted fields
291
+
292
+ ### Integration with Event Sourcing
293
+
294
+ The recommended pattern is a `FORGET_SUBJECT` command and `SUBJECT_FORGOTTEN`
295
+ event. The encryption module's event store wrapper automatically deletes DEKs
296
+ when it processes a `SUBJECT_FORGOTTEN` event. Read model projections should
297
+ handle `SUBJECT_FORGOTTEN` by cleaning up the subject's records.
298
+
299
+ ```javascript
300
+ // SubjectLifecycle aggregate
301
+ export default {
302
+ initial: () => ({}),
303
+ commands: {
304
+ FORGET_SUBJECT: (aggregate, payload) => ({
305
+ type: 'SUBJECT_FORGOTTEN',
306
+ payload,
307
+ }),
308
+ },
309
+ projections: {
310
+ SUBJECT_FORGOTTEN: (aggregate) => ({ ...aggregate, forgotten: true }),
311
+ },
312
+ };
313
+ ```
314
+
315
+ ## Key Rotation
316
+
317
+ Vault key stores support KEK rotation:
318
+
319
+ ```javascript
320
+ encryption.then((enc) => enc.rotateContextKey('personal'));
321
+ ```
322
+
323
+ This rotates the transit key in Vault. New DEKs will be wrapped with the new
324
+ key version. Existing wrapped DEKs can still be unwrapped (Vault supports key
325
+ versioning).
326
+
327
+ ## API Reference
328
+
329
+ ### `createEncryption(options)`
330
+
331
+ Returns a Promise that resolves to the encryption module.
332
+
333
+ **Options**:
334
+ - `schema` — Result of `defineEncryptionSchema()`
335
+ - `keyStore` — Key store instance (`inMemoryKeyStore()`, `mongoKeyStore()`,
336
+ or `vaultKeyStore()`)
337
+ - `contexts` — Object mapping context names to `{ roles: string[] }`
338
+ - `readModelEncryption` — Optional read model field encryption config
339
+ - `cache` — Optional `{ maxSize: number, ttlMs: number }` (default:
340
+ `{ maxSize: 10000, ttlMs: 300000 }`)
341
+ - `fallbackValue` — Value used when decryption fails (default: `'[deleted]'`)
342
+
343
+ **Resolved object methods**:
344
+ - `wrapEventStore(eventStoreFactory)` — Returns a wrapped factory that
345
+ encrypts/decrypts events
346
+ - `wrapEventBus(eventBusFactory)` — Returns a wrapped factory that encrypts
347
+ events before publishing
348
+ - `createProjectionDecryptor(role)` — Returns `(event) => Promise<event>` that
349
+ decrypts event fields for the given role
350
+ - `wrapStorage(storageFactory)` — Returns a wrapped factory that encrypts
351
+ read model fields on write
352
+ - `createQueryDecryptor()` — Returns a decryptor for query results with
353
+ role-based access control
354
+ - `forgetSubject(subjectId)` — Deletes all DEKs for a subject
355
+ (crypto-shredding)
356
+ - `rotateContextKey(contextName)` — Rotates the KEK for a context (Vault only)
357
+ - `getSchema()` — Returns the encryption schema
358
+ - `getContexts()` — Returns the contexts configuration
359
+
360
+ ### `defineEncryptionSchema(schemaDef)`
361
+
362
+ Validates and returns the schema definition. Throws if any field is missing
363
+ `context` or `subjectField`.
364
+
365
+ ### `inMemoryKeyStore(initialKEKs)`
366
+
367
+ Creates an in-memory key store. `initialKEKs` is an object mapping context
368
+ names to 32-byte Buffer or base64-encoded key strings.
369
+
370
+ ### `mongoKeyStore(options)`
371
+
372
+ Creates a MongoDB-backed key store.
373
+
374
+ - `url` — MongoDB connection URL
375
+ - `rootSecret` — 32-byte Buffer or base64 string used to derive KEKs
376
+ - `database` — Database name (default: `'encryption-keys'`)
377
+ - `dekCollection` — Collection name (default: `'deks'`)
378
+
379
+ ### `vaultKeyStore(options)`
380
+
381
+ Creates a Vault/OpenBao-backed key store.
382
+
383
+ - `vaultUrl` — Vault server URL
384
+ - `token` — Direct token authentication (for dev mode)
385
+ - `authMethod` — Authentication method (e.g., `appRole()`)
386
+ - `dekBackend` — Optional `{ url, database, collection }` for MongoDB-backed
387
+ DEK storage
388
+
389
+ ### `appRole(options)`
390
+
391
+ Creates an AppRole authentication method for Vault.
392
+
393
+ - `roleId` — AppRole role ID
394
+ - `secretId` — AppRole secret ID
package/encryption.js ADDED
@@ -0,0 +1,146 @@
1
+ import { createFieldEncryptor } from './fieldEncryption.js';
2
+ import { createEnvelopeManager } from './envelopeEncryption.js';
3
+ import { createKeyCache } from './keyCache.js';
4
+ import { createFallbackHandler } from './fallback.js';
5
+ import { createStorageEncryptor } from './storageEncryption.js';
6
+ import { createQueryDecryptor as createQueryDecryptorImpl } from './queryDecryptor.js';
7
+ import { getLogger } from '@lazyapps/logger';
8
+
9
+ export const createEncryption = ({
10
+ schema,
11
+ keyStore,
12
+ contexts,
13
+ readModelEncryption,
14
+ cache = { maxSize: 10000, ttlMs: 300000 },
15
+ fallbackValue = '[deleted]',
16
+ }) => {
17
+ const log = getLogger('Encryption', 'INIT');
18
+
19
+ return keyStore
20
+ .initialize()
21
+ .then((ks) => createKeyCache(ks, cache))
22
+ .then((cachedKs) => {
23
+ const envelope = createEnvelopeManager(cachedKs, contexts);
24
+ const fieldEncryptor = createFieldEncryptor(envelope, schema);
25
+ const fallbackHandler = createFallbackHandler(schema, fallbackValue);
26
+
27
+ const decryptEventSafe = (event) =>
28
+ fieldEncryptor
29
+ .decryptEvent(event)
30
+ .catch(() => fallbackHandler.applyFallbacks(event));
31
+
32
+ log.info('Encryption service initialized');
33
+
34
+ return {
35
+ wrapEventStore:
36
+ (eventStoreFactory) =>
37
+ (...factoryArgs) =>
38
+ eventStoreFactory(...factoryArgs).then((store) => ({
39
+ addEvent: (correlationId) => (event) => {
40
+ const encLog = getLogger('Encryption/Store', correlationId);
41
+
42
+ const shredIfForget = (evt) =>
43
+ evt.type === 'SUBJECT_FORGOTTEN'
44
+ ? cachedKs
45
+ .deleteKeysForSubject(evt.payload.subjectId)
46
+ .then(() => evt)
47
+ : Promise.resolve(evt);
48
+
49
+ return fieldEncryptor
50
+ .encryptEvent(event)
51
+ .then((encryptedEvent) => {
52
+ encLog.debug(
53
+ `Encrypted event type=${event.type} ` +
54
+ `aggregate=${event.aggregateName}(${event.aggregateId})`,
55
+ );
56
+ return store
57
+ .addEvent(correlationId)(encryptedEvent)
58
+ .then(() => shredIfForget(event));
59
+ });
60
+ },
61
+
62
+ // Wrap replay to decrypt events before aggregate
63
+ // projection. During command processor startup, replay
64
+ // reads encrypted events from the store and passes them
65
+ // to applyAggregateProjection which needs plaintext.
66
+ // The original replay uses collection.find().forEach()
67
+ // calling applyAggregateProjection(correlationId)(event).
68
+ // We intercept the cmdProcContext to wrap
69
+ // applyAggregateProjection with a decrypt step.
70
+ replay: (correlationId) => (cmdProcContext) => {
71
+ const wrappedContext = {
72
+ ...cmdProcContext,
73
+ aggregateStore: {
74
+ ...cmdProcContext.aggregateStore,
75
+ applyAggregateProjection: (corrId) => (event) =>
76
+ decryptEventSafe(event).then((decrypted) =>
77
+ cmdProcContext.aggregateStore.applyAggregateProjection(
78
+ corrId,
79
+ )(decrypted),
80
+ ),
81
+ },
82
+ };
83
+ return store.replay(correlationId)(wrappedContext);
84
+ },
85
+
86
+ close: store.close,
87
+ })),
88
+
89
+ wrapEventBus:
90
+ (eventBusFactory) =>
91
+ (...factoryArgs) =>
92
+ eventBusFactory(...factoryArgs).then((bus) => ({
93
+ ...bus,
94
+ publishEvent: (correlationId) => (event) => {
95
+ const busLog = getLogger('Encryption/Bus', correlationId);
96
+ return fieldEncryptor.hasEncryptedFields(event)
97
+ ? bus.publishEvent(correlationId)(event)
98
+ : fieldEncryptor.encryptEvent(event).then((encrypted) => {
99
+ busLog.debug(
100
+ `Encrypted event for bus: type=${event.type}`,
101
+ );
102
+ return bus.publishEvent(correlationId)(encrypted);
103
+ });
104
+ },
105
+ })),
106
+
107
+ createProjectionDecryptor: (role) => (event) =>
108
+ fieldEncryptor
109
+ .decryptEvent(event, { role, contexts })
110
+ .catch(() => fallbackHandler.applyFallbacks(event)),
111
+
112
+ wrapStorage: (storageFactory) =>
113
+ readModelEncryption
114
+ ? createStorageEncryptor(
115
+ storageFactory,
116
+ readModelEncryption,
117
+ envelope,
118
+ )
119
+ : storageFactory,
120
+
121
+ createQueryDecryptor: () =>
122
+ readModelEncryption
123
+ ? createQueryDecryptorImpl(
124
+ readModelEncryption,
125
+ envelope,
126
+ fallbackValue,
127
+ contexts,
128
+ )
129
+ : null,
130
+
131
+ forgetSubject: (subjectId) => {
132
+ log.info(`Forgetting subject: ${subjectId}`);
133
+ return cachedKs.deleteKeysForSubject(subjectId);
134
+ },
135
+
136
+ rotateContextKey: (contextName) => {
137
+ log.info(`Rotating KEK for context: ${contextName}`);
138
+ return envelope.rotateKEK(contextName);
139
+ },
140
+
141
+ getSchema: () => schema,
142
+
143
+ getContexts: () => contexts,
144
+ };
145
+ });
146
+ };
@@ -0,0 +1,35 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ export const createEnvelopeManager = (keyStore, contexts) => ({
4
+ getDEK: (subjectId, contextName, version) =>
5
+ keyStore.getDEK(subjectId, contextName, version).then((storedDEK) => {
6
+ if (!storedDEK) {
7
+ const newDEK = randomBytes(32);
8
+ return keyStore
9
+ .wrapDEK(contextName, newDEK)
10
+ .then((wrappedKey) => ({
11
+ key: newDEK,
12
+ version: 1,
13
+ wrappedKey,
14
+ }))
15
+ .then((dekInfo) =>
16
+ keyStore
17
+ .storeDEK(subjectId, contextName, dekInfo)
18
+ .then(() => dekInfo),
19
+ );
20
+ }
21
+ return keyStore
22
+ .unwrapDEK(contextName, storedDEK.wrappedKey)
23
+ .then((plainDEK) => ({
24
+ key: plainDEK,
25
+ version: storedDEK.version,
26
+ }));
27
+ }),
28
+
29
+ rotateKEK: (contextName) =>
30
+ keyStore.rotateKEK
31
+ ? keyStore.rotateKEK(contextName)
32
+ : Promise.reject(
33
+ new Error('KEK rotation not supported by this key store tier'),
34
+ ),
35
+ });
package/fallback.js ADDED
@@ -0,0 +1,23 @@
1
+ import { getNestedValue, setNestedValue } from './pathUtils.js';
2
+
3
+ export const createFallbackHandler = (
4
+ schema,
5
+ defaultFallback = '[deleted]',
6
+ ) => ({
7
+ applyFallbacks: (event) => {
8
+ const fieldDefs = schema[event.type];
9
+ if (!fieldDefs) return Promise.resolve(event);
10
+
11
+ const result = { ...event, payload: { ...event.payload } };
12
+
13
+ for (const [fieldPath, fieldConfig] of Object.entries(fieldDefs)) {
14
+ const value = getNestedValue(result, fieldPath);
15
+ if (value && value.__encrypted) {
16
+ const fallback = fieldConfig.fallback || defaultFallback;
17
+ setNestedValue(result, fieldPath, fallback);
18
+ }
19
+ }
20
+
21
+ return Promise.resolve(result);
22
+ },
23
+ });
@@ -0,0 +1,106 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2
+ import { getNestedValue, setNestedValue } from './pathUtils.js';
3
+
4
+ const ALGORITHM = 'aes-256-gcm';
5
+ const IV_LENGTH = 12;
6
+
7
+ export const encryptValue = (key, plaintext) => {
8
+ const iv = randomBytes(IV_LENGTH);
9
+ const cipher = createCipheriv(ALGORITHM, key, iv);
10
+ const encrypted = Buffer.concat([
11
+ cipher.update(String(plaintext), 'utf8'),
12
+ cipher.final(),
13
+ ]);
14
+ const tag = cipher.getAuthTag();
15
+ return {
16
+ __encrypted: true,
17
+ alg: ALGORITHM,
18
+ iv: iv.toString('base64'),
19
+ data: encrypted.toString('base64'),
20
+ tag: tag.toString('base64'),
21
+ };
22
+ };
23
+
24
+ export const decryptValue = (key, envelope) => {
25
+ const decipher = createDecipheriv(
26
+ envelope.alg || ALGORITHM,
27
+ key,
28
+ Buffer.from(envelope.iv, 'base64'),
29
+ );
30
+ decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
31
+ return (
32
+ decipher.update(Buffer.from(envelope.data, 'base64'), null, 'utf8') +
33
+ decipher.final('utf8')
34
+ );
35
+ };
36
+
37
+ export const createFieldEncryptor = (envelope, schema) => ({
38
+ encryptEvent: (event) => {
39
+ const fieldDefs = schema[event.type];
40
+ if (!fieldDefs) return Promise.resolve(event);
41
+
42
+ const encrypted = { ...event, payload: { ...event.payload } };
43
+
44
+ return Object.entries(fieldDefs).reduce(
45
+ (promise, [fieldPath, fieldConfig]) =>
46
+ promise.then((evt) => {
47
+ const value = getNestedValue(evt, fieldPath);
48
+ if (value === undefined || value === null) return evt;
49
+
50
+ const subjectId = getNestedValue(evt, fieldConfig.subjectField);
51
+ if (!subjectId) return evt;
52
+
53
+ return envelope.getDEK(subjectId, fieldConfig.context).then((dek) => {
54
+ const encryptedField = {
55
+ ...encryptValue(dek.key, value),
56
+ ctx: fieldConfig.context,
57
+ kid: subjectId,
58
+ kv: dek.version,
59
+ };
60
+ return setNestedValue(evt, fieldPath, encryptedField);
61
+ });
62
+ }),
63
+ Promise.resolve(encrypted),
64
+ );
65
+ },
66
+
67
+ decryptEvent: (event, accessControl) => {
68
+ const fieldDefs = schema[event.type];
69
+ if (!fieldDefs) return Promise.resolve(event);
70
+
71
+ const decrypted = { ...event, payload: { ...event.payload } };
72
+
73
+ return Object.entries(fieldDefs).reduce(
74
+ (promise, [fieldPath, fieldConfig]) =>
75
+ promise.then((evt) => {
76
+ const value = getNestedValue(evt, fieldPath);
77
+ if (!value || !value.__encrypted) return evt;
78
+
79
+ if (accessControl) {
80
+ const contextConfig = accessControl.contexts[value.ctx];
81
+ if (
82
+ contextConfig &&
83
+ !contextConfig.roles.includes(accessControl.role)
84
+ ) {
85
+ return evt;
86
+ }
87
+ }
88
+
89
+ return envelope.getDEK(value.kid, value.ctx, value.kv).then((dek) => {
90
+ const plaintext = decryptValue(dek.key, value);
91
+ return setNestedValue(evt, fieldPath, plaintext);
92
+ });
93
+ }),
94
+ Promise.resolve(decrypted),
95
+ );
96
+ },
97
+
98
+ hasEncryptedFields: (event) => {
99
+ const fieldDefs = schema[event.type];
100
+ if (!fieldDefs) return false;
101
+ return Object.keys(fieldDefs).some((fieldPath) => {
102
+ const value = getNestedValue(event, fieldPath);
103
+ return value && value.__encrypted;
104
+ });
105
+ },
106
+ });