@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/lib/availability/catalogOnlyProvider.js +65 -0
- package/dist/lib/availability/catalogOnlyProvider.js.map +7 -0
- package/dist/lib/availability/index.js +8 -0
- package/dist/lib/availability/index.js.map +7 -0
- package/dist/lib/availability/registry.js +47 -0
- package/dist/lib/availability/registry.js.map +7 -0
- package/dist/lib/availability/types.js +7 -0
- package/dist/lib/availability/types.js.map +7 -0
- package/dist/lib/crud/factory.js +21 -7
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/db/pg-errors.js +9 -1
- package/dist/lib/db/pg-errors.js.map +2 -2
- package/dist/lib/encryption/aes.js +14 -0
- package/dist/lib/encryption/aes.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +17 -1
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +6 -2
- package/src/lib/availability/__tests__/catalogOnlyProvider.test.ts +98 -0
- package/src/lib/availability/__tests__/registry.test.ts +115 -0
- package/src/lib/availability/catalogOnlyProvider.ts +119 -0
- package/src/lib/availability/index.ts +18 -0
- package/src/lib/availability/registry.ts +120 -0
- package/src/lib/availability/types.ts +71 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +91 -4
- package/src/lib/crud/factory.ts +37 -6
- package/src/lib/db/__tests__/pg-errors.test.ts +31 -1
- package/src/lib/db/pg-errors.ts +23 -0
- package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +97 -9
- package/src/lib/encryption/aes.ts +43 -0
- 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
|
+
})
|
package/src/lib/db/pg-errors.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 {
|
|
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
|