@open-mercato/shared 0.6.4-develop.4371.1.8f3030407e → 0.6.4

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.
Files changed (95) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +10 -0
  3. package/dist/lib/auth/apiKeyAuthCache.js +17 -6
  4. package/dist/lib/auth/apiKeyAuthCache.js.map +2 -2
  5. package/dist/lib/commands/command-bus.js +56 -47
  6. package/dist/lib/commands/command-bus.js.map +2 -2
  7. package/dist/lib/commands/flush.js +23 -1
  8. package/dist/lib/commands/flush.js.map +2 -2
  9. package/dist/lib/commands/index.js +6 -1
  10. package/dist/lib/commands/index.js.map +2 -2
  11. package/dist/lib/commands/redo.js +106 -0
  12. package/dist/lib/commands/redo.js.map +7 -0
  13. package/dist/lib/commands/runCrudCommandWrite.js +38 -0
  14. package/dist/lib/commands/runCrudCommandWrite.js.map +7 -0
  15. package/dist/lib/commands/scope.js +51 -37
  16. package/dist/lib/commands/scope.js.map +2 -2
  17. package/dist/lib/commands/types.js.map +2 -2
  18. package/dist/lib/crud/errors.js +22 -0
  19. package/dist/lib/crud/errors.js.map +2 -2
  20. package/dist/lib/crud/factory.js +16 -0
  21. package/dist/lib/crud/factory.js.map +2 -2
  22. package/dist/lib/crud/optimistic-lock-command.js +109 -0
  23. package/dist/lib/crud/optimistic-lock-command.js.map +7 -0
  24. package/dist/lib/crud/optimistic-lock-headers.js +15 -0
  25. package/dist/lib/crud/optimistic-lock-headers.js.map +7 -0
  26. package/dist/lib/crud/optimistic-lock-store.js +52 -0
  27. package/dist/lib/crud/optimistic-lock-store.js.map +7 -0
  28. package/dist/lib/crud/optimistic-lock.js +172 -0
  29. package/dist/lib/crud/optimistic-lock.js.map +7 -0
  30. package/dist/lib/data/engine.js +2 -2
  31. package/dist/lib/data/engine.js.map +2 -2
  32. package/dist/lib/di/container.js +18 -2
  33. package/dist/lib/di/container.js.map +2 -2
  34. package/dist/lib/encryption/aes.js +37 -3
  35. package/dist/lib/encryption/aes.js.map +2 -2
  36. package/dist/lib/encryption/kms.js +57 -23
  37. package/dist/lib/encryption/kms.js.map +2 -2
  38. package/dist/lib/encryption/subscriber.js +41 -8
  39. package/dist/lib/encryption/subscriber.js.map +2 -2
  40. package/dist/lib/encryption/tenantDataEncryptionService.js +35 -7
  41. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  42. package/dist/lib/i18n/context.js +5 -0
  43. package/dist/lib/i18n/context.js.map +2 -2
  44. package/dist/lib/query/engine.js +41 -31
  45. package/dist/lib/query/engine.js.map +2 -2
  46. package/dist/lib/version.js +1 -1
  47. package/dist/lib/version.js.map +1 -1
  48. package/dist/modules/integrations/types.js.map +2 -2
  49. package/dist/modules/search.js.map +2 -2
  50. package/package.json +8 -9
  51. package/src/lib/auth/__tests__/apiKeyAuthCache.test.ts +35 -0
  52. package/src/lib/auth/apiKeyAuthCache.ts +20 -6
  53. package/src/lib/commands/__tests__/command-bus.cache.test.ts +2 -0
  54. package/src/lib/commands/__tests__/command-bus.undo-audit.test.ts +2 -0
  55. package/src/lib/commands/__tests__/command-bus.undo-toctou.test.ts +122 -0
  56. package/src/lib/commands/__tests__/flush.test.ts +110 -9
  57. package/src/lib/commands/__tests__/redo.test.ts +265 -0
  58. package/src/lib/commands/__tests__/runCrudCommandWrite.test.ts +390 -0
  59. package/src/lib/commands/__tests__/scope.test.ts +48 -0
  60. package/src/lib/commands/command-bus.ts +62 -44
  61. package/src/lib/commands/flush.ts +79 -2
  62. package/src/lib/commands/index.ts +9 -0
  63. package/src/lib/commands/redo.ts +235 -0
  64. package/src/lib/commands/runCrudCommandWrite.ts +82 -0
  65. package/src/lib/commands/scope.ts +70 -55
  66. package/src/lib/commands/types.ts +54 -1
  67. package/src/lib/crud/__tests__/crud-factory.test.ts +106 -0
  68. package/src/lib/crud/__tests__/optimistic-lock-command.test.ts +425 -0
  69. package/src/lib/crud/__tests__/optimistic-lock-store.test.ts +194 -0
  70. package/src/lib/crud/__tests__/optimistic-lock.test.ts +526 -0
  71. package/src/lib/crud/errors.ts +29 -0
  72. package/src/lib/crud/factory.ts +23 -0
  73. package/src/lib/crud/optimistic-lock-command.ts +305 -0
  74. package/src/lib/crud/optimistic-lock-headers.ts +30 -0
  75. package/src/lib/crud/optimistic-lock-store.ts +87 -0
  76. package/src/lib/crud/optimistic-lock.ts +379 -0
  77. package/src/lib/data/engine.ts +11 -8
  78. package/src/lib/di/container.ts +17 -1
  79. package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +194 -0
  80. package/src/lib/encryption/__tests__/kms.test.ts +44 -6
  81. package/src/lib/encryption/__tests__/lookupHash.test.ts +113 -0
  82. package/src/lib/encryption/__tests__/subscriber.change-tracking.test.ts +96 -0
  83. package/src/lib/encryption/__tests__/subscriber.deep-decrypt-collections.test.ts +123 -0
  84. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +68 -1
  85. package/src/lib/encryption/aes.ts +78 -2
  86. package/src/lib/encryption/kms.ts +76 -24
  87. package/src/lib/encryption/subscriber.ts +54 -9
  88. package/src/lib/encryption/tenantDataEncryptionService.ts +53 -8
  89. package/src/lib/i18n/context.tsx +11 -0
  90. package/src/lib/query/__tests__/resolve-registered-entity-table.test.ts +83 -0
  91. package/src/lib/query/engine.ts +59 -30
  92. package/src/modules/integrations/types.ts +14 -0
  93. package/src/modules/notifications/handler.ts +7 -0
  94. package/src/modules/search.ts +9 -0
  95. package/src/modules/vector.ts +7 -0
