@open-mercato/shared 0.6.5-develop.5099.1.85c0aff78c → 0.6.5-develop.5116.1.f0af9e5080
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.
|
@@ -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
|
}
|
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.5116.1.f0af9e5080'\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.5116.1.f0af9e5080",
|
|
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.5116.1.f0af9e5080",
|
|
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
|
+
})
|
|
@@ -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
|
/**
|