@open-mercato/shared 0.7.1-develop.7183.1.db9678eeb8 → 0.7.1-develop.7185.1.0f280ef1f1
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/encryption/aes.js +9 -0
- package/dist/lib/encryption/aes.js.map +2 -2
- package/dist/lib/encryption/kms.js +6 -1
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/telemetry/error-code.js +10 -0
- package/dist/lib/telemetry/error-code.js.map +7 -0
- package/dist/lib/telemetry/runtime.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/encryption/__tests__/encryptionMode.test.ts +72 -0
- package/src/lib/encryption/aes.ts +32 -0
- package/src/lib/encryption/kms.ts +22 -0
- package/src/lib/telemetry/error-code.ts +25 -0
- package/src/lib/telemetry/runtime.ts +6 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:shared] found
|
|
1
|
+
[build:shared] found 285 entry points
|
|
2
2
|
[build:shared] built successfully
|
|
@@ -17,6 +17,14 @@ class TenantDataEncryptionError extends Error {
|
|
|
17
17
|
this.code = code;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
const BASE64_PART = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
21
|
+
function looksLikeEncryptedPayload(value) {
|
|
22
|
+
if (typeof value !== "string") return false;
|
|
23
|
+
const parts = value.split(":");
|
|
24
|
+
if (parts.length !== 4 || parts[3] !== "v1") return false;
|
|
25
|
+
const [iv, ciphertext, tag] = parts;
|
|
26
|
+
return iv.length === 16 && tag.length === 24 && ciphertext.length > 0 && BASE64_PART.test(iv) && BASE64_PART.test(ciphertext) && BASE64_PART.test(tag);
|
|
27
|
+
}
|
|
20
28
|
function generateDek() {
|
|
21
29
|
return crypto.randomBytes(32).toString("base64");
|
|
22
30
|
}
|
|
@@ -146,6 +154,7 @@ export {
|
|
|
146
154
|
generateDek,
|
|
147
155
|
hashForLookup,
|
|
148
156
|
legacyHashForLookup,
|
|
157
|
+
looksLikeEncryptedPayload,
|
|
149
158
|
lookupHashCandidates
|
|
150
159
|
};
|
|
151
160
|
//# sourceMappingURL=aes.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/encryption/aes.ts"],
|
|
4
|
-
"sourcesContent": ["import crypto from 'node:crypto'\nimport { isEncryptionDebugEnabled } from './toggles'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'encryption' })\n\nexport type EncryptionPayload = {\n value: string | null\n raw: string\n version: string\n}\n\nexport enum TenantDataEncryptionErrorCode {\n AUTH_FAILED = 'AUTH_FAILED',\n MALFORMED_PAYLOAD = 'MALFORMED_PAYLOAD',\n KMS_UNAVAILABLE = 'KMS_UNAVAILABLE',\n WRONG_KEY = 'WRONG_KEY',\n DECRYPT_INTERNAL = 'DECRYPT_INTERNAL',\n}\n\nexport class TenantDataEncryptionError extends Error {\n code: TenantDataEncryptionErrorCode\n constructor(code: TenantDataEncryptionErrorCode, message: string) {\n super(message)\n this.name = 'TenantDataEncryptionError'\n this.code = code\n }\n}\n\nexport function generateDek(): string {\n return crypto.randomBytes(32).toString('base64')\n}\n\nfunction logDebug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n logger.debug(event, payload)\n } catch {\n // ignore\n }\n}\n\nexport function encryptWithAesGcm(value: string, dekBase64: string): EncryptionPayload {\n const dek = Buffer.from(dekBase64, 'base64')\n const iv = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv)\n const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n const payload = [\n iv.toString('base64'),\n ciphertext.toString('base64'),\n tag.toString('base64'),\n 'v1',\n ].join(':')\n logDebug('encrypt', { length: ciphertext.length })\n return { value: payload, raw: payload, version: 'v1' }\n}\n\nfunction runAesGcmDecrypt(dek: Buffer, iv: Buffer, ciphertext: Buffer, tag: Buffer): string {\n const decipher = crypto.createDecipheriv('aes-256-gcm', dek, iv)\n decipher.setAuthTag(tag)\n return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')\n}\n\nexport function decryptWithAesGcm(payload: string, dekBase64: string): string | null {\n if (!payload) return null\n const parts = payload.split(':')\n if (parts.length !== 4) return null\n const [ivB64, ciphertextB64, tagB64, version] = parts\n if (version !== 'v1') return null\n const dek = Buffer.from(dekBase64, 'base64')\n const iv = Buffer.from(ivB64, 'base64')\n const ciphertext = Buffer.from(ciphertextB64, 'base64')\n const tag = Buffer.from(tagB64, 'base64')\n try {\n const result = runAesGcmDecrypt(dek, iv, ciphertext, tag)\n logDebug('decrypt', { iv: ivB64, tag: tagB64 })\n return result\n } catch (err) {\n logDebug('decrypt_error', { message: (err as Error)?.message || String(err) })\n return null\n }\n}\n\nconst LOOKUP_HASH_V2_PREFIX = 'v2:'\n\nfunction normalizeLookupValue(value: string): string {\n return value.toLowerCase().trim()\n}\n\n/**\n * Legacy, unkeyed lookup digest (`sha256(lower(trim(value)))`).\n *\n * @deprecated Unkeyed digests are vulnerable to offline rainbow-table attacks and\n * cross-installation correlation (issue #2718). New writes use {@link hashForLookup},\n * which emits a keyed `v2:` HMAC when a lookup pepper is configured. This helper is\n * retained only so existing `*_hash` columns written before the keyed format can still\n * be matched (see {@link lookupHashCandidates}) until a backfill migration recomputes them.\n */\nexport function legacyHashForLookup(value: string): string {\n return crypto.createHash('sha256').update(normalizeLookupValue(value)).digest('hex')\n}\n\n/**\n * Resolve the installation-wide lookup pepper used to key lookup hashes.\n *\n * Order of precedence (never `AUTH_SECRET`, per issue #2718):\n * 1. `LOOKUP_HASH_PEPPER` \u2014 dedicated secret for lookup hashing\n * 2. `TENANT_DATA_ENCRYPTION_FALLBACK_KEY` \u2014 existing encryption fallback secret\n * 3. `TENANT_DATA_ENCRYPTION_KEY` \u2014 existing encryption secret\n *\n * Returns `null` when no secret is configured, in which case {@link hashForLookup}\n * falls back to the legacy unkeyed digest so deployments without any configured key\n * keep working unchanged.\n */\nfunction resolveLookupPepper(): string | null {\n const candidates = [\n process.env.LOOKUP_HASH_PEPPER,\n process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY,\n process.env.TENANT_DATA_ENCRYPTION_KEY,\n ]\n for (const candidate of candidates) {\n if (typeof candidate !== 'string') continue\n const normalized = candidate.trim().replace(/(?:^['\"]|['\"]$)/g, '')\n if (normalized) return normalized\n }\n return null\n}\n\n/**\n * Compute a deterministic lookup hash for a low-entropy PII value (email, phone, \u2026).\n *\n * When a lookup pepper is configured the result is a keyed HMAC-SHA-256 prefixed with\n * `v2:` and bound to the optional `context` (entity/field) so digests are not portable\n * across columns, installations, or tenants without the secret. When no pepper is\n * configured it falls back to the legacy unkeyed digest for backward compatibility.\n *\n * The `context` MUST be supplied identically on both the write and the read side for a\n * given column; callers that do not pass one stay mutually consistent.\n */\nexport function hashForLookup(value: string, context?: string): string {\n const pepper = resolveLookupPepper()\n const normalized = normalizeLookupValue(value)\n if (!pepper) {\n return legacyHashForLookup(value)\n }\n const message = context ? `${context}:${normalized}` : normalized\n const digest = crypto.createHmac('sha256', pepper).update(message).digest('hex')\n return `${LOOKUP_HASH_V2_PREFIX}${digest}`\n}\n\n/**\n * Candidate lookup hashes for matching a value against `*_hash` columns that may hold\n * either the new keyed (`v2:`) digest or a legacy unkeyed digest. Use this in `$in` /\n * `IN (...)` filters during the migration window so reads keep matching rows written\n * before the keyed format. Once a backfill has recomputed all columns this can collapse\n * back to a single {@link hashForLookup} value.\n */\nexport function lookupHashCandidates(value: string, context?: string): string[] {\n const primary = hashForLookup(value, context)\n const legacy = legacyHashForLookup(value)\n return primary === legacy ? [primary] : [primary, legacy]\n}\n\n/**\n * Strict variant of decryptWithAesGcm that throws typed TenantDataEncryptionError.\n * - Format mismatch (not iv:ct:tag:v1): throws AUTH_FAILED (treat as plaintext).\n * - Valid format but invalid buffer sizes (bad base64): throws MALFORMED_PAYLOAD.\n * - AES-GCM auth tag failure: throws AUTH_FAILED.\n * - Unexpected crypto error: throws DECRYPT_INTERNAL.\n */\nexport function decryptWithAesGcmStrict(payload: string, dekBase64: string): string {\n const parts = payload.split(':')\n if (parts.length !== 4 || parts[3] !== 'v1') {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.AUTH_FAILED,\n 'Value is not an encrypted payload (format mismatch)',\n )\n }\n const [ivB64, ciphertextB64, tagB64] = parts as [string, string, string, string]\n let dek: Buffer, iv: Buffer, ciphertext: Buffer, tag: Buffer\n try {\n dek = Buffer.from(dekBase64, 'base64')\n iv = Buffer.from(ivB64, 'base64')\n ciphertext = Buffer.from(ciphertextB64, 'base64')\n tag = Buffer.from(tagB64, 'base64')\n } catch {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.MALFORMED_PAYLOAD,\n 'Failed to decode base64 components',\n )\n }\n if (iv.length !== 12 || tag.length !== 16 || ciphertext.length === 0) {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.MALFORMED_PAYLOAD,\n 'Invalid AES-GCM payload: unexpected IV, tag, or ciphertext size',\n )\n }\n try {\n return runAesGcmDecrypt(dek, iv, ciphertext, tag)\n } catch {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.AUTH_FAILED,\n 'AES-GCM authentication tag verification failed',\n )\n }\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import crypto from 'node:crypto'\nimport { isEncryptionDebugEnabled } from './toggles'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'encryption' })\n\nexport type EncryptionPayload = {\n value: string | null\n raw: string\n version: string\n}\n\nexport enum TenantDataEncryptionErrorCode {\n AUTH_FAILED = 'AUTH_FAILED',\n MALFORMED_PAYLOAD = 'MALFORMED_PAYLOAD',\n KMS_UNAVAILABLE = 'KMS_UNAVAILABLE',\n WRONG_KEY = 'WRONG_KEY',\n DECRYPT_INTERNAL = 'DECRYPT_INTERNAL',\n}\n\nexport class TenantDataEncryptionError extends Error {\n code: TenantDataEncryptionErrorCode\n constructor(code: TenantDataEncryptionErrorCode, message: string) {\n super(message)\n this.name = 'TenantDataEncryptionError'\n this.code = code\n }\n}\n\nconst BASE64_PART = /^[A-Za-z0-9+/]+={0,2}$/\n\n/**\n * Keyless structural check for the `base64(iv):base64(ciphertext):base64(tag):v1` envelope\n * {@link encryptWithAesGcm} emits.\n *\n * Answers \"is this column holding ciphertext?\" without a DEK, which is the only question\n * available once encryption has been switched off \u2014 the KMS is a noop by then, so\n * {@link decryptWithAesGcm} cannot distinguish ciphertext from plaintext. A 12-byte IV and a\n * 16-byte tag encode to exactly 16 and 24 base64 characters, so the shape is specific enough\n * that plaintext colliding with it by accident is not a practical concern.\n *\n * Deliberate collision is, though: writing `<16 b64>:<b64>:<24 b64>:v1` is trivial, and\n * `TenantDataEncryptionService` dropped a structural check of exactly this shape for that\n * reason (#2720). So this is only safe on values the SERVER wrote \u2014 never as a test applied to\n * attacker-supplied input while a DEK is reachable, where `isEncryptedWithDek` is the test to use.\n * Callers that must run it over user-controlled data are responsible for confirming first that no\n * DEK is reachable, which is what makes forgery pointless: there is nothing to impersonate.\n */\nexport function looksLikeEncryptedPayload(value: unknown): boolean {\n if (typeof value !== 'string') return false\n const parts = value.split(':')\n if (parts.length !== 4 || parts[3] !== 'v1') return false\n const [iv, ciphertext, tag] = parts as [string, string, string, string]\n return iv.length === 16\n && tag.length === 24\n && ciphertext.length > 0\n && BASE64_PART.test(iv)\n && BASE64_PART.test(ciphertext)\n && BASE64_PART.test(tag)\n}\n\nexport function generateDek(): string {\n return crypto.randomBytes(32).toString('base64')\n}\n\nfunction logDebug(event: string, payload: Record<string, unknown>) {\n if (!isEncryptionDebugEnabled()) return\n try {\n logger.debug(event, payload)\n } catch {\n // ignore\n }\n}\n\nexport function encryptWithAesGcm(value: string, dekBase64: string): EncryptionPayload {\n const dek = Buffer.from(dekBase64, 'base64')\n const iv = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv('aes-256-gcm', dek, iv)\n const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])\n const tag = cipher.getAuthTag()\n const payload = [\n iv.toString('base64'),\n ciphertext.toString('base64'),\n tag.toString('base64'),\n 'v1',\n ].join(':')\n logDebug('encrypt', { length: ciphertext.length })\n return { value: payload, raw: payload, version: 'v1' }\n}\n\nfunction runAesGcmDecrypt(dek: Buffer, iv: Buffer, ciphertext: Buffer, tag: Buffer): string {\n const decipher = crypto.createDecipheriv('aes-256-gcm', dek, iv)\n decipher.setAuthTag(tag)\n return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')\n}\n\nexport function decryptWithAesGcm(payload: string, dekBase64: string): string | null {\n if (!payload) return null\n const parts = payload.split(':')\n if (parts.length !== 4) return null\n const [ivB64, ciphertextB64, tagB64, version] = parts\n if (version !== 'v1') return null\n const dek = Buffer.from(dekBase64, 'base64')\n const iv = Buffer.from(ivB64, 'base64')\n const ciphertext = Buffer.from(ciphertextB64, 'base64')\n const tag = Buffer.from(tagB64, 'base64')\n try {\n const result = runAesGcmDecrypt(dek, iv, ciphertext, tag)\n logDebug('decrypt', { iv: ivB64, tag: tagB64 })\n return result\n } catch (err) {\n logDebug('decrypt_error', { message: (err as Error)?.message || String(err) })\n return null\n }\n}\n\nconst LOOKUP_HASH_V2_PREFIX = 'v2:'\n\nfunction normalizeLookupValue(value: string): string {\n return value.toLowerCase().trim()\n}\n\n/**\n * Legacy, unkeyed lookup digest (`sha256(lower(trim(value)))`).\n *\n * @deprecated Unkeyed digests are vulnerable to offline rainbow-table attacks and\n * cross-installation correlation (issue #2718). New writes use {@link hashForLookup},\n * which emits a keyed `v2:` HMAC when a lookup pepper is configured. This helper is\n * retained only so existing `*_hash` columns written before the keyed format can still\n * be matched (see {@link lookupHashCandidates}) until a backfill migration recomputes them.\n */\nexport function legacyHashForLookup(value: string): string {\n return crypto.createHash('sha256').update(normalizeLookupValue(value)).digest('hex')\n}\n\n/**\n * Resolve the installation-wide lookup pepper used to key lookup hashes.\n *\n * Order of precedence (never `AUTH_SECRET`, per issue #2718):\n * 1. `LOOKUP_HASH_PEPPER` \u2014 dedicated secret for lookup hashing\n * 2. `TENANT_DATA_ENCRYPTION_FALLBACK_KEY` \u2014 existing encryption fallback secret\n * 3. `TENANT_DATA_ENCRYPTION_KEY` \u2014 existing encryption secret\n *\n * Returns `null` when no secret is configured, in which case {@link hashForLookup}\n * falls back to the legacy unkeyed digest so deployments without any configured key\n * keep working unchanged.\n */\nfunction resolveLookupPepper(): string | null {\n const candidates = [\n process.env.LOOKUP_HASH_PEPPER,\n process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY,\n process.env.TENANT_DATA_ENCRYPTION_KEY,\n ]\n for (const candidate of candidates) {\n if (typeof candidate !== 'string') continue\n const normalized = candidate.trim().replace(/(?:^['\"]|['\"]$)/g, '')\n if (normalized) return normalized\n }\n return null\n}\n\n/**\n * Compute a deterministic lookup hash for a low-entropy PII value (email, phone, \u2026).\n *\n * When a lookup pepper is configured the result is a keyed HMAC-SHA-256 prefixed with\n * `v2:` and bound to the optional `context` (entity/field) so digests are not portable\n * across columns, installations, or tenants without the secret. When no pepper is\n * configured it falls back to the legacy unkeyed digest for backward compatibility.\n *\n * The `context` MUST be supplied identically on both the write and the read side for a\n * given column; callers that do not pass one stay mutually consistent.\n */\nexport function hashForLookup(value: string, context?: string): string {\n const pepper = resolveLookupPepper()\n const normalized = normalizeLookupValue(value)\n if (!pepper) {\n return legacyHashForLookup(value)\n }\n const message = context ? `${context}:${normalized}` : normalized\n const digest = crypto.createHmac('sha256', pepper).update(message).digest('hex')\n return `${LOOKUP_HASH_V2_PREFIX}${digest}`\n}\n\n/**\n * Candidate lookup hashes for matching a value against `*_hash` columns that may hold\n * either the new keyed (`v2:`) digest or a legacy unkeyed digest. Use this in `$in` /\n * `IN (...)` filters during the migration window so reads keep matching rows written\n * before the keyed format. Once a backfill has recomputed all columns this can collapse\n * back to a single {@link hashForLookup} value.\n */\nexport function lookupHashCandidates(value: string, context?: string): string[] {\n const primary = hashForLookup(value, context)\n const legacy = legacyHashForLookup(value)\n return primary === legacy ? [primary] : [primary, legacy]\n}\n\n/**\n * Strict variant of decryptWithAesGcm that throws typed TenantDataEncryptionError.\n * - Format mismatch (not iv:ct:tag:v1): throws AUTH_FAILED (treat as plaintext).\n * - Valid format but invalid buffer sizes (bad base64): throws MALFORMED_PAYLOAD.\n * - AES-GCM auth tag failure: throws AUTH_FAILED.\n * - Unexpected crypto error: throws DECRYPT_INTERNAL.\n */\nexport function decryptWithAesGcmStrict(payload: string, dekBase64: string): string {\n const parts = payload.split(':')\n if (parts.length !== 4 || parts[3] !== 'v1') {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.AUTH_FAILED,\n 'Value is not an encrypted payload (format mismatch)',\n )\n }\n const [ivB64, ciphertextB64, tagB64] = parts as [string, string, string, string]\n let dek: Buffer, iv: Buffer, ciphertext: Buffer, tag: Buffer\n try {\n dek = Buffer.from(dekBase64, 'base64')\n iv = Buffer.from(ivB64, 'base64')\n ciphertext = Buffer.from(ciphertextB64, 'base64')\n tag = Buffer.from(tagB64, 'base64')\n } catch {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.MALFORMED_PAYLOAD,\n 'Failed to decode base64 components',\n )\n }\n if (iv.length !== 12 || tag.length !== 16 || ciphertext.length === 0) {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.MALFORMED_PAYLOAD,\n 'Invalid AES-GCM payload: unexpected IV, tag, or ciphertext size',\n )\n }\n try {\n return runAesGcmDecrypt(dek, iv, ciphertext, tag)\n } catch {\n throw new TenantDataEncryptionError(\n TenantDataEncryptionErrorCode.AUTH_FAILED,\n 'AES-GCM authentication tag verification failed',\n )\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["TenantDataEncryptionErrorCode"]
|
|
7
7
|
}
|
|
@@ -330,6 +330,10 @@ function logDerivedKeyFallbackBanner(opts) {
|
|
|
330
330
|
secretFingerprint: fingerprintSecret(opts.secret)
|
|
331
331
|
});
|
|
332
332
|
}
|
|
333
|
+
function resolveEncryptionMode(kms) {
|
|
334
|
+
if (!isTenantDataEncryptionEnabled()) return "disabled";
|
|
335
|
+
return kms.isHealthy() ? "active" : "unavailable";
|
|
336
|
+
}
|
|
333
337
|
function createKmsService() {
|
|
334
338
|
if (!isTenantDataEncryptionEnabled()) return new NoopKmsService();
|
|
335
339
|
const primary = new HashicorpVaultKmsService();
|
|
@@ -356,6 +360,7 @@ export {
|
|
|
356
360
|
NoopKmsService,
|
|
357
361
|
buildDerivedKeyFallbackBannerLines,
|
|
358
362
|
createKmsService,
|
|
359
|
-
hashForLookup
|
|
363
|
+
hashForLookup,
|
|
364
|
+
resolveEncryptionMode
|
|
360
365
|
};
|
|
361
366
|
//# sourceMappingURL=kms.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/encryption/kms.ts"],
|
|
4
|
-
"sourcesContent": ["import crypto from 'node:crypto'\nimport { generateDek, hashForLookup } from './aes'\nimport { isEncryptionDebugEnabled, isTenantDataEncryptionEnabled } from './toggles'\nimport { parseBooleanToken } from '../boolean'\nimport { createLogger } from '../logger'\nimport { fetchWithTimeout, resolveTimeoutMs } from '../http/fetchWithTimeout'\n\nconst logger = createLogger('shared').child({ component: 'kms' })\n\nconst DEFAULT_VAULT_REQUEST_TIMEOUT_MS = 1_000\nconst DEFAULT_VAULT_RECOVERY_COOLDOWN_MS = 30_000\n\nfunction resolveVaultRequestTimeoutMs(): number {\n const raw = process.env.VAULT_REQUEST_TIMEOUT_MS\n const parsed = raw ? Number.parseInt(raw, 10) : undefined\n return resolveTimeoutMs(parsed, DEFAULT_VAULT_REQUEST_TIMEOUT_MS)\n}\n\nfunction resolveVaultRecoveryCooldownMs(): number {\n const raw = process.env.VAULT_RECOVERY_COOLDOWN_MS\n const parsed = raw ? Number.parseInt(raw, 10) : undefined\n return resolveTimeoutMs(parsed, DEFAULT_VAULT_RECOVERY_COOLDOWN_MS)\n}\n\nexport type TenantDek = {\n tenantId: string\n key: string // base64\n fetchedAt: number\n}\n\nexport interface KmsService {\n getTenantDek(tenantId: string): Promise<TenantDek | null>\n createTenantDek(tenantId: string): Promise<TenantDek | null>\n isHealthy(): boolean\n invalidateDek?(tenantId: string): void\n}\n\nclass FallbackKmsService implements KmsService {\n private notified = false\n constructor(\n private readonly primary: KmsService,\n private readonly fallback: KmsService | null,\n private readonly onFallback?: () => void,\n ) {}\n\n isHealthy(): boolean {\n return this.primary.isHealthy() || Boolean(this.fallback?.isHealthy?.())\n }\n\n private notifyFallback() {\n if (this.notified) return\n this.notified = true\n this.onFallback?.()\n }\n\n private async fromPrimary<T>(op: () => Promise<T | null>): Promise<T | null> {\n try {\n return await op()\n } catch (err) {\n logger.warn('Primary KMS failed, will try fallback', { err })\n return null\n }\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (this.primary.isHealthy()) {\n const dek = await this.fromPrimary(() => this.primary.getTenantDek(tenantId))\n if (dek) return dek\n }\n if (this.fallback?.isHealthy()) {\n this.notifyFallback()\n return this.fallback.getTenantDek(tenantId)\n }\n return null\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (this.primary.isHealthy()) {\n const dek = await this.fromPrimary(() => this.primary.createTenantDek(tenantId))\n if (dek) return dek\n }\n if (this.fallback?.isHealthy()) {\n this.notifyFallback()\n return this.fallback.createTenantDek(tenantId)\n }\n return null\n }\n\n invalidateDek(tenantId: string): void {\n this.primary.invalidateDek?.(tenantId)\n this.fallback?.invalidateDek?.(tenantId)\n }\n}\n\ntype VaultClientOpts = {\n vaultAddr?: string\n vaultToken?: string\n mountPath?: string\n ttlMs?: number\n requestTimeoutMs?: number\n recoveryCooldownMs?: number\n}\n\ntype VaultReadResponse = {\n data?: { data?: { key?: string; version?: number }; metadata?: Record<string, unknown> }\n}\n\n// 'conflict' = a check-and-set write lost to a concurrent writer (normal race\n// outcome, Vault still healthy); 'error' = the write genuinely failed.\ntype VaultWriteOutcome = 'ok' | 'conflict' | 'error'\n\nfunction normalizeEnv(value: string | undefined): string {\n if (!value) return ''\n return value.trim().replace(/(?:^['\"]|['\"]$)/g, '')\n}\n\ntype DerivedSecret = { secret: string; source: 'explicit' | 'dev-default'; envName: string }\n\nfunction resolveDerivedKeySecret(): DerivedSecret | null {\n const candidates: Array<{ value: string | null; envName: string }> = [\n { value: process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY ?? null, envName: 'TENANT_DATA_ENCRYPTION_FALLBACK_KEY' },\n { value: process.env.TENANT_DATA_ENCRYPTION_KEY ?? null, envName: 'TENANT_DATA_ENCRYPTION_KEY' },\n ]\n for (const raw of candidates) {\n const normalized = normalizeEnv(raw.value ?? undefined)\n if (normalized) return { secret: normalized, source: 'explicit', envName: raw.envName }\n }\n if (\n process.env.NODE_ENV !== 'production'\n && parseBooleanToken(process.env.ALLOW_DERIVED_KMS_FALLBACK) === true\n ) {\n return { secret: 'om-dev-tenant-encryption', source: 'dev-default', envName: 'DEV_DEFAULT' }\n }\n return null\n}\n\nexport class NoopKmsService implements KmsService {\n isHealthy(): boolean { return !isTenantDataEncryptionEnabled() }\n async getTenantDek(): Promise<TenantDek | null> { return null }\n async createTenantDek(): Promise<TenantDek | null> { return null }\n}\n\nclass DerivedKmsService implements KmsService {\n private root: Buffer\n constructor(secret: string) {\n // Derive a stable root key from the provided secret so derived tenant keys are deterministic\n this.root = crypto.createHash('sha256').update(secret).digest()\n }\n\n isHealthy(): boolean {\n return true\n }\n\n private deriveKey(tenantId: string): string {\n const iterations = 310_000\n const keyLength = 32\n const derived = crypto.pbkdf2Sync(this.root, tenantId, iterations, keyLength, 'sha512')\n return derived.toString('base64')\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (!tenantId) return null\n return { tenantId, key: this.deriveKey(tenantId), fetchedAt: Date.now() }\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n return this.getTenantDek(tenantId)\n }\n}\n\nexport class HashicorpVaultKmsService implements KmsService {\n private cache = new Map<string, TenantDek>()\n private readonly vaultAddr: string\n private readonly vaultToken: string\n private readonly mountPath: string\n private readonly ttlMs: number\n private readonly requestTimeoutMs: number\n private readonly recoveryCooldownMs: number\n private healthy = true\n // Sticky terminal failure (missing VAULT_ADDR/VAULT_TOKEN): no amount of\n // re-probing fixes a misconfiguration, so this never self-heals \u2014 only a\n // restart with corrected config does.\n private misconfigured = false\n // Timestamp of the last transient failure (timeout / network blip / 5xx).\n // Drives the half-open circuit breaker in isHealthy(): after the cooldown the\n // instance reports healthy again so the next call re-probes Vault.\n private lastTransientFailureAt: number | null = null\n private readonly debugEnabled: boolean\n private static loggedInit = false\n\n constructor(opts: VaultClientOpts = {}) {\n this.vaultAddr = normalizeEnv(opts.vaultAddr || process.env.VAULT_ADDR || '')\n this.vaultToken = normalizeEnv(opts.vaultToken || process.env.VAULT_TOKEN || '')\n this.mountPath = (opts.mountPath || process.env.VAULT_KV_PATH || 'secret/data').replace(/\\/+$/, '')\n this.ttlMs = opts.ttlMs ?? 15 * 60 * 1000\n this.requestTimeoutMs = resolveTimeoutMs(opts.requestTimeoutMs, resolveVaultRequestTimeoutMs())\n this.recoveryCooldownMs = resolveTimeoutMs(opts.recoveryCooldownMs, resolveVaultRecoveryCooldownMs())\n this.debugEnabled = isEncryptionDebugEnabled()\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n this.misconfigured = true\n if (this.debugEnabled) {\n logger.warn('Vault misconfigured (missing VAULT_ADDR or VAULT_TOKEN)')\n }\n }\n if (this.healthy && !HashicorpVaultKmsService.loggedInit && this.debugEnabled) {\n HashicorpVaultKmsService.loggedInit = true\n if (this.debugEnabled) {\n logger.info('Hashicorp Vault KMS enabled')\n }\n }\n }\n\n isHealthy(): boolean {\n // A missing-config failure is terminal \u2014 never report healthy again.\n if (this.misconfigured) return false\n if (this.healthy) return true\n // Half-open circuit breaker: once the cooldown since the last transient\n // failure has elapsed, report healthy so the next read/write re-probes\n // Vault. A successful probe flips `healthy` back on; a failing one records a\n // fresh failure timestamp and re-opens the breaker for another cooldown.\n if (this.lastTransientFailureAt === null) return false\n return this.now() - this.lastTransientFailureAt >= this.recoveryCooldownMs\n }\n\n private now(): number {\n return Date.now()\n }\n\n // Vault responded successfully (or is provably reachable): close the breaker.\n private markHealthy(): void {\n this.healthy = true\n this.lastTransientFailureAt = null\n }\n\n // Transient infra failure (timeout / network blip / 5xx): open the breaker and\n // start the recovery cooldown so a later call can re-probe and self-heal.\n private markTransientFailure(): void {\n this.healthy = false\n this.lastTransientFailureAt = this.now()\n }\n\n private cacheHit(tenantId: string): TenantDek | null {\n const entry = this.cache.get(tenantId)\n if (!entry) return null\n if (this.now() - entry.fetchedAt > this.ttlMs) {\n this.cache.delete(tenantId)\n return null\n }\n return entry\n }\n\n private async readVault(path: string): Promise<VaultReadResponse | null> {\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n this.misconfigured = true\n return null\n }\n try {\n const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {\n method: 'GET',\n headers: { 'X-Vault-Token': this.vaultToken },\n timeoutMs: this.requestTimeoutMs,\n })\n if (!res.ok) {\n // 5xx = Vault down/erroring (transient). <500 (auth/not-found/etc.) means\n // Vault is reachable and answered, so keep it healthy \u2014 a 404 for a\n // not-yet-created tenant DEK is the normal read-before-write path.\n if (res.status >= 500) this.markTransientFailure()\n else this.markHealthy()\n logger.warn('Vault read failed', { path, status: res.status })\n return null\n }\n this.markHealthy()\n if (this.debugEnabled) {\n logger.info('Vault read ok', { path })\n }\n return (await res.json()) as VaultReadResponse\n } catch (err) {\n this.markTransientFailure()\n logger.warn('Vault read error', { path, err, timeoutMs: this.requestTimeoutMs })\n return null\n }\n }\n\n private async writeVault(path: string, key: string, opts?: { cas?: number }): Promise<VaultWriteOutcome> {\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n this.misconfigured = true\n return 'error'\n }\n const body: { data: { key: string }; options?: { cas: number } } = { data: { key } }\n if (typeof opts?.cas === 'number') body.options = { cas: opts.cas }\n try {\n const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {\n method: 'POST',\n headers: {\n 'X-Vault-Token': this.vaultToken,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n timeoutMs: this.requestTimeoutMs,\n })\n if (res.ok) {\n this.markHealthy()\n return 'ok'\n }\n // KV v2 returns 400 when a check-and-set write loses to a concurrent\n // writer (path already at a newer version). That is a normal race outcome,\n // not an unhealthy Vault \u2014 Vault is reachable, so close the breaker.\n if (typeof opts?.cas === 'number' && res.status === 400) {\n this.markHealthy()\n logger.warn('Vault write CAS conflict (concurrent DEK create)', { path, status: res.status })\n return 'conflict'\n }\n this.markTransientFailure()\n logger.warn('Vault write failed', { path, status: res.status })\n return 'error'\n } catch (err) {\n this.markTransientFailure()\n logger.warn('Vault write error', { path, err, timeoutMs: this.requestTimeoutMs })\n return 'error'\n }\n }\n\n private buildKeyPath(tenantId: string): string {\n const suffix = `tenant_key_${tenantId}`\n const normalizedMount = this.mountPath.replace(/^\\/+/, '')\n return `${normalizedMount}/${suffix}`\n }\n\n private remember(entry: TenantDek): TenantDek {\n this.cache.set(entry.tenantId, entry)\n return entry\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n const cached = this.cacheHit(tenantId)\n if (cached) return cached\n const path = this.buildKeyPath(tenantId)\n const res = await this.readVault(path)\n const key = res?.data?.data?.key\n if (!key) {\n logger.warn('No tenant DEK found in Vault', { tenantId, path })\n return null\n }\n const dek: TenantDek = { tenantId, key, fetchedAt: this.now() }\n return this.remember(dek)\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n const path = this.buildKeyPath(tenantId)\n // Read-before-write: if a DEK already exists for this tenant (another request\n // or process created it first), adopt it instead of overwriting the active\n // key \u2014 overwriting orphans every row already encrypted under it (#2746).\n const existing = await this.readVault(path)\n const existingKey = existing?.data?.data?.key\n if (existingKey) {\n return this.remember({ tenantId, key: existingKey, fetchedAt: this.now() })\n }\n // A read failure (timeout / 5xx) flips `healthy` off; don't blind-write a new\n // key over a possibly-existing one we just couldn't read \u2014 let the caller fall back.\n if (!this.healthy) return null\n const key = generateDek()\n const outcome = await this.writeVault(path, key, { cas: 0 })\n if (outcome === 'ok') {\n logger.info('Stored tenant DEK in Vault', { tenantId, path })\n return this.remember({ tenantId, key, fetchedAt: this.now() })\n }\n if (outcome === 'conflict') {\n // A concurrent create won the CAS race \u2014 adopt the winner's key so both\n // callers encrypt under the same DEK.\n const winner = await this.readVault(path)\n const winnerKey = winner?.data?.data?.key\n if (winnerKey) {\n logger.info('Adopted concurrently-created tenant DEK', { tenantId, path })\n return this.remember({ tenantId, key: winnerKey, fetchedAt: this.now() })\n }\n }\n logger.warn('Failed to store tenant DEK in Vault', { tenantId, path })\n return null\n }\n\n invalidateDek(tenantId: string): void {\n this.cache.delete(tenantId)\n }\n}\n\nlet loggedDerivedKeyFallbackBanner = false\n\nfunction fingerprintSecret(secret: string): string {\n return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)\n}\n\nexport function buildDerivedKeyFallbackBannerLines(opts: DerivedSecret): string[] {\n const sourceLine =\n opts.source === 'explicit' ? `Source: ${opts.envName}` : 'Source: dev default secret (do NOT use in production)'\n return [\n '\uD83D\uDEA8 Using derived tenant encryption keys (Vault unavailable / no DEK)',\n sourceLine,\n `Secret fingerprint (sha256, truncated): ${fingerprintSecret(opts.secret)}`,\n 'Persist this secret securely. Without it, encrypted tenant data cannot be recovered after restart.',\n ]\n}\n\nfunction logDerivedKeyFallbackBanner(opts: DerivedSecret): void {\n if (process.env.NODE_ENV === 'test' || loggedDerivedKeyFallbackBanner) return\n loggedDerivedKeyFallbackBanner = true\n const redBg = '\\x1b[41m'\n const white = '\\x1b[97m'\n const reset = '\\x1b[0m'\n const width = 110\n const border = `${redBg}${white}${'\u2501'.repeat(width)}${reset}`\n const body = buildDerivedKeyFallbackBannerLines(opts)\n const bannerLines = [\n border,\n ...body.map((line) => `${redBg}${white} ${line.padEnd(width - 2, ' ')} ${reset}`),\n border,\n ]\n process.stderr.write(bannerLines.join('\\n') + '\\n')\n logger.warn('Using derived tenant encryption keys (Vault unavailable / no DEK)', {\n secretFingerprint: fingerprintSecret(opts.secret),\n })\n}\n\nexport function createKmsService(): KmsService {\n if (!isTenantDataEncryptionEnabled()) return new NoopKmsService()\n const primary = new HashicorpVaultKmsService()\n\n const derived = resolveDerivedKeySecret()\n const fallback = derived ? new DerivedKmsService(derived.secret) : null\n const notifyFallback = derived\n ? () => {\n logDerivedKeyFallbackBanner(derived)\n }\n : undefined\n\n if (!primary.isHealthy()) {\n if (fallback) {\n notifyFallback?.()\n return fallback\n }\n logger.warn('Vault not healthy or misconfigured (missing VAULT_ADDR/VAULT_TOKEN) and no fallback secret provided; falling back to noop KMS')\n return new NoopKmsService()\n }\n\n if (fallback) {\n return new FallbackKmsService(primary, fallback, notifyFallback)\n }\n\n return primary\n}\n\nexport { hashForLookup }\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import crypto from 'node:crypto'\nimport { generateDek, hashForLookup } from './aes'\nimport { isEncryptionDebugEnabled, isTenantDataEncryptionEnabled } from './toggles'\nimport { parseBooleanToken } from '../boolean'\nimport { createLogger } from '../logger'\nimport { fetchWithTimeout, resolveTimeoutMs } from '../http/fetchWithTimeout'\n\nconst logger = createLogger('shared').child({ component: 'kms' })\n\nconst DEFAULT_VAULT_REQUEST_TIMEOUT_MS = 1_000\nconst DEFAULT_VAULT_RECOVERY_COOLDOWN_MS = 30_000\n\nfunction resolveVaultRequestTimeoutMs(): number {\n const raw = process.env.VAULT_REQUEST_TIMEOUT_MS\n const parsed = raw ? Number.parseInt(raw, 10) : undefined\n return resolveTimeoutMs(parsed, DEFAULT_VAULT_REQUEST_TIMEOUT_MS)\n}\n\nfunction resolveVaultRecoveryCooldownMs(): number {\n const raw = process.env.VAULT_RECOVERY_COOLDOWN_MS\n const parsed = raw ? Number.parseInt(raw, 10) : undefined\n return resolveTimeoutMs(parsed, DEFAULT_VAULT_RECOVERY_COOLDOWN_MS)\n}\n\nexport type TenantDek = {\n tenantId: string\n key: string // base64\n fetchedAt: number\n}\n\nexport interface KmsService {\n getTenantDek(tenantId: string): Promise<TenantDek | null>\n createTenantDek(tenantId: string): Promise<TenantDek | null>\n isHealthy(): boolean\n invalidateDek?(tenantId: string): void\n}\n\nclass FallbackKmsService implements KmsService {\n private notified = false\n constructor(\n private readonly primary: KmsService,\n private readonly fallback: KmsService | null,\n private readonly onFallback?: () => void,\n ) {}\n\n isHealthy(): boolean {\n return this.primary.isHealthy() || Boolean(this.fallback?.isHealthy?.())\n }\n\n private notifyFallback() {\n if (this.notified) return\n this.notified = true\n this.onFallback?.()\n }\n\n private async fromPrimary<T>(op: () => Promise<T | null>): Promise<T | null> {\n try {\n return await op()\n } catch (err) {\n logger.warn('Primary KMS failed, will try fallback', { err })\n return null\n }\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (this.primary.isHealthy()) {\n const dek = await this.fromPrimary(() => this.primary.getTenantDek(tenantId))\n if (dek) return dek\n }\n if (this.fallback?.isHealthy()) {\n this.notifyFallback()\n return this.fallback.getTenantDek(tenantId)\n }\n return null\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (this.primary.isHealthy()) {\n const dek = await this.fromPrimary(() => this.primary.createTenantDek(tenantId))\n if (dek) return dek\n }\n if (this.fallback?.isHealthy()) {\n this.notifyFallback()\n return this.fallback.createTenantDek(tenantId)\n }\n return null\n }\n\n invalidateDek(tenantId: string): void {\n this.primary.invalidateDek?.(tenantId)\n this.fallback?.invalidateDek?.(tenantId)\n }\n}\n\ntype VaultClientOpts = {\n vaultAddr?: string\n vaultToken?: string\n mountPath?: string\n ttlMs?: number\n requestTimeoutMs?: number\n recoveryCooldownMs?: number\n}\n\ntype VaultReadResponse = {\n data?: { data?: { key?: string; version?: number }; metadata?: Record<string, unknown> }\n}\n\n// 'conflict' = a check-and-set write lost to a concurrent writer (normal race\n// outcome, Vault still healthy); 'error' = the write genuinely failed.\ntype VaultWriteOutcome = 'ok' | 'conflict' | 'error'\n\nfunction normalizeEnv(value: string | undefined): string {\n if (!value) return ''\n return value.trim().replace(/(?:^['\"]|['\"]$)/g, '')\n}\n\ntype DerivedSecret = { secret: string; source: 'explicit' | 'dev-default'; envName: string }\n\nfunction resolveDerivedKeySecret(): DerivedSecret | null {\n const candidates: Array<{ value: string | null; envName: string }> = [\n { value: process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY ?? null, envName: 'TENANT_DATA_ENCRYPTION_FALLBACK_KEY' },\n { value: process.env.TENANT_DATA_ENCRYPTION_KEY ?? null, envName: 'TENANT_DATA_ENCRYPTION_KEY' },\n ]\n for (const raw of candidates) {\n const normalized = normalizeEnv(raw.value ?? undefined)\n if (normalized) return { secret: normalized, source: 'explicit', envName: raw.envName }\n }\n if (\n process.env.NODE_ENV !== 'production'\n && parseBooleanToken(process.env.ALLOW_DERIVED_KMS_FALLBACK) === true\n ) {\n return { secret: 'om-dev-tenant-encryption', source: 'dev-default', envName: 'DEV_DEFAULT' }\n }\n return null\n}\n\nexport class NoopKmsService implements KmsService {\n isHealthy(): boolean { return !isTenantDataEncryptionEnabled() }\n async getTenantDek(): Promise<TenantDek | null> { return null }\n async createTenantDek(): Promise<TenantDek | null> { return null }\n}\n\nclass DerivedKmsService implements KmsService {\n private root: Buffer\n constructor(secret: string) {\n // Derive a stable root key from the provided secret so derived tenant keys are deterministic\n this.root = crypto.createHash('sha256').update(secret).digest()\n }\n\n isHealthy(): boolean {\n return true\n }\n\n private deriveKey(tenantId: string): string {\n const iterations = 310_000\n const keyLength = 32\n const derived = crypto.pbkdf2Sync(this.root, tenantId, iterations, keyLength, 'sha512')\n return derived.toString('base64')\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n if (!tenantId) return null\n return { tenantId, key: this.deriveKey(tenantId), fetchedAt: Date.now() }\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n return this.getTenantDek(tenantId)\n }\n}\n\nexport class HashicorpVaultKmsService implements KmsService {\n private cache = new Map<string, TenantDek>()\n private readonly vaultAddr: string\n private readonly vaultToken: string\n private readonly mountPath: string\n private readonly ttlMs: number\n private readonly requestTimeoutMs: number\n private readonly recoveryCooldownMs: number\n private healthy = true\n // Sticky terminal failure (missing VAULT_ADDR/VAULT_TOKEN): no amount of\n // re-probing fixes a misconfiguration, so this never self-heals \u2014 only a\n // restart with corrected config does.\n private misconfigured = false\n // Timestamp of the last transient failure (timeout / network blip / 5xx).\n // Drives the half-open circuit breaker in isHealthy(): after the cooldown the\n // instance reports healthy again so the next call re-probes Vault.\n private lastTransientFailureAt: number | null = null\n private readonly debugEnabled: boolean\n private static loggedInit = false\n\n constructor(opts: VaultClientOpts = {}) {\n this.vaultAddr = normalizeEnv(opts.vaultAddr || process.env.VAULT_ADDR || '')\n this.vaultToken = normalizeEnv(opts.vaultToken || process.env.VAULT_TOKEN || '')\n this.mountPath = (opts.mountPath || process.env.VAULT_KV_PATH || 'secret/data').replace(/\\/+$/, '')\n this.ttlMs = opts.ttlMs ?? 15 * 60 * 1000\n this.requestTimeoutMs = resolveTimeoutMs(opts.requestTimeoutMs, resolveVaultRequestTimeoutMs())\n this.recoveryCooldownMs = resolveTimeoutMs(opts.recoveryCooldownMs, resolveVaultRecoveryCooldownMs())\n this.debugEnabled = isEncryptionDebugEnabled()\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n this.misconfigured = true\n if (this.debugEnabled) {\n logger.warn('Vault misconfigured (missing VAULT_ADDR or VAULT_TOKEN)')\n }\n }\n if (this.healthy && !HashicorpVaultKmsService.loggedInit && this.debugEnabled) {\n HashicorpVaultKmsService.loggedInit = true\n if (this.debugEnabled) {\n logger.info('Hashicorp Vault KMS enabled')\n }\n }\n }\n\n isHealthy(): boolean {\n // A missing-config failure is terminal \u2014 never report healthy again.\n if (this.misconfigured) return false\n if (this.healthy) return true\n // Half-open circuit breaker: once the cooldown since the last transient\n // failure has elapsed, report healthy so the next read/write re-probes\n // Vault. A successful probe flips `healthy` back on; a failing one records a\n // fresh failure timestamp and re-opens the breaker for another cooldown.\n if (this.lastTransientFailureAt === null) return false\n return this.now() - this.lastTransientFailureAt >= this.recoveryCooldownMs\n }\n\n private now(): number {\n return Date.now()\n }\n\n // Vault responded successfully (or is provably reachable): close the breaker.\n private markHealthy(): void {\n this.healthy = true\n this.lastTransientFailureAt = null\n }\n\n // Transient infra failure (timeout / network blip / 5xx): open the breaker and\n // start the recovery cooldown so a later call can re-probe and self-heal.\n private markTransientFailure(): void {\n this.healthy = false\n this.lastTransientFailureAt = this.now()\n }\n\n private cacheHit(tenantId: string): TenantDek | null {\n const entry = this.cache.get(tenantId)\n if (!entry) return null\n if (this.now() - entry.fetchedAt > this.ttlMs) {\n this.cache.delete(tenantId)\n return null\n }\n return entry\n }\n\n private async readVault(path: string): Promise<VaultReadResponse | null> {\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n this.misconfigured = true\n return null\n }\n try {\n const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {\n method: 'GET',\n headers: { 'X-Vault-Token': this.vaultToken },\n timeoutMs: this.requestTimeoutMs,\n })\n if (!res.ok) {\n // 5xx = Vault down/erroring (transient). <500 (auth/not-found/etc.) means\n // Vault is reachable and answered, so keep it healthy \u2014 a 404 for a\n // not-yet-created tenant DEK is the normal read-before-write path.\n if (res.status >= 500) this.markTransientFailure()\n else this.markHealthy()\n logger.warn('Vault read failed', { path, status: res.status })\n return null\n }\n this.markHealthy()\n if (this.debugEnabled) {\n logger.info('Vault read ok', { path })\n }\n return (await res.json()) as VaultReadResponse\n } catch (err) {\n this.markTransientFailure()\n logger.warn('Vault read error', { path, err, timeoutMs: this.requestTimeoutMs })\n return null\n }\n }\n\n private async writeVault(path: string, key: string, opts?: { cas?: number }): Promise<VaultWriteOutcome> {\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n this.misconfigured = true\n return 'error'\n }\n const body: { data: { key: string }; options?: { cas: number } } = { data: { key } }\n if (typeof opts?.cas === 'number') body.options = { cas: opts.cas }\n try {\n const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {\n method: 'POST',\n headers: {\n 'X-Vault-Token': this.vaultToken,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n timeoutMs: this.requestTimeoutMs,\n })\n if (res.ok) {\n this.markHealthy()\n return 'ok'\n }\n // KV v2 returns 400 when a check-and-set write loses to a concurrent\n // writer (path already at a newer version). That is a normal race outcome,\n // not an unhealthy Vault \u2014 Vault is reachable, so close the breaker.\n if (typeof opts?.cas === 'number' && res.status === 400) {\n this.markHealthy()\n logger.warn('Vault write CAS conflict (concurrent DEK create)', { path, status: res.status })\n return 'conflict'\n }\n this.markTransientFailure()\n logger.warn('Vault write failed', { path, status: res.status })\n return 'error'\n } catch (err) {\n this.markTransientFailure()\n logger.warn('Vault write error', { path, err, timeoutMs: this.requestTimeoutMs })\n return 'error'\n }\n }\n\n private buildKeyPath(tenantId: string): string {\n const suffix = `tenant_key_${tenantId}`\n const normalizedMount = this.mountPath.replace(/^\\/+/, '')\n return `${normalizedMount}/${suffix}`\n }\n\n private remember(entry: TenantDek): TenantDek {\n this.cache.set(entry.tenantId, entry)\n return entry\n }\n\n async getTenantDek(tenantId: string): Promise<TenantDek | null> {\n const cached = this.cacheHit(tenantId)\n if (cached) return cached\n const path = this.buildKeyPath(tenantId)\n const res = await this.readVault(path)\n const key = res?.data?.data?.key\n if (!key) {\n logger.warn('No tenant DEK found in Vault', { tenantId, path })\n return null\n }\n const dek: TenantDek = { tenantId, key, fetchedAt: this.now() }\n return this.remember(dek)\n }\n\n async createTenantDek(tenantId: string): Promise<TenantDek | null> {\n const path = this.buildKeyPath(tenantId)\n // Read-before-write: if a DEK already exists for this tenant (another request\n // or process created it first), adopt it instead of overwriting the active\n // key \u2014 overwriting orphans every row already encrypted under it (#2746).\n const existing = await this.readVault(path)\n const existingKey = existing?.data?.data?.key\n if (existingKey) {\n return this.remember({ tenantId, key: existingKey, fetchedAt: this.now() })\n }\n // A read failure (timeout / 5xx) flips `healthy` off; don't blind-write a new\n // key over a possibly-existing one we just couldn't read \u2014 let the caller fall back.\n if (!this.healthy) return null\n const key = generateDek()\n const outcome = await this.writeVault(path, key, { cas: 0 })\n if (outcome === 'ok') {\n logger.info('Stored tenant DEK in Vault', { tenantId, path })\n return this.remember({ tenantId, key, fetchedAt: this.now() })\n }\n if (outcome === 'conflict') {\n // A concurrent create won the CAS race \u2014 adopt the winner's key so both\n // callers encrypt under the same DEK.\n const winner = await this.readVault(path)\n const winnerKey = winner?.data?.data?.key\n if (winnerKey) {\n logger.info('Adopted concurrently-created tenant DEK', { tenantId, path })\n return this.remember({ tenantId, key: winnerKey, fetchedAt: this.now() })\n }\n }\n logger.warn('Failed to store tenant DEK in Vault', { tenantId, path })\n return null\n }\n\n invalidateDek(tenantId: string): void {\n this.cache.delete(tenantId)\n }\n}\n\nlet loggedDerivedKeyFallbackBanner = false\n\nfunction fingerprintSecret(secret: string): string {\n return crypto.createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16)\n}\n\nexport function buildDerivedKeyFallbackBannerLines(opts: DerivedSecret): string[] {\n const sourceLine =\n opts.source === 'explicit' ? `Source: ${opts.envName}` : 'Source: dev default secret (do NOT use in production)'\n return [\n '\uD83D\uDEA8 Using derived tenant encryption keys (Vault unavailable / no DEK)',\n sourceLine,\n `Secret fingerprint (sha256, truncated): ${fingerprintSecret(opts.secret)}`,\n 'Persist this secret securely. Without it, encrypted tenant data cannot be recovered after restart.',\n ]\n}\n\nfunction logDerivedKeyFallbackBanner(opts: DerivedSecret): void {\n if (process.env.NODE_ENV === 'test' || loggedDerivedKeyFallbackBanner) return\n loggedDerivedKeyFallbackBanner = true\n const redBg = '\\x1b[41m'\n const white = '\\x1b[97m'\n const reset = '\\x1b[0m'\n const width = 110\n const border = `${redBg}${white}${'\u2501'.repeat(width)}${reset}`\n const body = buildDerivedKeyFallbackBannerLines(opts)\n const bannerLines = [\n border,\n ...body.map((line) => `${redBg}${white} ${line.padEnd(width - 2, ' ')} ${reset}`),\n border,\n ]\n process.stderr.write(bannerLines.join('\\n') + '\\n')\n logger.warn('Using derived tenant encryption keys (Vault unavailable / no DEK)', {\n secretFingerprint: fingerprintSecret(opts.secret),\n })\n}\n\n/**\n * What the runtime should do about tenant data encryption right now.\n *\n * `isHealthy()` alone cannot answer this: {@link NoopKmsService} reports healthy precisely when\n * encryption is switched OFF, so `enabled && healthy` collapses correctly but a bare\n * `if (!kms.isHealthy())` guard reads the two opposite situations as the same one. They call for\n * opposite handling, so name them:\n *\n * - `disabled` \u2014 the operator set `TENANT_DATA_ENCRYPTION=no`. Plaintext is the intended\n * outcome; degrade to it rather than failing.\n * - `active` \u2014 encryption is on and a DEK is reachable. Encrypt.\n * - `unavailable` \u2014 encryption is on but no DEK is reachable (Vault down, no fallback secret).\n * Data that is meant to be ciphertext MUST NOT be written as plaintext; callers\n * holding secrets fail closed here (spec 2026-05-29, security finding #7).\n */\nexport type TenantDataEncryptionMode = 'disabled' | 'active' | 'unavailable'\n\nexport function resolveEncryptionMode(kms: Pick<KmsService, 'isHealthy'>): TenantDataEncryptionMode {\n if (!isTenantDataEncryptionEnabled()) return 'disabled'\n return kms.isHealthy() ? 'active' : 'unavailable'\n}\n\nexport function createKmsService(): KmsService {\n if (!isTenantDataEncryptionEnabled()) return new NoopKmsService()\n const primary = new HashicorpVaultKmsService()\n\n const derived = resolveDerivedKeySecret()\n const fallback = derived ? new DerivedKmsService(derived.secret) : null\n const notifyFallback = derived\n ? () => {\n logDerivedKeyFallbackBanner(derived)\n }\n : undefined\n\n if (!primary.isHealthy()) {\n if (fallback) {\n notifyFallback?.()\n return fallback\n }\n logger.warn('Vault not healthy or misconfigured (missing VAULT_ADDR/VAULT_TOKEN) and no fallback secret provided; falling back to noop KMS')\n return new NoopKmsService()\n }\n\n if (fallback) {\n return new FallbackKmsService(primary, fallback, notifyFallback)\n }\n\n return primary\n}\n\nexport { hashForLookup }\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const ERROR_CODE_SHAPE = /^[a-z0-9_]+\.[a-z0-9_]+$/;
|
|
2
|
+
function groupableCode(value, fallback) {
|
|
3
|
+
const code = typeof value === "string" ? value.trim() : "";
|
|
4
|
+
return ERROR_CODE_SHAPE.test(code) ? code : fallback;
|
|
5
|
+
}
|
|
6
|
+
export {
|
|
7
|
+
ERROR_CODE_SHAPE,
|
|
8
|
+
groupableCode
|
|
9
|
+
};
|
|
10
|
+
//# sourceMappingURL=error-code.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/telemetry/error-code.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * The `module.reason` shape a failure fingerprint must have to be used as a\n * metric label (`data_sync.item_failed`, `queue.job_failed`).\n *\n * Enforced rather than documented because a `code` frequently originates outside\n * the framework \u2014 an adapter's `data.errorCode`, a third-party module's\n * `integrationLogService.write({ code })`. An interpolated `` `http_${status}_${url}` ``\n * would open one `om.errors` series per URL, and metric labels \u2014 unlike\n * attributes \u2014 never pass through redaction, so an interpolated customer email\n * would egress unredacted.\n */\nexport const ERROR_CODE_SHAPE = /^[a-z0-9_]+\\.[a-z0-9_]+$/\n\n/**\n * Narrow an untrusted value to a usable fingerprint, or to `fallback`.\n *\n * The fallback is a real code rather than `unknown` wherever a caller has one, so\n * grouping still works for a writer that supplies nothing or supplies rubbish.\n */\nexport function groupableCode(value: unknown, fallback: string): string\nexport function groupableCode(value: unknown, fallback?: undefined): string | undefined\nexport function groupableCode(value: unknown, fallback?: string): string | undefined {\n const code = typeof value === 'string' ? value.trim() : ''\n return ERROR_CODE_SHAPE.test(code) ? code : fallback\n}\n"],
|
|
5
|
+
"mappings": "AAWO,MAAM,mBAAmB;AAUzB,SAAS,cAAc,OAAgB,UAAuC;AACnF,QAAM,OAAO,OAAO,UAAU,WAAW,MAAM,KAAK,IAAI;AACxD,SAAO,iBAAiB,KAAK,IAAI,IAAI,OAAO;AAC9C;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/telemetry/runtime.ts"],
|
|
4
|
-
"sourcesContent": ["export type TelemetryTraceCarrier = Record<string, string>\n\nexport type TelemetrySpanAttributes = Record<string, string | number | boolean | undefined>\n\nexport type TelemetrySpanKind = 'internal' | 'server' | 'client' | 'producer' | 'consumer'\n\n/** The subset of the telemetry package's `Span` that bridge consumers need. */\nexport type TelemetrySpan = {\n setAttributes(attributes: TelemetrySpanAttributes): void\n /**\n * Rename an in-flight span whose identity is only known once it has run.\n * Optional so a bootstrap predating it still satisfies the contract \u2014 call it\n * as `span.updateName?.(\u2026)`.\n */\n updateName?(name: string): void\n}\n\nexport type TelemetrySpanOptions = {\n kind?: TelemetrySpanKind\n attributes?: TelemetrySpanAttributes\n /** Start a new trace so the sampler decides for this span alone. */\n root?: boolean\n /** Causal links to other traces, as W3C carriers. */\n links?: TelemetryTraceCarrier[]\n}\n\nexport type TelemetryRuntime = {\n /**\n * True only when the active SDK may safely use the process-global W3C\n * propagator for cross-boundary extraction.\n */\n canUseGlobalTracePropagation(): boolean\n captureTraceContext(): TelemetryTraceCarrier\n continueTrace<T>(\n carrier: TelemetryTraceCarrier | undefined,\n name: string,\n fn: () => T,\n options?: { kind?: 'internal' | 'server' | 'client' | 'producer' | 'consumer' },\n ): T\n /**\n * Optional so an older bootstrap that predates span support still satisfies\n * the contract; consumers go through `withTelemetrySpan` and degrade to\n * running `fn` untraced.\n */\n withSpan?<T>(name: string, fn: (span: TelemetrySpan) => T, options?: TelemetrySpanOptions): T\n recordHttpDuration(method: string, route: string, status: number, startedAt: number): void\n reportError(\n error: unknown,\n context?: {\n module?: string\n attributes?: Record<string, string | number | boolean | undefined>\n },\n ): void\n shutdown(): Promise<void>\n}\n\nconst GLOBAL_KEY = Symbol.for('@open-mercato/shared.telemetryRuntime')\nconst ENABLED_BACKENDS = new Set(['console', 'signoz', 'newrelic', 'otlp'])\n\ntype TelemetryRuntimeStore = {\n active?: TelemetryRuntime\n}\n\nfunction store(): TelemetryRuntimeStore {\n const globalStore = globalThis as unknown as Record<symbol, TelemetryRuntimeStore | undefined>\n let current = globalStore[GLOBAL_KEY]\n if (!current) {\n current = {}\n globalStore[GLOBAL_KEY] = current\n }\n return current\n}\n\n/**\n * This check is intentionally owned by shared code so hosts can decide whether\n * to dynamically import the telemetry package without evaluating that package.\n */\nexport function isTelemetryBackendEnabled(raw?: string): boolean {\n const value = raw ?? (\n typeof process === 'undefined'\n ? undefined\n : process.env.TELEMETRY_BACKEND\n )\n return ENABLED_BACKENDS.has((value ?? '').trim().toLowerCase())\n}\n\nexport function registerTelemetryRuntime(runtime: TelemetryRuntime): () => void {\n store().active = runtime\n return () => {\n const current = store()\n if (current.active === runtime) current.active = undefined\n }\n}\n\nexport function getTelemetryRuntime(): TelemetryRuntime | undefined {\n return store().active\n}\n\n/** Test-only: clear the process-wide telemetry bridge. */\nexport function resetTelemetryRuntime(): void {\n store().active = undefined\n}\n\nconst NOOP_SPAN: TelemetrySpan = { setAttributes() {} }\n\n/**\n * Run `fn` inside a span, from a package that must not depend on\n * `@open-mercato/telemetry`. With telemetry off this is `fn` plus one global\n * lookup \u2014 no span object is allocated and the OTEL SDK is never reached.\n *\n * Pass `root: true` for the unit of work a long-lived job should be sampled and\n * rendered by (a batch, a page) so the job is not one trace under one sampling\n * decision, and `links` to keep the causal chain back to what triggered it.\n */\nexport function withTelemetrySpan<T>(\n name: string,\n fn: (span: TelemetrySpan) => T,\n options?: TelemetrySpanOptions,\n): T {\n const runtime = getTelemetryRuntime()\n if (!runtime?.withSpan) return fn(NOOP_SPAN)\n return runtime.withSpan(name, fn, options)\n}\n\n/**\n * The active trace as a carrier, for use as a `links` entry. `undefined` when\n * telemetry is off or nothing is active, which `withTelemetrySpan` treats as\n * \"no link\" rather than an invalid one.\n */\nexport function captureTelemetryTrace(): TelemetryTraceCarrier | undefined {\n const carrier = getTelemetryRuntime()?.captureTraceContext()\n return carrier && Object.keys(carrier).length > 0 ? carrier : undefined\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["export type TelemetryTraceCarrier = Record<string, string>\n\nexport type TelemetrySpanAttributes = Record<string, string | number | boolean | undefined>\n\nexport type TelemetrySpanKind = 'internal' | 'server' | 'client' | 'producer' | 'consumer'\n\n/** The subset of the telemetry package's `Span` that bridge consumers need. */\nexport type TelemetrySpan = {\n setAttributes(attributes: TelemetrySpanAttributes): void\n /**\n * Rename an in-flight span whose identity is only known once it has run.\n * Optional so a bootstrap predating it still satisfies the contract \u2014 call it\n * as `span.updateName?.(\u2026)`.\n */\n updateName?(name: string): void\n}\n\nexport type TelemetrySpanOptions = {\n kind?: TelemetrySpanKind\n attributes?: TelemetrySpanAttributes\n /** Start a new trace so the sampler decides for this span alone. */\n root?: boolean\n /** Causal links to other traces, as W3C carriers. */\n links?: TelemetryTraceCarrier[]\n}\n\nexport type TelemetryRuntime = {\n /**\n * True only when the active SDK may safely use the process-global W3C\n * propagator for cross-boundary extraction.\n */\n canUseGlobalTracePropagation(): boolean\n captureTraceContext(): TelemetryTraceCarrier\n continueTrace<T>(\n carrier: TelemetryTraceCarrier | undefined,\n name: string,\n fn: () => T,\n options?: { kind?: 'internal' | 'server' | 'client' | 'producer' | 'consumer' },\n ): T\n /**\n * Optional so an older bootstrap that predates span support still satisfies\n * the contract; consumers go through `withTelemetrySpan` and degrade to\n * running `fn` untraced.\n */\n withSpan?<T>(name: string, fn: (span: TelemetrySpan) => T, options?: TelemetrySpanOptions): T\n recordHttpDuration(method: string, route: string, status: number, startedAt: number): void\n reportError(\n error: unknown,\n context?: {\n module?: string\n /**\n * Stable, enumerated fingerprint (`module.reason`) the backend groups on.\n * Optional so a bootstrap predating it still satisfies the contract \u2014 an\n * older bridge simply ignores the field.\n */\n code?: string\n attributes?: Record<string, string | number | boolean | undefined>\n },\n ): void\n shutdown(): Promise<void>\n}\n\nconst GLOBAL_KEY = Symbol.for('@open-mercato/shared.telemetryRuntime')\nconst ENABLED_BACKENDS = new Set(['console', 'signoz', 'newrelic', 'otlp'])\n\ntype TelemetryRuntimeStore = {\n active?: TelemetryRuntime\n}\n\nfunction store(): TelemetryRuntimeStore {\n const globalStore = globalThis as unknown as Record<symbol, TelemetryRuntimeStore | undefined>\n let current = globalStore[GLOBAL_KEY]\n if (!current) {\n current = {}\n globalStore[GLOBAL_KEY] = current\n }\n return current\n}\n\n/**\n * This check is intentionally owned by shared code so hosts can decide whether\n * to dynamically import the telemetry package without evaluating that package.\n */\nexport function isTelemetryBackendEnabled(raw?: string): boolean {\n const value = raw ?? (\n typeof process === 'undefined'\n ? undefined\n : process.env.TELEMETRY_BACKEND\n )\n return ENABLED_BACKENDS.has((value ?? '').trim().toLowerCase())\n}\n\nexport function registerTelemetryRuntime(runtime: TelemetryRuntime): () => void {\n store().active = runtime\n return () => {\n const current = store()\n if (current.active === runtime) current.active = undefined\n }\n}\n\nexport function getTelemetryRuntime(): TelemetryRuntime | undefined {\n return store().active\n}\n\n/** Test-only: clear the process-wide telemetry bridge. */\nexport function resetTelemetryRuntime(): void {\n store().active = undefined\n}\n\nconst NOOP_SPAN: TelemetrySpan = { setAttributes() {} }\n\n/**\n * Run `fn` inside a span, from a package that must not depend on\n * `@open-mercato/telemetry`. With telemetry off this is `fn` plus one global\n * lookup \u2014 no span object is allocated and the OTEL SDK is never reached.\n *\n * Pass `root: true` for the unit of work a long-lived job should be sampled and\n * rendered by (a batch, a page) so the job is not one trace under one sampling\n * decision, and `links` to keep the causal chain back to what triggered it.\n */\nexport function withTelemetrySpan<T>(\n name: string,\n fn: (span: TelemetrySpan) => T,\n options?: TelemetrySpanOptions,\n): T {\n const runtime = getTelemetryRuntime()\n if (!runtime?.withSpan) return fn(NOOP_SPAN)\n return runtime.withSpan(name, fn, options)\n}\n\n/**\n * The active trace as a carrier, for use as a `links` entry. `undefined` when\n * telemetry is off or nothing is active, which `withTelemetrySpan` treats as\n * \"no link\" rather than an invalid one.\n */\nexport function captureTelemetryTrace(): TelemetryTraceCarrier | undefined {\n const carrier = getTelemetryRuntime()?.captureTraceContext()\n return carrier && Object.keys(carrier).length > 0 ? carrier : undefined\n}\n"],
|
|
5
|
+
"mappings": "AA8DA,MAAM,aAAa,uBAAO,IAAI,uCAAuC;AACrE,MAAM,mBAAmB,oBAAI,IAAI,CAAC,WAAW,UAAU,YAAY,MAAM,CAAC;AAM1E,SAAS,QAA+B;AACtC,QAAM,cAAc;AACpB,MAAI,UAAU,YAAY,UAAU;AACpC,MAAI,CAAC,SAAS;AACZ,cAAU,CAAC;AACX,gBAAY,UAAU,IAAI;AAAA,EAC5B;AACA,SAAO;AACT;AAMO,SAAS,0BAA0B,KAAuB;AAC/D,QAAM,QAAQ,QACZ,OAAO,YAAY,cACf,SACA,QAAQ,IAAI;AAElB,SAAO,iBAAiB,KAAK,SAAS,IAAI,KAAK,EAAE,YAAY,CAAC;AAChE;AAEO,SAAS,yBAAyB,SAAuC;AAC9E,QAAM,EAAE,SAAS;AACjB,SAAO,MAAM;AACX,UAAM,UAAU,MAAM;AACtB,QAAI,QAAQ,WAAW,QAAS,SAAQ,SAAS;AAAA,EACnD;AACF;AAEO,SAAS,sBAAoD;AAClE,SAAO,MAAM,EAAE;AACjB;AAGO,SAAS,wBAA8B;AAC5C,QAAM,EAAE,SAAS;AACnB;AAEA,MAAM,YAA2B,EAAE,gBAAgB;AAAC,EAAE;AAW/C,SAAS,kBACd,MACA,IACA,SACG;AACH,QAAM,UAAU,oBAAoB;AACpC,MAAI,CAAC,SAAS,SAAU,QAAO,GAAG,SAAS;AAC3C,SAAO,QAAQ,SAAS,MAAM,IAAI,OAAO;AAC3C;AAOO,SAAS,wBAA2D;AACzE,QAAM,UAAU,oBAAoB,GAAG,oBAAoB;AAC3D,SAAO,WAAW,OAAO,KAAK,OAAO,EAAE,SAAS,IAAI,UAAU;AAChE;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.7185.1.0f280ef1f1';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7185.1.0f280ef1f1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"@mikro-orm/core": "^7.1.14",
|
|
114
114
|
"@mikro-orm/decorators": "^7.1.14",
|
|
115
115
|
"@mikro-orm/postgresql": "^7.1.14",
|
|
116
|
-
"@open-mercato/cache": "0.7.1-develop.
|
|
116
|
+
"@open-mercato/cache": "0.7.1-develop.7185.1.0f280ef1f1",
|
|
117
117
|
"@types/html-to-text": "^9.0.4",
|
|
118
118
|
"@types/sanitize-html": "^2.16.1",
|
|
119
119
|
"dotenv": "^17.4.2",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { encryptWithAesGcm, generateDek, looksLikeEncryptedPayload } from '../aes'
|
|
2
|
+
import { NoopKmsService, resolveEncryptionMode } from '../kms'
|
|
3
|
+
|
|
4
|
+
const originalEnv = { ...process.env }
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
process.env = { ...originalEnv }
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Once encryption is switched off the KMS is a noop, so `decryptWithAesGcm` cannot tell ciphertext
|
|
12
|
+
* from plaintext — it returns null for both. Every caller that has to answer "is this column still
|
|
13
|
+
* holding an envelope?" in that state depends on the shape check instead.
|
|
14
|
+
*/
|
|
15
|
+
describe('looksLikeEncryptedPayload', () => {
|
|
16
|
+
it('recognises what encryptWithAesGcm produces', () => {
|
|
17
|
+
const payload = encryptWithAesGcm('sk_live_secret', generateDek()).value
|
|
18
|
+
expect(looksLikeEncryptedPayload(payload)).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it.each([
|
|
22
|
+
['plain text', 'Renewal for ACME Ltd'],
|
|
23
|
+
['empty string', ''],
|
|
24
|
+
['a colon-separated value with the wrong arity', 'a:b:v1'],
|
|
25
|
+
['a four-part value with the wrong version', 'aaaaaaaaaaaaaaaa:Y2lwaGVy:aaaaaaaaaaaaaaaaaaaaaaaa:v2'],
|
|
26
|
+
['a time-like string', '12:30:00:v1'],
|
|
27
|
+
['non-base64 components of the right length', '****************:Y2lwaGVy:************************:v1'],
|
|
28
|
+
['an empty ciphertext component', 'YWJjZGVmZ2hpamtsbW5v:' + ':YWJjZGVmZ2hpamtsbW5vcHFyc3R1:v1'],
|
|
29
|
+
])('rejects %s', (_label, value) => {
|
|
30
|
+
expect(looksLikeEncryptedPayload(value)).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it.each([[null], [undefined], [42], [{}], [[]]])('rejects the non-string %p', (value) => {
|
|
34
|
+
expect(looksLikeEncryptedPayload(value)).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The distinction this type exists to make. `NoopKmsService.isHealthy()` is deliberately inverted
|
|
40
|
+
* — it reports healthy exactly when encryption is OFF — so that `enabled && healthy` collapses
|
|
41
|
+
* correctly. The cost is that a bare `if (!kms.isHealthy())` reads "operator opted out" and "Vault
|
|
42
|
+
* is down" as the same condition, and they need opposite handling.
|
|
43
|
+
*/
|
|
44
|
+
describe('resolveEncryptionMode', () => {
|
|
45
|
+
it('reports disabled when the operator opted out, however the KMS answers', () => {
|
|
46
|
+
process.env.TENANT_DATA_ENCRYPTION = 'no'
|
|
47
|
+
expect(resolveEncryptionMode(new NoopKmsService())).toBe('disabled')
|
|
48
|
+
expect(resolveEncryptionMode({ isHealthy: () => true })).toBe('disabled')
|
|
49
|
+
expect(resolveEncryptionMode({ isHealthy: () => false })).toBe('disabled')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('reports active when encryption is on and a DEK is reachable', () => {
|
|
53
|
+
delete process.env.TENANT_DATA_ENCRYPTION
|
|
54
|
+
expect(resolveEncryptionMode({ isHealthy: () => true })).toBe('active')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('separates an unreachable KMS from an operator opt-out', () => {
|
|
58
|
+
process.env.TENANT_DATA_ENCRYPTION = 'yes'
|
|
59
|
+
expect(resolveEncryptionMode({ isHealthy: () => false })).toBe('unavailable')
|
|
60
|
+
// Same KMS object, same `isHealthy()` answer, opposite mode — the toggle is what differs.
|
|
61
|
+
expect(new NoopKmsService().isHealthy()).toBe(false)
|
|
62
|
+
process.env.TENANT_DATA_ENCRYPTION = 'no'
|
|
63
|
+
expect(new NoopKmsService().isHealthy()).toBe(true)
|
|
64
|
+
expect(resolveEncryptionMode(new NoopKmsService())).toBe('disabled')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('defaults to encrypting when the toggle is unset', () => {
|
|
68
|
+
delete process.env.TENANT_DATA_ENCRYPTION
|
|
69
|
+
expect(resolveEncryptionMode({ isHealthy: () => true })).toBe('active')
|
|
70
|
+
expect(resolveEncryptionMode({ isHealthy: () => false })).toBe('unavailable')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -27,6 +27,38 @@ export class TenantDataEncryptionError extends Error {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
const BASE64_PART = /^[A-Za-z0-9+/]+={0,2}$/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Keyless structural check for the `base64(iv):base64(ciphertext):base64(tag):v1` envelope
|
|
34
|
+
* {@link encryptWithAesGcm} emits.
|
|
35
|
+
*
|
|
36
|
+
* Answers "is this column holding ciphertext?" without a DEK, which is the only question
|
|
37
|
+
* available once encryption has been switched off — the KMS is a noop by then, so
|
|
38
|
+
* {@link decryptWithAesGcm} cannot distinguish ciphertext from plaintext. A 12-byte IV and a
|
|
39
|
+
* 16-byte tag encode to exactly 16 and 24 base64 characters, so the shape is specific enough
|
|
40
|
+
* that plaintext colliding with it by accident is not a practical concern.
|
|
41
|
+
*
|
|
42
|
+
* Deliberate collision is, though: writing `<16 b64>:<b64>:<24 b64>:v1` is trivial, and
|
|
43
|
+
* `TenantDataEncryptionService` dropped a structural check of exactly this shape for that
|
|
44
|
+
* reason (#2720). So this is only safe on values the SERVER wrote — never as a test applied to
|
|
45
|
+
* attacker-supplied input while a DEK is reachable, where `isEncryptedWithDek` is the test to use.
|
|
46
|
+
* Callers that must run it over user-controlled data are responsible for confirming first that no
|
|
47
|
+
* DEK is reachable, which is what makes forgery pointless: there is nothing to impersonate.
|
|
48
|
+
*/
|
|
49
|
+
export function looksLikeEncryptedPayload(value: unknown): boolean {
|
|
50
|
+
if (typeof value !== 'string') return false
|
|
51
|
+
const parts = value.split(':')
|
|
52
|
+
if (parts.length !== 4 || parts[3] !== 'v1') return false
|
|
53
|
+
const [iv, ciphertext, tag] = parts as [string, string, string, string]
|
|
54
|
+
return iv.length === 16
|
|
55
|
+
&& tag.length === 24
|
|
56
|
+
&& ciphertext.length > 0
|
|
57
|
+
&& BASE64_PART.test(iv)
|
|
58
|
+
&& BASE64_PART.test(ciphertext)
|
|
59
|
+
&& BASE64_PART.test(tag)
|
|
60
|
+
}
|
|
61
|
+
|
|
30
62
|
export function generateDek(): string {
|
|
31
63
|
return crypto.randomBytes(32).toString('base64')
|
|
32
64
|
}
|
|
@@ -423,6 +423,28 @@ function logDerivedKeyFallbackBanner(opts: DerivedSecret): void {
|
|
|
423
423
|
})
|
|
424
424
|
}
|
|
425
425
|
|
|
426
|
+
/**
|
|
427
|
+
* What the runtime should do about tenant data encryption right now.
|
|
428
|
+
*
|
|
429
|
+
* `isHealthy()` alone cannot answer this: {@link NoopKmsService} reports healthy precisely when
|
|
430
|
+
* encryption is switched OFF, so `enabled && healthy` collapses correctly but a bare
|
|
431
|
+
* `if (!kms.isHealthy())` guard reads the two opposite situations as the same one. They call for
|
|
432
|
+
* opposite handling, so name them:
|
|
433
|
+
*
|
|
434
|
+
* - `disabled` — the operator set `TENANT_DATA_ENCRYPTION=no`. Plaintext is the intended
|
|
435
|
+
* outcome; degrade to it rather than failing.
|
|
436
|
+
* - `active` — encryption is on and a DEK is reachable. Encrypt.
|
|
437
|
+
* - `unavailable` — encryption is on but no DEK is reachable (Vault down, no fallback secret).
|
|
438
|
+
* Data that is meant to be ciphertext MUST NOT be written as plaintext; callers
|
|
439
|
+
* holding secrets fail closed here (spec 2026-05-29, security finding #7).
|
|
440
|
+
*/
|
|
441
|
+
export type TenantDataEncryptionMode = 'disabled' | 'active' | 'unavailable'
|
|
442
|
+
|
|
443
|
+
export function resolveEncryptionMode(kms: Pick<KmsService, 'isHealthy'>): TenantDataEncryptionMode {
|
|
444
|
+
if (!isTenantDataEncryptionEnabled()) return 'disabled'
|
|
445
|
+
return kms.isHealthy() ? 'active' : 'unavailable'
|
|
446
|
+
}
|
|
447
|
+
|
|
426
448
|
export function createKmsService(): KmsService {
|
|
427
449
|
if (!isTenantDataEncryptionEnabled()) return new NoopKmsService()
|
|
428
450
|
const primary = new HashicorpVaultKmsService()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `module.reason` shape a failure fingerprint must have to be used as a
|
|
3
|
+
* metric label (`data_sync.item_failed`, `queue.job_failed`).
|
|
4
|
+
*
|
|
5
|
+
* Enforced rather than documented because a `code` frequently originates outside
|
|
6
|
+
* the framework — an adapter's `data.errorCode`, a third-party module's
|
|
7
|
+
* `integrationLogService.write({ code })`. An interpolated `` `http_${status}_${url}` ``
|
|
8
|
+
* would open one `om.errors` series per URL, and metric labels — unlike
|
|
9
|
+
* attributes — never pass through redaction, so an interpolated customer email
|
|
10
|
+
* would egress unredacted.
|
|
11
|
+
*/
|
|
12
|
+
export const ERROR_CODE_SHAPE = /^[a-z0-9_]+\.[a-z0-9_]+$/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Narrow an untrusted value to a usable fingerprint, or to `fallback`.
|
|
16
|
+
*
|
|
17
|
+
* The fallback is a real code rather than `unknown` wherever a caller has one, so
|
|
18
|
+
* grouping still works for a writer that supplies nothing or supplies rubbish.
|
|
19
|
+
*/
|
|
20
|
+
export function groupableCode(value: unknown, fallback: string): string
|
|
21
|
+
export function groupableCode(value: unknown, fallback?: undefined): string | undefined
|
|
22
|
+
export function groupableCode(value: unknown, fallback?: string): string | undefined {
|
|
23
|
+
const code = typeof value === 'string' ? value.trim() : ''
|
|
24
|
+
return ERROR_CODE_SHAPE.test(code) ? code : fallback
|
|
25
|
+
}
|
|
@@ -48,6 +48,12 @@ export type TelemetryRuntime = {
|
|
|
48
48
|
error: unknown,
|
|
49
49
|
context?: {
|
|
50
50
|
module?: string
|
|
51
|
+
/**
|
|
52
|
+
* Stable, enumerated fingerprint (`module.reason`) the backend groups on.
|
|
53
|
+
* Optional so a bootstrap predating it still satisfies the contract — an
|
|
54
|
+
* older bridge simply ignores the field.
|
|
55
|
+
*/
|
|
56
|
+
code?: string
|
|
51
57
|
attributes?: Record<string, string | number | boolean | undefined>
|
|
52
58
|
},
|
|
53
59
|
): void
|