@open-mercato/shared 0.8.1-develop.7228.1.b7ea09d2d3 → 0.8.1-develop.7238.1.3abe669ea5

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 (33) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/lib/availability/catalogOnlyProvider.js +65 -0
  3. package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
  4. package/dist/lib/availability/index.js +8 -0
  5. package/dist/lib/availability/index.js.map +7 -0
  6. package/dist/lib/availability/registry.js +47 -0
  7. package/dist/lib/availability/registry.js.map +7 -0
  8. package/dist/lib/availability/types.js +7 -0
  9. package/dist/lib/availability/types.js.map +7 -0
  10. package/dist/lib/crud/factory.js +21 -7
  11. package/dist/lib/crud/factory.js.map +2 -2
  12. package/dist/lib/db/pg-errors.js +9 -1
  13. package/dist/lib/db/pg-errors.js.map +2 -2
  14. package/dist/lib/encryption/aes.js +14 -0
  15. package/dist/lib/encryption/aes.js.map +2 -2
  16. package/dist/lib/encryption/tenantDataEncryptionService.js +17 -1
  17. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  18. package/dist/lib/version.js +1 -1
  19. package/dist/lib/version.js.map +1 -1
  20. package/package.json +6 -2
  21. package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +98 -0
  22. package/src/lib/availability/__tests__/registry.test.ts +115 -0
  23. package/src/lib/availability/catalogOnlyProvider.ts +119 -0
  24. package/src/lib/availability/index.ts +18 -0
  25. package/src/lib/availability/registry.ts +120 -0
  26. package/src/lib/availability/types.ts +71 -0
  27. package/src/lib/crud/__tests__/crud-factory.test.ts +91 -4
  28. package/src/lib/crud/factory.ts +37 -6
  29. package/src/lib/db/__tests__/pg-errors.test.ts +31 -1
  30. package/src/lib/db/pg-errors.ts +23 -0
  31. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +97 -9
  32. package/src/lib/encryption/aes.ts +43 -0
  33. package/src/lib/encryption/tenantDataEncryptionService.ts +44 -1
@@ -1,4 +1,4 @@
1
- import { getForeignKeyViolationConstraint, isForeignKeyViolation, isTransientDbError, isUniqueViolation } from '../pg-errors'
1
+ import { getForeignKeyViolationConstraint, isForeignKeyViolation, isTransientDbError, isUniqueViolation, readPgSqlState } from '../pg-errors'
2
2
 
