@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/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
- #key: CryptoKey | null = null;
41
- #secret: string;
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.#secret = options.secret;
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
- if (this.#key) return this.#key;
49
- const hash = new Bun.CryptoHasher("sha256").update(this.#secret).digest();
50
- this.#key = await crypto.subtle.importKey(
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
- return this.#key;
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, aad),
123
+ gcmParams(iv, ctx),
66
124
  key,
67
125
  new TextEncoder().encode(text)
68
126
  );
69
- const buffer = new Uint8Array(IV_LENGTH + encrypted.byteLength);
70
- buffer.set(iv, 0);
71
- buffer.set(new Uint8Array(encrypted), IV_LENGTH);
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
- async decrypt<T = string>(cipher: string, parseJson?: boolean, aad?: string): Promise<T> {
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
- const buffer = new Uint8Array(Buffer.from(cipher, "base64"));
78
- if (buffer.length < IV_LENGTH + TAG_LENGTH) {
79
- throw new Error("ciphertext too short");
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
- const iv = buffer.slice(0, IV_LENGTH);
82
- const data = buffer.slice(IV_LENGTH);
83
- const key = await this.#getKey();
84
- const decrypted = await crypto.subtle.decrypt(
85
- gcmParams(iv, aad),
86
- key,
87
- data
88
- );
89
- const text = new TextDecoder().decode(decrypted);
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
- async decrypt<T = string>(cipher: string, parseJson?: boolean, aad?: string): Promise<T> {
183
- try {
184
- const buffer = new Uint8Array(Buffer.from(cipher, "base64"));
185
- if (buffer.length < RSA_KEY_SIZE + IV_LENGTH + TAG_LENGTH) {
186
- throw new Error("ciphertext too short");
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
- const wrappedKey = buffer.slice(0, RSA_KEY_SIZE);
190
- const iv = buffer.slice(RSA_KEY_SIZE, RSA_KEY_SIZE + IV_LENGTH);
191
- const ciphertext = buffer.slice(RSA_KEY_SIZE + IV_LENGTH);
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
- const { privateKey } = await this.#getKeyPair();
194
- const rawAesKey = await crypto.subtle.decrypt(
195
- { name: "RSA-OAEP" },
196
- privateKey,
197
- wrappedKey
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
- const aesKey = await crypto.subtle.importKey(
201
- "raw",
202
- rawAesKey,
203
- { name: "AES-GCM" },
204
- false,
205
- ["decrypt"]
206
- );
309
+ const aesKey = await crypto.subtle.importKey(
310
+ "raw",
311
+ rawAesKey,
312
+ { name: "AES-GCM" },
313
+ false,
314
+ ["decrypt"]
315
+ );
207
316
 
208
- const decrypted = await crypto.subtle.decrypt(
209
- gcmParams(iv, aad),
210
- aesKey,
211
- ciphertext
212
- );
213
- const text = new TextDecoder().decode(decrypted);
214
- if (parseJson) return JSON.parse(text) as T;
215
- return text as T;
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
- const sym = (options: { secret: string }) => new CryptSym(options);
241
- const asym = (options?: { privateKey?: JsonWebKey }) => new CryptAsym(options);
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