@anteros/core 0.0.33 → 0.0.34
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/bench-cache.ts +70 -0
- package/database/field.crypto.ts +219 -0
- package/database/mongodbadapter.ts +27 -10
- package/index.ts +0 -2
- package/package.json +1 -1
- package/server/boot.ts +2 -1
- package/server/config.ts +13 -0
- package/tests/crypto.test.ts +262 -2
- package/tests/field-encryption.test.ts +193 -0
- package/types/config.d.ts +15 -0
- package/types/field.d.ts +6 -0
- package/utils/crypto.ts +236 -62
- package/utils/index.ts +3 -0
package/tests/crypto.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import { useSymCrypt, useAsymCrypt } from "../utils/crypto";
|
|
1
|
+
import { describe, it, expect, afterAll } from "bun:test";
|
|
2
|
+
import { useSymCrypt, useAsymCrypt, resolve } from "../utils/crypto";
|
|
3
|
+
import { cfg, formatConfig } from "../server/config";
|
|
4
|
+
import utils from "../utils";
|
|
3
5
|
|
|
4
6
|
describe("crypto — symmetric AES-256-GCM", () => {
|
|
5
7
|
it("round-trips a string", async () => {
|
|
@@ -57,6 +59,191 @@ describe("crypto — symmetric AES-256-GCM", () => {
|
|
|
57
59
|
const c = useSymCrypt({ secret: "s" });
|
|
58
60
|
await expect(c.decrypt("YWJjZA==")).rejects.toThrow();
|
|
59
61
|
});
|
|
62
|
+
|
|
63
|
+
it("decrypt(cipher, true, fields) decrypts the listed sub-fields inside the JSON", async () => {
|
|
64
|
+
const c = useSymCrypt({ secret: "s" });
|
|
65
|
+
// json contenant des sous-champs chiffrés (convention field-level : JSON.stringify)
|
|
66
|
+
const encZip = await c.encrypt(JSON.stringify("75000"));
|
|
67
|
+
const encPhone = await c.encrypt(JSON.stringify("0600000000"));
|
|
68
|
+
const container = await c.encrypt({ city: "Paris", zip: encZip, contact: { phone: encPhone } });
|
|
69
|
+
|
|
70
|
+
const dec = await c.decrypt<any>(container, true, ["zip", "contact.phone"]);
|
|
71
|
+
expect(dec.city).toBe("Paris");
|
|
72
|
+
expect(dec.zip).toBe("75000");
|
|
73
|
+
expect(dec.contact.phone).toBe("0600000000");
|
|
74
|
+
|
|
75
|
+
// sans les champs → sous-champs toujours chiffrés
|
|
76
|
+
const raw = await c.decrypt<any>(container, true);
|
|
77
|
+
expect(raw.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
78
|
+
expect(raw.city).toBe("Paris");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("decrypt(jsonObject, true, fields) decrypts sub-fields of a plaintext json container", async () => {
|
|
82
|
+
const c = useSymCrypt({ secret: "s" });
|
|
83
|
+
const encZip = await c.encrypt(JSON.stringify("69001"));
|
|
84
|
+
const json = { street: "Rue X", zip: encZip, tags: [{ code: encZip }] };
|
|
85
|
+
|
|
86
|
+
const dec = await c.decrypt<any>(json, true, ["zip", "tags.0.code"]);
|
|
87
|
+
expect(dec.street).toBe("Rue X");
|
|
88
|
+
expect(dec.zip).toBe("69001");
|
|
89
|
+
expect(dec.tags[0].code).toBe("69001");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("decrypt(..., fields) keeps non-ciphertext values as-is", async () => {
|
|
93
|
+
const c = useSymCrypt({ secret: "s" });
|
|
94
|
+
const json = { city: "Paris", note: "plain text", zip: await c.encrypt(JSON.stringify("75000")) };
|
|
95
|
+
const dec = await c.decrypt<any>(json, true, ["zip", "note", "missing"]);
|
|
96
|
+
expect(dec.city).toBe("Paris");
|
|
97
|
+
expect(dec.note).toBe("plain text"); // pas un ciphertext → inchangé
|
|
98
|
+
expect(dec.zip).toBe("75000");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("decrypt handles an ARRAY of json objects with encrypted fields", async () => {
|
|
102
|
+
const c = useSymCrypt({ secret: "s" });
|
|
103
|
+
const encZip = await c.encrypt(JSON.stringify("69001"));
|
|
104
|
+
const encOther = await c.encrypt(JSON.stringify("69002"));
|
|
105
|
+
|
|
106
|
+
// array conteneur direct (ex: doc.addresses renvoyé par une requête)
|
|
107
|
+
const arr = [
|
|
108
|
+
{ city: "Lyon", zip: encZip },
|
|
109
|
+
{ city: "Villeurbanne", zip: encOther },
|
|
110
|
+
];
|
|
111
|
+
const dec = await c.decrypt<any>(arr, true, ["zip"]);
|
|
112
|
+
expect(dec[0].zip).toBe("69001");
|
|
113
|
+
expect(dec[1].zip).toBe("69002");
|
|
114
|
+
expect(dec[0].city).toBe("Lyon"); // non-chiffré conservé
|
|
115
|
+
expect(dec).not.toBe(arr); // copie, pas de mutation
|
|
116
|
+
expect(arr[0]!.zip).toMatch(/^[A-Za-z0-9+/=]+$/); // input intact
|
|
117
|
+
|
|
118
|
+
// ciphertext dont le plaintext est un array de json
|
|
119
|
+
const encContainer = await c.encrypt([{ zip: encZip }, { zip: encOther }]);
|
|
120
|
+
const dec2 = await c.decrypt<any>(encContainer, true, ["zip"]);
|
|
121
|
+
expect(dec2[0].zip).toBe("69001");
|
|
122
|
+
expect(dec2[1].zip).toBe("69002");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("crypto — key rotation (versioned secrets)", () => {
|
|
127
|
+
it("decrypts old ciphertexts after rotating the secret", async () => {
|
|
128
|
+
const old = useSymCrypt({ secret: "v1-secret" }); // version 1 par défaut
|
|
129
|
+
const enc = await old.encrypt({ apiKey: "sk-rot" });
|
|
130
|
+
|
|
131
|
+
// Rotation : nouvelle clé active v2, l'ancienne conservée en decrypt-only
|
|
132
|
+
const rotated = useSymCrypt({ secret: "v2-secret", version: 2, previousSecrets: { 1: "v1-secret" } });
|
|
133
|
+
const dec = await rotated.decrypt<{ apiKey: string }>(enc, true);
|
|
134
|
+
expect(dec.apiKey).toBe("sk-rot");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("tags new ciphertexts with the active version", async () => {
|
|
138
|
+
const c = useSymCrypt({ secret: "s", version: 2 });
|
|
139
|
+
const enc = await c.encrypt("data");
|
|
140
|
+
const buf = Buffer.from(enc, "base64");
|
|
141
|
+
expect(buf[0]).toBe(2);
|
|
142
|
+
// round-trip avec la même instance
|
|
143
|
+
const dec = await c.decrypt(enc);
|
|
144
|
+
expect(dec).toBe("data");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("still decrypts legacy ciphertexts (no version byte)", async () => {
|
|
148
|
+
const c = useSymCrypt({ secret: "s" });
|
|
149
|
+
const enc = await c.encrypt("legacy");
|
|
150
|
+
// Simule un ciphertext produit AVANT la rotation (format sans octet de version)
|
|
151
|
+
const legacy = Buffer.from(Buffer.from(enc, "base64").subarray(1)).toString("base64");
|
|
152
|
+
const dec = await c.decrypt(legacy);
|
|
153
|
+
expect(dec).toBe("legacy");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("the old secret cannot decrypt data encrypted with the new key", async () => {
|
|
157
|
+
const c = useSymCrypt({ secret: "v2", version: 2, previousSecrets: { 1: "v1" } });
|
|
158
|
+
const enc = await c.encrypt("new-data");
|
|
159
|
+
const old = useSymCrypt({ secret: "v1" });
|
|
160
|
+
await expect(old.decrypt(enc)).rejects.toThrow();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("binds ciphertexts to the config default AAD when none is passed", async () => {
|
|
164
|
+
const c = useSymCrypt({ secret: "s", aad: "myapp:prod" });
|
|
165
|
+
const enc = await c.encrypt({ apiKey: "sk-aad" });
|
|
166
|
+
|
|
167
|
+
// même instance → OK (défaut appliqué au decrypt aussi)
|
|
168
|
+
const dec = await c.decrypt<{ apiKey: string }>(enc, true);
|
|
169
|
+
expect(dec.apiKey).toBe("sk-aad");
|
|
170
|
+
|
|
171
|
+
// sans l'AAD config → échec (tag invalide)
|
|
172
|
+
const other = useSymCrypt({ secret: "s" });
|
|
173
|
+
await expect(other.decrypt(enc, true)).rejects.toThrow();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("explicit AAD wins over the config default", async () => {
|
|
177
|
+
const c = useSymCrypt({ secret: "s", aad: "default:ctx" });
|
|
178
|
+
const enc = await c.encrypt("x", "explicit:ctx");
|
|
179
|
+
const dec = await c.decrypt(enc, false, "explicit:ctx");
|
|
180
|
+
expect(dec).toBe("x");
|
|
181
|
+
// le défaut ne déchiffre pas le chiffré lié à l'explicite
|
|
182
|
+
await expect(c.decrypt(enc, false, "default:ctx")).rejects.toThrow();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("legacy ciphertexts (no AAD) still decrypt after a default AAD is configured", async () => {
|
|
186
|
+
const legacy = useSymCrypt({ secret: "s" }); // chiffré SANS AAD (ancien comportement)
|
|
187
|
+
const enc = await legacy.encrypt("old-data");
|
|
188
|
+
|
|
189
|
+
const c = useSymCrypt({ secret: "s", aad: "myapp:prod" }); // config avec AAD par défaut
|
|
190
|
+
const dec = await c.decrypt(enc);
|
|
191
|
+
expect(dec).toBe("old-data");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("crypto — encryption mode (server.encryption.mode)", () => {
|
|
196
|
+
afterAll(() => {
|
|
197
|
+
delete (cfg.server as any).encryption;
|
|
198
|
+
formatConfig({ server: { port: 4000 }, tenants: [] });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("boot validation: symmetric mode requires a secret", () => {
|
|
202
|
+
delete (cfg.server as any).encryption;
|
|
203
|
+
expect(() => formatConfig({
|
|
204
|
+
server: { port: 4000, encryption: { mode: "symmetric" } },
|
|
205
|
+
tenants: [],
|
|
206
|
+
})).toThrow(/symmetric.*requires a secret/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("boot validation: asymmetric mode requires a privateKey", () => {
|
|
210
|
+
delete (cfg.server as any).encryption;
|
|
211
|
+
expect(() => formatConfig({
|
|
212
|
+
server: { port: 4000, encryption: { mode: "asymmetric" } },
|
|
213
|
+
tenants: [],
|
|
214
|
+
})).toThrow(/asymmetric.*requires a privateKey/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("useSymCrypt() without explicit secret rejects an asymmetric mode", () => {
|
|
218
|
+
delete (cfg.server as any).encryption;
|
|
219
|
+
formatConfig({ server: { port: 4000, encryption: { mode: "asymmetric", privateKey: {} as any } }, tenants: [] });
|
|
220
|
+
expect(() => useSymCrypt()).toThrow(/mode is 'asymmetric'/);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("resolve() returns the instance matching the configured mode", async () => {
|
|
224
|
+
delete (cfg.server as any).encryption;
|
|
225
|
+
formatConfig({ server: { port: 4000 }, tenants: [] });
|
|
226
|
+
const priv = await useAsymCrypt().exportPrivateKey(); // pair fraîche, pas de mode config
|
|
227
|
+
|
|
228
|
+
delete (cfg.server as any).encryption;
|
|
229
|
+
formatConfig({ server: { port: 4000, encryption: { mode: "symmetric", secret: "cfg" } }, tenants: [] });
|
|
230
|
+
const sym = resolve();
|
|
231
|
+
const enc = await sym.encrypt({ a: 1 });
|
|
232
|
+
expect((await sym.decrypt<{ a: number }>(enc, true)).a).toBe(1);
|
|
233
|
+
|
|
234
|
+
delete (cfg.server as any).encryption;
|
|
235
|
+
formatConfig({ server: { port: 4000, encryption: { mode: "asymmetric", privateKey: priv } }, tenants: [] });
|
|
236
|
+
const asym = resolve();
|
|
237
|
+
const enc2 = await asym.encrypt("hi");
|
|
238
|
+
const dec2 = await asym.decrypt(enc2);
|
|
239
|
+
expect(dec2).toBe("hi");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("resolve() throws when mode is missing", () => {
|
|
243
|
+
delete (cfg.server as any).encryption;
|
|
244
|
+
formatConfig({ server: { port: 4000 }, tenants: [] });
|
|
245
|
+
expect(() => resolve()).toThrow(/mode is required/);
|
|
246
|
+
});
|
|
60
247
|
});
|
|
61
248
|
|
|
62
249
|
describe("crypto — asymmetric RSA-OAEP envelope", () => {
|
|
@@ -98,4 +285,77 @@ describe("crypto — asymmetric RSA-OAEP envelope", () => {
|
|
|
98
285
|
// Missing context → rejected
|
|
99
286
|
await expect(a.decrypt(enc, true)).rejects.toThrow();
|
|
100
287
|
});
|
|
288
|
+
|
|
289
|
+
it("decrypt(cipher, true, fields) decrypts listed sub-fields (asym)", async () => {
|
|
290
|
+
const a = useAsymCrypt();
|
|
291
|
+
const encZip = await a.encrypt(JSON.stringify("75000"));
|
|
292
|
+
const container = await a.encrypt({ city: "Paris", zip: encZip });
|
|
293
|
+
|
|
294
|
+
const dec = await a.decrypt<any>(container, true, ["zip"]);
|
|
295
|
+
expect(dec.city).toBe("Paris");
|
|
296
|
+
expect(dec.zip).toBe("75000");
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("crypto — public surface", () => {
|
|
301
|
+
it("is exposed via utils.crypt (not a top-level crypto export)", async () => {
|
|
302
|
+
expect(typeof utils.crypt.useSymCrypt).toBe("function");
|
|
303
|
+
expect(typeof utils.crypt.useAsymCrypt).toBe("function");
|
|
304
|
+
expect(typeof utils.crypt.resolve).toBe("function");
|
|
305
|
+
const top: any = await import("../index");
|
|
306
|
+
expect(top.crypto).toBeUndefined();
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("crypto — server config defaults (server.encryption)", () => {
|
|
311
|
+
afterAll(() => {
|
|
312
|
+
delete (cfg.server as any).encryption;
|
|
313
|
+
formatConfig({ server: { port: 4000 }, tenants: [] });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("useSymCrypt() without args uses server.encryption.secret", async () => {
|
|
317
|
+
formatConfig({
|
|
318
|
+
server: { port: 4000, encryption: { mode: "symmetric", secret: "cfg-secret" } },
|
|
319
|
+
tenants: [],
|
|
320
|
+
});
|
|
321
|
+
const c = useSymCrypt();
|
|
322
|
+
const enc = await c.encrypt({ apiKey: "sk-x" });
|
|
323
|
+
const dec = await c.decrypt<{ apiKey: string }>(enc, true);
|
|
324
|
+
expect(dec.apiKey).toBe("sk-x");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("useSymCrypt() throws a clear error when no secret is available", () => {
|
|
328
|
+
delete (cfg.server as any).encryption; // formatConfig merges — reset explicit
|
|
329
|
+
formatConfig({ server: { port: 4000 }, tenants: [] });
|
|
330
|
+
delete Bun.env.APP_SECRET;
|
|
331
|
+
expect(() => useSymCrypt()).toThrow(/missing secret/);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("useAsymCrypt() without args uses server.encryption.privateKey (JWK)", async () => {
|
|
335
|
+
const gen = useAsymCrypt();
|
|
336
|
+
const priv = await gen.exportPrivateKey();
|
|
337
|
+
|
|
338
|
+
formatConfig({
|
|
339
|
+
server: { port: 4000, encryption: { mode: "asymmetric", privateKey: priv } },
|
|
340
|
+
tenants: [],
|
|
341
|
+
});
|
|
342
|
+
const enc = await gen.encrypt("cfg-asym");
|
|
343
|
+
const c = useAsymCrypt(); // pas de privateKey → prend cfg
|
|
344
|
+
const dec = await c.decrypt(enc);
|
|
345
|
+
expect(dec).toBe("cfg-asym");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("useAsymCrypt() accepts privateKey as a JSON string", async () => {
|
|
349
|
+
const gen = useAsymCrypt();
|
|
350
|
+
const priv = await gen.exportPrivateKey();
|
|
351
|
+
|
|
352
|
+
formatConfig({
|
|
353
|
+
server: { port: 4000, encryption: { mode: "asymmetric", privateKey: JSON.stringify(priv) } },
|
|
354
|
+
tenants: [],
|
|
355
|
+
});
|
|
356
|
+
const enc = await gen.encrypt("json-key");
|
|
357
|
+
const c = useAsymCrypt();
|
|
358
|
+
const dec = await c.decrypt(enc);
|
|
359
|
+
expect(dec).toBe("json-key");
|
|
360
|
+
});
|
|
101
361
|
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { ObjectId } from "mongodb";
|
|
3
|
+
import { useRest } from "../database/rest";
|
|
4
|
+
import { formatConfig, cfg } from "../server/config";
|
|
5
|
+
import { syncTenants } from "../database/tenant";
|
|
6
|
+
import { syncCollections } from "../database/collection";
|
|
7
|
+
import { resolve } from "../utils/crypto";
|
|
8
|
+
|
|
9
|
+
const TENANT = "enc-test";
|
|
10
|
+
const DB = "mongodb://localhost:27017/_DB_ENC_TEST";
|
|
11
|
+
|
|
12
|
+
let rest: InstanceType<typeof useRest>;
|
|
13
|
+
|
|
14
|
+
function encCollection(fields: any[]) {
|
|
15
|
+
return { _tenant_: TENANT, slug: "patients", fields, api: { access: { "*": true } } };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
formatConfig({
|
|
20
|
+
server: { port: 4000, encryption: { mode: "symmetric", secret: "field-enc-secret" } },
|
|
21
|
+
tenants: [{ id: TENANT, dir: "enc-tenant", database: { uri: DB } }],
|
|
22
|
+
});
|
|
23
|
+
await syncTenants();
|
|
24
|
+
await syncCollections();
|
|
25
|
+
(cfg as any).collections = [encCollection([
|
|
26
|
+
{ name: "name", type: "string" },
|
|
27
|
+
{ name: "ssn", type: "string", encryption: true }, // champ chiffré
|
|
28
|
+
{ name: "profile", type: "json", encryption: true }, // objet chiffré
|
|
29
|
+
{ name: "age", type: "number" },
|
|
30
|
+
{ name: "address", type: "json", encryptionOptions: { path: "zip" } }, // json : seul address.zip est chiffré
|
|
31
|
+
{ name: "contacts", type: "json", encryptionOptions: { path: ["phone", "email"] } }, // plusieurs chemins
|
|
32
|
+
{ name: "addresses", type: "json", encryptionOptions: { path: "zip" } }, // array de json
|
|
33
|
+
])];
|
|
34
|
+
rest = new useRest({ internal: false, tenant_id: TENANT });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterAll(async () => {
|
|
38
|
+
delete (cfg.server as any).encryption;
|
|
39
|
+
formatConfig({ server: { port: 4000 }, tenants: [] });
|
|
40
|
+
try { await rest.db.collection("patients").drop(); } catch {}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("field-level encryption", () => {
|
|
44
|
+
it("insert stores ciphertext in Mongo but returns plaintext", async () => {
|
|
45
|
+
const doc: any = await rest.insertOne("patients", { name: "John", ssn: "123-45-6789", profile: { city: "Paris", zip: 75000 }, age: 42 });
|
|
46
|
+
|
|
47
|
+
// réponse API = plaintext
|
|
48
|
+
expect(doc.ssn).toBe("123-45-6789");
|
|
49
|
+
expect(doc.profile).toEqual({ city: "Paris", zip: 75000 });
|
|
50
|
+
|
|
51
|
+
// en base = ciphertext (version byte + IV + data)
|
|
52
|
+
const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
|
|
53
|
+
expect(raw).not.toBeNull();
|
|
54
|
+
expect(raw!.ssn).not.toBe("123-45-6789");
|
|
55
|
+
expect(typeof raw!.ssn).toBe("string");
|
|
56
|
+
expect(raw!.ssn).toMatch(/^[A-Za-z0-9+/=]+$/); // base64
|
|
57
|
+
expect(raw!.profile).not.toEqual({ city: "Paris" });
|
|
58
|
+
// champ non chiffré intact
|
|
59
|
+
expect(raw!.name).toBe("John");
|
|
60
|
+
expect(raw!.age).toBe(42);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("find / findOne return ciphertext (no decrypt on output)", async () => {
|
|
64
|
+
const list: any = await rest.find("patients", { $match: { name: "John" } });
|
|
65
|
+
// lecture = ciphertext (jamais décrypté automatiquement)
|
|
66
|
+
expect(list[0].ssn).not.toBe("123-45-6789");
|
|
67
|
+
expect(list[0].ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
68
|
+
expect(list[0].profile).not.toEqual({ city: "Paris", zip: 75000 });
|
|
69
|
+
// champs non chiffrés intacts
|
|
70
|
+
expect(list[0].name).toBe("John");
|
|
71
|
+
|
|
72
|
+
const one: any = await rest.findOne("patients", list[0]._id);
|
|
73
|
+
expect(one.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
74
|
+
|
|
75
|
+
// décryptage MANUEL explicite (utils.crypt.resolve())
|
|
76
|
+
const cipher = resolve();
|
|
77
|
+
const dec = await cipher.decrypt<string>(one.ssn, true);
|
|
78
|
+
expect(dec).toBe("123-45-6789");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("update via $set encrypts the new value (returns ciphertext)", async () => {
|
|
82
|
+
const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "John" } }))[0]!._id);
|
|
83
|
+
const updated: any = await rest.updateOne("patients", doc._id, { $set: { ssn: "987-65-4321" } });
|
|
84
|
+
|
|
85
|
+
// retour = ciphertext (plus de décryptage automatique)
|
|
86
|
+
expect(updated.ssn).not.toBe("987-65-4321");
|
|
87
|
+
expect(updated.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
88
|
+
// base = nouveau ciphertext
|
|
89
|
+
const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
|
|
90
|
+
expect(raw).not.toBeNull();
|
|
91
|
+
expect(raw!.ssn).not.toBe("987-65-4321");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("insertMany encrypts every doc", async () => {
|
|
95
|
+
await rest.insertMany("patients", [
|
|
96
|
+
{ name: "A", ssn: "111-11-1111" },
|
|
97
|
+
{ name: "B", ssn: "222-22-2222" },
|
|
98
|
+
]);
|
|
99
|
+
const raw = await rest.db.collection("patients").find({ name: { $in: ["A", "B"] } }).toArray();
|
|
100
|
+
for (const r of raw) expect(r.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
101
|
+
const list: any = await rest.find("patients", { $match: { name: { $in: ["A", "B"] } } });
|
|
102
|
+
// lecture = ciphertext, décrypté manuellement
|
|
103
|
+
for (const d of list) expect(d.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rejects operators that cannot work on ciphertext ($inc on an encrypted field)", async () => {
|
|
107
|
+
const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "John" } }))[0]!._id);
|
|
108
|
+
await expect(
|
|
109
|
+
rest.updateOne("patients", doc._id, { $set: { ssn: "x" }, $inc: { age: 1 } }) // $inc sur age (non chiffré) OK
|
|
110
|
+
).resolves.toBeTruthy();
|
|
111
|
+
await expect(
|
|
112
|
+
rest.updateOne("patients", doc._id, { $set: { ssn: "y" }, $inc: { ssn: 1 } as any }) // $inc sur champ chiffré → rejeté
|
|
113
|
+
).rejects.toThrow(/ENCRYPTED_FIELD_OPERATOR|not supported/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("throws ENCRYPTION_NOT_CONFIGURED when server.encryption is missing", async () => {
|
|
117
|
+
delete (cfg.server as any).encryption;
|
|
118
|
+
formatConfig({ server: { port: 4000 }, tenants: [] });
|
|
119
|
+
await expect(rest.insertOne("patients", { name: "NoCfg", ssn: "000-00-0000" }))
|
|
120
|
+
.rejects.toThrow(/ENCRYPTION_NOT_CONFIGURED|server\.encryption/);
|
|
121
|
+
|
|
122
|
+
// restore config pour les tests suivants
|
|
123
|
+
formatConfig({ server: { port: 4000, encryption: { mode: "symmetric", secret: "field-enc-secret" } }, tenants: [] });
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("field-level encryption — encryptionOptions.path (json sub-paths)", () => {
|
|
128
|
+
it("encrypts only the configured sub-path of a json field", async () => {
|
|
129
|
+
const doc: any = await rest.insertOne("patients", {
|
|
130
|
+
name: "Alice",
|
|
131
|
+
address: { street: "Rue X", city: "Paris", zip: "75000" },
|
|
132
|
+
});
|
|
133
|
+
// réponse = plaintext partout
|
|
134
|
+
expect(doc.address.zip).toBe("75000");
|
|
135
|
+
expect(doc.address.city).toBe("Paris");
|
|
136
|
+
|
|
137
|
+
// en base : seul zip est ciphertext, le reste est en clair
|
|
138
|
+
const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
|
|
139
|
+
expect(raw!.address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
140
|
+
expect(raw!.address.city).toBe("Paris");
|
|
141
|
+
expect(raw!.address.street).toBe("Rue X");
|
|
142
|
+
|
|
143
|
+
// lecture rest = ciphertext au path, plaintext ailleurs
|
|
144
|
+
const list: any = await rest.find("patients", { $match: { name: "Alice" } });
|
|
145
|
+
expect(list[0].address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
146
|
+
expect(list[0].address.city).toBe("Paris");
|
|
147
|
+
|
|
148
|
+
// décryptage manuel
|
|
149
|
+
const cipher = resolve();
|
|
150
|
+
const zip = await cipher.decrypt<string>(list[0].address.zip, true);
|
|
151
|
+
expect(zip).toBe("75000");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("supports multiple paths and array-of-json", async () => {
|
|
155
|
+
const doc: any = await rest.insertOne("patients", {
|
|
156
|
+
name: "Bob",
|
|
157
|
+
contacts: { phone: "0600000000", email: "bob@x.io", note: "public" },
|
|
158
|
+
addresses: [{ zip: "69001" }, { zip: "69002", city: "Lyon" }],
|
|
159
|
+
});
|
|
160
|
+
const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
|
|
161
|
+
// array de json : chaque élément chiffré sur son zip
|
|
162
|
+
expect(raw!.addresses[0].zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
163
|
+
expect(raw!.addresses[1].zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
164
|
+
expect(raw!.addresses[1].city).toBe("Lyon"); // non-chiffré intact
|
|
165
|
+
// multiple paths
|
|
166
|
+
expect(raw!.contacts.phone).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
167
|
+
expect(raw!.contacts.email).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
168
|
+
expect(raw!.contacts.note).toBe("public");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("encrypts the path on $set replacement and dot-notation $set", async () => {
|
|
172
|
+
const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "Alice" } }))[0]!._id);
|
|
173
|
+
|
|
174
|
+
// $set du json complet → zip re-chiffré dans le remplacement
|
|
175
|
+
const up1: any = await rest.updateOne("patients", doc._id, { $set: { address: { city: "Nice", zip: "06000" } } });
|
|
176
|
+
expect(up1.address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
177
|
+
expect(up1.address.city).toBe("Nice");
|
|
178
|
+
|
|
179
|
+
// dot-notation $set sur le path exact → valeur chiffrée
|
|
180
|
+
const up2: any = await rest.updateOne("patients", doc._id, { $set: { "address.zip": "06100" } });
|
|
181
|
+
expect(up2.address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
182
|
+
const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
|
|
183
|
+
const cipher = resolve();
|
|
184
|
+
expect(await cipher.decrypt<string>(raw!.address.zip, true)).toBe("06100");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("rejects $inc on an encrypted sub-path", async () => {
|
|
188
|
+
const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "Bob" } }))[0]!._id);
|
|
189
|
+
await expect(
|
|
190
|
+
rest.updateOne("patients", doc._id, { $inc: { "addresses.0.zip": 1 } as any })
|
|
191
|
+
).rejects.toThrow(/ENCRYPTED_FIELD_OPERATOR|not supported/);
|
|
192
|
+
});
|
|
193
|
+
});
|
package/types/config.d.ts
CHANGED
|
@@ -95,6 +95,21 @@ export type ServerConfig = {
|
|
|
95
95
|
/** Max `_id` tags per cached entry — large results are tagged up to this count (default: 50000; each tag costs ~0.18µs per cache hit) */
|
|
96
96
|
maxTags?: number;
|
|
97
97
|
};
|
|
98
|
+
/** Encryption keys — defaults used by utils.crypt.useSymCrypt() / utils.crypt.useAsymCrypt() */
|
|
99
|
+
encryption?: {
|
|
100
|
+
/** REQUIRED — encryption mode: 'symmetric' (AES-256-GCM) or 'asymmetric' (RSA-OAEP envelope) */
|
|
101
|
+
mode: 'symmetric' | 'asymmetric';
|
|
102
|
+
/** Symmetric AES-256-GCM secret (useSymCrypt) — required when mode is 'symmetric'. Falls back to env APP_SECRET */
|
|
103
|
+
secret?: string;
|
|
104
|
+
/** Version of the active secret — written into new ciphertexts (default: 1) */
|
|
105
|
+
version?: number;
|
|
106
|
+
/** Older secrets for decrypt-only — key rotation, e.g. { 1: 'v1-secret' } */
|
|
107
|
+
previousSecrets?: Record<number, string>;
|
|
108
|
+
/** Default AAD context — bound to every ciphertext when no explicit AAD is passed. Must stay stable (changing it makes old data undecryptable) */
|
|
109
|
+
aad?: string;
|
|
110
|
+
/** Asymmetric RSA-OAEP private key JWK for decryption (useAsymCrypt) — required when mode is 'asymmetric'. Object or JSON string */
|
|
111
|
+
privateKey?: JsonWebKey | string;
|
|
112
|
+
};
|
|
98
113
|
}
|
|
99
114
|
tenants: Tenant[];
|
|
100
115
|
}
|
package/types/field.d.ts
CHANGED
|
@@ -53,6 +53,12 @@ export type Field = {
|
|
|
53
53
|
enumOptions?: enumOptions;
|
|
54
54
|
slugOptions?: SlugOptions;
|
|
55
55
|
unique?: boolean | 1 | 0; // Mongo-style: true/1 → unique index, false/0 → non-unique
|
|
56
|
+
/** Encrypt this field's values at rest (uses server.encryption + the configured mode) */
|
|
57
|
+
encryption?: boolean;
|
|
58
|
+
/** Encrypt sub-path(s) inside a json / array-of-json field (dot notation) — e.g. { path: 'zip' } on field 'address' encrypts address.zip */
|
|
59
|
+
encryptionOptions?: {
|
|
60
|
+
path?: string | string[];
|
|
61
|
+
};
|
|
56
62
|
nullable?: boolean;
|
|
57
63
|
empty?: boolean;
|
|
58
64
|
defaultValue?: number | string | boolean | object | array<any>;
|