@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 +394 -0
- package/encryption.js +146 -0
- package/envelopeEncryption.js +35 -0
- package/fallback.js +23 -0
- package/fieldEncryption.js +106 -0
- package/forgetSubjectEndpoints.js +90 -0
- package/index.js +8 -0
- package/keyCache.js +52 -0
- package/keystores/inmemory.js +93 -0
- package/keystores/mongo.js +125 -0
- package/keystores/vault.js +144 -0
- package/package.json +52 -0
- package/pathUtils.js +13 -0
- package/queryDecryptor.js +55 -0
- package/schema.js +26 -0
- package/storageEncryption.js +162 -0
- package/subjectLifecycle.js +34 -0
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
|
+
});
|