@open-mercato/shared 0.6.5-develop.5099.1.85c0aff78c → 0.6.5-develop.5139.1.g7925348493
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/dist/lib/encryption/aes.js +37 -3
- package/dist/lib/encryption/aes.js.map +2 -2
- package/dist/lib/encryption/subscriber.js +7 -7
- package/dist/lib/encryption/subscriber.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/encryption/__tests__/lookupHash.test.ts +113 -0
- package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +123 -0
- package/src/lib/encryption/aes.ts +78 -2
- package/src/lib/encryption/subscriber.ts +11 -8
|
@@ -64,8 +64,40 @@ function decryptWithAesGcm(payload, dekBase64) {
|
|
|
64
64
|
return null;
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
const LOOKUP_HASH_V2_PREFIX = "v2:";
|
|
68
|
+
function normalizeLookupValue(value) {
|
|
69
|
+
return value.toLowerCase().trim();
|
|
70
|
+
}
|
|
71
|
+
function legacyHashForLookup(value) {
|
|
72
|
+
return crypto.createHash("sha256").update(normalizeLookupValue(value)).digest("hex");
|
|
73
|
+
}
|
|
74
|
+
function resolveLookupPepper() {
|
|
75
|
+
const candidates = [
|
|
76
|
+
process.env.LOOKUP_HASH_PEPPER,
|
|
77
|
+
process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY,
|
|
78
|
+
process.env.TENANT_DATA_ENCRYPTION_KEY
|
|
79
|
+
];
|
|
80
|
+
for (const candidate of candidates) {
|
|
81
|
+
if (typeof candidate !== "string") continue;
|
|
82
|
+
const normalized = candidate.trim().replace(/(?:^['"]|['"]$)/g, "");
|
|
83
|
+
if (normalized) return normalized;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
function hashForLookup(value, context) {
|
|
88
|
+
const pepper = resolveLookupPepper();
|
|
89
|
+
const normalized = normalizeLookupValue(value);
|
|
90
|
+
if (!pepper) {
|
|
91
|
+
return legacyHashForLookup(value);
|
|
92
|
+
}
|
|
93
|
+
const message = context ? `${context}:${normalized}` : normalized;
|
|
94
|
+
const digest = crypto.createHmac("sha256", pepper).update(message).digest("hex");
|
|
95
|
+
return `${LOOKUP_HASH_V2_PREFIX}${digest}`;
|
|
96
|
+
}
|
|
97
|
+
function lookupHashCandidates(value, context) {
|
|
98
|
+
const primary = hashForLookup(value, context);
|
|
99
|
+
const legacy = legacyHashForLookup(value);
|
|
100
|
+
return primary === legacy ? [primary] : [primary, legacy];
|
|
69
101
|
}
|
|
70
102
|
function decryptWithAesGcmStrict(payload, dekBase64) {
|
|
71
103
|
const parts = payload.split(":");
|
|
@@ -110,6 +142,8 @@ export {
|
|
|
110
142
|
decryptWithAesGcmStrict,
|
|
111
143
|
encryptWithAesGcm,
|
|
112
144
|
generateDek,
|
|
113
|
-
hashForLookup
|
|
145
|
+
hashForLookup,
|
|
146
|
+
legacyHashForLookup,
|
|
147
|
+
lookupHashCandidates
|
|
114
148
|
};
|
|
115
149
|
//# sourceMappingURL=aes.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/encryption/aes.ts"],
|
|
4
|
-
"sourcesContent": ["import crypto from 'node:crypto'\nimport { isEncryptionDebugEnabled } from './toggles'\n\nexport type EncryptionPayload = {\n value: string | null\n raw: string\n version: string\n}\n\nexport enum TenantDataEncryptionErrorCode {\n AUTH_FAILED = 'AUTH_FAILED',\n MALFORMED_PAYLOAD = 'MALFORMED_PAYLOAD',\n KMS_UNAVAILABLE = 'KMS_UNAVAILABLE',\n WRONG_KEY = 'WRONG_KEY',\n DECRYPT_INTERNAL = 'DECRYPT_INTERNAL',\n}\n\nexport class TenantDataEncryptionError extends Error {\n code: TenantDataEncryptionErrorCode\n constructor(code: TenantDataEncryptionErrorCode, message: string) {\n super(message)\n this.name = 'TenantDataEncryptionError'\n this.code = code\n }\n}\n\nexport function generateDek(): string {\n return crypto.randomBytes(32).toString('base64')\n}\n\nfunction logDebug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n // eslint-disable-next-line no-console\n console.debug('[encryption]', event, payload)\n } catch {\n // ignore\n }\n}\n\nexport function encryptWithAesGcm(value: string, dekBase64: string): EncryptionPayload {\n const dek = Buffer.from(dekBase64, 'base64')\n const iv = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv)\n const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n const payload = [\n iv.toString('base64'),\n ciphertext.toString('base64'),\n tag.toString('base64'),\n 'v1',\n ].join(':')\n logDebug('encrypt', { length: ciphertext.length })\n return { value: payload, raw: payload, version: 'v1' }\n}\n\nfunction runAesGcmDecrypt(dek: Buffer, iv: Buffer, ciphertext: Buffer, tag: Buffer): string {\n const decipher = crypto.createDecipheriv('aes-256-gcm', dek, iv)\n decipher.setAuthTag(tag)\n return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')\n}\n\nexport function decryptWithAesGcm(payload: string, dekBase64: string): string | null {\n if (!payload) return null\n const parts = payload.split(':')\n if (parts.length !== 4) return null\n const [ivB64, ciphertextB64, tagB64, version] = parts\n if (version !== 'v1') return null\n const dek = Buffer.from(dekBase64, 'base64')\n const iv = Buffer.from(ivB64, 'base64')\n const ciphertext = Buffer.from(ciphertextB64, 'base64')\n const tag = Buffer.from(tagB64, 'base64')\n try {\n const result = runAesGcmDecrypt(dek, iv, ciphertext, tag)\n logDebug('decrypt', { iv: ivB64, tag: tagB64 })\n return result\n } catch (err) {\n logDebug('decrypt_error', { message: (err as Error)?.message || String(err) })\n return null\n }\n}\n\nexport function
|
|
5
|
-
"mappings": "AAAA,OAAO,YAAY;AACnB,SAAS,gCAAgC;AAQlC,IAAK,gCAAL,kBAAKA,mCAAL;AACL,EAAAA,+BAAA,iBAAc;AACd,EAAAA,+BAAA,uBAAoB;AACpB,EAAAA,+BAAA,qBAAkB;AAClB,EAAAA,+BAAA,eAAY;AACZ,EAAAA,+BAAA,sBAAmB;AALT,SAAAA;AAAA,GAAA;AAQL,MAAM,kCAAkC,MAAM;AAAA,EAEnD,YAAY,MAAqC,SAAiB;AAChE,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,OAAO;AAAA,EACd;AACF;AAEO,SAAS,cAAsB;AACpC,SAAO,OAAO,YAAY,EAAE,EAAE,SAAS,QAAQ;AACjD;AAEA,SAAS,SAAS,OAAe,SAAkC;AACjE,MAAI,CAAC,yBAAyB,EAAG;AACjC,MAAI;AAEF,YAAQ,MAAM,gBAAgB,OAAO,OAAO;AAAA,EAC9C,QAAQ;AAAA,EAER;AACF;AAEO,SAAS,kBAAkB,OAAe,WAAsC;AACrF,QAAM,MAAM,OAAO,KAAK,WAAW,QAAQ;AAC3C,QAAM,KAAK,OAAO,YAAY,EAAE;AAChC,QAAM,SAAS,OAAO,eAAe,eAAe,KAAK,EAAE;AAC3D,QAAM,aAAa,OAAO,OAAO,CAAC,OAAO,OAAO,OAAO,MAAM,GAAG,OAAO,MAAM,CAAC,CAAC;AAC/E,QAAM,MAAM,OAAO,WAAW;AAC9B,QAAM,UAAU;AAAA,IACd,GAAG,SAAS,QAAQ;AAAA,IACpB,WAAW,SAAS,QAAQ;AAAA,IAC5B,IAAI,SAAS,QAAQ;AAAA,IACrB;AAAA,EACF,EAAE,KAAK,GAAG;AACV,WAAS,WAAW,EAAE,QAAQ,WAAW,OAAO,CAAC;AACjD,SAAO,EAAE,OAAO,SAAS,KAAK,SAAS,SAAS,KAAK;AACvD;AAEA,SAAS,iBAAiB,KAAa,IAAY,YAAoB,KAAqB;AAC1F,QAAM,WAAW,OAAO,iBAAiB,eAAe,KAAK,EAAE;AAC/D,WAAS,WAAW,GAAG;AACvB,SAAO,OAAO,OAAO,CAAC,SAAS,OAAO,UAAU,GAAG,SAAS,MAAM,CAAC,CAAC,EAAE,SAAS,MAAM;AACvF;AAEO,SAAS,kBAAkB,SAAiB,WAAkC;AACnF,MAAI,CAAC,QAAS,QAAO;AACrB,QAAM,QAAQ,QAAQ,MAAM,GAAG;AAC/B,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,CAAC,OAAO,eAAe,QAAQ,OAAO,IAAI;AAChD,MAAI,YAAY,KAAM,QAAO;AAC7B,QAAM,MAAM,OAAO,KAAK,WAAW,QAAQ;AAC3C,QAAM,KAAK,OAAO,KAAK,OAAO,QAAQ;AACtC,QAAM,aAAa,OAAO,KAAK,eAAe,QAAQ;AACtD,QAAM,MAAM,OAAO,KAAK,QAAQ,QAAQ;AACxC,MAAI;AACF,UAAM,SAAS,iBAAiB,KAAK,IAAI,YAAY,GAAG;AACxD,aAAS,WAAW,EAAE,IAAI,OAAO,KAAK,OAAO,CAAC;AAC9C,WAAO;AAAA,EACT,SAAS,KAAK;AACZ,aAAS,iBAAiB,EAAE,SAAU,KAAe,WAAW,OAAO,GAAG,EAAE,CAAC;AAC7E,WAAO;AAAA,EACT;AACF;
|
|
4
|
+
"sourcesContent": ["import crypto from 'node:crypto'\nimport { isEncryptionDebugEnabled } from './toggles'\n\nexport type EncryptionPayload = {\n value: string | null\n raw: string\n version: string\n}\n\nexport enum TenantDataEncryptionErrorCode {\n AUTH_FAILED = 'AUTH_FAILED',\n MALFORMED_PAYLOAD = 'MALFORMED_PAYLOAD',\n KMS_UNAVAILABLE = 'KMS_UNAVAILABLE',\n WRONG_KEY = 'WRONG_KEY',\n DECRYPT_INTERNAL = 'DECRYPT_INTERNAL',\n}\n\nexport class TenantDataEncryptionError extends Error {\n code: TenantDataEncryptionErrorCode\n constructor(code: TenantDataEncryptionErrorCode, message: string) {\n super(message)\n this.name = 'TenantDataEncryptionError'\n this.code = code\n }\n}\n\nexport function generateDek(): string {\n return crypto.randomBytes(32).toString('base64')\n}\n\nfunction logDebug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n // eslint-disable-next-line no-console\n console.debug('[encryption]', event, payload)\n } catch {\n // ignore\n }\n}\n\nexport function encryptWithAesGcm(value: string, dekBase64: string): EncryptionPayload {\n const dek = Buffer.from(dekBase64, 'base64')\n const iv = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv)\n const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n const payload = [\n iv.toString('base64'),\n ciphertext.toString('base64'),\n tag.toString('base64'),\n 'v1',\n ].join(':')\n logDebug('encrypt', { length: ciphertext.length })\n return { value: payload, raw: payload, version: 'v1' }\n}\n\nfunction runAesGcmDecrypt(dek: Buffer, iv: Buffer, ciphertext: Buffer, tag: Buffer): string {\n const decipher = crypto.createDecipheriv('aes-256-gcm', dek, iv)\n decipher.setAuthTag(tag)\n return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')\n}\n\nexport function decryptWithAesGcm(payload: string, dekBase64: string): string | null {\n if (!payload) return null\n const parts = payload.split(':')\n if (parts.length !== 4) return null\n const [ivB64, ciphertextB64, tagB64, version] = parts\n if (version !== 'v1') return null\n const dek = Buffer.from(dekBase64, 'base64')\n const iv = Buffer.from(ivB64, 'base64')\n const ciphertext = Buffer.from(ciphertextB64, 'base64')\n const tag = Buffer.from(tagB64, 'base64')\n try {\n const result = runAesGcmDecrypt(dek, iv, ciphertext, tag)\n logDebug('decrypt', { iv: ivB64, tag: tagB64 })\n return result\n } catch (err) {\n logDebug('decrypt_error', { message: (err as Error)?.message || String(err) })\n return null\n }\n}\n\nconst LOOKUP_HASH_V2_PREFIX = 'v2:'\n\nfunction normalizeLookupValue(value: string): string {\n return value.toLowerCase().trim()\n}\n\n/**\n * Legacy, unkeyed lookup digest (`sha256(lower(trim(value)))`).\n *\n * @deprecated Unkeyed digests are vulnerable to offline rainbow-table attacks and\n * cross-installation correlation (issue #2718). New writes use {@link hashForLookup},\n * which emits a keyed `v2:` HMAC when a lookup pepper is configured. This helper is\n * retained only so existing `*_hash` columns written before the keyed format can still\n * be matched (see {@link lookupHashCandidates}) until a backfill migration recomputes them.\n */\nexport function legacyHashForLookup(value: string): string {\n return crypto.createHash('sha256').update(normalizeLookupValue(value)).digest('hex')\n}\n\n/**\n * Resolve the installation-wide lookup pepper used to key lookup hashes.\n *\n * Order of precedence (never `AUTH_SECRET`, per issue #2718):\n * 1. `LOOKUP_HASH_PEPPER` \u2014 dedicated secret for lookup hashing\n * 2. `TENANT_DATA_ENCRYPTION_FALLBACK_KEY` \u2014 existing encryption fallback secret\n * 3. `TENANT_DATA_ENCRYPTION_KEY` \u2014 existing encryption secret\n *\n * Returns `null` when no secret is configured, in which case {@link hashForLookup}\n * falls back to the legacy unkeyed digest so deployments without any configured key\n * keep working unchanged.\n */\nfunction resolveLookupPepper(): string | null {\n const candidates = [\n process.env.LOOKUP_HASH_PEPPER,\n process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY,\n process.env.TENANT_DATA_ENCRYPTION_KEY,\n ]\n for (const candidate of candidates) {\n if (typeof candidate !== 'string') continue\n const normalized = candidate.trim().replace(/(?:^['\"]|['\"]$)/g, '')\n if (normalized) return normalized\n }\n return null\n}\n\n/**\n * Compute a deterministic lookup hash for a low-entropy PII value (email, phone, \u2026).\n *\n * When a lookup pepper is configured the result is a keyed HMAC-SHA-256 prefixed with\n * `v2:` and bound to the optional `context` (entity/field) so digests are not portable\n * across columns, installations, or tenants without the secret. When no pepper is\n * configured it falls back to the legacy unkeyed digest for backward compatibility.\n *\n * The `context` MUST be supplied identically on both the write and the read side for a\n * given column; callers that do not pass one stay mutually consistent.\n */\nexport function hashForLookup(value: string, context?: string): string {\n const pepper = resolveLookupPepper()\n const normalized = normalizeLookupValue(value)\n if (!pepper) {\n return legacyHashForLookup(value)\n }\n const message = context ? `${context}:${normalized}` : normalized\n const digest = crypto.createHmac('sha256', pepper).update(message).digest('hex')\n return `${LOOKUP_HASH_V2_PREFIX}${digest}`\n}\n\n/**\n * Candidate lookup hashes for matching a value against `*_hash` columns that may hold\n * either the new keyed (`v2:`) digest or a legacy unkeyed digest. Use this in `$in` /\n * `IN (...)` filters during the migration window so reads keep matching rows written\n * before the keyed format. Once a backfill has recomputed all columns this can collapse\n * back to a single {@link hashForLookup} value.\n */\nexport function lookupHashCandidates(value: string, context?: string): string[] {\n const primary = hashForLookup(value, context)\n const legacy = legacyHashForLookup(value)\n return primary === legacy ? [primary] : [primary, legacy]\n}\n\n/**\n * Strict variant of decryptWithAesGcm that throws typed TenantDataEncryptionError.\n * - Format mismatch (not iv:ct:tag:v1): throws AUTH_FAILED (treat as plaintext).\n * - Valid format but invalid buffer sizes (bad base64): throws MALFORMED_PAYLOAD.\n * - AES-GCM auth tag failure: throws AUTH_FAILED.\n * - Unexpected crypto error: throws DECRYPT_INTERNAL.\n */\nexport function decryptWithAesGcmStrict(payload: string, dekBase64: string): string {\n const parts = payload.split(':')\n if (parts.length !== 4 || parts[3] !== 'v1') {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.AUTH_FAILED,\n 'Value is not an encrypted payload (format mismatch)',\n )\n }\n const [ivB64, ciphertextB64, tagB64] = parts as [string, string, string, string]\n let dek: Buffer, iv: Buffer, ciphertext: Buffer, tag: Buffer\n try {\n dek = Buffer.from(dekBase64, 'base64')\n iv = Buffer.from(ivB64, 'base64')\n ciphertext = Buffer.from(ciphertextB64, 'base64')\n tag = Buffer.from(tagB64, 'base64')\n } catch {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.MALFORMED_PAYLOAD,\n 'Failed to decode base64 components',\n )\n }\n if (iv.length !== 12 || tag.length !== 16 || ciphertext.length === 0) {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.MALFORMED_PAYLOAD,\n 'Invalid AES-GCM payload: unexpected IV, tag, or ciphertext size',\n )\n }\n try {\n return runAesGcmDecrypt(dek, iv, ciphertext, tag)\n } catch {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.AUTH_FAILED,\n 'AES-GCM authentication tag verification failed',\n )\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["TenantDataEncryptionErrorCode"]
|
|
7
7
|
}
|
|
@@ -245,6 +245,13 @@ class TenantEncryptionSubscriber {
|
|
|
245
245
|
try {
|
|
246
246
|
const extractEntities = (value) => {
|
|
247
247
|
if (!value) return [];
|
|
248
|
+
if (typeof value === "object" && typeof value.isInitialized === "function" && typeof value.getItems === "function") {
|
|
249
|
+
try {
|
|
250
|
+
return value.isInitialized() ? value.getItems() ?? [] : [];
|
|
251
|
+
} catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
248
255
|
if (typeof value === "object" && typeof value.isInitialized === "function") {
|
|
249
256
|
try {
|
|
250
257
|
if (value.isInitialized()) {
|
|
@@ -255,13 +262,6 @@ class TenantEncryptionSubscriber {
|
|
|
255
262
|
}
|
|
256
263
|
return [];
|
|
257
264
|
}
|
|
258
|
-
if (typeof value === "object" && typeof value.isInitialized === "function" && typeof value.getItems === "function") {
|
|
259
|
-
try {
|
|
260
|
-
return value.isInitialized() ? value.getItems() ?? [] : [];
|
|
261
|
-
} catch {
|
|
262
|
-
return [];
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
265
|
if (Array.isArray(value)) return value;
|
|
266
266
|
if (typeof value === "object") return [value];
|
|
267
267
|
return [];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/encryption/subscriber.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityMetadata, EventArgs, EventSubscriber } from '@mikro-orm/core'\nimport { ReferenceKind } from '@mikro-orm/core'\nimport { resolveEntityIdFromMetadata } from './entityIds'\nimport { TenantDataEncryptionService } from './tenantDataEncryptionService'\nimport { isTenantDataEncryptionEnabled } from './toggles'\nimport { isEncryptionDebugEnabled } from './toggles'\nimport { resolveTenantEncryptionService } from './customFieldValues'\n\ntype Scoped = {\n tenantId?: string | null\n tenant_id?: string | null\n tenant?: { id?: string | null } | null\n organizationId?: string | null\n organization_id?: string | null\n organization?: { id?: string | null } | null\n}\n\ntype Scope = { tenantId: string | null; organizationId: string | null }\n\nfunction resolveScope(entity: Scoped): Scope {\n const tenantId = entity.tenantId ?? entity.tenant_id ?? entity.tenant?.id ?? null\n const organizationId = entity.organizationId ?? entity.organization_id ?? entity.organization?.id ?? null\n return {\n tenantId: tenantId ? String(tenantId) : null,\n organizationId: organizationId ? String(organizationId) : null,\n }\n}\n\nfunction debug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n // eslint-disable-next-line no-console\n console.debug(event, payload)\n } catch {\n // ignore\n }\n}\n\nconst registeredEventManagers = new WeakSet<object>()\n\nconst subscribersByService = new WeakMap<TenantDataEncryptionService, TenantEncryptionSubscriber>()\n\nfunction getSubscriberForService(service: TenantDataEncryptionService): TenantEncryptionSubscriber {\n const existing = subscribersByService.get(service)\n if (existing) return existing\n const subscriber = new TenantEncryptionSubscriber(service)\n subscribersByService.set(service, subscriber)\n return subscriber\n}\n\nconst toSnakeCase = (value: string): string =>\n value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()\n\nexport class TenantEncryptionSubscriber implements EventSubscriber<any> {\n constructor(private readonly service: TenantDataEncryptionService) {}\n\n getSubscribedEntities() {\n return [] // listen to all entities\n }\n\n private resolveMeta(\n meta: EntityMetadata<any> | undefined,\n entity: Record<string, unknown>,\n em?: { getMetadata?: () => any },\n ): EntityMetadata<any> | undefined {\n if (meta) return meta\n const ctor = (entity as any)?.constructor\n const name = ctor?.name\n const registry = em?.getMetadata?.()\n if (!registry || !name) return meta\n try { return registry.find?.(name) } catch {}\n try { return registry.find?.(ctor) } catch {}\n try { return registry.get?.(name) } catch {}\n try { return registry.get?.(ctor) } catch {}\n const all =\n (typeof registry.getAll === 'function' && registry.getAll()) ||\n (Array.isArray((registry as any).metadata) ? (registry as any).metadata : undefined) ||\n (registry as any).metadata ||\n {}\n try {\n const entries = Array.isArray(all) ? all : Object.values<any>(all)\n const match = entries.find(\n (m: any) =>\n m?.className === name ||\n m?.name === name ||\n m?.entityName === name ||\n m?.collection === ctor?.prototype?.__meta?.tableName ||\n m?.tableName === ctor?.prototype?.__meta?.tableName,\n )\n if (match) return match as EntityMetadata<any>\n } catch {\n // best-effort\n }\n return meta\n }\n\n private resolveEntityId(meta: EntityMetadata<any> | undefined): string | null {\n try {\n return resolveEntityIdFromMetadata(meta)\n } catch {\n return null\n }\n }\n\n private syncOriginalEntityData(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getComparator?: () => any },\n ) {\n const helper = (target as any)?.__helper\n if (!helper || typeof helper !== 'object') return\n\n // Prefer MikroORM comparator snapshot so change detection uses the expected shape.\n try {\n const comparator = em?.getComparator?.()\n if (comparator?.prepareEntity) {\n helper.__originalEntityData = comparator.prepareEntity(target)\n helper.__touched = false\n return\n }\n } catch (err) {\n debug('\u26AA\uFE0F subscriber.sync_original.comparator_failed', {\n entity: meta?.className || meta?.name,\n message: (err as Error)?.message ?? String(err),\n })\n }\n\n // Fallback: shallow snapshot of scalar/owner props to keep entities clean without comparator.\n const properties = meta?.properties ? Object.values(meta.properties) : []\n if (properties.length === 0) return\n const snapshot: Record<string, unknown> = { ...(helper.__originalEntityData ?? {}) }\n for (const prop of properties) {\n if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes((prop as any).kind)) continue\n const name = (prop as any).name\n if (typeof name !== 'string' || !name.length) continue\n snapshot[name] = (target as Record<string, unknown>)[name]\n }\n helper.__originalEntityData = snapshot\n helper.__touched = false\n }\n\n /**\n * Reports whether a managed entity currently carries un-flushed changes relative to its load\n * baseline, using MikroORM's own comparator so the verdict matches the change-set computer that\n * runs at flush time. Used to avoid re-baselining (and thereby discarding) pending writes when a\n * decrypt pass traverses back into an entity a command already mutated.\n */\n private hasPendingChanges(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getComparator?: () => any },\n ): boolean {\n const helper = (target as any)?.__helper\n if (!helper || typeof helper !== 'object') return false\n const original = helper.__originalEntityData\n if (!original) return false\n const entityName = meta?.className || meta?.name\n try {\n const comparator = em?.getComparator?.()\n if (entityName && comparator?.prepareEntity) {\n const current = comparator.prepareEntity(target)\n if (typeof comparator.matching === 'function') {\n return !comparator.matching(entityName, original, current)\n }\n if (typeof comparator.diffEntities === 'function') {\n const diff = comparator.diffEntities(entityName, original, current)\n return !!diff && Object.keys(diff).length > 0\n }\n }\n } catch (err) {\n debug('\u26AA\uFE0F subscriber.pending_changes.compare_failed', {\n entity: entityName,\n message: (err as Error)?.message ?? String(err),\n })\n }\n return helper.__touched === true\n }\n\n private async encrypt(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getMetadata?: () => any; getComparator?: () => any },\n changeSet?: { payload?: Record<string, unknown> },\n ) {\n if (!isTenantDataEncryptionEnabled() || !this.service.isEnabled()) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'disabled', entity: meta?.className || meta?.name })\n return\n }\n const resolvedMeta = this.resolveMeta(meta, target, em)\n const entityId = this.resolveEntityId(resolvedMeta)\n if (!entityId) {\n debug('\u26A0\uFE0F subscriber.decrypt.skip.entity_id_missing', {\n metaName: resolvedMeta?.className || resolvedMeta?.name,\n table: (resolvedMeta as any)?.tableName,\n })\n return\n }\n const { tenantId, organizationId } = resolveScope(target)\n if (!tenantId) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'no-tenant', entityId })\n return\n }\n const encrypted = await this.service.encryptEntityPayload(entityId, target, tenantId, organizationId)\n const metaProps: Record<string, unknown> = resolvedMeta?.properties && typeof resolvedMeta.properties === 'object'\n ? resolvedMeta.properties\n : {}\n const payloadObj: Record<string, unknown> | null =\n changeSet && typeof changeSet === 'object'\n ? (typeof changeSet.payload === 'object' && changeSet.payload\n ? (changeSet.payload as Record<string, unknown>)\n : ((changeSet.payload = {}) as Record<string, unknown>))\n : null\n const updates: Record<string, unknown> = {}\n const columnNameFor = (propKey: string, prop: Record<string, unknown> | undefined): string => {\n try {\n if (prop && typeof prop === 'object') {\n const explicit = (prop as any)?.fieldName\n if (typeof explicit === 'string' && explicit.length) return explicit\n const name = (prop as any)?.name\n if (typeof name === 'string' && name.length) return name\n }\n } catch (err) {\n debug('\u26A0\uFE0F subscriber.column_name.resolve', {\n entityId,\n propKey,\n message: (err as Error)?.message ?? String(err),\n })\n }\n return toSnakeCase(propKey)\n }\n\n for (const [key, value] of Object.entries(encrypted)) {\n const prop = (metaProps as Record<string, any>)[key]\n if (!prop || typeof prop !== 'object') continue\n if ((target as Record<string, unknown>)[key] === value) continue\n updates[key] = value\n }\n if (Object.keys(updates).length === 0) return\n Object.assign(target, updates)\n if (payloadObj) {\n try {\n const ensureColumnKey = (propKey: string, value: unknown) => {\n const columnName = columnNameFor(propKey, (metaProps as Record<string, any>)[propKey])\n const canonicalKey = columnName || toSnakeCase(propKey)\n const aliases = new Set(\n [propKey, toSnakeCase(propKey), columnName, columnName ? toSnakeCase(columnName) : undefined].filter(\n (v): v is string => typeof v === 'string' && v.length > 0,\n ),\n )\n for (const alias of aliases) {\n if (Object.prototype.hasOwnProperty.call(payloadObj, alias)) delete payloadObj[alias]\n }\n const finalKey = columnName || toSnakeCase(propKey)\n payloadObj[finalKey] = value\n }\n for (const key of Object.keys(updates)) {\n ensureColumnKey(key, updates[key])\n }\n } catch (err) {\n debug('\u26A0\uFE0F subscriber.payload.normalize.error', {\n entityId,\n message: (err as Error)?.message ?? String(err),\n })\n }\n }\n }\n\n async decryptEntityGraph(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getMetadata?: () => any; getComparator?: () => any },\n opts: { syncOriginal?: boolean; seen?: WeakSet<object>; fallbackScope?: Scope } = {},\n ) {\n await this.decrypt(target, meta, em, opts)\n }\n\n private async decrypt(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getMetadata?: () => any; getComparator?: () => any },\n {\n syncOriginal = false,\n seen,\n fallbackScope,\n }: { syncOriginal?: boolean; seen?: WeakSet<object>; fallbackScope?: Scope } = {},\n ) {\n const visited = seen ?? new WeakSet<object>()\n if (visited.has(target as object)) return\n visited.add(target as object)\n if (!isTenantDataEncryptionEnabled() || !this.service.isEnabled()) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'disabled', entity: meta?.className || meta?.name })\n return\n }\n const resolvedMeta = this.resolveMeta(meta, target, em)\n const entityId = this.resolveEntityId(resolvedMeta)\n if (!entityId) return\n const { tenantId, organizationId } = resolveScope(target)\n const scopedTenantId = tenantId ?? fallbackScope?.tenantId ?? null\n const scopedOrgId = organizationId ?? fallbackScope?.organizationId ?? null\n if (!scopedTenantId) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'no-tenant', entityId })\n return\n }\n // Capture pending (un-flushed) changes BEFORE decrypt mutates the target. Re-baselining a\n // managed entity that a command already mutated would clear its dirty changeset and silently\n // drop the pending write (e.g. an undo handler that mutates an entity, then loads a related\n // encrypted entity whose deep-decrypt recurses back into the still-dirty entity before flush).\n const hadPendingChanges = syncOriginal ? this.hasPendingChanges(target, resolvedMeta, em as any) : false\n const decrypted = await this.service.decryptEntityPayload(entityId, target, scopedTenantId, scopedOrgId)\n Object.assign(target, decrypted)\n if (syncOriginal && !hadPendingChanges) {\n this.syncOriginalEntityData(target, resolvedMeta, em as any)\n }\n const nextFallback =\n fallbackScope ??\n (tenantId || organizationId\n ? { tenantId: tenantId ?? null, organizationId: organizationId ?? null }\n : { tenantId: scopedTenantId, organizationId: scopedOrgId })\n // Best-effort deep decrypt for loaded relations so populated graphs get cleaned too.\n try {\n const extractEntities = (value: any): any[] => {\n if (!value) return []\n // MikroORM Reference wrapper\n if (typeof value === 'object' && typeof (value as any).isInitialized === 'function') {\n try {\n if ((value as any).isInitialized()) {\n const unwrapped = typeof (value as any).unwrap === 'function' ? (value as any).unwrap() : (value as any).__entity ?? (value as any)\n if (unwrapped && typeof unwrapped === 'object') return [unwrapped]\n }\n } catch {\n // ignore\n }\n return []\n }\n // Collection wrapper\n if (typeof value === 'object' && typeof (value as any).isInitialized === 'function' && typeof (value as any).getItems === 'function') {\n try {\n return (value as any).isInitialized() ? (value as any).getItems() ?? [] : []\n } catch {\n return []\n }\n }\n if (Array.isArray(value)) return value\n if (typeof value === 'object') return [value]\n return []\n }\n const props = resolvedMeta?.properties ? Object.values(resolvedMeta.properties) : []\n for (const prop of props) {\n const kind = (prop as any)?.kind\n const name = (prop as any)?.name\n if (typeof name !== 'string' || !name.length) continue\n const value = (target as any)[name]\n if (!value) continue\n // Single-valued relation\n if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(kind)) {\n const nestedEntities = extractEntities(value)\n for (const nested of nestedEntities) {\n const nestedMeta = this.resolveMeta((nested as any).__meta ?? (nested as any).__helper?.__meta, nested, em)\n await this.decrypt(nested as Record<string, unknown>, nestedMeta, em, {\n syncOriginal: true,\n seen: visited,\n fallbackScope: nextFallback,\n })\n }\n continue\n }\n // Collections\n if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(kind)) {\n const items = extractEntities(value)\n for (const item of items) {\n if (!item || typeof item !== 'object') continue\n const nestedMeta = this.resolveMeta((item as any).__meta ?? (item as any).__helper?.__meta, item, em)\n await this.decrypt(item as Record<string, unknown>, nestedMeta, em, {\n syncOriginal: true,\n seen: visited,\n fallbackScope: nextFallback,\n })\n }\n }\n }\n } catch (err) {\n debug('\u26A0\uFE0F subscriber.deep_decrypt.error', {\n entityId,\n message: (err as Error)?.message ?? String(err),\n })\n }\n }\n\n async beforeCreate(args: EventArgs<any>) {\n await this.encrypt(args.entity as Record<string, unknown>, args.meta, args.em, args.changeSet as any)\n }\n\n async beforeUpdate(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em)\n await this.encrypt(args.entity as Record<string, unknown>, args.meta, args.em, args.changeSet as any)\n }\n\n async afterCreate(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async afterUpdate(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async afterUpsert(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async onLoad(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async afterFind(args: EventArgs<any> & { entities?: unknown[] }) {\n const entities = Array.isArray(args.entities) ? args.entities : []\n for (const entity of entities) {\n await this.decrypt(entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n }\n}\n\nexport function registerTenantEncryptionSubscriber(\n em: { getEventManager?: () => { registerSubscriber?: (subscriber: EventSubscriber<any>) => void } } | null | undefined,\n service: TenantDataEncryptionService,\n): void {\n const eventManager = em?.getEventManager?.()\n if (!eventManager || typeof eventManager.registerSubscriber !== 'function') return\n if (registeredEventManagers.has(eventManager)) return\n eventManager.registerSubscriber(new TenantEncryptionSubscriber(service))\n registeredEventManagers.add(eventManager)\n}\n\nexport async function decryptEntitiesWithFallbackScope(\n targets: unknown | unknown[],\n {\n em,\n tenantId,\n organizationId,\n encryptionService,\n }: {\n em: { getMetadata?: () => any; getComparator?: () => any }\n tenantId?: string | null\n organizationId?: string | null\n encryptionService?: TenantDataEncryptionService | null\n },\n): Promise<void> {\n if (!isTenantDataEncryptionEnabled()) return\n const list = Array.isArray(targets) ? targets : [targets]\n if (!list.length) return\n const service = encryptionService ?? resolveTenantEncryptionService(em as any)\n if (!service || !service.isEnabled()) return\n const subscriber = getSubscriberForService(service)\n const fallback: Scope | undefined =\n tenantId || organizationId\n ? {\n tenantId: tenantId ?? null,\n organizationId: organizationId ?? null,\n }\n : undefined\n for (const entity of list) {\n if (!entity || typeof entity !== 'object') continue\n const meta = (entity as any).__meta ?? (entity as any).__helper?.__meta\n await subscriber.decryptEntityGraph(entity as Record<string, unknown>, meta, em as any, {\n syncOriginal: true,\n fallbackScope: fallback,\n })\n }\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import type { EntityMetadata, EventArgs, EventSubscriber } from '@mikro-orm/core'\nimport { ReferenceKind } from '@mikro-orm/core'\nimport { resolveEntityIdFromMetadata } from './entityIds'\nimport { TenantDataEncryptionService } from './tenantDataEncryptionService'\nimport { isTenantDataEncryptionEnabled } from './toggles'\nimport { isEncryptionDebugEnabled } from './toggles'\nimport { resolveTenantEncryptionService } from './customFieldValues'\n\ntype Scoped = {\n tenantId?: string | null\n tenant_id?: string | null\n tenant?: { id?: string | null } | null\n organizationId?: string | null\n organization_id?: string | null\n organization?: { id?: string | null } | null\n}\n\ntype Scope = { tenantId: string | null; organizationId: string | null }\n\nfunction resolveScope(entity: Scoped): Scope {\n const tenantId = entity.tenantId ?? entity.tenant_id ?? entity.tenant?.id ?? null\n const organizationId = entity.organizationId ?? entity.organization_id ?? entity.organization?.id ?? null\n return {\n tenantId: tenantId ? String(tenantId) : null,\n organizationId: organizationId ? String(organizationId) : null,\n }\n}\n\nfunction debug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n // eslint-disable-next-line no-console\n console.debug(event, payload)\n } catch {\n // ignore\n }\n}\n\nconst registeredEventManagers = new WeakSet<object>()\n\nconst subscribersByService = new WeakMap<TenantDataEncryptionService, TenantEncryptionSubscriber>()\n\nfunction getSubscriberForService(service: TenantDataEncryptionService): TenantEncryptionSubscriber {\n const existing = subscribersByService.get(service)\n if (existing) return existing\n const subscriber = new TenantEncryptionSubscriber(service)\n subscribersByService.set(service, subscriber)\n return subscriber\n}\n\nconst toSnakeCase = (value: string): string =>\n value.replace(/([A-Z])/g, '_$1').replace(/__/g, '_').toLowerCase()\n\nexport class TenantEncryptionSubscriber implements EventSubscriber<any> {\n constructor(private readonly service: TenantDataEncryptionService) {}\n\n getSubscribedEntities() {\n return [] // listen to all entities\n }\n\n private resolveMeta(\n meta: EntityMetadata<any> | undefined,\n entity: Record<string, unknown>,\n em?: { getMetadata?: () => any },\n ): EntityMetadata<any> | undefined {\n if (meta) return meta\n const ctor = (entity as any)?.constructor\n const name = ctor?.name\n const registry = em?.getMetadata?.()\n if (!registry || !name) return meta\n try { return registry.find?.(name) } catch {}\n try { return registry.find?.(ctor) } catch {}\n try { return registry.get?.(name) } catch {}\n try { return registry.get?.(ctor) } catch {}\n const all =\n (typeof registry.getAll === 'function' && registry.getAll()) ||\n (Array.isArray((registry as any).metadata) ? (registry as any).metadata : undefined) ||\n (registry as any).metadata ||\n {}\n try {\n const entries = Array.isArray(all) ? all : Object.values<any>(all)\n const match = entries.find(\n (m: any) =>\n m?.className === name ||\n m?.name === name ||\n m?.entityName === name ||\n m?.collection === ctor?.prototype?.__meta?.tableName ||\n m?.tableName === ctor?.prototype?.__meta?.tableName,\n )\n if (match) return match as EntityMetadata<any>\n } catch {\n // best-effort\n }\n return meta\n }\n\n private resolveEntityId(meta: EntityMetadata<any> | undefined): string | null {\n try {\n return resolveEntityIdFromMetadata(meta)\n } catch {\n return null\n }\n }\n\n private syncOriginalEntityData(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getComparator?: () => any },\n ) {\n const helper = (target as any)?.__helper\n if (!helper || typeof helper !== 'object') return\n\n // Prefer MikroORM comparator snapshot so change detection uses the expected shape.\n try {\n const comparator = em?.getComparator?.()\n if (comparator?.prepareEntity) {\n helper.__originalEntityData = comparator.prepareEntity(target)\n helper.__touched = false\n return\n }\n } catch (err) {\n debug('\u26AA\uFE0F subscriber.sync_original.comparator_failed', {\n entity: meta?.className || meta?.name,\n message: (err as Error)?.message ?? String(err),\n })\n }\n\n // Fallback: shallow snapshot of scalar/owner props to keep entities clean without comparator.\n const properties = meta?.properties ? Object.values(meta.properties) : []\n if (properties.length === 0) return\n const snapshot: Record<string, unknown> = { ...(helper.__originalEntityData ?? {}) }\n for (const prop of properties) {\n if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes((prop as any).kind)) continue\n const name = (prop as any).name\n if (typeof name !== 'string' || !name.length) continue\n snapshot[name] = (target as Record<string, unknown>)[name]\n }\n helper.__originalEntityData = snapshot\n helper.__touched = false\n }\n\n /**\n * Reports whether a managed entity currently carries un-flushed changes relative to its load\n * baseline, using MikroORM's own comparator so the verdict matches the change-set computer that\n * runs at flush time. Used to avoid re-baselining (and thereby discarding) pending writes when a\n * decrypt pass traverses back into an entity a command already mutated.\n */\n private hasPendingChanges(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getComparator?: () => any },\n ): boolean {\n const helper = (target as any)?.__helper\n if (!helper || typeof helper !== 'object') return false\n const original = helper.__originalEntityData\n if (!original) return false\n const entityName = meta?.className || meta?.name\n try {\n const comparator = em?.getComparator?.()\n if (entityName && comparator?.prepareEntity) {\n const current = comparator.prepareEntity(target)\n if (typeof comparator.matching === 'function') {\n return !comparator.matching(entityName, original, current)\n }\n if (typeof comparator.diffEntities === 'function') {\n const diff = comparator.diffEntities(entityName, original, current)\n return !!diff && Object.keys(diff).length > 0\n }\n }\n } catch (err) {\n debug('\u26AA\uFE0F subscriber.pending_changes.compare_failed', {\n entity: entityName,\n message: (err as Error)?.message ?? String(err),\n })\n }\n return helper.__touched === true\n }\n\n private async encrypt(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getMetadata?: () => any; getComparator?: () => any },\n changeSet?: { payload?: Record<string, unknown> },\n ) {\n if (!isTenantDataEncryptionEnabled() || !this.service.isEnabled()) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'disabled', entity: meta?.className || meta?.name })\n return\n }\n const resolvedMeta = this.resolveMeta(meta, target, em)\n const entityId = this.resolveEntityId(resolvedMeta)\n if (!entityId) {\n debug('\u26A0\uFE0F subscriber.decrypt.skip.entity_id_missing', {\n metaName: resolvedMeta?.className || resolvedMeta?.name,\n table: (resolvedMeta as any)?.tableName,\n })\n return\n }\n const { tenantId, organizationId } = resolveScope(target)\n if (!tenantId) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'no-tenant', entityId })\n return\n }\n const encrypted = await this.service.encryptEntityPayload(entityId, target, tenantId, organizationId)\n const metaProps: Record<string, unknown> = resolvedMeta?.properties && typeof resolvedMeta.properties === 'object'\n ? resolvedMeta.properties\n : {}\n const payloadObj: Record<string, unknown> | null =\n changeSet && typeof changeSet === 'object'\n ? (typeof changeSet.payload === 'object' && changeSet.payload\n ? (changeSet.payload as Record<string, unknown>)\n : ((changeSet.payload = {}) as Record<string, unknown>))\n : null\n const updates: Record<string, unknown> = {}\n const columnNameFor = (propKey: string, prop: Record<string, unknown> | undefined): string => {\n try {\n if (prop && typeof prop === 'object') {\n const explicit = (prop as any)?.fieldName\n if (typeof explicit === 'string' && explicit.length) return explicit\n const name = (prop as any)?.name\n if (typeof name === 'string' && name.length) return name\n }\n } catch (err) {\n debug('\u26A0\uFE0F subscriber.column_name.resolve', {\n entityId,\n propKey,\n message: (err as Error)?.message ?? String(err),\n })\n }\n return toSnakeCase(propKey)\n }\n\n for (const [key, value] of Object.entries(encrypted)) {\n const prop = (metaProps as Record<string, any>)[key]\n if (!prop || typeof prop !== 'object') continue\n if ((target as Record<string, unknown>)[key] === value) continue\n updates[key] = value\n }\n if (Object.keys(updates).length === 0) return\n Object.assign(target, updates)\n if (payloadObj) {\n try {\n const ensureColumnKey = (propKey: string, value: unknown) => {\n const columnName = columnNameFor(propKey, (metaProps as Record<string, any>)[propKey])\n const canonicalKey = columnName || toSnakeCase(propKey)\n const aliases = new Set(\n [propKey, toSnakeCase(propKey), columnName, columnName ? toSnakeCase(columnName) : undefined].filter(\n (v): v is string => typeof v === 'string' && v.length > 0,\n ),\n )\n for (const alias of aliases) {\n if (Object.prototype.hasOwnProperty.call(payloadObj, alias)) delete payloadObj[alias]\n }\n const finalKey = columnName || toSnakeCase(propKey)\n payloadObj[finalKey] = value\n }\n for (const key of Object.keys(updates)) {\n ensureColumnKey(key, updates[key])\n }\n } catch (err) {\n debug('\u26A0\uFE0F subscriber.payload.normalize.error', {\n entityId,\n message: (err as Error)?.message ?? String(err),\n })\n }\n }\n }\n\n async decryptEntityGraph(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getMetadata?: () => any; getComparator?: () => any },\n opts: { syncOriginal?: boolean; seen?: WeakSet<object>; fallbackScope?: Scope } = {},\n ) {\n await this.decrypt(target, meta, em, opts)\n }\n\n private async decrypt(\n target: Record<string, unknown>,\n meta: EntityMetadata<any> | undefined,\n em?: { getMetadata?: () => any; getComparator?: () => any },\n {\n syncOriginal = false,\n seen,\n fallbackScope,\n }: { syncOriginal?: boolean; seen?: WeakSet<object>; fallbackScope?: Scope } = {},\n ) {\n const visited = seen ?? new WeakSet<object>()\n if (visited.has(target as object)) return\n visited.add(target as object)\n if (!isTenantDataEncryptionEnabled() || !this.service.isEnabled()) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'disabled', entity: meta?.className || meta?.name })\n return\n }\n const resolvedMeta = this.resolveMeta(meta, target, em)\n const entityId = this.resolveEntityId(resolvedMeta)\n if (!entityId) return\n const { tenantId, organizationId } = resolveScope(target)\n const scopedTenantId = tenantId ?? fallbackScope?.tenantId ?? null\n const scopedOrgId = organizationId ?? fallbackScope?.organizationId ?? null\n if (!scopedTenantId) {\n debug('\u26AA\uFE0F subscriber.skip', { reason: 'no-tenant', entityId })\n return\n }\n // Capture pending (un-flushed) changes BEFORE decrypt mutates the target. Re-baselining a\n // managed entity that a command already mutated would clear its dirty changeset and silently\n // drop the pending write (e.g. an undo handler that mutates an entity, then loads a related\n // encrypted entity whose deep-decrypt recurses back into the still-dirty entity before flush).\n const hadPendingChanges = syncOriginal ? this.hasPendingChanges(target, resolvedMeta, em as any) : false\n const decrypted = await this.service.decryptEntityPayload(entityId, target, scopedTenantId, scopedOrgId)\n Object.assign(target, decrypted)\n if (syncOriginal && !hadPendingChanges) {\n this.syncOriginalEntityData(target, resolvedMeta, em as any)\n }\n const nextFallback =\n fallbackScope ??\n (tenantId || organizationId\n ? { tenantId: tenantId ?? null, organizationId: organizationId ?? null }\n : { tenantId: scopedTenantId, organizationId: scopedOrgId })\n // Best-effort deep decrypt for loaded relations so populated graphs get cleaned too.\n try {\n const extractEntities = (value: any): any[] => {\n if (!value) return []\n // MikroORM Collection wrapper \u2014 MUST be checked before the Reference branch: both wrappers\n // expose isInitialized(), but only a Collection exposes getItems(). Matching the Reference\n // branch first returned the Collection wrapper itself instead of its items, so collection\n // relations were never deep-decrypted and leaked ciphertext (issue #2744).\n if (typeof value === 'object' && typeof (value as any).isInitialized === 'function' && typeof (value as any).getItems === 'function') {\n try {\n return (value as any).isInitialized() ? (value as any).getItems() ?? [] : []\n } catch {\n return []\n }\n }\n // MikroORM Reference wrapper\n if (typeof value === 'object' && typeof (value as any).isInitialized === 'function') {\n try {\n if ((value as any).isInitialized()) {\n const unwrapped = typeof (value as any).unwrap === 'function' ? (value as any).unwrap() : (value as any).__entity ?? (value as any)\n if (unwrapped && typeof unwrapped === 'object') return [unwrapped]\n }\n } catch {\n // ignore\n }\n return []\n }\n if (Array.isArray(value)) return value\n if (typeof value === 'object') return [value]\n return []\n }\n const props = resolvedMeta?.properties ? Object.values(resolvedMeta.properties) : []\n for (const prop of props) {\n const kind = (prop as any)?.kind\n const name = (prop as any)?.name\n if (typeof name !== 'string' || !name.length) continue\n const value = (target as any)[name]\n if (!value) continue\n // Single-valued relation\n if ([ReferenceKind.MANY_TO_ONE, ReferenceKind.ONE_TO_ONE].includes(kind)) {\n const nestedEntities = extractEntities(value)\n for (const nested of nestedEntities) {\n const nestedMeta = this.resolveMeta((nested as any).__meta ?? (nested as any).__helper?.__meta, nested, em)\n await this.decrypt(nested as Record<string, unknown>, nestedMeta, em, {\n syncOriginal: true,\n seen: visited,\n fallbackScope: nextFallback,\n })\n }\n continue\n }\n // Collections\n if ([ReferenceKind.ONE_TO_MANY, ReferenceKind.MANY_TO_MANY].includes(kind)) {\n const items = extractEntities(value)\n for (const item of items) {\n if (!item || typeof item !== 'object') continue\n const nestedMeta = this.resolveMeta((item as any).__meta ?? (item as any).__helper?.__meta, item, em)\n await this.decrypt(item as Record<string, unknown>, nestedMeta, em, {\n syncOriginal: true,\n seen: visited,\n fallbackScope: nextFallback,\n })\n }\n }\n }\n } catch (err) {\n debug('\u26A0\uFE0F subscriber.deep_decrypt.error', {\n entityId,\n message: (err as Error)?.message ?? String(err),\n })\n }\n }\n\n async beforeCreate(args: EventArgs<any>) {\n await this.encrypt(args.entity as Record<string, unknown>, args.meta, args.em, args.changeSet as any)\n }\n\n async beforeUpdate(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em)\n await this.encrypt(args.entity as Record<string, unknown>, args.meta, args.em, args.changeSet as any)\n }\n\n async afterCreate(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async afterUpdate(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async afterUpsert(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async onLoad(args: EventArgs<any>) {\n await this.decrypt(args.entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n\n async afterFind(args: EventArgs<any> & { entities?: unknown[] }) {\n const entities = Array.isArray(args.entities) ? args.entities : []\n for (const entity of entities) {\n await this.decrypt(entity as Record<string, unknown>, args.meta, args.em, { syncOriginal: true })\n }\n }\n}\n\nexport function registerTenantEncryptionSubscriber(\n em: { getEventManager?: () => { registerSubscriber?: (subscriber: EventSubscriber<any>) => void } } | null | undefined,\n service: TenantDataEncryptionService,\n): void {\n const eventManager = em?.getEventManager?.()\n if (!eventManager || typeof eventManager.registerSubscriber !== 'function') return\n if (registeredEventManagers.has(eventManager)) return\n eventManager.registerSubscriber(new TenantEncryptionSubscriber(service))\n registeredEventManagers.add(eventManager)\n}\n\nexport async function decryptEntitiesWithFallbackScope(\n targets: unknown | unknown[],\n {\n em,\n tenantId,\n organizationId,\n encryptionService,\n }: {\n em: { getMetadata?: () => any; getComparator?: () => any }\n tenantId?: string | null\n organizationId?: string | null\n encryptionService?: TenantDataEncryptionService | null\n },\n): Promise<void> {\n if (!isTenantDataEncryptionEnabled()) return\n const list = Array.isArray(targets) ? targets : [targets]\n if (!list.length) return\n const service = encryptionService ?? resolveTenantEncryptionService(em as any)\n if (!service || !service.isEnabled()) return\n const subscriber = getSubscriberForService(service)\n const fallback: Scope | undefined =\n tenantId || organizationId\n ? {\n tenantId: tenantId ?? null,\n organizationId: organizationId ?? null,\n }\n : undefined\n for (const entity of list) {\n if (!entity || typeof entity !== 'object') continue\n const meta = (entity as any).__meta ?? (entity as any).__helper?.__meta\n await subscriber.decryptEntityGraph(entity as Record<string, unknown>, meta, em as any, {\n syncOriginal: true,\n fallbackScope: fallback,\n })\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.5-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.5-develop.5139.1.g7925348493'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.5139.1.g7925348493",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"@mikro-orm/core": "^7.1.3",
|
|
93
93
|
"@mikro-orm/decorators": "^7.1.3",
|
|
94
94
|
"@mikro-orm/postgresql": "^7.1.3",
|
|
95
|
-
"@open-mercato/cache": "0.6.5-develop.
|
|
95
|
+
"@open-mercato/cache": "0.6.5-develop.5139.1.g7925348493",
|
|
96
96
|
"dotenv": "^17.4.2",
|
|
97
97
|
"rate-limiter-flexible": "^11.1.0",
|
|
98
98
|
"re2js": "2.8.3",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { hashForLookup, legacyHashForLookup, lookupHashCandidates } from '../aes'
|
|
3
|
+
|
|
4
|
+
const originalEnv = { ...process.env }
|
|
5
|
+
|
|
6
|
+
function clearLookupEnv() {
|
|
7
|
+
delete process.env.LOOKUP_HASH_PEPPER
|
|
8
|
+
delete process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY
|
|
9
|
+
delete process.env.TENANT_DATA_ENCRYPTION_KEY
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('hashForLookup keyed digest (issue #2718)', () => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
process.env = { ...originalEnv }
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('emits a keyed v2 HMAC when a lookup pepper is configured', () => {
|
|
18
|
+
clearLookupEnv()
|
|
19
|
+
process.env.LOOKUP_HASH_PEPPER = 'installation-pepper-secret-value'
|
|
20
|
+
const digest = hashForLookup('User@Example.com')
|
|
21
|
+
expect(digest.startsWith('v2:')).toBe(true)
|
|
22
|
+
const expected = crypto
|
|
23
|
+
.createHmac('sha256', 'installation-pepper-secret-value')
|
|
24
|
+
.update('user@example.com')
|
|
25
|
+
.digest('hex')
|
|
26
|
+
expect(digest).toBe(`v2:${expected}`)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('is not equal to the legacy unkeyed sha256 (defeats precomputed rainbow tables)', () => {
|
|
30
|
+
clearLookupEnv()
|
|
31
|
+
process.env.LOOKUP_HASH_PEPPER = 'installation-pepper-secret-value'
|
|
32
|
+
const keyed = hashForLookup('user@example.com')
|
|
33
|
+
const legacy = legacyHashForLookup('user@example.com')
|
|
34
|
+
expect(keyed).not.toBe(legacy)
|
|
35
|
+
// The legacy digest is exactly what an attacker would precompute.
|
|
36
|
+
const naive = crypto.createHash('sha256').update('user@example.com').digest('hex')
|
|
37
|
+
expect(legacy).toBe(naive)
|
|
38
|
+
expect(keyed).not.toContain(naive)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('produces different digests across installations (no cross-installation correlation)', () => {
|
|
42
|
+
clearLookupEnv()
|
|
43
|
+
process.env.LOOKUP_HASH_PEPPER = 'pepper-installation-a'
|
|
44
|
+
const a = hashForLookup('user@example.com')
|
|
45
|
+
process.env.LOOKUP_HASH_PEPPER = 'pepper-installation-b'
|
|
46
|
+
const b = hashForLookup('user@example.com')
|
|
47
|
+
expect(a).not.toBe(b)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('binds the digest to the optional field/entity context (not portable across columns)', () => {
|
|
51
|
+
clearLookupEnv()
|
|
52
|
+
process.env.LOOKUP_HASH_PEPPER = 'installation-pepper-secret-value'
|
|
53
|
+
const withoutContext = hashForLookup('user@example.com')
|
|
54
|
+
const emailContext = hashForLookup('user@example.com', 'auth.user:email')
|
|
55
|
+
const phoneContext = hashForLookup('user@example.com', 'auth.user:phone')
|
|
56
|
+
expect(emailContext).not.toBe(withoutContext)
|
|
57
|
+
expect(emailContext).not.toBe(phoneContext)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('normalizes case and surrounding whitespace before hashing', () => {
|
|
61
|
+
clearLookupEnv()
|
|
62
|
+
process.env.LOOKUP_HASH_PEPPER = 'installation-pepper-secret-value'
|
|
63
|
+
expect(hashForLookup(' User@Example.com ')).toBe(hashForLookup('user@example.com'))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('resolves the pepper from existing encryption secrets when no dedicated pepper is set', () => {
|
|
67
|
+
clearLookupEnv()
|
|
68
|
+
process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY = 'fallback-secret'
|
|
69
|
+
const fromFallback = hashForLookup('user@example.com')
|
|
70
|
+
expect(fromFallback.startsWith('v2:')).toBe(true)
|
|
71
|
+
|
|
72
|
+
clearLookupEnv()
|
|
73
|
+
process.env.TENANT_DATA_ENCRYPTION_KEY = 'fallback-secret'
|
|
74
|
+
const fromKey = hashForLookup('user@example.com')
|
|
75
|
+
expect(fromKey).toBe(fromFallback)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('prefers LOOKUP_HASH_PEPPER over the encryption fallback secrets', () => {
|
|
79
|
+
clearLookupEnv()
|
|
80
|
+
process.env.LOOKUP_HASH_PEPPER = 'dedicated-pepper'
|
|
81
|
+
process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY = 'fallback-secret'
|
|
82
|
+
const expected = crypto
|
|
83
|
+
.createHmac('sha256', 'dedicated-pepper')
|
|
84
|
+
.update('user@example.com')
|
|
85
|
+
.digest('hex')
|
|
86
|
+
expect(hashForLookup('user@example.com')).toBe(`v2:${expected}`)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('falls back to the legacy unkeyed digest only when no secret is configured', () => {
|
|
90
|
+
clearLookupEnv()
|
|
91
|
+
const digest = hashForLookup('user@example.com')
|
|
92
|
+
expect(digest.startsWith('v2:')).toBe(false)
|
|
93
|
+
expect(digest).toBe(legacyHashForLookup('user@example.com'))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('exposes keyed and legacy candidates for migration-window reads', () => {
|
|
97
|
+
clearLookupEnv()
|
|
98
|
+
process.env.LOOKUP_HASH_PEPPER = 'installation-pepper-secret-value'
|
|
99
|
+
const candidates = lookupHashCandidates('user@example.com')
|
|
100
|
+
expect(candidates).toEqual([
|
|
101
|
+
hashForLookup('user@example.com'),
|
|
102
|
+
legacyHashForLookup('user@example.com'),
|
|
103
|
+
])
|
|
104
|
+
expect(candidates).toHaveLength(2)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('collapses candidates to a single value when running in legacy (no-pepper) mode', () => {
|
|
108
|
+
clearLookupEnv()
|
|
109
|
+
const candidates = lookupHashCandidates('user@example.com')
|
|
110
|
+
expect(candidates).toEqual([legacyHashForLookup('user@example.com')])
|
|
111
|
+
expect(candidates).toHaveLength(1)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { ReferenceKind } from '@mikro-orm/core'
|
|
2
|
+
import { TenantEncryptionSubscriber } from '../subscriber'
|
|
3
|
+
import { registerEntityIds } from '../entityIds'
|
|
4
|
+
import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
|
|
5
|
+
|
|
6
|
+
// Regression coverage for issue #2744: a loaded *-to-many Collection relation must be expanded into
|
|
7
|
+
// its items during deep-decrypt. extractEntities() previously matched the MikroORM Reference branch
|
|
8
|
+
// first — both a Collection and a Reference expose isInitialized() — so a Collection (which has no
|
|
9
|
+
// unwrap()/__entity) was returned as the wrapper itself and decrypt() ran on the Collection instead
|
|
10
|
+
// of each item, leaving encrypted child fields as ciphertext in populated response graphs.
|
|
11
|
+
|
|
12
|
+
// Mirrors a MikroORM Collection: exposes isInitialized() + getItems(), but NO unwrap()/__entity.
|
|
13
|
+
function makeCollection(items: Record<string, unknown>[], initialized = true) {
|
|
14
|
+
return {
|
|
15
|
+
isInitialized: () => initialized,
|
|
16
|
+
getItems: () => items,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Mirrors a MikroORM Reference: exposes isInitialized() + unwrap(), but NO getItems().
|
|
21
|
+
function makeReference(entity: Record<string, unknown>, initialized = true) {
|
|
22
|
+
return {
|
|
23
|
+
isInitialized: () => initialized,
|
|
24
|
+
unwrap: () => entity,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const CHILD_META = { className: 'Child', tableName: 'children', properties: {} } as any
|
|
29
|
+
|
|
30
|
+
const parentMeta = (childKind: ReferenceKind, relationName: string) =>
|
|
31
|
+
({
|
|
32
|
+
className: 'Parent',
|
|
33
|
+
tableName: 'parents',
|
|
34
|
+
properties: { [relationName]: { name: relationName, kind: childKind } },
|
|
35
|
+
}) as any
|
|
36
|
+
|
|
37
|
+
function makeEm() {
|
|
38
|
+
return { getMetadata: () => undefined, getComparator: () => undefined }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Decrypts by stripping an "enc:" prefix; records every entity id it was asked to decrypt.
|
|
42
|
+
function makeService(decryptedEntityIds: string[]): TenantDataEncryptionService {
|
|
43
|
+
return {
|
|
44
|
+
isEnabled: () => true,
|
|
45
|
+
async decryptEntityPayload(entityId: string, target: Record<string, unknown>) {
|
|
46
|
+
decryptedEntityIds.push(entityId)
|
|
47
|
+
const value = target.secret
|
|
48
|
+
if (typeof value === 'string' && value.startsWith('enc:')) {
|
|
49
|
+
return { secret: value.slice('enc:'.length) }
|
|
50
|
+
}
|
|
51
|
+
return {}
|
|
52
|
+
},
|
|
53
|
+
} as unknown as TenantDataEncryptionService
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('TenantEncryptionSubscriber deep-decrypt of loaded collection relations (issue #2744)', () => {
|
|
57
|
+
const originalToggle = process.env.TENANT_DATA_ENCRYPTION
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
delete process.env.TENANT_DATA_ENCRYPTION // default => encryption enabled
|
|
61
|
+
registerEntityIds({ test: { parent: 'test:parent', child: 'test:child' } })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
|
|
66
|
+
else process.env.TENANT_DATA_ENCRYPTION = originalToggle
|
|
67
|
+
jest.restoreAllMocks()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('decrypts every item of an initialized ONE_TO_MANY collection', async () => {
|
|
71
|
+
const decryptedEntityIds: string[] = []
|
|
72
|
+
const childA = { secret: 'enc:alpha', tenantId: 't1', __meta: CHILD_META }
|
|
73
|
+
const childB = { secret: 'enc:beta', tenantId: 't1', __meta: CHILD_META }
|
|
74
|
+
const meta = parentMeta(ReferenceKind.ONE_TO_MANY, 'children')
|
|
75
|
+
const parent = { tenantId: 't1', children: makeCollection([childA, childB]), __meta: meta }
|
|
76
|
+
|
|
77
|
+
const subscriber = new TenantEncryptionSubscriber(makeService(decryptedEntityIds))
|
|
78
|
+
await subscriber.decryptEntityGraph(parent, meta, makeEm(), { syncOriginal: true })
|
|
79
|
+
|
|
80
|
+
expect(childA.secret).toBe('alpha')
|
|
81
|
+
expect(childB.secret).toBe('beta')
|
|
82
|
+
expect(decryptedEntityIds.filter((id) => id === 'test:child')).toHaveLength(2)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('decrypts every item of an initialized MANY_TO_MANY collection', async () => {
|
|
86
|
+
const decryptedEntityIds: string[] = []
|
|
87
|
+
const child = { secret: 'enc:gamma', tenantId: 't1', __meta: CHILD_META }
|
|
88
|
+
const meta = parentMeta(ReferenceKind.MANY_TO_MANY, 'children')
|
|
89
|
+
const parent = { tenantId: 't1', children: makeCollection([child]), __meta: meta }
|
|
90
|
+
|
|
91
|
+
const subscriber = new TenantEncryptionSubscriber(makeService(decryptedEntityIds))
|
|
92
|
+
await subscriber.decryptEntityGraph(parent, meta, makeEm(), { syncOriginal: true })
|
|
93
|
+
|
|
94
|
+
expect(child.secret).toBe('gamma')
|
|
95
|
+
expect(decryptedEntityIds).toContain('test:child')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('leaves an uninitialized collection untouched (does not decrypt unloaded items)', async () => {
|
|
99
|
+
const decryptedEntityIds: string[] = []
|
|
100
|
+
const child = { secret: 'enc:delta', tenantId: 't1', __meta: CHILD_META }
|
|
101
|
+
const meta = parentMeta(ReferenceKind.ONE_TO_MANY, 'children')
|
|
102
|
+
const parent = { tenantId: 't1', children: makeCollection([child], false), __meta: meta }
|
|
103
|
+
|
|
104
|
+
const subscriber = new TenantEncryptionSubscriber(makeService(decryptedEntityIds))
|
|
105
|
+
await subscriber.decryptEntityGraph(parent, meta, makeEm(), { syncOriginal: true })
|
|
106
|
+
|
|
107
|
+
expect(child.secret).toBe('enc:delta')
|
|
108
|
+
expect(decryptedEntityIds).not.toContain('test:child')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('still decrypts a single-valued MANY_TO_ONE Reference (reorder must not regress references)', async () => {
|
|
112
|
+
const decryptedEntityIds: string[] = []
|
|
113
|
+
const child = { secret: 'enc:epsilon', tenantId: 't1', __meta: CHILD_META }
|
|
114
|
+
const meta = parentMeta(ReferenceKind.MANY_TO_ONE, 'child')
|
|
115
|
+
const parent = { tenantId: 't1', child: makeReference(child), __meta: meta }
|
|
116
|
+
|
|
117
|
+
const subscriber = new TenantEncryptionSubscriber(makeService(decryptedEntityIds))
|
|
118
|
+
await subscriber.decryptEntityGraph(parent, meta, makeEm(), { syncOriginal: true })
|
|
119
|
+
|
|
120
|
+
expect(child.secret).toBe('epsilon')
|
|
121
|
+
expect(decryptedEntityIds).toContain('test:child')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -80,8 +80,84 @@ export function decryptWithAesGcm(payload: string, dekBase64: string): string |
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
const LOOKUP_HASH_V2_PREFIX = 'v2:'
|
|
84
|
+
|
|
85
|
+
function normalizeLookupValue(value: string): string {
|
|
86
|
+
return value.toLowerCase().trim()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Legacy, unkeyed lookup digest (`sha256(lower(trim(value)))`).
|
|
91
|
+
*
|
|
92
|
+
* @deprecated Unkeyed digests are vulnerable to offline rainbow-table attacks and
|
|
93
|
+
* cross-installation correlation (issue #2718). New writes use {@link hashForLookup},
|
|
94
|
+
* which emits a keyed `v2:` HMAC when a lookup pepper is configured. This helper is
|
|
95
|
+
* retained only so existing `*_hash` columns written before the keyed format can still
|
|
96
|
+
* be matched (see {@link lookupHashCandidates}) until a backfill migration recomputes them.
|
|
97
|
+
*/
|
|
98
|
+
export function legacyHashForLookup(value: string): string {
|
|
99
|
+
return crypto.createHash('sha256').update(normalizeLookupValue(value)).digest('hex')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the installation-wide lookup pepper used to key lookup hashes.
|
|
104
|
+
*
|
|
105
|
+
* Order of precedence (never `AUTH_SECRET`, per issue #2718):
|
|
106
|
+
* 1. `LOOKUP_HASH_PEPPER` — dedicated secret for lookup hashing
|
|
107
|
+
* 2. `TENANT_DATA_ENCRYPTION_FALLBACK_KEY` — existing encryption fallback secret
|
|
108
|
+
* 3. `TENANT_DATA_ENCRYPTION_KEY` — existing encryption secret
|
|
109
|
+
*
|
|
110
|
+
* Returns `null` when no secret is configured, in which case {@link hashForLookup}
|
|
111
|
+
* falls back to the legacy unkeyed digest so deployments without any configured key
|
|
112
|
+
* keep working unchanged.
|
|
113
|
+
*/
|
|
114
|
+
function resolveLookupPepper(): string | null {
|
|
115
|
+
const candidates = [
|
|
116
|
+
process.env.LOOKUP_HASH_PEPPER,
|
|
117
|
+
process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY,
|
|
118
|
+
process.env.TENANT_DATA_ENCRYPTION_KEY,
|
|
119
|
+
]
|
|
120
|
+
for (const candidate of candidates) {
|
|
121
|
+
if (typeof candidate !== 'string') continue
|
|
122
|
+
const normalized = candidate.trim().replace(/(?:^['"]|['"]$)/g, '')
|
|
123
|
+
if (normalized) return normalized
|
|
124
|
+
}
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compute a deterministic lookup hash for a low-entropy PII value (email, phone, …).
|
|
130
|
+
*
|
|
131
|
+
* When a lookup pepper is configured the result is a keyed HMAC-SHA-256 prefixed with
|
|
132
|
+
* `v2:` and bound to the optional `context` (entity/field) so digests are not portable
|
|
133
|
+
* across columns, installations, or tenants without the secret. When no pepper is
|
|
134
|
+
* configured it falls back to the legacy unkeyed digest for backward compatibility.
|
|
135
|
+
*
|
|
136
|
+
* The `context` MUST be supplied identically on both the write and the read side for a
|
|
137
|
+
* given column; callers that do not pass one stay mutually consistent.
|
|
138
|
+
*/
|
|
139
|
+
export function hashForLookup(value: string, context?: string): string {
|
|
140
|
+
const pepper = resolveLookupPepper()
|
|
141
|
+
const normalized = normalizeLookupValue(value)
|
|
142
|
+
if (!pepper) {
|
|
143
|
+
return legacyHashForLookup(value)
|
|
144
|
+
}
|
|
145
|
+
const message = context ? `${context}:${normalized}` : normalized
|
|
146
|
+
const digest = crypto.createHmac('sha256', pepper).update(message).digest('hex')
|
|
147
|
+
return `${LOOKUP_HASH_V2_PREFIX}${digest}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Candidate lookup hashes for matching a value against `*_hash` columns that may hold
|
|
152
|
+
* either the new keyed (`v2:`) digest or a legacy unkeyed digest. Use this in `$in` /
|
|
153
|
+
* `IN (...)` filters during the migration window so reads keep matching rows written
|
|
154
|
+
* before the keyed format. Once a backfill has recomputed all columns this can collapse
|
|
155
|
+
* back to a single {@link hashForLookup} value.
|
|
156
|
+
*/
|
|
157
|
+
export function lookupHashCandidates(value: string, context?: string): string[] {
|
|
158
|
+
const primary = hashForLookup(value, context)
|
|
159
|
+
const legacy = legacyHashForLookup(value)
|
|
160
|
+
return primary === legacy ? [primary] : [primary, legacy]
|
|
85
161
|
}
|
|
86
162
|
|
|
87
163
|
/**
|
|
@@ -320,6 +320,17 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
320
320
|
try {
|
|
321
321
|
const extractEntities = (value: any): any[] => {
|
|
322
322
|
if (!value) return []
|
|
323
|
+
// MikroORM Collection wrapper — MUST be checked before the Reference branch: both wrappers
|
|
324
|
+
// expose isInitialized(), but only a Collection exposes getItems(). Matching the Reference
|
|
325
|
+
// branch first returned the Collection wrapper itself instead of its items, so collection
|
|
326
|
+
// relations were never deep-decrypted and leaked ciphertext (issue #2744).
|
|
327
|
+
if (typeof value === 'object' && typeof (value as any).isInitialized === 'function' && typeof (value as any).getItems === 'function') {
|
|
328
|
+
try {
|
|
329
|
+
return (value as any).isInitialized() ? (value as any).getItems() ?? [] : []
|
|
330
|
+
} catch {
|
|
331
|
+
return []
|
|
332
|
+
}
|
|
333
|
+
}
|
|
323
334
|
// MikroORM Reference wrapper
|
|
324
335
|
if (typeof value === 'object' && typeof (value as any).isInitialized === 'function') {
|
|
325
336
|
try {
|
|
@@ -332,14 +343,6 @@ export class TenantEncryptionSubscriber implements EventSubscriber<any> {
|
|
|
332
343
|
}
|
|
333
344
|
return []
|
|
334
345
|
}
|
|
335
|
-
// Collection wrapper
|
|
336
|
-
if (typeof value === 'object' && typeof (value as any).isInitialized === 'function' && typeof (value as any).getItems === 'function') {
|
|
337
|
-
try {
|
|
338
|
-
return (value as any).isInitialized() ? (value as any).getItems() ?? [] : []
|
|
339
|
-
} catch {
|
|
340
|
-
return []
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
346
|
if (Array.isArray(value)) return value
|
|
344
347
|
if (typeof value === 'object') return [value]
|
|
345
348
|
return []
|