3
3
  describe('isTransientDbError', () => {
4
4
  it('is true for the max_connections SQLSTATE', () => {
@@ -104,3 +104,33 @@ describe('getForeignKeyViolationConstraint', () => {
104
104
  expect(getForeignKeyViolationConstraint(null)).toBeNull()
105
105
  })
106
106
  })
107
+
108
+ describe('readPgSqlState', () => {
109
+ it('reads a 5-character SQLSTATE off the top-level error', () => {
110
+ expect(readPgSqlState({ code: '42P01' })).toBe('42P01')
111
+ })
112
+
113
+ it('reads a SQLSTATE from a nested cause', () => {
114
+ expect(readPgSqlState({ message: 'wrapped', cause: { code: '23505' } })).toBe('23505')
115
+ })
116
+
117
+ it('is null for a Node system error code, which is not a 5-character SQLSTATE', () => {
118
+ expect(readPgSqlState({ code: 'ECONNREFUSED' })).toBeNull()
119
+ })
120
+
121
+ it('is null for a Node internal error code', () => {
122
+ expect(readPgSqlState({ code: 'ERR_INVALID_ARG_TYPE' })).toBeNull()
123
+ })
124
+
125
+ it('is null for five-letter Node errno codes, which are not a 5-character SQLSTATE', () => {
126
+ expect(readPgSqlState({ code: 'EPIPE' })).toBeNull()
127
+ expect(readPgSqlState({ code: 'EPERM' })).toBeNull()
128
+ expect(readPgSqlState({ code: 'EBUSY' })).toBeNull()
129
+ })
130
+
131
+ it('is null for non-DB and empty errors', () => {
132
+ expect(readPgSqlState(new Error('something unrelated broke'))).toBeNull()
133
+ expect(readPgSqlState(null)).toBeNull()
134
+ expect(readPgSqlState(undefined)).toBeNull()
135
+ })
136
+ })
@@ -68,6 +68,29 @@ export function getForeignKeyViolationConstraint(err: unknown): string | null {
68
68
  return null
69
69
  }
70
70
 
71
+ /**
72
+ * A Postgres SQLSTATE is always exactly 5 characters from `[0-9A-Z]`, and every
73
+ * class in the standard SQLSTATE table carries at least one digit. Node system
74
+ * errors (`ECONNREFUSED`), Node internal errors (`ERR_INVALID_ARG_TYPE`), and the
75
+ * five-letter Node errno codes (`EPIPE`, `EPERM`, `EBUSY`) would otherwise be
76
+ * misread as a SQLSTATE, since they also populate a string `code` field; the
77
+ * digit requirement rules those out without excluding any real SQLSTATE.
78
+ */
79
+ const SQLSTATE_PATTERN = /^(?=.*[0-9])[0-9A-Z]{5}$/
80
+
81
+ /**
82
+ * The Postgres SQLSTATE behind an error, read from the pg `code` field on any
83
+ * layer of the driver-error wrapper chain (see `pgErrorCandidates`). Returns
84
+ * `null` when no layer carries a `code` matching the SQLSTATE shape, which
85
+ * also covers non-Postgres errors whose `code` is a Node error code.
86
+ */
87
+ export function readPgSqlState(err: unknown): string | null {
88
+ for (const candidate of pgErrorCandidates(err)) {
89
+ if (typeof candidate.code === 'string' && SQLSTATE_PATTERN.test(candidate.code)) return candidate.code
90
+ }
91
+ return null
92
+ }
93
+
71
94
  /**
72
95
  * Postgres SQLSTATEs for transient connection / availability failures — the
73
96
  * database (or its connection pool) is temporarily unreachable and the request
@@ -1,4 +1,11 @@
1
- import { decryptWithAesGcm, encryptWithAesGcm, hashForLookup } from '../aes'
1
+ import {
2
+ TenantDataEncryptionError,
3
+ TenantDataEncryptionErrorCode,
4
+ decryptWithAesGcm,
5
+ encryptWithAesGcm,
6
+ hashForLookup,
7
+ isEncryptedPayloadShape,
8
+ } from '../aes'
2
9
  import {
3
10
  TenantDataEncryptionService,
4
11
  parseDecryptedFieldValue,
@@ -129,6 +136,34 @@ describe('TenantDataEncryptionService.decryptFields (issue #1734)', () => {
129
136
  })
130
137
  })
131
138
 
139
+ describe('isEncryptedPayloadShape (issue #5951)', () => {
140
+ it('accepts a real AES-GCM envelope regardless of which key sealed it', () => {
141
+ const otherKey = Buffer.alloc(32, 7).toString('base64')
142
+ expect(isEncryptedPayloadShape(encryptWithAesGcm('x', fixedKey).value)).toBe(true)
143
+ expect(isEncryptedPayloadShape(encryptWithAesGcm('x', otherKey).value)).toBe(true)
144
+ })
145
+
146
+ it('rejects the loose four-segment shapes a length-blind check would accept', () => {
147
+ // The IV and tag decode to 3 and 3 bytes, not 12 and 16 — no AES-GCM payload looks like this.
148
+ expect(isEncryptedPayloadShape('aaaa:bbbb:cccc:v1')).toBe(false)
149
+ expect(isEncryptedPayloadShape('user:supplied:colon:v1')).toBe(false)
150
+ })
151
+
152
+ it('rejects plaintext, non-strings, and wrong-version payloads', () => {
153
+ expect(isEncryptedPayloadShape('mail@example.com')).toBe(false)
154
+ expect(isEncryptedPayloadShape('')).toBe(false)
155
+ expect(isEncryptedPayloadShape(null)).toBe(false)
156
+ expect(isEncryptedPayloadShape(42)).toBe(false)
157
+ expect(isEncryptedPayloadShape((encryptWithAesGcm('x', fixedKey).value as string).replace(/:v1$/, ':v2'))).toBe(false)
158
+ })
159
+
160
+ it('rejects an envelope whose ciphertext segment is empty', () => {
161
+ const real = encryptWithAesGcm('x', fixedKey).value as string
162
+ const [iv, , tag] = real.split(':')
163
+ expect(isEncryptedPayloadShape(`${iv}::${tag}:v1`)).toBe(false)
164
+ })
165
+ })
166
+
132
167
  describe('TenantDataEncryptionService.encryptFields (issue #2720)', () => {
133
168
  function makeService() {
134
169
  type Anything = Record<string, unknown>
@@ -169,17 +204,70 @@ describe('TenantDataEncryptionService.encryptFields (issue #2720)', () => {
169
204
  expect(out.email).toBe(real)
170
205
  })
171
206
 
172
- it('encrypts a structurally-valid payload that was sealed with a different key', () => {
207
+ // Superseded by issue #5951: this case used to assert that a payload sealed under another
208
+ // key gets encrypted again. That is what produced the undetectable nested envelope — the
209
+ // value is real ciphertext (the previous DEK mid-rotation, or the derived key the KMS falls
210
+ // back to during a Vault outage), not a forgery, and wrapping it destroys it. The write now
211
+ // fails closed. #2720 is unaffected: nothing is stored verbatim on this path either way.
212
+ it('refuses to re-encrypt a payload that was sealed with a different key', () => {
173
213
  const service = makeService()
174
214
  const otherKey = Buffer.alloc(32, 2).toString('base64')
175
215
  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)
216
+ expect(() =>
217
+ service.encryptFields(
218
+ { email: sealedElsewhere },
219
+ [{ field: 'email' }],
220
+ { key: fixedKey } as never,
221
+ ),
222
+ ).toThrow(TenantDataEncryptionError)
223
+ })
224
+
225
+ it('reports the wrong-key case with a distinct error code and leaves the value out of the message', () => {
226
+ const service = makeService()
227
+ const otherKey = Buffer.alloc(32, 2).toString('base64')
228
+ const sealedElsewhere = encryptWithAesGcm('secret@example.com', otherKey).value as string
229
+ try {
230
+ service.encryptFields(
231
+ { email: sealedElsewhere },
232
+ [{ field: 'email' }],
233
+ { key: fixedKey } as never,
234
+ )
235
+ throw new Error('[internal] expected encryptFields to throw')
236
+ } catch (err) {
237
+ expect(err).toBeInstanceOf(TenantDataEncryptionError)
238
+ expect((err as TenantDataEncryptionError).code).toBe(TenantDataEncryptionErrorCode.WRONG_KEY)
239
+ // The ciphertext must never be echoed back into an error surfaced to a caller.
240
+ expect((err as TenantDataEncryptionError).message).not.toContain(sealedElsewhere)
241
+ expect((err as TenantDataEncryptionError).message).toContain('email')
242
+ }
243
+ })
244
+
245
+ it('never emits a nested envelope or a hash of ciphertext when the DEK changed', () => {
246
+ const service = makeService()
247
+ const previousDek = Buffer.alloc(32, 2).toString('base64')
248
+ const sealedUnderPreviousDek = encryptWithAesGcm('mail@example.com', previousDek).value as string
249
+ const input = { email: sealedUnderPreviousDek, email_hash: hashForLookup('mail@example.com') }
250
+ const inputSnapshot = { ...input }
251
+
252
+ // encryptFields must reject the call outright rather than return a payload — a toBeNull()
253
+ // check on a try/catch result would also pass if it threw for an unrelated reason, so assert
254
+ // the throw directly.
255
+ expect(() =>
256
+ service.encryptFields(
257
+ input,
258
+ [{ field: 'email', hashField: 'email_hash' }],
259
+ { key: fixedKey } as never,
260
+ ),
261
+ ).toThrow(TenantDataEncryptionError)
262
+
263
+ // encryptFields clones before mutating, so a rejected call must leave the caller's object
264
+ // untouched — no nested envelope, no hash overwritten with one computed over ciphertext.
265
+ expect(input).toEqual(inputSnapshot)
266
+
267
+ // Illustrative only (not an assertion on the code under test): before the fix, the case
268
+ // above returned a writable payload containing one more AES-GCM layer whose plaintext was
269
+ // the previous envelope, plus a lookup hash computed over ciphertext instead of over the
270
+ // email — neither of which any read path could undo.
183
271
  })
184
272
 
185
273
  it('encrypts plaintext that happens to look like a v1 payload', () => {
@@ -45,6 +45,11 @@ const BASE64_PART = /^[A-Za-z0-9+/]+={0,2}$/
45
45
  * attacker-supplied input while a DEK is reachable, where `isEncryptedWithDek` is the test to use.
46
46
  * Callers that must run it over user-controlled data are responsible for confirming first that no
47
47
  * DEK is reachable, which is what makes forgery pointless: there is nothing to impersonate.
48
+ *
49
+ * See also {@link isEncryptedPayloadShape}, which answers the stricter "would the decrypt path
50
+ * accept this?" question by decoded byte length instead of encoded character length. The two
51
+ * agree on anything the server actually wrote; use this one for offline detection with no DEK
52
+ * reachable, and that one to predict whether a decrypt attempt will succeed.
48
53
  */
49
54
  export function looksLikeEncryptedPayload(value: unknown): boolean {
50
55
  if (typeof value !== 'string') return false
@@ -88,6 +93,44 @@ export function encryptWithAesGcm(value: string, dekBase64: string): EncryptionP
88
93
  return { value: payload, raw: payload, version: 'v1' }
89
94
  }
90
95
 
96
+ const AES_GCM_IV_BYTES = 12
97
+ const AES_GCM_TAG_BYTES = 16
98
+
99
+ /**
100
+ * Reports whether a value is a **structurally** well-formed `<iv>:<ct>:<tag>:v1` envelope —
101
+ * the same shape validation {@link decryptWithAesGcmStrict} performs before it attempts a
102
+ * decrypt, without needing a key.
103
+ *
104
+ * This is deliberately NOT an "is this encrypted" oracle: the shape is forgeable, so a caller
105
+ * deciding whether a value is already sealed MUST still bind that decision to a successful
106
+ * authenticated decrypt (issue #2720). Its purpose is to separate "ciphertext we cannot open
107
+ * with the key we hold" from "plaintext that happens to contain colons", so a write path can
108
+ * fail loudly on the former instead of silently wrapping it a second time (issue #5951).
109
+ *
110
+ * The byte-length checks matter: `Buffer.from(value, 'base64')` is lenient, so a loose
111
+ * four-segment check matches strings like `aaaa:bbbb:cccc:v1` that no AES-GCM payload could be.
112
+ *
113
+ * See also {@link looksLikeEncryptedPayload}, the pre-existing looser check by encoded character
114
+ * length. Use that one when no DEK is reachable at all (it needs none); use this one whenever the
115
+ * question is "will the encrypt/decrypt path accept this?", since it matches the same byte-length
116
+ * validation {@link decryptWithAesGcmStrict} performs.
117
+ */
118
+ export function isEncryptedPayloadShape(value: unknown): value is string {
119
+ if (typeof value !== 'string') return false
120
+ const parts = value.split(':')
121
+ if (parts.length !== 4 || parts[3] !== 'v1') return false
122
+ const [ivB64, ciphertextB64, tagB64] = parts as [string, string, string, string]
123
+ try {
124
+ return (
125
+ Buffer.from(ivB64, 'base64').length === AES_GCM_IV_BYTES
126
+ && Buffer.from(tagB64, 'base64').length === AES_GCM_TAG_BYTES
127
+ && Buffer.from(ciphertextB64, 'base64').length > 0
128
+ )
129
+ } catch {
130
+ return false
131
+ }
132
+ }
133
+
91
134
  function runAesGcmDecrypt(dek: Buffer, iv: Buffer, ciphertext: Buffer, tag: Buffer): string {
92
135
  const decipher = crypto.createDecipheriv('aes-256-gcm', dek, iv)
93
136
  decipher.setAuthTag(tag)
@@ -1,6 +1,13 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import type { CacheStrategy } from '@open-mercato/cache'
3
- import { decryptWithAesGcm, encryptWithAesGcm, hashForLookup } from './aes'
3
+ import {
4
+ TenantDataEncryptionError,
5
+ TenantDataEncryptionErrorCode,
6
+ decryptWithAesGcm,
7
+ encryptWithAesGcm,
8
+ hashForLookup,
9
+ isEncryptedPayloadShape,
10
+ } from './aes'
4
11
  import { createKmsService, type KmsService, type TenantDek } from './kms'
5
12
  import { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from './toggles'
6
13
  import { createLogger } from '../logger'
@@ -124,6 +131,32 @@ function isEncryptedWithDek(value: unknown, dek: TenantDek): boolean {
124
131
  return decryptWithAesGcm(value, dek.key) !== null
125
132
  }
126
133
 
134
+ /**
135
+ * Guard the encrypt path against re-wrapping ciphertext this process cannot open.
136
+ *
137
+ * Called only after {@link isEncryptedWithDek} has already said "not sealed under the
138
+ * current DEK". At that point a structurally well-formed envelope means one of two things:
139
+ * genuine ciphertext under some other key, or a length-valid forgery — the shape check is
140
+ * length-based, not content-based, so any length-correct string qualifies, not just a
141
+ * byte-exact match against something real. Both must stop the write — the first because
142
+ * nesting envelopes silently destroys recoverable data, the second because rejecting it is
143
+ * strictly safer than persisting attacker-chosen bytes.
144
+ *
145
+ * The field name is safe to report (it comes from the encryption map, not user input); the
146
+ * value never is, so it stays out of both the error message and the log.
147
+ */
148
+ function assertNotSealedUnderAnotherKey(value: unknown, field: string): void {
149
+ if (!isEncryptedPayloadShape(value)) return
150
+ logger.error('Refusing to re-encrypt a value sealed under a different key', { field })
151
+ throw new TenantDataEncryptionError(
152
+ TenantDataEncryptionErrorCode.WRONG_KEY,
153
+ `[internal] Field "${field}" already holds an encrypted payload that does not decrypt under the current tenant DEK. `
154
+ + 'Encrypting it again would produce an unreadable nested envelope. '
155
+ + 'Complete the key rotation for this tenant (mercato entities rotate-encryption-key --old-key …) '
156
+ + 'or restore the DEK that sealed it before writing this record again.',
157
+ )
158
+ }
159
+
127
160
  function normalizeEncryptedFieldRules(
128
161
  fields: readonly { field?: unknown; hashField?: unknown }[] | null | undefined,
129
162
  ): EncryptedFieldRule[] {
@@ -564,6 +597,16 @@ export class TenantDataEncryptionService {
564
597
  // A forged ciphertext-shaped string fails this check and is encrypted as
565
598
  // plaintext, closing the encryption-at-rest bypass (issue #2720).
566
599
  if (isEncryptedWithDek(value, dek)) continue
600
+ // Failing that check does not prove the value is plaintext. A well-formed
601
+ // envelope sealed under a *different* key — the previous DEK mid-rotation, or
602
+ // the derived key the KMS falls back to during a Vault outage — lands here too,
603
+ // and encrypting it again would nest one envelope inside another: unreadable by
604
+ // any normal decrypt, indistinguishable from correct ciphertext by inspection,
605
+ // and it would overwrite the lookup hash with a hash of ciphertext (issue #5951).
606
+ // Fail the write closed instead. Nothing is ever stored verbatim, so #2720 stays
607
+ // shut: a forgery whose shape is not length-valid still gets encrypted as plaintext
608
+ // above, and a length-valid one is rejected rather than persisted.
609
+ assertNotSealedUnderAnotherKey(value, rule.field)
567
610
  const serialized = typeof value === 'string' ? value : JSON.stringify(value)
568
611
  const payload = encryptWithAesGcm(serialized, dek.key)
569
612
  clone[key] = payload.value