@open-mercato/shared 0.6.5-develop.5048.1.fd82f4ae17 → 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.
@@ -1,44 +1,69 @@
1
1
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
2
2
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
3
3
  import { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'
4
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
4
5
  import { env } from 'process'
5
6
 
7
+ function buildScopeLogContext(ctx: CommandRuntimeContext) {
8
+ const requestInfo =
9
+ ctx.request && typeof ctx.request === 'object'
10
+ ? {
11
+ method: (ctx.request as Request).method ?? undefined,
12
+ url: (ctx.request as Request).url ?? undefined,
13
+ }
14
+ : null
15
+ const scope = ctx.organizationScope
16
+ ? {
17
+ selectedId: ctx.organizationScope.selectedId ?? null,
18
+ tenantId: ctx.organizationScope.tenantId ?? null,
19
+ allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)
20
+ ? ctx.organizationScope.allowedIds.length
21
+ : null,
22
+ filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)
23
+ ? ctx.organizationScope.filterIds.length
24
+ : null,
25
+ }
26
+ : null
27
+ return {
28
+ userId: ctx.auth?.sub ?? null,
29
+ actorTenantId: ctx.auth?.tenantId ?? null,
30
+ actorOrganizationId: ctx.auth?.orgId ?? null,
31
+ selectedOrganizationId: ctx.selectedOrganizationId ?? null,
32
+ organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
33
+ scope,
34
+ request: requestInfo,
35
+ }
36
+ }
37
+
38
+ function isStrictOrganizationScopeEnforced(): boolean {
39
+ return parseBooleanWithDefault(env.OM_ENFORCE_ORG_SCOPE_STRICT, false)
40
+ }
41
+
6
42
  function logScopeViolation(
7
43
  ctx: CommandRuntimeContext,
8
44
  expected: string,
9
45
  actual: string | null
10
46
  ): void {
11
47
  try {
12
- const requestInfo =
13
- ctx.request && typeof ctx.request === 'object'
14
- ? {
15
- method: (ctx.request as Request).method ?? undefined,
16
- url: (ctx.request as Request).url ?? undefined,
17
- }
18
- : null
19
- const scope = ctx.organizationScope
20
- ? {
21
- selectedId: ctx.organizationScope.selectedId ?? null,
22
- tenantId: ctx.organizationScope.tenantId ?? null,
23
- allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)
24
- ? ctx.organizationScope.allowedIds.length
25
- : null,
26
- filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)
27
- ? ctx.organizationScope.filterIds.length
28
- : null,
29
- }
30
- : null
31
48
  if (env.NODE_ENV !== 'test') {
32
49
  console.warn('[scope] Forbidden organization scope mismatch detected', {
33
50
  expectedId: expected,
34
51
  actualId: actual,
35
- userId: ctx.auth?.sub ?? null,
36
- actorTenantId: ctx.auth?.tenantId ?? null,
37
- actorOrganizationId: ctx.auth?.orgId ?? null,
38
- selectedOrganizationId: ctx.selectedOrganizationId ?? null,
39
- organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
40
- scope,
41
- request: requestInfo,
52
+ ...buildScopeLogContext(ctx),
53
+ })
54
+ }
55
+ } catch {
56
+ // best-effort logging
57
+ }
58
+ }
59
+
60
+ function logUnscopedOrganizationAccess(ctx: CommandRuntimeContext, organizationId: string): void {
61
+ try {
62
+ if (env.NODE_ENV !== 'test') {
63
+ console.warn('[scope] Unscoped organization command executed without organization context', {
64
+ targetOrganizationId: organizationId,
65
+ strictEnforcement: isStrictOrganizationScopeEnforced(),
66
+ ...buildScopeLogContext(ctx),
42
67
  })
43
68
  }
44
69
  } catch {
@@ -52,36 +77,11 @@ function logTenantScopeViolation(
52
77
  actualTenantId: string | null
53
78
  ): void {
54
79
  try {
55
- const requestInfo =
56
- ctx.request && typeof ctx.request === 'object'
57
- ? {
58
- method: (ctx.request as Request).method ?? undefined,
59
- url: (ctx.request as Request).url ?? undefined,
60
- }
61
- : null
62
- const scope = ctx.organizationScope
63
- ? {
64
- selectedId: ctx.organizationScope.selectedId ?? null,
65
- tenantId: ctx.organizationScope.tenantId ?? null,
66
- allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)
67
- ? ctx.organizationScope.allowedIds.length
68
- : null,
69
- filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)
70
- ? ctx.organizationScope.filterIds.length
71
- : null,
72
- }
73
- : null
74
80
  if (env.NODE_ENV !== 'test') {
75
81
  console.warn('[scope] Forbidden tenant scope mismatch detected', {
76
82
  expectedTenantId,
77
83
  actualTenantId,
78
- userId: ctx.auth?.sub ?? null,
79
- actorTenantId: ctx.auth?.tenantId ?? null,
80
- actorOrganizationId: ctx.auth?.orgId ?? null,
81
- selectedOrganizationId: ctx.selectedOrganizationId ?? null,
82
- organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
83
- scope,
84
- request: requestInfo,
84
+ ...buildScopeLogContext(ctx),
85
85
  })
86
86
  }
87
87
  } catch {
@@ -100,9 +100,24 @@ export function ensureOrganizationScope(ctx: CommandRuntimeContext, organization
100
100
  if (!scope) {
101
101
  if (isSuperAdmin) return
102
102
  const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
103
- if (currentOrg && currentOrg !== organizationId) {
104
- logScopeViolation(ctx, organizationId, currentOrg)
105
- throw new CrudHttpError(403, { error: 'Forbidden' })
103
+ if (currentOrg) {
104
+ if (currentOrg !== organizationId) {
105
+ logScopeViolation(ctx, organizationId, currentOrg)
106
+ throw new CrudHttpError(403, { error: 'Forbidden' })
107
+ }
108
+ return
109
+ }
110
+ // No current org could be resolved either. This branch previously returned
111
+ // with no validation and no signal — a fail-open-by-omission shape (#2441):
112
+ // a new command path reaching here with `organizationScope: null` would act
113
+ // on an arbitrary target org silently. Preserve the legacy allow behavior by
114
+ // default (the path is load-bearing) but make the unscoped access observable,
115
+ // and let operators harden it into a deny via OM_ENFORCE_ORG_SCOPE_STRICT.
116
+ if (organizationId) {
117
+ logUnscopedOrganizationAccess(ctx, organizationId)
118
+ if (isStrictOrganizationScopeEnforced()) {
119
+ throw new CrudHttpError(403, { error: 'Forbidden' })
120
+ }
106
121
  }
107
122
  return
108
123
  }
@@ -0,0 +1,194 @@
1
+ import { decryptWithAesGcm, generateDek } from '../aes'
2
+ import { HashicorpVaultKmsService, type KmsService, type TenantDek } from '../kms'
3
+ import { TenantDataEncryptionService } from '../tenantDataEncryptionService'
4
+
5
+ const originalEnv = { ...process.env }
6
+
7
+ type DuckResponse = { ok: boolean; status: number; json: () => Promise<unknown> }
8
+
9
+ function jsonResponse(status: number, body: Record<string, unknown>): DuckResponse {
10
+ return { ok: status >= 200 && status < 300, status, json: async () => body }
11
+ }
12
+
13
+ // Minimal in-memory Vault KV v2 simulator: GET reads the stored key, POST writes
14
+ // it unless a `cas: 0` write loses to an existing version (concurrent create).
15
+ function makeVaultSim() {
16
+ const store = new Map<string, string>()
17
+ const counts = { reads: 0, posts: 0, persisted: 0 }
18
+ const fetchMock = jest.fn(async (input: unknown, init?: RequestInit): Promise<DuckResponse> => {
19
+ const url = String(input)
20
+ const path = url.slice(url.indexOf('/v1/') + '/v1/'.length)
21
+ const method = (init?.method || 'GET').toUpperCase()
22
+ if (method === 'GET') {
23
+ counts.reads++
24
+ const key = store.get(path)
25
+ if (!key) return jsonResponse(404, { errors: [] })
26
+ return jsonResponse(200, { data: { data: { key } } })
27
+ }
28
+ counts.posts++
29
+ const body = init?.body ? (JSON.parse(String(init.body)) as { data?: { key?: string }; options?: { cas?: number } }) : {}
30
+ const cas = body.options?.cas
31
+ if (cas === 0 && store.has(path)) {
32
+ return jsonResponse(400, { errors: ['check-and-set parameter did not match the current version'] })
33
+ }
34
+ store.set(path, body.data?.key ?? '')
35
+ counts.persisted++
36
+ return jsonResponse(200, { data: { version: 1 } })
37
+ })
38
+ return { store, counts, fetchMock }
39
+ }
40
+
41
+ function installFetch(fetchMock: jest.Mock) {
42
+ ;(globalThis as { fetch?: typeof fetch }).fetch = fetchMock as unknown as typeof fetch
43
+ }
44
+
45
+ const fixedKey = (fill: number) => Buffer.alloc(32, fill).toString('base64')
46
+
47
+ describe('HashicorpVaultKmsService DEK creation race (issue #2746)', () => {
48
+ afterEach(() => {
49
+ process.env = { ...originalEnv }
50
+ jest.restoreAllMocks()
51
+ })
52
+
53
+ it('reuses an existing Vault DEK instead of overwriting it (read-before-write)', async () => {
54
+ const { store, counts, fetchMock } = makeVaultSim()
55
+ installFetch(fetchMock)
56
+ const service = new HashicorpVaultKmsService({ vaultAddr: 'http://vault.test', vaultToken: 'token' })
57
+ const existingKey = fixedKey(7)
58
+ store.set('secret/data/tenant_key_tenant-existing', existingKey)
59
+
60
+ const dek = await service.createTenantDek('tenant-existing')
61
+
62
+ expect(dek?.key).toBe(existingKey)
63
+ expect(counts.persisted).toBe(0) // never overwrote the active key
64
+ })
65
+
66
+ it('converges concurrent createTenantDek calls on a single key via CAS', async () => {
67
+ const { counts, fetchMock } = makeVaultSim()
68
+ installFetch(fetchMock)
69
+ const service = new HashicorpVaultKmsService({ vaultAddr: 'http://vault.test', vaultToken: 'token' })
70
+
71
+ const [a, b] = await Promise.all([
72
+ service.createTenantDek('tenant-cas'),
73
+ service.createTenantDek('tenant-cas'),
74
+ ])
75
+
76
+ expect(a?.key).toBeTruthy()
77
+ expect(a?.key).toBe(b?.key) // both adopt the same winning key — no orphaned rows
78
+ expect(counts.persisted).toBe(1) // exactly one key persisted, last-write-wins eliminated
79
+ })
80
+
81
+ it('invalidateDek forces a re-read from Vault', async () => {
82
+ const { store, counts, fetchMock } = makeVaultSim()
83
+ installFetch(fetchMock)
84
+ const service = new HashicorpVaultKmsService({ vaultAddr: 'http://vault.test', vaultToken: 'token' })
85
+ const path = 'secret/data/tenant_key_tenant-inv'
86
+ store.set(path, fixedKey(3))
87
+
88
+ const first = await service.getTenantDek('tenant-inv')
89
+ await service.getTenantDek('tenant-inv') // served from the KMS TTL cache
90
+ expect(counts.reads).toBe(1)
91
+
92
+ store.set(path, fixedKey(9)) // operator rotates the key in Vault
93
+ service.invalidateDek('tenant-inv')
94
+ const rotated = await service.getTenantDek('tenant-inv')
95
+
96
+ expect(counts.reads).toBe(2)
97
+ expect(rotated?.key).not.toBe(first?.key)
98
+ })
99
+ })
100
+
101
+ describe('TenantDataEncryptionService DEK lifecycle (issue #2746)', () => {
102
+ const entityId = 'customers:person'
103
+ let tenantSeq = 0
104
+ const uniqueTenant = (label: string) => `tenant-${label}-2746-${tenantSeq++}`
105
+
106
+ afterEach(() => {
107
+ jest.restoreAllMocks()
108
+ })
109
+
110
+ function makeCreatingKms() {
111
+ const created: string[] = []
112
+ const kms: KmsService = {
113
+ isHealthy: () => true,
114
+ getTenantDek: jest.fn(async (): Promise<TenantDek | null> => null),
115
+ createTenantDek: jest.fn(async (tenantId: string): Promise<TenantDek | null> => {
116
+ const key = generateDek()
117
+ created.push(key)
118
+ return { tenantId, key, fetchedAt: Date.now() }
119
+ }),
120
+ }
121
+ return { kms, created }
122
+ }
123
+
124
+ function makeFetchingKms() {
125
+ const kms: KmsService = {
126
+ isHealthy: () => true,
127
+ getTenantDek: jest.fn(async (tenantId: string): Promise<TenantDek | null> => ({
128
+ tenantId,
129
+ key: generateDek(),
130
+ fetchedAt: Date.now(),
131
+ })),
132
+ createTenantDek: jest.fn(async (): Promise<TenantDek | null> => null),
133
+ }
134
+ return { kms }
135
+ }
136
+
137
+ it('dedupes concurrent first-time DEK creation so no row is orphaned', async () => {
138
+ const { kms, created } = makeCreatingKms()
139
+ const service = new TenantDataEncryptionService({} as never, { kms })
140
+ jest.spyOn(service, 'isEnabled').mockReturnValue(true)
141
+ ;(service as unknown as { getMap: () => Promise<{ entityId: string; fields: { field: string }[] }> }).getMap =
142
+ jest.fn(async () => ({ entityId, fields: [{ field: 'secret' }] }))
143
+ const tenantId = uniqueTenant('race')
144
+
145
+ const rows = await Promise.all(
146
+ Array.from({ length: 12 }, (_, i) =>
147
+ service.encryptEntityPayload(entityId, { secret: `value-${i}` }, tenantId),
148
+ ),
149
+ )
150
+
151
+ expect((kms.createTenantDek as jest.Mock)).toHaveBeenCalledTimes(1)
152
+ expect(new Set(created).size).toBe(1)
153
+
154
+ const dek = await service.getDek(tenantId)
155
+ expect(dek).not.toBeNull()
156
+ rows.forEach((row, i) => {
157
+ expect(decryptWithAesGcm(String(row.secret), (dek as TenantDek).key)).toBe(`value-${i}`)
158
+ })
159
+ })
160
+
161
+ it('re-fetches a cached DEK after the TTL elapses (no stale key after rotation)', async () => {
162
+ const { kms } = makeFetchingKms()
163
+ const service = new TenantDataEncryptionService({} as never, { kms })
164
+ const tenantId = uniqueTenant('ttl')
165
+ let now = 1_700_000_000_000
166
+ jest.spyOn(Date, 'now').mockImplementation(() => now)
167
+
168
+ await service.getDek(tenantId)
169
+ await service.getDek(tenantId)
170
+ expect((kms.getTenantDek as jest.Mock)).toHaveBeenCalledTimes(1) // cached within TTL
171
+
172
+ now += 15 * 60 * 1000 + 1
173
+ await service.getDek(tenantId)
174
+ expect((kms.getTenantDek as jest.Mock)).toHaveBeenCalledTimes(2) // expired → re-fetch
175
+ })
176
+
177
+ it('invalidateDek clears both the service cache and the KMS cache', async () => {
178
+ const { kms } = makeFetchingKms()
179
+ const kmsInvalidate = jest.fn()
180
+ ;(kms as KmsService).invalidateDek = kmsInvalidate
181
+ const service = new TenantDataEncryptionService({} as never, { kms })
182
+ const tenantId = uniqueTenant('invalidate')
183
+
184
+ await service.getDek(tenantId)
185
+ await service.getDek(tenantId)
186
+ expect((kms.getTenantDek as jest.Mock)).toHaveBeenCalledTimes(1)
187
+
188
+ service.invalidateDek(tenantId)
189
+ expect(kmsInvalidate).toHaveBeenCalledWith(tenantId)
190
+
191
+ await service.getDek(tenantId)
192
+ expect((kms.getTenantDek as jest.Mock)).toHaveBeenCalledTimes(2)
193
+ })
194
+ })
@@ -10,11 +10,17 @@ describe('kms timeout handling', () => {
10
10
  })
11
11
 
12
12
  it('marks Vault unhealthy after a timed out write', async () => {
13
- const fetchMock = jest.fn((_url: string, init?: RequestInit) =>
14
- new Promise((_resolve, reject) => {
13
+ // createTenantDek now reads-before-write (#2746): the read probe answers fast
14
+ // with no existing key so the flow reaches the write, which then times out.
15
+ const fetchMock = jest.fn((_url: string, init?: RequestInit) => {
16
+ const method = (init?.method || 'GET').toUpperCase()
17
+ if (method === 'GET') {
18
+ return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: { data: {} } }) })
19
+ }
20
+ return new Promise((_resolve, reject) => {
15
21
  init?.signal?.addEventListener('abort', () => reject(new Error('aborted')))
16
- }),
17
- )
22
+ })
23
+ })
18
24
  ;(globalThis as { fetch?: typeof fetch }).fetch = fetchMock as typeof fetch
19
25
 
20
26
  const service = new HashicorpVaultKmsService({
@@ -25,7 +31,7 @@ describe('kms timeout handling', () => {
25
31
 
26
32
  await expect(service.createTenantDek('tenant-1')).resolves.toBeNull()
27
33
  expect(service.isHealthy()).toBe(false)
28
- expect(fetchMock).toHaveBeenCalledTimes(1)
34
+ expect(fetchMock).toHaveBeenCalledTimes(2) // read probe + the timed-out write
29
35
  })
30
36
 
31
37
  it('falls back to derived keys after the primary Vault call times out', async () => {
@@ -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
  /**