@@ -1,4 +1,5 @@
1
- import { createKmsService, HashicorpVaultKmsService } from '../kms'
1
+ import crypto from 'node:crypto'
2
+ import { buildDerivedKeyFallbackBannerLines, createKmsService, HashicorpVaultKmsService } from '../kms'
2
3
 
3
4
  const originalEnv = { ...process.env }
4
5
 
@@ -9,11 +10,17 @@ describe('kms timeout handling', () => {
9
10
  })
10
11
 
11
12
  it('marks Vault unhealthy after a timed out write', async () => {
12
- const fetchMock = jest.fn((_url: string, init?: RequestInit) =>
13
- 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) => {
14
21
  init?.signal?.addEventListener('abort', () => reject(new Error('aborted')))
15
- }),
16
- )
22
+ })
23
+ })
17
24
  ;(globalThis as { fetch?: typeof fetch }).fetch = fetchMock as typeof fetch
18
25
 
19
26
  const service = new HashicorpVaultKmsService({
@@ -24,7 +31,7 @@ describe('kms timeout handling', () => {
24
31
 
25
32
  await expect(service.createTenantDek('tenant-1')).resolves.toBeNull()
26
33
  expect(service.isHealthy()).toBe(false)
27
- expect(fetchMock).toHaveBeenCalledTimes(1)
34
+ expect(fetchMock).toHaveBeenCalledTimes(2) // read probe + the timed-out write
28
35
  })
29
36
 
30
37
  it('falls back to derived keys after the primary Vault call times out', async () => {
@@ -67,6 +74,37 @@ describe('kms timeout handling', () => {
67
74
  expect(dek).toBeNull()
68
75
  })
69
76
 
77
+ it('never prints the explicit fallback secret verbatim in the banner, regardless of NODE_ENV', () => {
78
+ const secret = 'super-secret-tenant-encryption-key'
79
+ for (const nodeEnv of ['development', 'staging', 'preview', 'PRODUCTION', 'production', undefined]) {
80
+ if (nodeEnv === undefined) delete process.env.NODE_ENV
81
+ else process.env.NODE_ENV = nodeEnv
82
+
83
+ const lines = buildDerivedKeyFallbackBannerLines({
84
+ secret,
85
+ source: 'explicit',
86
+ envName: 'TENANT_DATA_ENCRYPTION_FALLBACK_KEY',
87
+ })
88
+ const rendered = lines.join('\n')
89
+
90
+ expect(rendered).not.toContain(secret)
91
+ expect(rendered).toContain('Source: TENANT_DATA_ENCRYPTION_FALLBACK_KEY')
92
+ const expectedFingerprint = crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)
93
+ expect(rendered).toContain(`Secret fingerprint (sha256, truncated): ${expectedFingerprint}`)
94
+ }
95
+ })
96
+
97
+ it('does not echo the dev default secret verbatim in the banner either', () => {
98
+ const lines = buildDerivedKeyFallbackBannerLines({
99
+ secret: 'om-dev-tenant-encryption',
100
+ source: 'dev-default',
101
+ envName: 'DEV_DEFAULT',
102
+ })
103
+ const rendered = lines.join('\n')
104
+ expect(rendered).not.toContain('om-dev-tenant-encryption')
105
+ expect(rendered).toContain('Source: dev default secret (do NOT use in production)')
106
+ })
107
+
70
108
  it('requires an explicit opt-in before using the dev default derived key', async () => {
71
109
  process.env.NODE_ENV = 'test'
72
110
  process.env.TENANT_DATA_ENCRYPTION = 'yes'
@@ -0,0 +1,113 @@
1
+ import crypto from 'node:crypto'
2
+ import { hashForLookup, legacyHashForLookup, lookupHashCandidates } from '../aes'
3
+
4
+ const originalEnv = { ...process.env }
5
+
6
+ function clearLookupEnv() {
7
+ delete process.env.LOOKUP_HASH_PEPPER
8
+ delete process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY
9
+ delete process.env.TENANT_DATA_ENCRYPTION_KEY
10
+ }
11
+
12
+ describe('hashForLookup keyed digest (issue #2718)', () => {
13
+ afterEach(() => {
14
+ process.env = { ...originalEnv }
15
+ })
16
+
17
+ it('emits a keyed v2 HMAC when a lookup pepper is configured', () => {
18
+ clearLookupEnv()
19
+ process.env.LOOKUP_HASH_PEPPER = 'installation-pepper-secret-value'
20
+ const digest = hashForLookup('User@Example.com')
21
+ expect(digest.startsWith('v2:')).toBe(true)
22
+ const expected = crypto
23
+ .createHmac('sha256', 'installation-pepper-secret-value')
24
+ .update('user@example.com')
25
+ .digest('hex')
26
+ expect(digest).toBe(`v2:${expected}`)
27
+ })
28
+
29
+ it('is not equal to the legacy unkeyed sha256 (defeats precomputed rainbow tables)', () => {
30
+ clearLookupEnv()
31
+ process.env.LOOKUP_HASH_PEPPER = 'installation-pepper-secret-value'
32
+ const keyed = hashForLookup('user@example.com')
33
+ const legacy = legacyHashForLookup('user@example.com')
34
+ expect(keyed).not.toBe(legacy)
35
+ // The legacy digest is exactly what an attacker would precompute.
36
+ const naive = crypto.createHash('sha256').update('user@example.com').digest('hex')
37
+ expect(legacy).toBe(naive)
38
+ expect(keyed).not.toContain(naive)
39
+ })
40
+
41
+ it('produces different digests across installations (no cross-installation correlation)', () => {
42
+ clearLookupEnv()
43
+ process.env.LOOKUP_HASH_PEPPER = 'pepper-installation-a'
44
+ const a = hashForLookup('user@example.com')
45
+ process.env.LOOKUP_HASH_PEPPER = 'pepper-installation-b'
46
+ const b = hashForLookup('user@example.com')
47
+ expect(a).not.toBe(b)
48
+ })
49
+
50
+ it('binds the digest to the optional field/entity context (not portable across columns)', () => {
51
+ clearLookupEnv()
52
+ process.env.LOOKUP_HASH_PEPPER = 'installation-pepper-secret-value'
53
+ const withoutContext = hashForLookup('user@example.com')
54
+ const emailContext = hashForLookup('user@example.com', 'auth.user:email')
55
+ const phoneContext = hashForLookup('user@example.com', 'auth.user:phone')
56
+ expect(emailContext).not.toBe(withoutContext)
57
+ expect(emailContext).not.toBe(phoneContext)
58
+ })
59
+
60
+ it('normalizes case and surrounding whitespace before hashing', () => {
61
+ clearLookupEnv()
62
+ process.env.LOOKUP_HASH_PEPPER = 'installation-pepper-secret-value'
63
+ expect(hashForLookup(' User@Example.com ')).toBe(hashForLookup('user@example.com'))
64
+ })
65
+
66
+ it('resolves the pepper from existing encryption secrets when no dedicated pepper is set', () => {
67
+ clearLookupEnv()
68
+ process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY = 'fallback-secret'
69
+ const fromFallback = hashForLookup('user@example.com')
70
+ expect(fromFallback.startsWith('v2:')).toBe(true)
71
+
72
+ clearLookupEnv()
73
+ process.env.TENANT_DATA_ENCRYPTION_KEY = 'fallback-secret'
74
+ const fromKey = hashForLookup('user@example.com')
75
+ expect(fromKey).toBe(fromFallback)
76
+ })
77
+
78
+ it('prefers LOOKUP_HASH_PEPPER over the encryption fallback secrets', () => {
79
+ clearLookupEnv()
80
+ process.env.LOOKUP_HASH_PEPPER = 'dedicated-pepper'
81
+ process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY = 'fallback-secret'
82
+ const expected = crypto
83
+ .createHmac('sha256', 'dedicated-pepper')
84
+ .update('user@example.com')
85
+ .digest('hex')
86
+ expect(hashForLookup('user@example.com')).toBe(`v2:${expected}`)
87
+ })
88
+
89
+ it('falls back to the legacy unkeyed digest only when no secret is configured', () => {
90
+ clearLookupEnv()
91
+ const digest = hashForLookup('user@example.com')
92
+ expect(digest.startsWith('v2:')).toBe(false)
93
+ expect(digest).toBe(legacyHashForLookup('user@example.com'))
94
+ })
95
+
96
+ it('exposes keyed and legacy candidates for migration-window reads', () => {
97
+ clearLookupEnv()
98
+ process.env.LOOKUP_HASH_PEPPER = 'installation-pepper-secret-value'
99
+ const candidates = lookupHashCandidates('user@example.com')
100
+ expect(candidates).toEqual([
101
+ hashForLookup('user@example.com'),
102
+ legacyHashForLookup('user@example.com'),
103
+ ])
104
+ expect(candidates).toHaveLength(2)
105
+ })
106
+
107
+ it('collapses candidates to a single value when running in legacy (no-pepper) mode', () => {
108
+ clearLookupEnv()
109
+ const candidates = lookupHashCandidates('user@example.com')
110
+ expect(candidates).toEqual([legacyHashForLookup('user@example.com')])
111
+ expect(candidates).toHaveLength(1)
112
+ })
113
+ })
@@ -0,0 +1,96 @@
1
+ import { TenantEncryptionSubscriber } from '../subscriber'
2
+ import { registerEntityIds } from '../entityIds'
3
+ import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
4
+
5
+ // Regression coverage for issue #2498: the deep-decrypt re-baseline (syncOriginalEntityData) must
6
+ // NOT clear a managed entity's pending changes. When a command mutates an entity and then loads a
7
+ // related encrypted entity (whose deep-decrypt recurses back into the still-dirty entity) before
8
+ // the final flush, re-baselining the dirty entity silently dropped the pending write — the update
9
+ // command issued no UPDATE and `updated_at` never fired. The fix gates the re-baseline on the
10
+ // entity having no un-flushed changes (per MikroORM's own comparator).
11
+
12
+ type Helper = { __originalEntityData?: Record<string, unknown>; __touched?: boolean }
13
+
14
+ function makeComparator() {
15
+ return {
16
+ // Mirror MikroORM's prepared snapshot: a plain scalar copy of the entity.
17
+ prepareEntity(entity: Record<string, unknown>) {
18
+ const snapshot: Record<string, unknown> = {}
19
+ for (const [key, value] of Object.entries(entity)) {
20
+ if (key === '__helper' || key === '__meta') continue
21
+ snapshot[key] = value
22
+ }
23
+ return snapshot
24
+ },
25
+ // True when the two snapshots are identical (no pending changes). Order-independent, like
26
+ // MikroORM's real comparator (the production fix depends on that property-wise semantics).
27
+ matching(_entityName: string, a: Record<string, unknown>, b: Record<string, unknown>) {
28
+ const aKeys = Object.keys(a)
29
+ const bKeys = Object.keys(b)
30
+ if (aKeys.length !== bKeys.length) return false
31
+ return aKeys.every((key) => JSON.stringify(a[key]) === JSON.stringify(b[key]))
32
+ },
33
+ }
34
+ }
35
+
36
+ function makeEm() {
37
+ const comparator = makeComparator()
38
+ return { getComparator: () => comparator, getMetadata: () => undefined }
39
+ }
40
+
41
+ const META = { className: 'Thing', tableName: 'things', properties: {} } as any
42
+
43
+ describe('TenantEncryptionSubscriber change-tracking preservation (issue #2498)', () => {
44
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
45
+
46
+ beforeEach(() => {
47
+ delete process.env.TENANT_DATA_ENCRYPTION // default => encryption enabled
48
+ registerEntityIds({ test: { thing: 'test:thing' } })
49
+ })
50
+
51
+ afterEach(() => {
52
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
53
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
54
+ jest.restoreAllMocks()
55
+ })
56
+
57
+ function makeService(
58
+ decryptEntityPayload: (entityId: string, target: Record<string, unknown>) => Record<string, unknown> = () => ({}),
59
+ ): TenantDataEncryptionService {
60
+ return {
61
+ isEnabled: () => true,
62
+ async decryptEntityPayload(entityId: string, target: Record<string, unknown>) {
63
+ return decryptEntityPayload(entityId, target)
64
+ },
65
+ } as unknown as TenantDataEncryptionService
66
+ }
67
+
68
+ it('preserves pending scalar changes when re-baselining a dirty managed entity', async () => {
69
+ const helper: Helper = { __originalEntityData: { displayName: 'CHANGED', tenantId: 't1' }, __touched: true }
70
+ // Command restored the value in-memory but has not flushed yet.
71
+ const entity: Record<string, unknown> = { tenantId: 't1', displayName: 'Before', __helper: helper }
72
+
73
+ const subscriber = new TenantEncryptionSubscriber(makeService())
74
+ await subscriber.decryptEntityGraph(entity, META, makeEm(), { syncOriginal: true })
75
+
76
+ // Baseline must still reflect the un-restored value so the flush computes a non-empty changeset.
77
+ expect(helper.__originalEntityData).toEqual({ displayName: 'CHANGED', tenantId: 't1' })
78
+ expect(helper.__touched).toBe(true)
79
+ })
80
+
81
+ it('still re-baselines a clean entity so decrypted values are not re-persisted', async () => {
82
+ const helper: Helper = { __originalEntityData: { secret: 'enc:plain', tenantId: 't1' }, __touched: false }
83
+ const entity: Record<string, unknown> = { tenantId: 't1', secret: 'enc:plain', __helper: helper }
84
+
85
+ // Decrypt rewrites the ciphertext column to plaintext.
86
+ const subscriber = new TenantEncryptionSubscriber(
87
+ makeService(() => ({ secret: 'plain' })),
88
+ )
89
+ await subscriber.decryptEntityGraph(entity, META, makeEm(), { syncOriginal: true })
90
+
91
+ // Clean entity: re-baseline must snapshot the decrypted value so the next flush sees no change.
92
+ expect(entity.secret).toBe('plain')
93
+ expect(helper.__originalEntityData).toEqual({ secret: 'plain', tenantId: 't1' })
94
+ expect(helper.__touched).toBe(false)
95
+ })
96
+ })
@@ -0,0 +1,123 @@
1
+ import { ReferenceKind } from '@mikro-orm/core'
2
+ import { TenantEncryptionSubscriber } from '../subscriber'
3
+ import { registerEntityIds } from '../entityIds'
4
+ import type { TenantDataEncryptionService } from '../tenantDataEncryptionService'
5
+
6
+ // Regression coverage for issue #2744: a loaded *-to-many Collection relation must be expanded into
7
+ // its items during deep-decrypt. extractEntities() previously matched the MikroORM Reference branch
8
+ // first — both a Collection and a Reference expose isInitialized() — so a Collection (which has no
9
+ // unwrap()/__entity) was returned as the wrapper itself and decrypt() ran on the Collection instead
10
+ // of each item, leaving encrypted child fields as ciphertext in populated response graphs.
11
+
12
+ // Mirrors a MikroORM Collection: exposes isInitialized() + getItems(), but NO unwrap()/__entity.
13
+ function makeCollection(items: Record<string, unknown>[], initialized = true) {
14
+ return {
15
+ isInitialized: () => initialized,
16
+ getItems: () => items,
17
+ }
18
+ }
19
+
20
+ // Mirrors a MikroORM Reference: exposes isInitialized() + unwrap(), but NO getItems().
21
+ function makeReference(entity: Record<string, unknown>, initialized = true) {
22
+ return {
23
+ isInitialized: () => initialized,
24
+ unwrap: () => entity,
25
+ }
26
+ }
27
+
28
+ const CHILD_META = { className: 'Child', tableName: 'children', properties: {} } as any
29
+
30
+ const parentMeta = (childKind: ReferenceKind, relationName: string) =>
31
+ ({
32
+ className: 'Parent',
33
+ tableName: 'parents',
34
+ properties: { [relationName]: { name: relationName, kind: childKind } },
35
+ }) as any
36
+
37
+ function makeEm() {
38
+ return { getMetadata: () => undefined, getComparator: () => undefined }
39
+ }
40
+
41
+ // Decrypts by stripping an "enc:" prefix; records every entity id it was asked to decrypt.
42
+ function makeService(decryptedEntityIds: string[]): TenantDataEncryptionService {
43
+ return {
44
+ isEnabled: () => true,
45
+ async decryptEntityPayload(entityId: string, target: Record<string, unknown>) {
46
+ decryptedEntityIds.push(entityId)
47
+ const value = target.secret
48
+ if (typeof value === 'string' && value.startsWith('enc:')) {
49
+ return { secret: value.slice('enc:'.length) }
50
+ }
51
+ return {}
52
+ },
53
+ } as unknown as TenantDataEncryptionService
54
+ }
55
+
56
+ describe('TenantEncryptionSubscriber deep-decrypt of loaded collection relations (issue #2744)', () => {
57
+ const originalToggle = process.env.TENANT_DATA_ENCRYPTION
58
+
59
+ beforeEach(() => {
60
+ delete process.env.TENANT_DATA_ENCRYPTION // default => encryption enabled
61
+ registerEntityIds({ test: { parent: 'test:parent', child: 'test:child' } })
62
+ })
63
+
64
+ afterEach(() => {
65
+ if (originalToggle === undefined) delete process.env.TENANT_DATA_ENCRYPTION
66
+ else process.env.TENANT_DATA_ENCRYPTION = originalToggle
67
+ jest.restoreAllMocks()
68
+ })
69
+
70
+ it('decrypts every item of an initialized ONE_TO_MANY collection', async () => {
71
+ const decryptedEntityIds: string[] = []
72
+ const childA = { secret: 'enc:alpha', tenantId: 't1', __meta: CHILD_META }
73
+ const childB = { secret: 'enc:beta', tenantId: 't1', __meta: CHILD_META }
74
+ const meta = parentMeta(ReferenceKind.ONE_TO_MANY, 'children')
75
+ const parent = { tenantId: 't1', children: makeCollection([childA, childB]), __meta: meta }
76
+
77
+ const subscriber = new TenantEncryptionSubscriber(makeService(decryptedEntityIds))
78
+ await subscriber.decryptEntityGraph(parent, meta, makeEm(), { syncOriginal: true })
79
+
80
+ expect(childA.secret).toBe('alpha')
81
+ expect(childB.secret).toBe('beta')
82
+ expect(decryptedEntityIds.filter((id) => id === 'test:child')).toHaveLength(2)
83
+ })
84
+
85
+ it('decrypts every item of an initialized MANY_TO_MANY collection', async () => {
86
+ const decryptedEntityIds: string[] = []
87
+ const child = { secret: 'enc:gamma', tenantId: 't1', __meta: CHILD_META }
88
+ const meta = parentMeta(ReferenceKind.MANY_TO_MANY, 'children')
89
+ const parent = { tenantId: 't1', children: makeCollection([child]), __meta: meta }
90
+
91
+ const subscriber = new TenantEncryptionSubscriber(makeService(decryptedEntityIds))
92
+ await subscriber.decryptEntityGraph(parent, meta, makeEm(), { syncOriginal: true })
93
+
94
+ expect(child.secret).toBe('gamma')
95
+ expect(decryptedEntityIds).toContain('test:child')
96
+ })
97
+
98
+ it('leaves an uninitialized collection untouched (does not decrypt unloaded items)', async () => {
99
+ const decryptedEntityIds: string[] = []
100
+ const child = { secret: 'enc:delta', tenantId: 't1', __meta: CHILD_META }
101
+ const meta = parentMeta(ReferenceKind.ONE_TO_MANY, 'children')
102
+ const parent = { tenantId: 't1', children: makeCollection([child], false), __meta: meta }
103
+
104
+ const subscriber = new TenantEncryptionSubscriber(makeService(decryptedEntityIds))
105
+ await subscriber.decryptEntityGraph(parent, meta, makeEm(), { syncOriginal: true })
106
+
107
+ expect(child.secret).toBe('enc:delta')
108
+ expect(decryptedEntityIds).not.toContain('test:child')
109
+ })
110
+
111
+ it('still decrypts a single-valued MANY_TO_ONE Reference (reorder must not regress references)', async () => {
112
+ const decryptedEntityIds: string[] = []
113
+ const child = { secret: 'enc:epsilon', tenantId: 't1', __meta: CHILD_META }
114
+ const meta = parentMeta(ReferenceKind.MANY_TO_ONE, 'child')
115
+ const parent = { tenantId: 't1', child: makeReference(child), __meta: meta }
116
+
117
+ const subscriber = new TenantEncryptionSubscriber(makeService(decryptedEntityIds))
118
+ await subscriber.decryptEntityGraph(parent, meta, makeEm(), { syncOriginal: true })
119
+
120
+ expect(child.secret).toBe('epsilon')
121
+ expect(decryptedEntityIds).toContain('test:child')
122
+ })
123
+ })
@@ -1,4 +1,4 @@
1
- import { encryptWithAesGcm } from '../aes'
1
+ import { decryptWithAesGcm, encryptWithAesGcm, hashForLookup } from '../aes'
2
2
  import {
3
3
  TenantDataEncryptionService,
4
4
  parseDecryptedFieldValue,
@@ -129,6 +129,73 @@ describe('TenantDataEncryptionService.decryptFields (issue #1734)', () => {
129
129
  })
130
130
  })
131
131
 
132
+ describe('TenantDataEncryptionService.encryptFields (issue #2720)', () => {
133
+ function makeService() {
134
+ type Anything = Record<string, unknown>
135
+ const service = new TenantDataEncryptionService({} as never) as unknown as {
136
+ encryptFields: (
137
+ obj: Anything,
138
+ fields: { field: string; hashField?: string | null }[],
139
+ dek: { key: string },
140
+ ) => Anything
141
+ }
142
+ return service
143
+ }
144
+
145
+ it('encrypts a forged ciphertext-shaped value instead of storing it verbatim', () => {
146
+ const service = makeService()
147
+ const forged = 'aaaa:bbbb:cccc:v1'
148
+ const out = service.encryptFields(
149
+ { email: forged },
150
+ [{ field: 'email', hashField: 'email_hash' }],
151
+ { key: fixedKey } as never,
152
+ )
153
+ expect(out.email).not.toBe(forged)
154
+ expect(typeof out.email).toBe('string')
155
+ // The stored value must be real ciphertext that decrypts back to the forged input.
156
+ expect(decryptWithAesGcm(out.email as string, fixedKey)).toBe(forged)
157
+ // The lookup hash must be generated (the bypass previously skipped it).
158
+ expect(out.email_hash).toBe(hashForLookup(forged))
159
+ })
160
+
161
+ it('does not re-encrypt a value that genuinely decrypts under the DEK', () => {
162
+ const service = makeService()
163
+ const real = encryptWithAesGcm('mail@example.com', fixedKey).value as string
164
+ const out = service.encryptFields(
165
+ { email: real },
166
+ [{ field: 'email' }],
167
+ { key: fixedKey } as never,
168
+ )
169
+ expect(out.email).toBe(real)
170
+ })
171
+
172
+ it('encrypts a structurally-valid payload that was sealed with a different key', () => {
173
+ const service = makeService()
174
+ const otherKey = Buffer.alloc(32, 2).toString('base64')
175
+ const sealedElsewhere = encryptWithAesGcm('secret', otherKey).value as string
176
+ const out = service.encryptFields(
177
+ { email: sealedElsewhere },
178
+ [{ field: 'email' }],
179
+ { key: fixedKey } as never,
180
+ )
181
+ expect(out.email).not.toBe(sealedElsewhere)
182
+ expect(decryptWithAesGcm(out.email as string, fixedKey)).toBe(sealedElsewhere)
183
+ })
184
+
185
+ it('encrypts plaintext that happens to look like a v1 payload', () => {
186
+ const service = makeService()
187
+ const plaintext = 'user:supplied:colon:v1'
188
+ const out = service.encryptFields(
189
+ { email: plaintext },
190
+ [{ field: 'email', hashField: 'email_hash' }],
191
+ { key: fixedKey } as never,
192
+ )
193
+ expect(out.email).not.toBe(plaintext)
194
+ expect(decryptWithAesGcm(out.email as string, fixedKey)).toBe(plaintext)
195
+ expect(out.email_hash).toBe(hashForLookup(plaintext))
196
+ })
197
+ })
198
+
132
199
  describe('TenantDataEncryptionService.getEncryptedFieldNames', () => {
133
200
  it('returns active encryption-map field names for query planning', async () => {
134
201
  const service = new TenantDataEncryptionService({} as never)
@@ -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
  /**