@getstrata/core 2.2.10 → 2.2.12

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @getstrata/core changelog
2
2
 
3
+ ## 2.2.12
4
+
5
+ - Require Bun >=1.4.3; align CI, generated/existing example runtime pins and Docker stages to 1.4.3, with bun-types >=1.4.3 behind the official @types/bun shim.
6
+
7
+ Add identified field-encryption keyrings, reader-first email/MFA configuration, and explicit transactional maintenance rotation for ciphertext and lookup keys with durable checkpoints, RLS scopes, fresh retirement audits and backup/rollback guidance. Fix SQLite primary-key compilation for non-generated keys. Preserve legacy auth defaults; no automatic data migration.
8
+
9
+ ## 2.2.11
10
+
11
+ Add independently interruptible read-only SQLite and MySQL observations; extend bounded failed-job and outbox metrics to file-backed SQLite and URL-backed MySQL with schema/index guards, real-engine regressions and three-process consistency tests. Preserve Postgres RLS and business transaction behavior.
12
+
3
13
  ## 2.2.10
4
14
 
5
15
  - Add bounded OpenTelemetry SDK health observations and selected native transaction-acquisition histograms through explicit metrics options.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @getstrata/core
2
2
 
3
- Runtime library for [Strata](https://github.com/EyK-26/strata) apps: HTTP, auth, database, queue, mail, and security. Requires Bun (tested on 1.4.x).
3
+ Runtime library for [Strata](https://github.com/EyK-26/strata) apps: HTTP, auth, database, queue, mail, and security. Requires Bun >=1.4.3 (tested on 1.4.3).
4
4
 
5
5
  Generate an app rather than wiring this by hand:
6
6
 
@@ -11,3 +11,32 @@ declare function protectEmail(email: string): {
11
11
  declare function revealEmail(storedEmail: string): string;
12
12
  declare function emailLookupForQuery(email: string): string;
13
13
  export { decryptField, emailLookupForQuery, encryptField, hashLookupValue, isFieldEncryptionEnabled, normalizeEmail, protectEmail, resolveEncryptionKey, revealEmail, };
14
+ interface FieldEncryptionKeyringOptions {
15
+ /** Public stable identifiers; never reuse an ID for different key material. */
16
+ activeKeyId: string;
17
+ encryptionKeys: Readonly<Record<string, Uint8Array>>;
18
+ /** Explicitly retained key for unidentified enc:v1 records and historical backups. */
19
+ legacyV1Key?: Uint8Array;
20
+ /** Pinned independently of write-key rotation. Use the original key to preserve v1 email hashes. */
21
+ lookupKey: Uint8Array;
22
+ }
23
+ interface FieldEncryptionKeyring {
24
+ readonly activeKeyId: string;
25
+ encrypt(plaintext: string, purpose: string): string;
26
+ decrypt(ciphertext: string, purpose: string): string;
27
+ /** Input must already be normalized using the application's existing lookup contract. */
28
+ lookup(normalizedValue: string): string;
29
+ }
30
+ /** Opt-in primitives only. This does not activate auth rotation or migrate persisted rows. */
31
+ declare function createFieldEncryptionKeyring(options: FieldEncryptionKeyringOptions): FieldEncryptionKeyring;
32
+ export type { FieldEncryptionKeyring, FieldEncryptionKeyringOptions };
33
+ export { createFieldEncryptionKeyring };
34
+ interface ConfiguredFieldEncryption {
35
+ readonly keyring: FieldEncryptionKeyring;
36
+ readonly writeVersion: 1 | 2;
37
+ encrypt(plaintext: string, purpose: string): string;
38
+ }
39
+ /** Stateless resolution keeps separately bundled auth entrypoints on the same configuration. */
40
+ declare function resolveFieldEncryptionKeyring(env?: Record<string, string | undefined>): ConfiguredFieldEncryption | null;
41
+ export type { ConfiguredFieldEncryption };
42
+ export { resolveFieldEncryptionKeyring };
@@ -0,0 +1,34 @@
1
+ import { BaseRepository } from "../database/baseRepository";
2
+ import type { Migration } from "../database/migrations/types";
3
+ import { type DatabaseDriver } from "../database/schema";
4
+ import { type FieldEncryptionKeyring } from "./fieldEncryption";
5
+ declare function createFieldEncryptionRotationMigration(name: string, driver: DatabaseDriver, options?: {
6
+ rls?: boolean;
7
+ }): Migration;
8
+ interface FieldEncryptionRotationOptions<TEntity extends object, PK extends keyof TEntity & string> {
9
+ repository: BaseRepository<TEntity, PK>;
10
+ /** A distinct durable run identity per table, tenant/filter scope and audit pass. */
11
+ runId: string;
12
+ scopeId: string;
13
+ /** Required operator assertion: all application/worker/admin writers are stopped. Not an auth grant. */
14
+ maintenanceMode: true;
15
+ source: FieldEncryptionKeyring;
16
+ target: FieldEncryptionKeyring;
17
+ fields: readonly {
18
+ column: Exclude<keyof TEntity & string, PK>;
19
+ purpose: string;
20
+ lookupColumn?: Exclude<keyof TEntity & string, PK>;
21
+ }[];
22
+ mode?: "rotate" | "audit";
23
+ batchSize?: number;
24
+ signal?: AbortSignal;
25
+ }
26
+ interface FieldEncryptionRotationBatch {
27
+ processed: number;
28
+ changed: number;
29
+ completed: boolean;
30
+ }
31
+ /** One bounded transaction. Persisted cursors advance only with committed row writes. No RLS bypass. */
32
+ declare function runFieldEncryptionRotationBatch<TEntity extends object, PK extends keyof TEntity & string>(options: FieldEncryptionRotationOptions<TEntity, PK>): Promise<FieldEncryptionRotationBatch>;
33
+ export type { FieldEncryptionRotationBatch, FieldEncryptionRotationOptions };
34
+ export { createFieldEncryptionRotationMigration, runFieldEncryptionRotationBatch };
@@ -17,6 +17,11 @@ interface DatabaseConnection {
17
17
  }): Promise<DatabaseConnection & {
18
18
  release(): void | Promise<void>;
19
19
  }>;
20
+ /** Optional independent, interruptible read-only observation; no business transaction or pool slot. */
21
+ observeReadOnly?<T>(operation: (connection: DatabaseConnection) => Promise<T>, options: {
22
+ signal: AbortSignal;
23
+ timeoutMs: number;
24
+ }): Promise<T>;
20
25
  /** SQLite closes synchronously; pooled drivers return a promise. */
21
26
  close?(): void | Promise<void>;
22
27
  }
@@ -0,0 +1,15 @@
1
+ import type { DatabaseConnection } from "./baseRepository";
2
+ interface ReadOnlyObservationOptions {
3
+ signal: AbortSignal;
4
+ timeoutMs: number;
5
+ }
6
+ declare function observeIsolatedReadOnly<T>(profile: {
7
+ driver: "sqlite";
8
+ filename: string;
9
+ } | {
10
+ driver: "mysql";
11
+ url: string;
12
+ module: string;
13
+ }, operation: (connection: DatabaseConnection) => Promise<T>, options: ReadOnlyObservationOptions): Promise<T>;
14
+ export type { ReadOnlyObservationOptions };
15
+ export { observeIsolatedReadOnly };
@@ -1,4 +1,6 @@
1
+ import type { DatabaseConnection } from "./baseRepository.ts";
1
2
  import { type ActiveDatabaseHandle } from "./connectionContext.ts";
3
+ import { type ReadOnlyObservationOptions } from "./isolatedReadOnlyObservation.ts";
2
4
  type MysqlExecutable = {
3
5
  execute: (sql: string, params?: unknown[]) => Promise<[unknown, unknown]>;
4
6
  query?: (sql: string) => Promise<[unknown, unknown]>;
@@ -19,6 +21,7 @@ type MysqlPoolConnection = MysqlExecutable & {
19
21
  type MysqlConnection = ActiveDatabaseHandle & {
20
22
  begin<T>(callback: (transaction: ActiveDatabaseHandle) => Promise<T>): Promise<T>;
21
23
  close(): Promise<void>;
24
+ observeReadOnly<T>(operation: (connection: DatabaseConnection) => Promise<T>, options: ReadOnlyObservationOptions): Promise<T>;
22
25
  };
23
26
  declare function resetMysqlLoaderForTests(importer?: () => Promise<unknown>): void;
24
27
  declare function createMysqlConnectionFromPool(pool: MysqlExecutable): MysqlConnection;
@@ -0,0 +1,7 @@
1
+ interface ReadOnlyCollector<T> {
2
+ collect(): Promise<T>;
3
+ close(): Promise<void>;
4
+ }
5
+ /** Internal shared observation lifecycle. No bypass, payload or tenant enumeration. */
6
+ declare function createReadOnlyCollector<T>(name: string, timeoutMs: number, collect: (remaining: () => number) => Promise<T>): ReadOnlyCollector<T>;
7
+ export { createReadOnlyCollector };
@@ -1,6 +1,9 @@
1
+ import type { DatabaseConnection } from "./baseRepository.ts";
1
2
  import { type ActiveDatabaseHandle } from "./connectionContext.ts";
3
+ import { type ReadOnlyObservationOptions } from "./isolatedReadOnlyObservation.ts";
2
4
  type SqliteConnection = ActiveDatabaseHandle & {
3
5
  begin<T>(callback: (transaction: ActiveDatabaseHandle) => Promise<T>): Promise<T>;
6
+ observeReadOnly<T>(operation: (connection: DatabaseConnection) => Promise<T>, options: ReadOnlyObservationOptions): Promise<T>;
4
7
  close(): void;
5
8
  };
6
9
  declare function createSqliteConnection(filename: string): SqliteConnection;
@@ -19,7 +19,7 @@ interface OutboxMetricsCollector {
19
19
  }
20
20
  declare const snapshotQuery: string;
21
21
  /** Explicit platform observation, using the same transaction-local RLS bypass as coordination.
22
- * Initially Postgres-only: other engines fail rather than receiving a false empty snapshot.
22
+ * Postgres uses scoped bypass; official SQLite/MySQL adapters use isolated read-only snapshots.
23
23
  */
24
24
  declare function createOutboxMetricsCollector(options?: OutboxMetricsOptions): OutboxMetricsCollector;
25
25
  declare function renderOutboxMetrics(snapshot: OutboxMetricsSnapshot): string;
@@ -530,7 +530,7 @@ function isFieldEncryptionEnabled() {
530
530
  if (featureFlag === "true") {
531
531
  return true;
532
532
  }
533
- return Boolean(resolveEncryptionKey());
533
+ return Boolean(resolveFieldEncryptionKeyring() || resolveEncryptionKey());
534
534
  }
535
535
  function encryptField(plaintext, key) {
536
536
  const iv = randomBytes(IV_LENGTH);
@@ -560,6 +560,13 @@ function normalizeEmail(email) {
560
560
  }
561
561
  function protectEmail(email) {
562
562
  const normalized = normalizeEmail(email);
563
+ const configured = resolveFieldEncryptionKeyring();
564
+ if (configured && isFieldEncryptionEnabled()) {
565
+ return {
566
+ storedEmail: configured.encrypt(normalized, "email"),
567
+ emailLookup: configured.keyring.lookup(normalized)
568
+ };
569
+ }
563
570
  const key = resolveEncryptionKey();
564
571
  if (!key || !isFieldEncryptionEnabled()) {
565
572
  return { storedEmail: normalized, emailLookup: normalized };
@@ -570,6 +577,11 @@ function protectEmail(email) {
570
577
  };
571
578
  }
572
579
  function revealEmail(storedEmail) {
580
+ const configured = resolveFieldEncryptionKeyring();
581
+ if (configured && storedEmail.startsWith("enc:"))
582
+ return configured.keyring.decrypt(storedEmail, "email");
583
+ if (storedEmail.startsWith("enc:") && !storedEmail.startsWith(ENCRYPTION_PREFIX))
584
+ throw new Error("Identified encrypted fields require KMS_ENCRYPTION_KEYRING.");
573
585
  const key = resolveEncryptionKey();
574
586
  if (!key || !storedEmail.startsWith(ENCRYPTION_PREFIX)) {
575
587
  return storedEmail;
@@ -578,15 +590,148 @@ function revealEmail(storedEmail) {
578
590
  }
579
591
  function emailLookupForQuery(email) {
580
592
  const normalized = normalizeEmail(email);
593
+ const configured = resolveFieldEncryptionKeyring();
594
+ if (configured && isFieldEncryptionEnabled())
595
+ return configured.keyring.lookup(normalized);
581
596
  const key = resolveEncryptionKey();
582
597
  if (!key || !isFieldEncryptionEnabled()) {
583
598
  return normalized;
584
599
  }
585
600
  return hashLookupValue(normalized, key);
586
601
  }
602
+ function createFieldEncryptionKeyring(options) {
603
+ const identifier = /^[A-Za-z0-9_-]{1,64}$/;
604
+ const copyKey = (value) => {
605
+ if (!(value instanceof Uint8Array) || value.byteLength !== 32)
606
+ throw new TypeError("Field encryption keys must be 32 bytes.");
607
+ return Buffer.from(value);
608
+ };
609
+ const entries = Object.entries(options.encryptionKeys);
610
+ if (entries.length < 1 || entries.length > 32)
611
+ throw new TypeError("Field encryption keyrings require 1 to 32 keys.");
612
+ const keys = new Map;
613
+ for (const [id, key] of entries) {
614
+ if (!identifier.test(id))
615
+ throw new TypeError("Invalid field encryption key identifier.");
616
+ keys.set(id, copyKey(key));
617
+ }
618
+ const activeKeyId = options.activeKeyId;
619
+ const active = keys.get(activeKeyId);
620
+ if (!active)
621
+ throw new TypeError("Active field encryption key is not retained.");
622
+ const legacy = options.legacyV1Key === undefined ? undefined : copyKey(options.legacyV1Key);
623
+ const lookup = copyKey(options.lookupKey);
624
+ const associatedData = (id, purpose) => {
625
+ if (!identifier.test(purpose))
626
+ throw new TypeError("Invalid field encryption purpose.");
627
+ return Buffer.from(`strata:field:v2:${id}:${purpose}`, "utf8");
628
+ };
629
+ const decodePayload = (encoded) => {
630
+ const payload = Buffer.from(encoded, "base64");
631
+ if (payload.length < IV_LENGTH + TAG_LENGTH || payload.toString("base64") !== encoded)
632
+ throw new Error("Invalid encrypted field.");
633
+ return payload;
634
+ };
635
+ return Object.freeze({
636
+ activeKeyId,
637
+ encrypt(plaintext, purpose) {
638
+ const aad = associatedData(activeKeyId, purpose);
639
+ const iv = randomBytes(IV_LENGTH);
640
+ const cipher = createCipheriv("aes-256-gcm", active, iv, { authTagLength: TAG_LENGTH });
641
+ cipher.setAAD(aad);
642
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
643
+ const payload = Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString("base64");
644
+ return `enc:v2:${activeKeyId}:${payload}`;
645
+ },
646
+ decrypt(value, purpose) {
647
+ associatedData(activeKeyId, purpose);
648
+ try {
649
+ if (value.startsWith(ENCRYPTION_PREFIX)) {
650
+ if (!legacy)
651
+ throw new Error("Missing legacy key");
652
+ decodePayload(value.slice(ENCRYPTION_PREFIX.length));
653
+ return decryptField(value, legacy);
654
+ }
655
+ const parts = value.split(":");
656
+ if (parts.length !== 4 || parts[0] !== "enc" || parts[1] !== "v2")
657
+ throw new Error("Invalid envelope");
658
+ const id = parts[2] ?? "";
659
+ const key = keys.get(id);
660
+ if (!key)
661
+ throw new Error("Missing retained key");
662
+ const payload = decodePayload(parts[3] ?? "");
663
+ const decipher = createDecipheriv("aes-256-gcm", key, payload.subarray(0, IV_LENGTH), {
664
+ authTagLength: TAG_LENGTH
665
+ });
666
+ decipher.setAAD(associatedData(id, purpose));
667
+ decipher.setAuthTag(payload.subarray(payload.length - TAG_LENGTH));
668
+ return Buffer.concat([
669
+ decipher.update(payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH)),
670
+ decipher.final()
671
+ ]).toString("utf8");
672
+ } catch {
673
+ throw new Error("Unable to decrypt encrypted field.");
674
+ }
675
+ },
676
+ lookup(normalizedValue) {
677
+ return hashLookupValue(normalizedValue, lookup);
678
+ }
679
+ });
680
+ }
681
+ function resolveFieldEncryptionKeyring(env = process.env) {
682
+ const raw = env.KMS_ENCRYPTION_KEYRING;
683
+ if (raw === undefined)
684
+ return null;
685
+ try {
686
+ if (!raw.trim() || raw.length > 16384 || env.KMS_ENCRYPTION_KEY?.trim())
687
+ throw new Error("Invalid configuration");
688
+ const parsed = JSON.parse(raw);
689
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
690
+ throw new Error("Invalid configuration");
691
+ const config = parsed;
692
+ if (Object.keys(config).some((key) => !["activeKeyId", "encryptionKeys", "legacyV1Key", "lookupKey", "writeVersion"].includes(key)))
693
+ throw new Error("Unknown option");
694
+ if (typeof config.activeKeyId !== "string" || config.writeVersion !== 1 && config.writeVersion !== 2 || !config.encryptionKeys || typeof config.encryptionKeys !== "object" || Array.isArray(config.encryptionKeys))
695
+ throw new Error("Invalid configuration");
696
+ const decodeKey = (value) => {
697
+ if (typeof value !== "string")
698
+ throw new Error("Invalid key");
699
+ if (/^[0-9a-f]{64}$/i.test(value))
700
+ return Buffer.from(value, "hex");
701
+ const decoded = Buffer.from(value, "base64");
702
+ if (decoded.length !== 32 || decoded.toString("base64") !== value)
703
+ throw new Error("Invalid key");
704
+ return decoded;
705
+ };
706
+ const keys = Object.fromEntries(Object.entries(config.encryptionKeys).map(([id, value]) => [id, decodeKey(value)]));
707
+ const legacy = config.legacyV1Key === undefined ? undefined : decodeKey(config.legacyV1Key);
708
+ const lookup = decodeKey(config.lookupKey);
709
+ if (config.writeVersion === 1 && !legacy || legacy && !legacy.equals(lookup))
710
+ throw new Error("Incompatible lookup key");
711
+ const keyring = createFieldEncryptionKeyring({
712
+ activeKeyId: config.activeKeyId,
713
+ encryptionKeys: keys,
714
+ legacyV1Key: legacy,
715
+ lookupKey: lookup
716
+ });
717
+ const writeVersion = config.writeVersion;
718
+ return Object.freeze({
719
+ keyring,
720
+ writeVersion,
721
+ encrypt(plaintext, purpose) {
722
+ return writeVersion === 1 && legacy ? encryptField(plaintext, legacy) : keyring.encrypt(plaintext, purpose);
723
+ }
724
+ });
725
+ } catch {
726
+ throw new Error("Invalid KMS_ENCRYPTION_KEYRING configuration.");
727
+ }
728
+ }
587
729
 
588
730
  // ../../src/core/crypto/mfaSecret.ts
589
731
  function protectMfaSecret(secret) {
732
+ const configured = resolveFieldEncryptionKeyring();
733
+ if (configured)
734
+ return configured.encrypt(secret, "mfa");
590
735
  const key = resolveEncryptionKey();
591
736
  if (!key) {
592
737
  throw new Error("MFA secrets require KMS_ENCRYPTION_KEY.");
@@ -597,6 +742,14 @@ function revealMfaSecret(stored) {
597
742
  if (!stored) {
598
743
  return null;
599
744
  }
745
+ const configured = resolveFieldEncryptionKeyring();
746
+ if (configured) {
747
+ if (!stored.startsWith("enc:"))
748
+ throw new Error("MFA secrets must be encrypted.");
749
+ return configured.keyring.decrypt(stored, "mfa");
750
+ }
751
+ if (stored.startsWith("enc:") && !stored.startsWith("enc:v1:"))
752
+ throw new Error("Identified MFA secrets require KMS_ENCRYPTION_KEYRING.");
600
753
  if (!stored.startsWith("enc:v1:")) {
601
754
  if (isProductionEnv() || resolveEncryptionKey()) {
602
755
  throw new Error("MFA secrets must be encrypted.");
@@ -274,7 +274,7 @@ function isFieldEncryptionEnabled() {
274
274
  if (featureFlag === "true") {
275
275
  return true;
276
276
  }
277
- return Boolean(resolveEncryptionKey());
277
+ return Boolean(resolveFieldEncryptionKeyring() || resolveEncryptionKey());
278
278
  }
279
279
  function encryptField(plaintext, key) {
280
280
  const iv = randomBytes(IV_LENGTH);
@@ -304,6 +304,13 @@ function normalizeEmail(email) {
304
304
  }
305
305
  function protectEmail(email) {
306
306
  const normalized = normalizeEmail(email);
307
+ const configured = resolveFieldEncryptionKeyring();
308
+ if (configured && isFieldEncryptionEnabled()) {
309
+ return {
310
+ storedEmail: configured.encrypt(normalized, "email"),
311
+ emailLookup: configured.keyring.lookup(normalized)
312
+ };
313
+ }
307
314
  const key = resolveEncryptionKey();
308
315
  if (!key || !isFieldEncryptionEnabled()) {
309
316
  return { storedEmail: normalized, emailLookup: normalized };
@@ -314,6 +321,11 @@ function protectEmail(email) {
314
321
  };
315
322
  }
316
323
  function revealEmail(storedEmail) {
324
+ const configured = resolveFieldEncryptionKeyring();
325
+ if (configured && storedEmail.startsWith("enc:"))
326
+ return configured.keyring.decrypt(storedEmail, "email");
327
+ if (storedEmail.startsWith("enc:") && !storedEmail.startsWith(ENCRYPTION_PREFIX))
328
+ throw new Error("Identified encrypted fields require KMS_ENCRYPTION_KEYRING.");
317
329
  const key = resolveEncryptionKey();
318
330
  if (!key || !storedEmail.startsWith(ENCRYPTION_PREFIX)) {
319
331
  return storedEmail;
@@ -322,15 +334,148 @@ function revealEmail(storedEmail) {
322
334
  }
323
335
  function emailLookupForQuery(email) {
324
336
  const normalized = normalizeEmail(email);
337
+ const configured = resolveFieldEncryptionKeyring();
338
+ if (configured && isFieldEncryptionEnabled())
339
+ return configured.keyring.lookup(normalized);
325
340
  const key = resolveEncryptionKey();
326
341
  if (!key || !isFieldEncryptionEnabled()) {
327
342
  return normalized;
328
343
  }
329
344
  return hashLookupValue(normalized, key);
330
345
  }
346
+ function createFieldEncryptionKeyring(options) {
347
+ const identifier = /^[A-Za-z0-9_-]{1,64}$/;
348
+ const copyKey = (value) => {
349
+ if (!(value instanceof Uint8Array) || value.byteLength !== 32)
350
+ throw new TypeError("Field encryption keys must be 32 bytes.");
351
+ return Buffer.from(value);
352
+ };
353
+ const entries = Object.entries(options.encryptionKeys);
354
+ if (entries.length < 1 || entries.length > 32)
355
+ throw new TypeError("Field encryption keyrings require 1 to 32 keys.");
356
+ const keys = new Map;
357
+ for (const [id, key] of entries) {
358
+ if (!identifier.test(id))
359
+ throw new TypeError("Invalid field encryption key identifier.");
360
+ keys.set(id, copyKey(key));
361
+ }
362
+ const activeKeyId = options.activeKeyId;
363
+ const active = keys.get(activeKeyId);
364
+ if (!active)
365
+ throw new TypeError("Active field encryption key is not retained.");
366
+ const legacy = options.legacyV1Key === undefined ? undefined : copyKey(options.legacyV1Key);
367
+ const lookup = copyKey(options.lookupKey);
368
+ const associatedData = (id, purpose) => {
369
+ if (!identifier.test(purpose))
370
+ throw new TypeError("Invalid field encryption purpose.");
371
+ return Buffer.from(`strata:field:v2:${id}:${purpose}`, "utf8");
372
+ };
373
+ const decodePayload = (encoded) => {
374
+ const payload = Buffer.from(encoded, "base64");
375
+ if (payload.length < IV_LENGTH + TAG_LENGTH || payload.toString("base64") !== encoded)
376
+ throw new Error("Invalid encrypted field.");
377
+ return payload;
378
+ };
379
+ return Object.freeze({
380
+ activeKeyId,
381
+ encrypt(plaintext, purpose) {
382
+ const aad = associatedData(activeKeyId, purpose);
383
+ const iv = randomBytes(IV_LENGTH);
384
+ const cipher = createCipheriv("aes-256-gcm", active, iv, { authTagLength: TAG_LENGTH });
385
+ cipher.setAAD(aad);
386
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
387
+ const payload = Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString("base64");
388
+ return `enc:v2:${activeKeyId}:${payload}`;
389
+ },
390
+ decrypt(value, purpose) {
391
+ associatedData(activeKeyId, purpose);
392
+ try {
393
+ if (value.startsWith(ENCRYPTION_PREFIX)) {
394
+ if (!legacy)
395
+ throw new Error("Missing legacy key");
396
+ decodePayload(value.slice(ENCRYPTION_PREFIX.length));
397
+ return decryptField(value, legacy);
398
+ }
399
+ const parts = value.split(":");
400
+ if (parts.length !== 4 || parts[0] !== "enc" || parts[1] !== "v2")
401
+ throw new Error("Invalid envelope");
402
+ const id = parts[2] ?? "";
403
+ const key = keys.get(id);
404
+ if (!key)
405
+ throw new Error("Missing retained key");
406
+ const payload = decodePayload(parts[3] ?? "");
407
+ const decipher = createDecipheriv("aes-256-gcm", key, payload.subarray(0, IV_LENGTH), {
408
+ authTagLength: TAG_LENGTH
409
+ });
410
+ decipher.setAAD(associatedData(id, purpose));
411
+ decipher.setAuthTag(payload.subarray(payload.length - TAG_LENGTH));
412
+ return Buffer.concat([
413
+ decipher.update(payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH)),
414
+ decipher.final()
415
+ ]).toString("utf8");
416
+ } catch {
417
+ throw new Error("Unable to decrypt encrypted field.");
418
+ }
419
+ },
420
+ lookup(normalizedValue) {
421
+ return hashLookupValue(normalizedValue, lookup);
422
+ }
423
+ });
424
+ }
425
+ function resolveFieldEncryptionKeyring(env = process.env) {
426
+ const raw = env.KMS_ENCRYPTION_KEYRING;
427
+ if (raw === undefined)
428
+ return null;
429
+ try {
430
+ if (!raw.trim() || raw.length > 16384 || env.KMS_ENCRYPTION_KEY?.trim())
431
+ throw new Error("Invalid configuration");
432
+ const parsed = JSON.parse(raw);
433
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
434
+ throw new Error("Invalid configuration");
435
+ const config = parsed;
436
+ if (Object.keys(config).some((key) => !["activeKeyId", "encryptionKeys", "legacyV1Key", "lookupKey", "writeVersion"].includes(key)))
437
+ throw new Error("Unknown option");
438
+ if (typeof config.activeKeyId !== "string" || config.writeVersion !== 1 && config.writeVersion !== 2 || !config.encryptionKeys || typeof config.encryptionKeys !== "object" || Array.isArray(config.encryptionKeys))
439
+ throw new Error("Invalid configuration");
440
+ const decodeKey = (value) => {
441
+ if (typeof value !== "string")
442
+ throw new Error("Invalid key");
443
+ if (/^[0-9a-f]{64}$/i.test(value))
444
+ return Buffer.from(value, "hex");
445
+ const decoded = Buffer.from(value, "base64");
446
+ if (decoded.length !== 32 || decoded.toString("base64") !== value)
447
+ throw new Error("Invalid key");
448
+ return decoded;
449
+ };
450
+ const keys = Object.fromEntries(Object.entries(config.encryptionKeys).map(([id, value]) => [id, decodeKey(value)]));
451
+ const legacy = config.legacyV1Key === undefined ? undefined : decodeKey(config.legacyV1Key);
452
+ const lookup = decodeKey(config.lookupKey);
453
+ if (config.writeVersion === 1 && !legacy || legacy && !legacy.equals(lookup))
454
+ throw new Error("Incompatible lookup key");
455
+ const keyring = createFieldEncryptionKeyring({
456
+ activeKeyId: config.activeKeyId,
457
+ encryptionKeys: keys,
458
+ legacyV1Key: legacy,
459
+ lookupKey: lookup
460
+ });
461
+ const writeVersion = config.writeVersion;
462
+ return Object.freeze({
463
+ keyring,
464
+ writeVersion,
465
+ encrypt(plaintext, purpose) {
466
+ return writeVersion === 1 && legacy ? encryptField(plaintext, legacy) : keyring.encrypt(plaintext, purpose);
467
+ }
468
+ });
469
+ } catch {
470
+ throw new Error("Invalid KMS_ENCRYPTION_KEYRING configuration.");
471
+ }
472
+ }
331
473
 
332
474
  // ../../src/core/crypto/mfaSecret.ts
333
475
  function protectMfaSecret(secret) {
476
+ const configured = resolveFieldEncryptionKeyring();
477
+ if (configured)
478
+ return configured.encrypt(secret, "mfa");
334
479
  const key = resolveEncryptionKey();
335
480
  if (!key) {
336
481
  throw new Error("MFA secrets require KMS_ENCRYPTION_KEY.");
@@ -341,6 +486,14 @@ function revealMfaSecret(stored) {
341
486
  if (!stored) {
342
487
  return null;
343
488
  }
489
+ const configured = resolveFieldEncryptionKeyring();
490
+ if (configured) {
491
+ if (!stored.startsWith("enc:"))
492
+ throw new Error("MFA secrets must be encrypted.");
493
+ return configured.keyring.decrypt(stored, "mfa");
494
+ }
495
+ if (stored.startsWith("enc:") && !stored.startsWith("enc:v1:"))
496
+ throw new Error("Identified MFA secrets require KMS_ENCRYPTION_KEYRING.");
344
497
  if (!stored.startsWith("enc:v1:")) {
345
498
  if (isProductionEnv() || resolveEncryptionKey()) {
346
499
  throw new Error("MFA secrets must be encrypted.");