@forklaunch/core 1.5.19 → 1.6.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.
@@ -166,7 +166,7 @@ import { p, Type as Type2 } from "@mikro-orm/core";
166
166
  // src/persistence/encryptedType.ts
167
167
  import { Type } from "@mikro-orm/core";
168
168
  import { AsyncLocalStorage } from "async_hooks";
169
- var ENCRYPTED_PREFIXES = ["v1:", "v2:"];
169
+ var ENCRYPTED_PREFIXES = ["v1:", "v2:", "v3:"];
170
170
  function isEncrypted(value) {
171
171
  return ENCRYPTED_PREFIXES.some((p3) => value.startsWith(p3));
172
172
  }
@@ -573,26 +573,122 @@ var IV_BYTES = 12;
573
573
  var KEY_BYTES = 32;
574
574
  var HKDF_HASH = "sha256";
575
575
  var HKDF_SALT = Buffer.alloc(0);
576
- var FieldEncryptor = class {
577
- masterKey;
578
- constructor(masterKey) {
576
+ var KEY_ID_HEX_CHARS = 12;
577
+ var DERIVED_KEY_CACHE_LIMIT = 4096;
578
+ var ENCRYPTED_PREFIXES2 = ["v1:", "v2:", "v3:"];
579
+ function isEncryptedCiphertext(value) {
580
+ return typeof value === "string" && ENCRYPTED_PREFIXES2.some((p3) => value.startsWith(p3));
581
+ }
582
+ var ENCRYPTION_KEY_ENV = "ENCRYPTION_KEY";
583
+ var LEGACY_ENCRYPTION_KEYS_ENV = "LEGACY_ENCRYPTION_KEYS";
584
+ var LEGACY_ENCRYPTION_KEY_ENV = "LEGACY_ENCRYPTION_KEY";
585
+ var ENCRYPTION_FORMAT_ENV = "ENCRYPTION_FORMAT";
586
+ function parseEncryptionKeyList(...sources) {
587
+ const keys = [];
588
+ for (const source of sources) {
589
+ for (const part of (source ?? "").split(/[\s,;]+/)) {
590
+ const key = part.trim().replace(/^["']|["']$/g, "");
591
+ if (key && !keys.includes(key)) keys.push(key);
592
+ }
593
+ }
594
+ return keys;
595
+ }
596
+ function encryptionKeyId(masterKey) {
597
+ return crypto.createHash("sha256").update(masterKey).digest("hex").slice(0, KEY_ID_HEX_CHARS);
598
+ }
599
+ function stampedKeyId(ciphertext) {
600
+ if (!ciphertext.startsWith("v3:")) return null;
601
+ const parts = ciphertext.split(":");
602
+ return parts.length === 5 ? parts[1] : null;
603
+ }
604
+ function stampedPrefix(keyId) {
605
+ return `v3:${keyId}:`;
606
+ }
607
+ var FieldEncryptor = class _FieldEncryptor {
608
+ ring;
609
+ byId;
610
+ derived = /* @__PURE__ */ new Map();
611
+ format;
612
+ constructor(masterKey, options = {}) {
579
613
  if (!masterKey) {
580
614
  throw new MissingEncryptionKeyError();
581
615
  }
582
- this.masterKey = masterKey;
616
+ const ring = [{ id: encryptionKeyId(masterKey), masterKey }];
617
+ for (const key of options.previousKeys ?? []) {
618
+ if (!key || ring.some((k) => k.masterKey === key)) continue;
619
+ ring.push({ id: encryptionKeyId(key), masterKey: key });
620
+ }
621
+ this.ring = ring;
622
+ this.byId = new Map(ring.map((k) => [k.id, k]));
623
+ this.format = options.format ?? "v2";
624
+ }
625
+ /**
626
+ * Build the ring from the environment: `ENCRYPTION_KEY` plus
627
+ * `LEGACY_ENCRYPTION_KEYS` (comma separated) and the older
628
+ * `LEGACY_ENCRYPTION_KEY`; `ENCRYPTION_FORMAT` selects the envelope.
629
+ */
630
+ static fromEnv(env = process.env) {
631
+ const masterKey = env[ENCRYPTION_KEY_ENV];
632
+ if (!masterKey) {
633
+ throw new MissingEncryptionKeyError(`${ENCRYPTION_KEY_ENV} must be set`);
634
+ }
635
+ const format = env[ENCRYPTION_FORMAT_ENV]?.trim().toLowerCase();
636
+ if (format && format !== "v2" && format !== "v3") {
637
+ throw new Error(
638
+ `${ENCRYPTION_FORMAT_ENV} must be "v2" or "v3", got "${format}"`
639
+ );
640
+ }
641
+ return new _FieldEncryptor(masterKey, {
642
+ previousKeys: parseEncryptionKeyList(
643
+ env[LEGACY_ENCRYPTION_KEYS_ENV],
644
+ env[LEGACY_ENCRYPTION_KEY_ENV]
645
+ ),
646
+ format: format ?? "v2"
647
+ });
648
+ }
649
+ /** Fingerprints of the ring, current first. Safe to log. */
650
+ get keyIds() {
651
+ return {
652
+ current: this.ring[0].id,
653
+ previous: this.ring.slice(1).map((k) => k.id)
654
+ };
655
+ }
656
+ /** A copy of this encryptor with a different set of previous keys. */
657
+ withPreviousKeys(previousKeys) {
658
+ return new _FieldEncryptor(this.ring[0].masterKey, {
659
+ previousKeys,
660
+ format: this.format
661
+ });
662
+ }
663
+ /** A copy of this encryptor writing a different envelope. */
664
+ withFormat(format) {
665
+ return new _FieldEncryptor(this.ring[0].masterKey, {
666
+ previousKeys: this.ring.slice(1).map((k) => k.masterKey),
667
+ format
668
+ });
583
669
  }
584
670
  /**
585
- * Derive a per-tenant 32-byte key using HKDF-SHA256.
586
- * The master key is used as input key material and the tenantId as info context.
671
+ * Derive a per-tenant 32-byte key using HKDF-SHA256 from the CURRENT
672
+ * master key. The master key is the input key material and the tenantId
673
+ * the info context.
587
674
  */
588
675
  deriveKey(tenantId) {
589
- return Buffer.from(
590
- crypto.hkdfSync(HKDF_HASH, this.masterKey, HKDF_SALT, tenantId, KEY_BYTES)
676
+ return this.deriveKeyFor(this.ring[0], tenantId);
677
+ }
678
+ deriveKeyFor(key, tenantId) {
679
+ const cacheKey = `${key.id} ${tenantId}`;
680
+ const cached = this.derived.get(cacheKey);
681
+ if (cached) return cached;
682
+ const derived = Buffer.from(
683
+ crypto.hkdfSync(HKDF_HASH, key.masterKey, HKDF_SALT, tenantId, KEY_BYTES)
591
684
  );
685
+ if (this.derived.size >= DERIVED_KEY_CACHE_LIMIT) this.derived.clear();
686
+ this.derived.set(cacheKey, derived);
687
+ return derived;
592
688
  }
593
689
  /**
594
690
  * Derive a deterministic IV from the key and plaintext using HMAC-SHA256,
595
- * truncated to IV_BYTES. Same plaintext + same key → same IV → same
691
+ * truncated to IV_BYTES. Same plaintext + same key -> same IV -> same
596
692
  * ciphertext. This enables WHERE clause matching on encrypted columns
597
693
  * while maintaining AES-256-GCM authenticated encryption.
598
694
  *
@@ -613,39 +709,95 @@ var FieldEncryptor = class {
613
709
  cipher.final()
614
710
  ]);
615
711
  const authTag = cipher.getAuthTag();
616
- return [
617
- "v2",
712
+ const body = [
618
713
  iv.toString("base64"),
619
714
  authTag.toString("base64"),
620
715
  encrypted.toString("base64")
621
- ].join(":");
716
+ ];
717
+ return this.format === "v3" ? ["v3", this.ring[0].id, ...body].join(":") : ["v2", ...body].join(":");
622
718
  }
623
719
  decrypt(ciphertext, tenantId) {
624
720
  if (ciphertext === null || ciphertext === void 0) {
625
721
  return null;
626
722
  }
723
+ return this.open(ciphertext, tenantId ?? "").plaintext;
724
+ }
725
+ /**
726
+ * Decrypt and report which key opened the value and whether a rewrite
727
+ * would change it. Throws {@link DecryptionError} when no key in the
728
+ * ring does.
729
+ */
730
+ open(ciphertext, tenantId = "") {
627
731
  const parts = ciphertext.split(":");
628
- if (parts.length !== 4 || parts[0] !== "v1" && parts[0] !== "v2") {
732
+ const version = parts[0];
733
+ let candidates;
734
+ let body;
735
+ if (version === "v3" && parts.length === 5) {
736
+ const key = this.byId.get(parts[1]);
737
+ if (!key) {
738
+ throw new DecryptionError(
739
+ `Decryption failed: value is stamped with key ${parts[1]}, which is not in the ring (current ${this.ring[0].id}; previous ${this.ring.slice(1).map((k) => k.id).join(", ") || "none"}). Add it to ${LEGACY_ENCRYPTION_KEYS_ENV}.`
740
+ );
741
+ }
742
+ candidates = [key];
743
+ body = parts.slice(2);
744
+ } else if ((version === "v1" || version === "v2") && parts.length === 4) {
745
+ candidates = this.ring;
746
+ body = parts.slice(1);
747
+ } else {
629
748
  throw new DecryptionError(
630
749
  `Unknown ciphertext version or malformed format`
631
750
  );
632
751
  }
633
- const [, ivB64, authTagB64, encryptedB64] = parts;
752
+ const [ivB64, authTagB64, encryptedB64] = body;
634
753
  const iv = Buffer.from(ivB64, "base64");
635
754
  const authTag = Buffer.from(authTagB64, "base64");
636
755
  const encrypted = Buffer.from(encryptedB64, "base64");
637
- const key = this.deriveKey(tenantId ?? "");
638
- try {
639
- const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
640
- decipher.setAuthTag(authTag);
641
- const decrypted = Buffer.concat([
642
- decipher.update(encrypted),
643
- decipher.final()
644
- ]);
645
- return decrypted.toString("utf8");
646
- } catch {
647
- throw new DecryptionError();
756
+ for (const key of candidates) {
757
+ try {
758
+ const decipher = crypto.createDecipheriv(
759
+ ALGORITHM,
760
+ this.deriveKeyFor(key, tenantId),
761
+ iv
762
+ );
763
+ decipher.setAuthTag(authTag);
764
+ const decrypted = Buffer.concat([
765
+ decipher.update(encrypted),
766
+ decipher.final()
767
+ ]);
768
+ const current = key === this.ring[0];
769
+ return {
770
+ plaintext: decrypted.toString("utf8"),
771
+ keyId: key.id,
772
+ current,
773
+ version,
774
+ stale: !current || this.format === "v3" && version !== "v3"
775
+ };
776
+ } catch {
777
+ }
648
778
  }
779
+ throw new DecryptionError(
780
+ candidates.length > 1 ? `Decryption failed: none of the ${candidates.length} keys in the ring opens this value (ciphertext corrupted, wrong tenant, or the key it was written with is not in ${LEGACY_ENCRYPTION_KEYS_ENV})` : `Decryption failed: key ${candidates[0].id} does not open this value (ciphertext corrupted or wrong tenant)`
781
+ );
782
+ }
783
+ /**
784
+ * True when a rewrite would change the stored bytes: the value is under
785
+ * a previous key, or unstamped while this encryptor writes `v3`. Throws
786
+ * when no key opens it.
787
+ */
788
+ needsRotation(ciphertext, tenantId = "") {
789
+ return this.open(ciphertext, tenantId).stale;
790
+ }
791
+ /**
792
+ * Rewrite a value under the current key in the current format. Returns
793
+ * the input unchanged when nothing would change, so callers can compare
794
+ * to detect writes.
795
+ */
796
+ rotate(ciphertext, tenantId = "") {
797
+ if (ciphertext === null || ciphertext === void 0) return null;
798
+ const opened = this.open(ciphertext, tenantId);
799
+ if (!opened.stale) return ciphertext;
800
+ return this.encrypt(opened.plaintext, tenantId);
649
801
  }
650
802
  };
651
803
 
@@ -703,6 +855,201 @@ function resolveEntityName2(entityNameOrEntity) {
703
855
  return void 0;
704
856
  }
705
857
 
858
+ // src/persistence/keyRotation.ts
859
+ var ENCRYPTED_LEVELS4 = /* @__PURE__ */ new Set(["pii", "phi", "pci"]);
860
+ var isEncryptedValue = isEncryptedCiphertext;
861
+ var tryOpen = (encryptor, value, tenantId) => {
862
+ try {
863
+ return encryptor.open(value, tenantId);
864
+ } catch {
865
+ return null;
866
+ }
867
+ };
868
+ function classifyEncryptedValue(value, encryptor, tenantIds, fallbackTenantIds = []) {
869
+ if (!isEncryptedValue(value)) return { kind: "plaintext" };
870
+ const primary = tenantIds.length ? tenantIds : [""];
871
+ const candidates = [
872
+ ...primary,
873
+ ...fallbackTenantIds.filter((t) => !primary.includes(t))
874
+ ];
875
+ for (const tenantId of candidates) {
876
+ const opened = tryOpen(encryptor, value, tenantId);
877
+ if (!opened) continue;
878
+ if (!opened.stale) {
879
+ return { kind: "current", tenantId, keyId: opened.keyId };
880
+ }
881
+ const next = encryptor.encrypt(opened.plaintext, tenantId);
882
+ return next ? { kind: "rewritten", tenantId, keyId: opened.keyId, next } : { kind: "unreadable" };
883
+ }
884
+ return { kind: "unreadable" };
885
+ }
886
+ function defaultTenantIdsFor(_entityName, row) {
887
+ const candidates = [];
888
+ for (const key of ["organization_id", "organizationId"]) {
889
+ const v = row[key];
890
+ if (typeof v === "string" && v) candidates.push(v);
891
+ }
892
+ candidates.push("");
893
+ return [...new Set(candidates)];
894
+ }
895
+ var quote = (id) => `"${id.replace(/"/g, '""')}"`;
896
+ async function collectFallbackTenantIds(execute) {
897
+ const ids = /* @__PURE__ */ new Set();
898
+ const tables = await execute(
899
+ `select table_name from information_schema.columns where table_schema = current_schema() and column_name = 'organization_id'`
900
+ );
901
+ for (const { table_name } of tables) {
902
+ const rows = await execute(
903
+ `select distinct "organization_id" as id from ${quote(table_name)} where "organization_id" is not null`
904
+ );
905
+ for (const { id } of rows) if (typeof id === "string" && id) ids.add(id);
906
+ }
907
+ const org = await execute(`select to_regclass('"organization"') as t`);
908
+ if (org[0]?.t) {
909
+ const rows = await execute(`select "id" from "organization"`);
910
+ for (const { id } of rows) if (typeof id === "string" && id) ids.add(id);
911
+ }
912
+ return [...ids];
913
+ }
914
+ function entityMetadataList(em) {
915
+ const all = em.getMetadata().getAll();
916
+ if (all instanceof Map) return [...all.values()];
917
+ if (Array.isArray(all)) return all;
918
+ if (all && typeof all === "object")
919
+ return Object.values(all);
920
+ return [];
921
+ }
922
+ async function reencryptEncryptedColumns(options) {
923
+ const { em, encryptor, dryRun = false, log = () => void 0 } = options;
924
+ const tenantIdsFor = options.tenantIdsFor ?? defaultTenantIdsFor;
925
+ const fallbackTenantIds = options.fallbackTenantIds ?? [];
926
+ const connection = em.getConnection();
927
+ const execute = options.execute ?? ((sql, params) => connection.execute(sql, params));
928
+ const reports = [];
929
+ const ids = encryptor.keyIds;
930
+ log(
931
+ `[key-rotation] current key ${ids.current}; previous: ${ids.previous.join(", ") || "none"}; ${fallbackTenantIds.length} fallback tenant(s)${dryRun ? "; DRY RUN" : ""}`
932
+ );
933
+ const metas = entityMetadataList(em);
934
+ if (metas.length === 0) {
935
+ throw new Error(
936
+ "[key-rotation] MikroORM reported no entities; refusing to report a sweep over nothing"
937
+ );
938
+ }
939
+ for (const meta of metas) {
940
+ if (meta.abstract || meta.embeddable || !meta.tableName) continue;
941
+ const fields = getEntityComplianceFields(meta.className);
942
+ if (!fields) continue;
943
+ const props = meta.properties;
944
+ const encryptedProps = [...fields.entries()].filter(([, level]) => ENCRYPTED_LEVELS4.has(level)).map(([prop]) => props[prop]).filter(
945
+ (p3) => !!p3 && Array.isArray(p3.fieldNames) && p3.fieldNames.length === 1
946
+ );
947
+ if (encryptedProps.length === 0) continue;
948
+ const pkProp = props[meta.primaryKeys[0]];
949
+ if (!pkProp) continue;
950
+ const pkColumn = pkProp.fieldNames[0];
951
+ const columns = encryptedProps.map((p3) => p3.fieldNames[0]);
952
+ const report = {
953
+ table: meta.tableName,
954
+ columns,
955
+ scanned: 0,
956
+ plaintext: 0,
957
+ current: 0,
958
+ rewritten: 0,
959
+ unreadable: 0,
960
+ unreadableIds: [],
961
+ byKeyId: {},
962
+ missingKeyIds: {}
963
+ };
964
+ const exists = await execute(`select to_regclass(?) as t`, [
965
+ quote(meta.tableName)
966
+ ]);
967
+ if (!exists[0]?.t) {
968
+ log(
969
+ `[key-rotation] ${meta.tableName}: table does not exist yet, skipped`
970
+ );
971
+ continue;
972
+ }
973
+ const rows = await execute(
974
+ `select * from ${quote(meta.tableName)}`
975
+ );
976
+ for (const row of rows) {
977
+ report.scanned += 1;
978
+ const tenantIds = tenantIdsFor(meta.className, row);
979
+ const updates = [];
980
+ let rowUnreadable = false;
981
+ for (const prop of encryptedProps) {
982
+ const column = prop.fieldNames[0];
983
+ const outcome = classifyEncryptedValue(
984
+ row[column],
985
+ encryptor,
986
+ tenantIds,
987
+ fallbackTenantIds
988
+ );
989
+ if (outcome.kind === "plaintext") report.plaintext += 1;
990
+ else if (outcome.kind === "current") report.current += 1;
991
+ else if (outcome.kind === "rewritten") {
992
+ report.rewritten += 1;
993
+ report.byKeyId[outcome.keyId] = (report.byKeyId[outcome.keyId] ?? 0) + 1;
994
+ updates.push({
995
+ column,
996
+ next: outcome.next,
997
+ jsonb: (prop.columnTypes?.[0] ?? "").toLowerCase().startsWith("json")
998
+ });
999
+ } else {
1000
+ report.unreadable += 1;
1001
+ rowUnreadable = true;
1002
+ const missing2 = stampedKeyId(String(row[column]));
1003
+ if (missing2)
1004
+ report.missingKeyIds[missing2] = (report.missingKeyIds[missing2] ?? 0) + 1;
1005
+ }
1006
+ }
1007
+ if (rowUnreadable) report.unreadableIds.push(String(row[pkColumn]));
1008
+ if (updates.length && !dryRun) {
1009
+ const sets = updates.map(
1010
+ (u) => `${quote(u.column)} = ${u.jsonb ? "to_jsonb(?::text)" : "?"}`
1011
+ );
1012
+ await execute(
1013
+ `update ${quote(meta.tableName)} set ${sets.join(", ")} where ${quote(pkColumn)} = ?`,
1014
+ [...updates.map((u) => u.next), row[pkColumn]]
1015
+ );
1016
+ }
1017
+ }
1018
+ const byKey = Object.entries(report.byKeyId).map(([k, n]) => `${k}: ${n}`).join(", ");
1019
+ const missing = Object.entries(report.missingKeyIds).map(([k, n]) => `${k}: ${n}`).join(", ");
1020
+ log(
1021
+ `[key-rotation] ${meta.tableName}: scanned ${report.scanned} rows, ${report.rewritten} values ${dryRun ? "would be " : ""}rewritten${byKey ? ` (from ${byKey})` : ""}, ${report.current} already current, ${report.plaintext} plaintext, ${report.unreadable} unreadable${missing ? ` (stamped with keys not in the ring: ${missing})` : ""}${report.unreadableIds.length ? ` (rows ${report.unreadableIds.slice(0, 10).join(", ")}${report.unreadableIds.length > 10 ? ", ..." : ""})` : ""}`
1022
+ );
1023
+ reports.push(report);
1024
+ }
1025
+ return reports;
1026
+ }
1027
+ async function countValuesByKeyId(execute, table, column) {
1028
+ const rows = await execute(
1029
+ `select case when ${quote(column)} like 'v3:%' then split_part(${quote(column)}, ':', 2) when ${quote(column)} like 'v1:%' or ${quote(column)} like 'v2:%' then '' else null end as key_id, count(*)::int as n from ${quote(table)} group by 1`
1030
+ );
1031
+ const byKeyId = {};
1032
+ let unstamped = 0;
1033
+ let plaintext = 0;
1034
+ for (const { key_id, n } of rows) {
1035
+ if (key_id === null) plaintext += Number(n);
1036
+ else if (key_id === "") unstamped += Number(n);
1037
+ else byKeyId[key_id] = (byKeyId[key_id] ?? 0) + Number(n);
1038
+ }
1039
+ return { byKeyId, unstamped, plaintext };
1040
+ }
1041
+ function rotationTotals(reports) {
1042
+ const sum = (pick) => reports.reduce((n, r) => n + pick(r), 0);
1043
+ return {
1044
+ tables: reports.length,
1045
+ scanned: sum((r) => r.scanned),
1046
+ rewritten: sum((r) => r.rewritten),
1047
+ current: sum((r) => r.current),
1048
+ plaintext: sum((r) => r.plaintext),
1049
+ unreadable: sum((r) => r.unreadable)
1050
+ };
1051
+ }
1052
+
706
1053
  // src/persistence/tenantFilter.ts
707
1054
  var TENANT_FILTER_NAME = "tenant";
708
1055
  function createTenantFilterDef(column = "organizationId", relationName) {
@@ -870,18 +1217,29 @@ function wrapEmWithTenantContext(em, tenantId) {
870
1217
  export {
871
1218
  ComplianceLevel,
872
1219
  DecryptionError,
1220
+ ENCRYPTED_PREFIXES2 as ENCRYPTED_PREFIXES,
1221
+ ENCRYPTION_FORMAT_ENV,
1222
+ ENCRYPTION_KEY_ENV,
873
1223
  EncryptedType,
874
1224
  EncryptionRequiredError,
875
1225
  FieldEncryptor,
1226
+ LEGACY_ENCRYPTION_KEYS_ENV,
1227
+ LEGACY_ENCRYPTION_KEY_ENV,
876
1228
  MissingEncryptionKeyError,
877
1229
  RetentionAction,
878
1230
  RetentionDuration,
879
1231
  RlsEventSubscriber,
880
1232
  TENANT_FILTER_NAME,
881
1233
  asEncryptionSafe,
1234
+ classifyEncryptedValue,
1235
+ collectFallbackTenantIds,
1236
+ countValuesByKeyId,
882
1237
  createTenantFilterDef,
1238
+ defaultTenantIdsFor,
883
1239
  defineComplianceEntity,
1240
+ encryptionKeyId,
884
1241
  entityHasEncryptedFields,
1242
+ entityMetadataList,
885
1243
  fp,
886
1244
  getAllRetentionPolicies,
887
1245
  getAllUserIdFields,
@@ -891,11 +1249,18 @@ export {
891
1249
  getEntityRetention,
892
1250
  getEntityUserIdField,
893
1251
  getSuperAdminContext,
1252
+ isEncryptedCiphertext,
1253
+ isEncryptedValue,
894
1254
  parseDuration,
1255
+ parseEncryptionKeyList,
1256
+ reencryptEncryptedColumns,
895
1257
  registerEncryptor,
1258
+ rotationTotals,
896
1259
  setEncryptionTenantId,
897
1260
  setupRls,
898
1261
  setupTenantFilter,
1262
+ stampedKeyId,
1263
+ stampedPrefix,
899
1264
  subtractDuration,
900
1265
  withEncryptionContext,
901
1266
  wrapEmWithForgivingDecryption,