@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
- function hashForLookup(value) {
68
- return crypto.createHash("sha256").update(value.toLowerCase().trim()).digest("hex");
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 hashForLookup(value: string): string {\n return crypto.createHash('sha256').update(value.toLowerCase().trim()).digest('hex')\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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",
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
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.5-develop.5099.1.85c0aff78c";
1
+ const APP_VERSION = "0.6.5-develop.5116.1.f0af9e5080";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -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.5099.1.85c0aff78c'\nexport const appVersion = APP_VERSION\n"],
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.5099.1.85c0aff78c",
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.5099.1.85c0aff78c",
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
- export function hashForLookup(value: string): string {
84
- return crypto.createHash('sha256').update(value.toLowerCase().trim()).digest('hex')
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
  /**