@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.
- package/dist/lib/auth/apiKeyAuthCache.js +17 -6
- package/dist/lib/auth/apiKeyAuthCache.js.map +2 -2
- package/dist/lib/commands/scope.js +51 -37
- package/dist/lib/commands/scope.js.map +2 -2
- package/dist/lib/encryption/aes.js +37 -3
- package/dist/lib/encryption/aes.js.map +2 -2
- package/dist/lib/encryption/kms.js +43 -15
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +31 -4
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/query/engine.js +41 -31
- package/dist/lib/query/engine.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/auth/__tests__/apiKeyAuthCache.test.ts +35 -0
- package/src/lib/auth/apiKeyAuthCache.ts +20 -6
- package/src/lib/commands/__tests__/scope.test.ts +48 -0
- package/src/lib/commands/scope.ts +70 -55
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +194 -0
- package/src/lib/encryption/__tests__/kms.test.ts +11 -5
- package/src/lib/encryption/__tests__/lookupHash.test.ts +113 -0
- package/src/lib/encryption/aes.ts +78 -2
- package/src/lib/encryption/kms.ts +60 -15
- package/src/lib/encryption/tenantDataEncryptionService.ts +37 -4
- package/src/lib/query/__tests__/resolve-registered-entity-table.test.ts +83 -0
- package/src/lib/query/engine.ts +59 -30
- package/src/modules/vector.ts +7 -0
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
/**
|