@anteros/core 0.0.32 → 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 +17 -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/utils/crypto.ts
CHANGED
|
@@ -1,7 +1,50 @@
|
|
|
1
|
+
import { cfg } from "../server/config";
|
|
2
|
+
|
|
1
3
|
const IV_LENGTH = 12;
|
|
2
4
|
const TAG_LENGTH = 16;
|
|
3
5
|
const RSA_KEY_SIZE = 256;
|
|
4
6
|
|
|
7
|
+
/** Read a dot-path value ('a.b.c') */
|
|
8
|
+
function getPath(obj: any, path: string): any {
|
|
9
|
+
return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Write a dot-path value ('a.b.c') — only if the intermediate chain exists */
|
|
13
|
+
function setPath(obj: Record<string, any>, path: string, value: any): void {
|
|
14
|
+
const parts = path.split(".");
|
|
15
|
+
let cur: any = obj;
|
|
16
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
17
|
+
const next = cur?.[parts[i]!];
|
|
18
|
+
if (!next || typeof next !== "object") return;
|
|
19
|
+
cur = next;
|
|
20
|
+
}
|
|
21
|
+
cur[parts[parts.length - 1]!] = value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Decrypt the values at `paths` inside a json value (object or array of json
|
|
26
|
+
* objects). Values are JSON-wrapped (field-level convention) → JSON.parse on decrypt.
|
|
27
|
+
* Non-ciphertext values are kept as-is.
|
|
28
|
+
*/
|
|
29
|
+
async function decryptJsonPaths(value: any, paths: string[], decryptOne: (ciphertext: string) => Promise<string>): Promise<any> {
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
return Promise.all(value.map((item) => decryptJsonPaths(item, paths, decryptOne)));
|
|
32
|
+
}
|
|
33
|
+
if (!value || typeof value !== "object") return value;
|
|
34
|
+
const out: Record<string, any> = { ...value };
|
|
35
|
+
for (const p of paths) {
|
|
36
|
+
const v = getPath(out, p);
|
|
37
|
+
if (typeof v === "string") {
|
|
38
|
+
try {
|
|
39
|
+
setPath(out, p, JSON.parse(await decryptOne(v))); // round-trips any JSON value
|
|
40
|
+
} catch {
|
|
41
|
+
// not a ciphertext (plaintext value) → keep as-is
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
5
48
|
/**
|
|
6
49
|
* Build AES-GCM parameters with optional Additional Authenticated Data.
|
|
7
50
|
* AAD binds the ciphertext to its context (field/record/tenant) so a valid
|
|
@@ -37,58 +80,124 @@ interface JsonWebKey {
|
|
|
37
80
|
}
|
|
38
81
|
|
|
39
82
|
class CryptSym {
|
|
40
|
-
#
|
|
41
|
-
#
|
|
83
|
+
#activeSecret: string;
|
|
84
|
+
#version: number;
|
|
85
|
+
#previousSecrets: Record<number, string>;
|
|
86
|
+
#defaultAad?: string;
|
|
87
|
+
#keyCache = new Map<string, CryptoKey>(); // derived key per secret
|
|
42
88
|
|
|
43
|
-
constructor(options: { secret: string }) {
|
|
44
|
-
this.#
|
|
89
|
+
constructor(options: { secret: string; version?: number; previousSecrets?: Record<number, string>; aad?: string }) {
|
|
90
|
+
this.#activeSecret = options.secret;
|
|
91
|
+
this.#version = options.version ?? 1;
|
|
92
|
+
this.#previousSecrets = options.previousSecrets ?? {};
|
|
93
|
+
this.#defaultAad = options.aad;
|
|
45
94
|
}
|
|
46
95
|
|
|
47
|
-
async #getKey(): Promise<CryptoKey> {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
96
|
+
async #getKey(secret: string): Promise<CryptoKey> {
|
|
97
|
+
let key = this.#keyCache.get(secret);
|
|
98
|
+
if (key) return key;
|
|
99
|
+
const hash = new Bun.CryptoHasher("sha256").update(secret).digest();
|
|
100
|
+
key = await crypto.subtle.importKey(
|
|
51
101
|
"raw",
|
|
52
102
|
new Uint8Array(hash),
|
|
53
103
|
{ name: "AES-GCM" },
|
|
54
104
|
false,
|
|
55
105
|
["encrypt", "decrypt"]
|
|
56
106
|
);
|
|
57
|
-
|
|
107
|
+
this.#keyCache.set(secret, key);
|
|
108
|
+
return key;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Secret for a given version — previous keys are decrypt-only */
|
|
112
|
+
#secretFor(version: number): string | null {
|
|
113
|
+
if (version === this.#version) return this.#activeSecret;
|
|
114
|
+
return this.#previousSecrets[version] ?? null;
|
|
58
115
|
}
|
|
59
116
|
|
|
60
117
|
async encrypt(data: string | object, aad?: string): Promise<string> {
|
|
61
118
|
const text = typeof data === "object" ? JSON.stringify(data) : data;
|
|
62
|
-
const key = await this.#getKey();
|
|
119
|
+
const key = await this.#getKey(this.#activeSecret);
|
|
63
120
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
121
|
+
const ctx = aad ?? this.#defaultAad; // explicit AAD wins, else config default
|
|
64
122
|
const encrypted = await crypto.subtle.encrypt(
|
|
65
|
-
gcmParams(iv,
|
|
123
|
+
gcmParams(iv, ctx),
|
|
66
124
|
key,
|
|
67
125
|
new TextEncoder().encode(text)
|
|
68
126
|
);
|
|
69
|
-
|
|
70
|
-
buffer
|
|
71
|
-
buffer
|
|
127
|
+
// [ version(1) | iv(12) | ciphertext ] — version tags the key used
|
|
128
|
+
const buffer = new Uint8Array(1 + IV_LENGTH + encrypted.byteLength);
|
|
129
|
+
buffer[0] = this.#version & 0xff;
|
|
130
|
+
buffer.set(iv, 1);
|
|
131
|
+
buffer.set(new Uint8Array(encrypted), 1 + IV_LENGTH);
|
|
72
132
|
return Buffer.from(buffer).toString("base64");
|
|
73
133
|
}
|
|
74
134
|
|
|
75
|
-
|
|
135
|
+
/** Decrypt ciphertext → plaintext string (versioned keyring + AAD candidates + legacy fallback) */
|
|
136
|
+
async #decryptText(cipher: string, aad?: string): Promise<string> {
|
|
137
|
+
const buffer = new Uint8Array(Buffer.from(cipher, "base64"));
|
|
138
|
+
if (buffer.length < IV_LENGTH + TAG_LENGTH) {
|
|
139
|
+
throw new Error("ciphertext too short");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// AAD candidates: explicit AAD wins → [aad]; otherwise try the config
|
|
143
|
+
// default first, then no-AAD (legacy ciphertexts from before this feature)
|
|
144
|
+
const aads: (string | undefined)[] = aad !== undefined
|
|
145
|
+
? [aad]
|
|
146
|
+
: this.#defaultAad !== undefined ? [this.#defaultAad, undefined] : [undefined];
|
|
147
|
+
|
|
148
|
+
const tryDecrypt = async (version: number, offset: number, ctx?: string): Promise<string | null> => {
|
|
149
|
+
const secret = this.#secretFor(version);
|
|
150
|
+
if (!secret || buffer.length < offset + IV_LENGTH + TAG_LENGTH) return null;
|
|
151
|
+
const iv = buffer.slice(offset, offset + IV_LENGTH);
|
|
152
|
+
const data = buffer.slice(offset + IV_LENGTH);
|
|
153
|
+
try {
|
|
154
|
+
const key = await this.#getKey(secret);
|
|
155
|
+
const decrypted = await crypto.subtle.decrypt(gcmParams(iv, ctx), key, data);
|
|
156
|
+
return new TextDecoder().decode(decrypted);
|
|
157
|
+
} catch {
|
|
158
|
+
return null; // wrong key, wrong AAD, or tampered data — try the next path
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// 1. Versioned format: first byte = key version (0 = legacy, never written)
|
|
163
|
+
const v = buffer[0] ?? 0;
|
|
164
|
+
if (v > 0) {
|
|
165
|
+
for (const ctx of aads) {
|
|
166
|
+
const plain = await tryDecrypt(v, 1, ctx);
|
|
167
|
+
if (plain !== null) return plain;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// 2. Legacy format (pre-rotation ciphertexts): no version byte, active key
|
|
171
|
+
for (const ctx of aads) {
|
|
172
|
+
const plain = await tryDecrypt(this.#version, 0, ctx);
|
|
173
|
+
if (plain !== null) return plain;
|
|
174
|
+
}
|
|
175
|
+
throw new Error("invalid ciphertext, tampered data, or unknown key version");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Decrypt a ciphertext (string) or a plaintext JSON container (object).
|
|
180
|
+
* - `parseJson: true` → the decrypted text is JSON-parsed.
|
|
181
|
+
* - 3rd param: `string` = AAD context; `string[]` = fields (dot-paths) to decrypt
|
|
182
|
+
* inside the JSON (encrypted sub-fields, e.g. ['zip', 'contacts.phone']).
|
|
183
|
+
*/
|
|
184
|
+
async decrypt<T = string>(cipher: string | object, parseJson?: boolean, fieldsOrAad?: string | string[]): Promise<T> {
|
|
76
185
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
186
|
+
// Plaintext JSON container (e.g. a json field returned by a query) with
|
|
187
|
+
// encrypted sub-fields → decrypt only the listed paths.
|
|
188
|
+
if (cipher && typeof cipher === "object") {
|
|
189
|
+
const fields = (Array.isArray(fieldsOrAad) ? fieldsOrAad : fieldsOrAad ? [fieldsOrAad] : []) as string[];
|
|
190
|
+
return await decryptJsonPaths(cipher, fields, (c) => this.#decryptText(c)) as T;
|
|
80
191
|
}
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (parseJson) return JSON.parse(text) as T;
|
|
91
|
-
return text as T;
|
|
192
|
+
|
|
193
|
+
const aad = typeof fieldsOrAad === "string" ? fieldsOrAad : undefined;
|
|
194
|
+
const fields = Array.isArray(fieldsOrAad) ? fieldsOrAad : [];
|
|
195
|
+
const plain = await this.#decryptText(cipher as string, aad);
|
|
196
|
+
let value: any = (parseJson || fields.length > 0) ? JSON.parse(plain) : plain;
|
|
197
|
+
if (fields.length && value && typeof value === "object") {
|
|
198
|
+
value = await decryptJsonPaths(value, fields, (c) => this.#decryptText(c));
|
|
199
|
+
}
|
|
200
|
+
return value as T;
|
|
92
201
|
} catch (err: any) {
|
|
93
202
|
throw new Error(`Decryption failed: ${err?.message || 'invalid ciphertext or tampered data'}`);
|
|
94
203
|
}
|
|
@@ -179,40 +288,61 @@ class CryptAsym {
|
|
|
179
288
|
return Buffer.from(result).toString("base64");
|
|
180
289
|
}
|
|
181
290
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
291
|
+
/** Decrypt ciphertext → plaintext string */
|
|
292
|
+
async #decryptText(cipher: string, aad?: string): Promise<string> {
|
|
293
|
+
const buffer = new Uint8Array(Buffer.from(cipher, "base64"));
|
|
294
|
+
if (buffer.length < RSA_KEY_SIZE + IV_LENGTH + TAG_LENGTH) {
|
|
295
|
+
throw new Error("ciphertext too short");
|
|
296
|
+
}
|
|
188
297
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
298
|
+
const wrappedKey = buffer.slice(0, RSA_KEY_SIZE);
|
|
299
|
+
const iv = buffer.slice(RSA_KEY_SIZE, RSA_KEY_SIZE + IV_LENGTH);
|
|
300
|
+
const ciphertext = buffer.slice(RSA_KEY_SIZE + IV_LENGTH);
|
|
192
301
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
302
|
+
const { privateKey } = await this.#getKeyPair();
|
|
303
|
+
const rawAesKey = await crypto.subtle.decrypt(
|
|
304
|
+
{ name: "RSA-OAEP" },
|
|
305
|
+
privateKey,
|
|
306
|
+
wrappedKey
|
|
307
|
+
);
|
|
199
308
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
309
|
+
const aesKey = await crypto.subtle.importKey(
|
|
310
|
+
"raw",
|
|
311
|
+
rawAesKey,
|
|
312
|
+
{ name: "AES-GCM" },
|
|
313
|
+
false,
|
|
314
|
+
["decrypt"]
|
|
315
|
+
);
|
|
207
316
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
317
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
318
|
+
gcmParams(iv, aad),
|
|
319
|
+
aesKey,
|
|
320
|
+
ciphertext
|
|
321
|
+
);
|
|
322
|
+
return new TextDecoder().decode(decrypted);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Decrypt a ciphertext (string) or a plaintext JSON container (object).
|
|
327
|
+
* - `parseJson: true` → the decrypted text is JSON-parsed.
|
|
328
|
+
* - 3rd param: `string` = AAD context; `string[]` = fields (dot-paths) to decrypt
|
|
329
|
+
* inside the JSON (encrypted sub-fields).
|
|
330
|
+
*/
|
|
331
|
+
async decrypt<T = string>(cipher: string | object, parseJson?: boolean, fieldsOrAad?: string | string[]): Promise<T> {
|
|
332
|
+
try {
|
|
333
|
+
if (cipher && typeof cipher === "object") {
|
|
334
|
+
const fields = (Array.isArray(fieldsOrAad) ? fieldsOrAad : fieldsOrAad ? [fieldsOrAad] : []) as string[];
|
|
335
|
+
return await decryptJsonPaths(cipher, fields, (c) => this.#decryptText(c)) as T;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const aad = typeof fieldsOrAad === "string" ? fieldsOrAad : undefined;
|
|
339
|
+
const fields = Array.isArray(fieldsOrAad) ? fieldsOrAad : [];
|
|
340
|
+
const plain = await this.#decryptText(cipher as string, aad);
|
|
341
|
+
let value: any = (parseJson || fields.length > 0) ? JSON.parse(plain) : plain;
|
|
342
|
+
if (fields.length && value && typeof value === "object") {
|
|
343
|
+
value = await decryptJsonPaths(value, fields, (c) => this.#decryptText(c));
|
|
344
|
+
}
|
|
345
|
+
return value as T;
|
|
216
346
|
} catch (err: any) {
|
|
217
347
|
throw new Error(`Decryption failed: ${err?.message || 'invalid ciphertext or tampered data'}`);
|
|
218
348
|
}
|
|
@@ -237,7 +367,51 @@ class CryptAsym {
|
|
|
237
367
|
}
|
|
238
368
|
}
|
|
239
369
|
|
|
240
|
-
|
|
241
|
-
|
|
370
|
+
type SymCryptOptions = {
|
|
371
|
+
secret?: string;
|
|
372
|
+
/** Version of the active secret — written into new ciphertexts (default: 1) */
|
|
373
|
+
version?: number;
|
|
374
|
+
/** Older secrets for decrypt-only — key rotation, e.g. { 1: 'v1-secret' } */
|
|
375
|
+
previousSecrets?: Record<number, string>;
|
|
376
|
+
/** Default AAD context — used when no explicit AAD is passed (config default wins over nothing) */
|
|
377
|
+
aad?: string;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const sym = (options?: SymCryptOptions) => {
|
|
381
|
+
const enc = cfg.server.encryption;
|
|
382
|
+
const secret = options?.secret ?? enc?.secret ?? Bun.env.APP_SECRET;
|
|
383
|
+
if (enc?.mode && enc.mode !== 'symmetric' && options?.secret === undefined) {
|
|
384
|
+
throw new Error(`useSymCrypt: server.encryption.mode is '${enc.mode}' — use utils.crypt.useAsymCrypt() or set mode: 'symmetric'`);
|
|
385
|
+
}
|
|
386
|
+
if (!secret) {
|
|
387
|
+
throw new Error("useSymCrypt: missing secret — pass { secret } or set server.encryption.secret / APP_SECRET env");
|
|
388
|
+
}
|
|
389
|
+
return new CryptSym({
|
|
390
|
+
secret,
|
|
391
|
+
version: options?.version ?? enc?.version ?? 1,
|
|
392
|
+
previousSecrets: options?.previousSecrets ?? enc?.previousSecrets,
|
|
393
|
+
aad: options?.aad ?? enc?.aad,
|
|
394
|
+
});
|
|
395
|
+
};
|
|
396
|
+
const asym = (options?: { privateKey?: JsonWebKey }) => {
|
|
397
|
+
const enc = cfg.server.encryption;
|
|
398
|
+
if (enc?.mode && enc.mode !== 'asymmetric' && options?.privateKey === undefined) {
|
|
399
|
+
throw new Error(`useAsymCrypt: server.encryption.mode is '${enc.mode}' — use utils.crypt.useSymCrypt() or set mode: 'asymmetric'`);
|
|
400
|
+
}
|
|
401
|
+
const cfgKey = enc?.privateKey;
|
|
402
|
+
const privateKey = options?.privateKey ?? (typeof cfgKey === 'string' ? JSON.parse(cfgKey) : cfgKey);
|
|
403
|
+
return new CryptAsym(privateKey ? { privateKey } : undefined);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Resolve the instance matching `server.encryption.mode`
|
|
408
|
+
* ('symmetric' → useSymCrypt, 'asymmetric' → useAsymCrypt).
|
|
409
|
+
*/
|
|
410
|
+
const resolve = () => {
|
|
411
|
+
const mode = cfg.server.encryption?.mode;
|
|
412
|
+
if (mode === 'symmetric') return sym();
|
|
413
|
+
if (mode === 'asymmetric') return asym();
|
|
414
|
+
throw new Error("utils.crypt.resolve(): server.encryption.mode is required ('symmetric' | 'asymmetric')");
|
|
415
|
+
};
|
|
242
416
|
|
|
243
|
-
export { sym as useSymCrypt, asym as useAsymCrypt };
|
|
417
|
+
export { sym as useSymCrypt, asym as useAsymCrypt, resolve };
|
package/utils/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cleanDeep, clone, deepCopy, omit, pick, jwt, isSlug } from './func';
|
|
2
|
+
import { useSymCrypt, useAsymCrypt, resolve } from './crypto';
|
|
2
3
|
const jose = jwt
|
|
3
4
|
const utils = {
|
|
4
5
|
password: Bun.password,
|
|
@@ -12,6 +13,8 @@ const utils = {
|
|
|
12
13
|
pick,
|
|
13
14
|
jose,
|
|
14
15
|
isSlug,
|
|
16
|
+
// Encryption — AES-256-GCM sym + RSA-OAEP asym (never shadow the Web Crypto global)
|
|
17
|
+
crypt: { useSymCrypt, useAsymCrypt, resolve },
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
|