@open-mercato/shared 0.6.5-develop.5048.1.fd82f4ae17 → 0.6.5-develop.5116.1.f0af9e5080
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/auth/apiKeyAuthCache.js +17 -6
- package/dist/lib/auth/apiKeyAuthCache.js.map +2 -2
- package/dist/lib/commands/scope.js +51 -37
- package/dist/lib/commands/scope.js.map +2 -2
- package/dist/lib/encryption/aes.js +37 -3
- package/dist/lib/encryption/aes.js.map +2 -2
- package/dist/lib/encryption/kms.js +43 -15
- package/dist/lib/encryption/kms.js.map +2 -2
- package/dist/lib/encryption/tenantDataEncryptionService.js +31 -4
- package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
- package/dist/lib/query/engine.js +41 -31
- package/dist/lib/query/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/auth/__tests__/apiKeyAuthCache.test.ts +35 -0
- package/src/lib/auth/apiKeyAuthCache.ts +20 -6
- package/src/lib/commands/__tests__/scope.test.ts +48 -0
- package/src/lib/commands/scope.ts +70 -55
- package/src/lib/encryption/__tests__/dek-lifecycle.test.ts +194 -0
- package/src/lib/encryption/__tests__/kms.test.ts +11 -5
- package/src/lib/encryption/__tests__/lookupHash.test.ts +113 -0
- package/src/lib/encryption/aes.ts +78 -2
- package/src/lib/encryption/kms.ts +60 -15
- package/src/lib/encryption/tenantDataEncryptionService.ts +37 -4
- package/src/lib/query/__tests__/resolve-registered-entity-table.test.ts +83 -0
- package/src/lib/query/engine.ts +59 -30
- package/src/modules/vector.ts +7 -0
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
1
2
|
const DEFAULT_SUCCESS_TTL_MS = 3e4;
|
|
2
3
|
const DEFAULT_NEGATIVE_TTL_MS = 5e3;
|
|
3
4
|
const DEFAULT_LAST_USED_WRITE_INTERVAL_MS = 6e4;
|
|
4
5
|
const DEFAULT_MAX_ENTRIES = 1e3;
|
|
6
|
+
const FINGERPRINT_BYTES = 16;
|
|
7
|
+
function createSecretFingerprinter() {
|
|
8
|
+
const key = randomBytes(32);
|
|
9
|
+
return (secret) => createHmac("sha256", key).update(secret, "utf8").digest("hex").slice(0, FINGERPRINT_BYTES * 2);
|
|
10
|
+
}
|
|
5
11
|
function resolveTtlEnv(name, fallback) {
|
|
6
12
|
const raw = process.env?.[name];
|
|
7
13
|
if (!raw) return fallback;
|
|
@@ -18,6 +24,7 @@ function createApiKeyAuthCache(options = {}) {
|
|
|
18
24
|
);
|
|
19
25
|
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
20
26
|
const now = options.now ?? (() => Date.now());
|
|
27
|
+
const fingerprint = createSecretFingerprinter();
|
|
21
28
|
const entries = /* @__PURE__ */ new Map();
|
|
22
29
|
const lastUsedWrites = /* @__PURE__ */ new Map();
|
|
23
30
|
function touch(key, entry) {
|
|
@@ -36,12 +43,13 @@ function createApiKeyAuthCache(options = {}) {
|
|
|
36
43
|
return {
|
|
37
44
|
get(secret) {
|
|
38
45
|
if (!secret) return void 0;
|
|
39
|
-
const
|
|
46
|
+
const key = fingerprint(secret);
|
|
47
|
+
const entry = entries.get(key);
|
|
40
48
|
if (!entry) return void 0;
|
|
41
49
|
const currentMs = now();
|
|
42
|
-
if (purgeStale(
|
|
43
|
-
entries.delete(
|
|
44
|
-
entries.set(
|
|
50
|
+
if (purgeStale(key, entry, currentMs)) return void 0;
|
|
51
|
+
entries.delete(key);
|
|
52
|
+
entries.set(key, entry);
|
|
45
53
|
return entry.auth;
|
|
46
54
|
},
|
|
47
55
|
setSuccess(secret, auth, expiresAtMs) {
|
|
@@ -51,13 +59,13 @@ function createApiKeyAuthCache(options = {}) {
|
|
|
51
59
|
const ttlEnd = currentMs + successTtlMs;
|
|
52
60
|
const effectiveExpiry = expiresAtMs != null ? Math.min(ttlEnd, expiresAtMs) : ttlEnd;
|
|
53
61
|
if (effectiveExpiry <= currentMs) return;
|
|
54
|
-
touch(secret, { auth, cachedAtMs: currentMs, expiresAtMs: effectiveExpiry });
|
|
62
|
+
touch(fingerprint(secret), { auth, cachedAtMs: currentMs, expiresAtMs: effectiveExpiry });
|
|
55
63
|
},
|
|
56
64
|
setMiss(secret) {
|
|
57
65
|
if (!secret) return;
|
|
58
66
|
if (negativeTtlMs <= 0) return;
|
|
59
67
|
const currentMs = now();
|
|
60
|
-
touch(secret, { auth: null, cachedAtMs: currentMs, expiresAtMs: currentMs + negativeTtlMs });
|
|
68
|
+
touch(fingerprint(secret), { auth: null, cachedAtMs: currentMs, expiresAtMs: currentMs + negativeTtlMs });
|
|
61
69
|
},
|
|
62
70
|
invalidateByKeyId(keyId) {
|
|
63
71
|
if (!keyId) return;
|
|
@@ -84,6 +92,9 @@ function createApiKeyAuthCache(options = {}) {
|
|
|
84
92
|
},
|
|
85
93
|
size() {
|
|
86
94
|
return entries.size;
|
|
95
|
+
},
|
|
96
|
+
inspectEntryKeysForTests() {
|
|
97
|
+
return Array.from(entries.keys());
|
|
87
98
|
}
|
|
88
99
|
};
|
|
89
100
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/auth/apiKeyAuthCache.ts"],
|
|
4
|
-
"sourcesContent": ["
|
|
5
|
-
"mappings": "AAAA,MAAM,yBAAyB;AAC/B,MAAM,0BAA0B;AAChC,MAAM,sCAAsC;AAC5C,MAAM,sBAAsB;
|
|
4
|
+
"sourcesContent": ["import { createHmac, randomBytes } from 'node:crypto'\n\nconst DEFAULT_SUCCESS_TTL_MS = 30_000\nconst DEFAULT_NEGATIVE_TTL_MS = 5_000\nconst DEFAULT_LAST_USED_WRITE_INTERVAL_MS = 60_000\nconst DEFAULT_MAX_ENTRIES = 1_000\nconst FINGERPRINT_BYTES = 16\n\nfunction createSecretFingerprinter(): (secret: string) => string {\n const key = randomBytes(32)\n return (secret) => createHmac('sha256', key).update(secret, 'utf8').digest('hex').slice(0, FINGERPRINT_BYTES * 2)\n}\n\nexport type CachedApiKeyAuth = Record<string, unknown> | null\n\ntype CachedEntry = {\n auth: CachedApiKeyAuth\n cachedAtMs: number\n expiresAtMs: number\n}\n\nexport type ApiKeyAuthCacheOptions = {\n successTtlMs?: number\n negativeTtlMs?: number\n lastUsedWriteIntervalMs?: number\n maxEntries?: number\n now?: () => number\n}\n\nexport type ApiKeyAuthCache = {\n get(secret: string): CachedApiKeyAuth | undefined\n setSuccess(secret: string, auth: Exclude<CachedApiKeyAuth, null>, expiresAtMs: number | null): void\n setMiss(secret: string): void\n invalidateByKeyId(keyId: string): void\n shouldWriteLastUsed(keyId: string): boolean\n clear(): void\n size(): number\n inspectEntryKeysForTests(): string[]\n}\n\nfunction resolveTtlEnv(name: string, fallback: number): number {\n const raw = process.env?.[name]\n if (!raw) return fallback\n const parsed = Number(raw)\n if (!Number.isFinite(parsed) || parsed < 0) return fallback\n return parsed\n}\n\nexport function createApiKeyAuthCache(options: ApiKeyAuthCacheOptions = {}): ApiKeyAuthCache {\n const successTtlMs = options.successTtlMs ?? resolveTtlEnv('OM_API_KEY_AUTH_TTL_MS', DEFAULT_SUCCESS_TTL_MS)\n const negativeTtlMs = options.negativeTtlMs ?? resolveTtlEnv('OM_API_KEY_AUTH_NEGATIVE_TTL_MS', DEFAULT_NEGATIVE_TTL_MS)\n const lastUsedWriteIntervalMs = options.lastUsedWriteIntervalMs ?? resolveTtlEnv(\n 'OM_API_KEY_LAST_USED_WRITE_INTERVAL_MS',\n DEFAULT_LAST_USED_WRITE_INTERVAL_MS,\n )\n const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES\n const now = options.now ?? (() => Date.now())\n const fingerprint = createSecretFingerprinter()\n\n const entries = new Map<string, CachedEntry>()\n const lastUsedWrites = new Map<string, number>()\n\n function touch(key: string, entry: CachedEntry) {\n entries.delete(key)\n entries.set(key, entry)\n if (entries.size > maxEntries) {\n const oldest = entries.keys().next().value\n if (typeof oldest === 'string') entries.delete(oldest)\n }\n }\n\n function purgeStale(key: string, entry: CachedEntry, currentMs: number): boolean {\n if (entry.expiresAtMs > currentMs) return false\n entries.delete(key)\n return true\n }\n\n return {\n get(secret) {\n if (!secret) return undefined\n const key = fingerprint(secret)\n const entry = entries.get(key)\n if (!entry) return undefined\n const currentMs = now()\n if (purgeStale(key, entry, currentMs)) return undefined\n entries.delete(key)\n entries.set(key, entry)\n return entry.auth\n },\n setSuccess(secret, auth, expiresAtMs) {\n if (!secret) return\n if (successTtlMs <= 0) return\n const currentMs = now()\n const ttlEnd = currentMs + successTtlMs\n const effectiveExpiry = expiresAtMs != null ? Math.min(ttlEnd, expiresAtMs) : ttlEnd\n if (effectiveExpiry <= currentMs) return\n touch(fingerprint(secret), { auth, cachedAtMs: currentMs, expiresAtMs: effectiveExpiry })\n },\n setMiss(secret) {\n if (!secret) return\n if (negativeTtlMs <= 0) return\n const currentMs = now()\n touch(fingerprint(secret), { auth: null, cachedAtMs: currentMs, expiresAtMs: currentMs + negativeTtlMs })\n },\n invalidateByKeyId(keyId) {\n if (!keyId) return\n for (const [key, entry] of entries) {\n const auth = entry.auth\n if (auth && typeof auth === 'object' && (auth as { keyId?: string }).keyId === keyId) {\n entries.delete(key)\n }\n }\n lastUsedWrites.delete(keyId)\n },\n shouldWriteLastUsed(keyId) {\n if (!keyId) return true\n if (lastUsedWriteIntervalMs <= 0) return true\n const currentMs = now()\n const previous = lastUsedWrites.get(keyId)\n if (previous != null && currentMs - previous < lastUsedWriteIntervalMs) return false\n lastUsedWrites.set(keyId, currentMs)\n return true\n },\n clear() {\n entries.clear()\n lastUsedWrites.clear()\n },\n size() {\n return entries.size\n },\n inspectEntryKeysForTests() {\n return Array.from(entries.keys())\n },\n }\n}\n\nlet sharedCache: ApiKeyAuthCache | null = null\n\nexport function getSharedApiKeyAuthCache(): ApiKeyAuthCache {\n if (!sharedCache) sharedCache = createApiKeyAuthCache()\n return sharedCache\n}\n\nexport function resetSharedApiKeyAuthCacheForTests(): void {\n sharedCache = null\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,29 +1,50 @@
|
|
|
1
1
|
import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
2
2
|
import { isOrganizationAccessAllowed } from "@open-mercato/shared/lib/auth/organizationAccess";
|
|
3
|
+
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
3
4
|
import { env } from "process";
|
|
5
|
+
function buildScopeLogContext(ctx) {
|
|
6
|
+
const requestInfo = ctx.request && typeof ctx.request === "object" ? {
|
|
7
|
+
method: ctx.request.method ?? void 0,
|
|
8
|
+
url: ctx.request.url ?? void 0
|
|
9
|
+
} : null;
|
|
10
|
+
const scope = ctx.organizationScope ? {
|
|
11
|
+
selectedId: ctx.organizationScope.selectedId ?? null,
|
|
12
|
+
tenantId: ctx.organizationScope.tenantId ?? null,
|
|
13
|
+
allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds) ? ctx.organizationScope.allowedIds.length : null,
|
|
14
|
+
filterIdsCount: Array.isArray(ctx.organizationScope.filterIds) ? ctx.organizationScope.filterIds.length : null
|
|
15
|
+
} : null;
|
|
16
|
+
return {
|
|
17
|
+
userId: ctx.auth?.sub ?? null,
|
|
18
|
+
actorTenantId: ctx.auth?.tenantId ?? null,
|
|
19
|
+
actorOrganizationId: ctx.auth?.orgId ?? null,
|
|
20
|
+
selectedOrganizationId: ctx.selectedOrganizationId ?? null,
|
|
21
|
+
organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
|
|
22
|
+
scope,
|
|
23
|
+
request: requestInfo
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function isStrictOrganizationScopeEnforced() {
|
|
27
|
+
return parseBooleanWithDefault(env.OM_ENFORCE_ORG_SCOPE_STRICT, false);
|
|
28
|
+
}
|
|
4
29
|
function logScopeViolation(ctx, expected, actual) {
|
|
5
30
|
try {
|
|
6
|
-
const requestInfo = ctx.request && typeof ctx.request === "object" ? {
|
|
7
|
-
method: ctx.request.method ?? void 0,
|
|
8
|
-
url: ctx.request.url ?? void 0
|
|
9
|
-
} : null;
|
|
10
|
-
const scope = ctx.organizationScope ? {
|
|
11
|
-
selectedId: ctx.organizationScope.selectedId ?? null,
|
|
12
|
-
tenantId: ctx.organizationScope.tenantId ?? null,
|
|
13
|
-
allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds) ? ctx.organizationScope.allowedIds.length : null,
|
|
14
|
-
filterIdsCount: Array.isArray(ctx.organizationScope.filterIds) ? ctx.organizationScope.filterIds.length : null
|
|
15
|
-
} : null;
|
|
16
31
|
if (env.NODE_ENV !== "test") {
|
|
17
32
|
console.warn("[scope] Forbidden organization scope mismatch detected", {
|
|
18
33
|
expectedId: expected,
|
|
19
34
|
actualId: actual,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
...buildScopeLogContext(ctx)
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function logUnscopedOrganizationAccess(ctx, organizationId) {
|
|
42
|
+
try {
|
|
43
|
+
if (env.NODE_ENV !== "test") {
|
|
44
|
+
console.warn("[scope] Unscoped organization command executed without organization context", {
|
|
45
|
+
targetOrganizationId: organizationId,
|
|
46
|
+
strictEnforcement: isStrictOrganizationScopeEnforced(),
|
|
47
|
+
...buildScopeLogContext(ctx)
|
|
27
48
|
});
|
|
28
49
|
}
|
|
29
50
|
} catch {
|
|
@@ -31,27 +52,11 @@ function logScopeViolation(ctx, expected, actual) {
|
|
|
31
52
|
}
|
|
32
53
|
function logTenantScopeViolation(ctx, expectedTenantId, actualTenantId) {
|
|
33
54
|
try {
|
|
34
|
-
const requestInfo = ctx.request && typeof ctx.request === "object" ? {
|
|
35
|
-
method: ctx.request.method ?? void 0,
|
|
36
|
-
url: ctx.request.url ?? void 0
|
|
37
|
-
} : null;
|
|
38
|
-
const scope = ctx.organizationScope ? {
|
|
39
|
-
selectedId: ctx.organizationScope.selectedId ?? null,
|
|
40
|
-
tenantId: ctx.organizationScope.tenantId ?? null,
|
|
41
|
-
allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds) ? ctx.organizationScope.allowedIds.length : null,
|
|
42
|
-
filterIdsCount: Array.isArray(ctx.organizationScope.filterIds) ? ctx.organizationScope.filterIds.length : null
|
|
43
|
-
} : null;
|
|
44
55
|
if (env.NODE_ENV !== "test") {
|
|
45
56
|
console.warn("[scope] Forbidden tenant scope mismatch detected", {
|
|
46
57
|
expectedTenantId,
|
|
47
58
|
actualTenantId,
|
|
48
|
-
|
|
49
|
-
actorTenantId: ctx.auth?.tenantId ?? null,
|
|
50
|
-
actorOrganizationId: ctx.auth?.orgId ?? null,
|
|
51
|
-
selectedOrganizationId: ctx.selectedOrganizationId ?? null,
|
|
52
|
-
organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,
|
|
53
|
-
scope,
|
|
54
|
-
request: requestInfo
|
|
59
|
+
...buildScopeLogContext(ctx)
|
|
55
60
|
});
|
|
56
61
|
}
|
|
57
62
|
} catch {
|
|
@@ -63,9 +68,18 @@ function ensureOrganizationScope(ctx, organizationId) {
|
|
|
63
68
|
if (!scope) {
|
|
64
69
|
if (isSuperAdmin) return;
|
|
65
70
|
const currentOrg2 = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null;
|
|
66
|
-
if (currentOrg2
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
if (currentOrg2) {
|
|
72
|
+
if (currentOrg2 !== organizationId) {
|
|
73
|
+
logScopeViolation(ctx, organizationId, currentOrg2);
|
|
74
|
+
throw new CrudHttpError(403, { error: "Forbidden" });
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (organizationId) {
|
|
79
|
+
logUnscopedOrganizationAccess(ctx, organizationId);
|
|
80
|
+
if (isStrictOrganizationScopeEnforced()) {
|
|
81
|
+
throw new CrudHttpError(403, { error: "Forbidden" });
|
|
82
|
+
}
|
|
69
83
|
}
|
|
70
84
|
return;
|
|
71
85
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/commands/scope.ts"],
|
|
4
|
-
"sourcesContent": ["import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'\nimport { env } from 'process'\n\nfunction
|
|
5
|
-
"mappings": "AAAA,SAAS,qBAAqB;AAE9B,SAAS,mCAAmC;AAC5C,SAAS,WAAW;AAEpB,SAAS,
|
|
4
|
+
"sourcesContent": ["import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { isOrganizationAccessAllowed } from '@open-mercato/shared/lib/auth/organizationAccess'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { env } from 'process'\n\nfunction buildScopeLogContext(ctx: CommandRuntimeContext) {\n const requestInfo =\n ctx.request && typeof ctx.request === 'object'\n ? {\n method: (ctx.request as Request).method ?? undefined,\n url: (ctx.request as Request).url ?? undefined,\n }\n : null\n const scope = ctx.organizationScope\n ? {\n selectedId: ctx.organizationScope.selectedId ?? null,\n tenantId: ctx.organizationScope.tenantId ?? null,\n allowedIdsCount: Array.isArray(ctx.organizationScope.allowedIds)\n ? ctx.organizationScope.allowedIds.length\n : null,\n filterIdsCount: Array.isArray(ctx.organizationScope.filterIds)\n ? ctx.organizationScope.filterIds.length\n : null,\n }\n : null\n return {\n userId: ctx.auth?.sub ?? null,\n actorTenantId: ctx.auth?.tenantId ?? null,\n actorOrganizationId: ctx.auth?.orgId ?? null,\n selectedOrganizationId: ctx.selectedOrganizationId ?? null,\n organizationIdsCount: Array.isArray(ctx.organizationIds) ? ctx.organizationIds.length : null,\n scope,\n request: requestInfo,\n }\n}\n\nfunction isStrictOrganizationScopeEnforced(): boolean {\n return parseBooleanWithDefault(env.OM_ENFORCE_ORG_SCOPE_STRICT, false)\n}\n\nfunction logScopeViolation(\n ctx: CommandRuntimeContext,\n expected: string,\n actual: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden organization scope mismatch detected', {\n expectedId: expected,\n actualId: actual,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logUnscopedOrganizationAccess(ctx: CommandRuntimeContext, organizationId: string): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Unscoped organization command executed without organization context', {\n targetOrganizationId: organizationId,\n strictEnforcement: isStrictOrganizationScopeEnforced(),\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nfunction logTenantScopeViolation(\n ctx: CommandRuntimeContext,\n expectedTenantId: string,\n actualTenantId: string | null\n): void {\n try {\n if (env.NODE_ENV !== 'test') {\n console.warn('[scope] Forbidden tenant scope mismatch detected', {\n expectedTenantId,\n actualTenantId,\n ...buildScopeLogContext(ctx),\n })\n }\n } catch {\n // best-effort logging\n }\n}\n\nexport function ensureOrganizationScope(ctx: CommandRuntimeContext, organizationId: string): void {\n const isSuperAdmin = ctx.auth?.isSuperAdmin === true\n const scope = ctx.organizationScope\n\n // Pattern C: when no organization scope was resolved (system/worker/non-user\n // command contexts that build ctx with `organizationScope: null`), preserve\n // the legacy currentOrg fallback. This branch is load-bearing \u2014 switching it\n // to deny would break payment, scheduled-command, and other scope-less flows.\n if (!scope) {\n if (isSuperAdmin) return\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (currentOrg) {\n if (currentOrg !== organizationId) {\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n return\n }\n // No current org could be resolved either. This branch previously returned\n // with no validation and no signal \u2014 a fail-open-by-omission shape (#2441):\n // a new command path reaching here with `organizationScope: null` would act\n // on an arbitrary target org silently. Preserve the legacy allow behavior by\n // default (the path is load-bearing) but make the unscoped access observable,\n // and let operators harden it into a deny via OM_ENFORCE_ORG_SCOPE_STRICT.\n if (organizationId) {\n logUnscopedOrganizationAccess(ctx, organizationId)\n if (isStrictOrganizationScopeEnforced()) {\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n }\n return\n }\n\n if (\n isOrganizationAccessAllowed({\n isSuperAdmin,\n allowedOrganizationIds: scope.allowedIds,\n targetOrganizationId: organizationId,\n })\n ) {\n return\n }\n\n const currentOrg = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n logScopeViolation(ctx, organizationId, currentOrg)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n}\n\nexport function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {\n const currentTenant = ctx.auth?.tenantId ?? null\n if (currentTenant && currentTenant !== tenantId) {\n logTenantScopeViolation(ctx, tenantId, currentTenant)\n throw new CrudHttpError(403, { error: 'Forbidden' })\n }\n}\n\nexport function ensureSameScope(\n entity: Pick<{ organizationId: string; tenantId: string }, 'organizationId' | 'tenantId'>,\n organizationId: string,\n tenantId: string\n): void {\n if (entity.organizationId !== organizationId || entity.tenantId !== tenantId) {\n throw new CrudHttpError(403, { error: 'Cross-tenant relation forbidden' })\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["currentOrg"]
|
|
7
7
|
}
|
|
@@ -64,8 +64,40 @@ function decryptWithAesGcm(payload, dekBase64) {
|
|
|
64
64
|
return null;
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
const LOOKUP_HASH_V2_PREFIX = "v2:";
|
|
68
|
+
function normalizeLookupValue(value) {
|
|
69
|
+
return value.toLowerCase().trim();
|
|
70
|
+
}
|
|
71
|
+
function legacyHashForLookup(value) {
|
|
72
|
+
return crypto.createHash("sha256").update(normalizeLookupValue(value)).digest("hex");
|
|
73
|
+
}
|
|
74
|
+
function resolveLookupPepper() {
|
|
75
|
+
const candidates = [
|
|
76
|
+
process.env.LOOKUP_HASH_PEPPER,
|
|
77
|
+
process.env.TENANT_DATA_ENCRYPTION_FALLBACK_KEY,
|
|
78
|
+
process.env.TENANT_DATA_ENCRYPTION_KEY
|
|
79
|
+
];
|
|
80
|
+
for (const candidate of candidates) {
|
|
81
|
+
if (typeof candidate !== "string") continue;
|
|
82
|
+
const normalized = candidate.trim().replace(/(?:^['"]|['"]$)/g, "");
|
|
83
|
+
if (normalized) return normalized;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
function hashForLookup(value, context) {
|
|
88
|
+
const pepper = resolveLookupPepper();
|
|
89
|
+
const normalized = normalizeLookupValue(value);
|
|
90
|
+
if (!pepper) {
|
|
91
|
+
return legacyHashForLookup(value);
|
|
92
|
+
}
|
|
93
|
+
const message = context ? `${context}:${normalized}` : normalized;
|
|
94
|
+
const digest = crypto.createHmac("sha256", pepper).update(message).digest("hex");
|
|
95
|
+
return `${LOOKUP_HASH_V2_PREFIX}${digest}`;
|
|
96
|
+
}
|
|
97
|
+
function lookupHashCandidates(value, context) {
|
|
98
|
+
const primary = hashForLookup(value, context);
|
|
99
|
+
const legacy = legacyHashForLookup(value);
|
|
100
|
+
return primary === legacy ? [primary] : [primary, legacy];
|
|
69
101
|
}
|
|
70
102
|
function decryptWithAesGcmStrict(payload, dekBase64) {
|
|
71
103
|
const parts = payload.split(":");
|
|
@@ -110,6 +142,8 @@ export {
|
|
|
110
142
|
decryptWithAesGcmStrict,
|
|
111
143
|
encryptWithAesGcm,
|
|
112
144
|
generateDek,
|
|
113
|
-
hashForLookup
|
|
145
|
+
hashForLookup,
|
|
146
|
+
legacyHashForLookup,
|
|
147
|
+
lookupHashCandidates
|
|
114
148
|
};
|
|
115
149
|
//# 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'\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 // eslint-disable-next-line no-console\n console.debug('[encryption]', 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\nexport function
|
|
5
|
-
"mappings": "AAAA,OAAO,YAAY;AACnB,SAAS,gCAAgC;AAQlC,IAAK,gCAAL,kBAAKA,mCAAL;AACL,EAAAA,+BAAA,iBAAc;AACd,EAAAA,+BAAA,uBAAoB;AACpB,EAAAA,+BAAA,qBAAkB;AAClB,EAAAA,+BAAA,eAAY;AACZ,EAAAA,+BAAA,sBAAmB;AALT,SAAAA;AAAA,GAAA;AAQL,MAAM,kCAAkC,MAAM;AAAA,EAEnD,YAAY,MAAqC,SAAiB;AAChE,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,OAAO;AAAA,EACd;AACF;AAEO,SAAS,cAAsB;AACpC,SAAO,OAAO,YAAY,EAAE,EAAE,SAAS,QAAQ;AACjD;AAEA,SAAS,SAAS,OAAe,SAAkC;AACjE,MAAI,CAAC,yBAAyB,EAAG;AACjC,MAAI;AAEF,YAAQ,MAAM,gBAAgB,OAAO,OAAO;AAAA,EAC9C,QAAQ;AAAA,EAER;AACF;AAEO,SAAS,kBAAkB,OAAe,WAAsC;AACrF,QAAM,MAAM,OAAO,KAAK,WAAW,QAAQ;AAC3C,QAAM,KAAK,OAAO,YAAY,EAAE;AAChC,QAAM,SAAS,OAAO,eAAe,eAAe,KAAK,EAAE;AAC3D,QAAM,aAAa,OAAO,OAAO,CAAC,OAAO,OAAO,OAAO,MAAM,GAAG,OAAO,MAAM,CAAC,CAAC;AAC/E,QAAM,MAAM,OAAO,WAAW;AAC9B,QAAM,UAAU;AAAA,IACd,GAAG,SAAS,QAAQ;AAAA,IACpB,WAAW,SAAS,QAAQ;AAAA,IAC5B,IAAI,SAAS,QAAQ;AAAA,IACrB;AAAA,EACF,EAAE,KAAK,GAAG;AACV,WAAS,WAAW,EAAE,QAAQ,WAAW,OAAO,CAAC;AACjD,SAAO,EAAE,OAAO,SAAS,KAAK,SAAS,SAAS,KAAK;AACvD;AAEA,SAAS,iBAAiB,KAAa,IAAY,YAAoB,KAAqB;AAC1F,QAAM,WAAW,OAAO,iBAAiB,eAAe,KAAK,EAAE;AAC/D,WAAS,WAAW,GAAG;AACvB,SAAO,OAAO,OAAO,CAAC,SAAS,OAAO,UAAU,GAAG,SAAS,MAAM,CAAC,CAAC,EAAE,SAAS,MAAM;AACvF;AAEO,SAAS,kBAAkB,SAAiB,WAAkC;AACnF,MAAI,CAAC,QAAS,QAAO;AACrB,QAAM,QAAQ,QAAQ,MAAM,GAAG;AAC/B,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,CAAC,OAAO,eAAe,QAAQ,OAAO,IAAI;AAChD,MAAI,YAAY,KAAM,QAAO;AAC7B,QAAM,MAAM,OAAO,KAAK,WAAW,QAAQ;AAC3C,QAAM,KAAK,OAAO,KAAK,OAAO,QAAQ;AACtC,QAAM,aAAa,OAAO,KAAK,eAAe,QAAQ;AACtD,QAAM,MAAM,OAAO,KAAK,QAAQ,QAAQ;AACxC,MAAI;AACF,UAAM,SAAS,iBAAiB,KAAK,IAAI,YAAY,GAAG;AACxD,aAAS,WAAW,EAAE,IAAI,OAAO,KAAK,OAAO,CAAC;AAC9C,WAAO;AAAA,EACT,SAAS,KAAK;AACZ,aAAS,iBAAiB,EAAE,SAAU,KAAe,WAAW,OAAO,GAAG,EAAE,CAAC;AAC7E,WAAO;AAAA,EACT;AACF;
|
|
4
|
+
"sourcesContent": ["import crypto from 'node:crypto'\nimport { isEncryptionDebugEnabled } from './toggles'\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 // eslint-disable-next-line no-console\n console.debug('[encryption]', 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
|
}
|
|
@@ -56,6 +56,10 @@ class FallbackKmsService {
|
|
|
56
56
|
}
|
|
57
57
|
return null;
|
|
58
58
|
}
|
|
59
|
+
invalidateDek(tenantId) {
|
|
60
|
+
this.primary.invalidateDek?.(tenantId);
|
|
61
|
+
this.fallback?.invalidateDek?.(tenantId);
|
|
62
|
+
}
|
|
59
63
|
}
|
|
60
64
|
function normalizeEnv(value) {
|
|
61
65
|
if (!value) return "";
|
|
@@ -178,11 +182,13 @@ class HashicorpVaultKmsService {
|
|
|
178
182
|
return null;
|
|
179
183
|
}
|
|
180
184
|
}
|
|
181
|
-
async writeVault(path, key) {
|
|
185
|
+
async writeVault(path, key, opts) {
|
|
182
186
|
if (!this.vaultAddr || !this.vaultToken) {
|
|
183
187
|
this.healthy = false;
|
|
184
|
-
return
|
|
188
|
+
return "error";
|
|
185
189
|
}
|
|
190
|
+
const body = { data: { key } };
|
|
191
|
+
if (typeof opts?.cas === "number") body.options = { cas: opts.cas };
|
|
186
192
|
try {
|
|
187
193
|
const res = await fetchWithTimeout(`${this.vaultAddr}/v1/${path}`, {
|
|
188
194
|
method: "POST",
|
|
@@ -190,14 +196,20 @@ class HashicorpVaultKmsService {
|
|
|
190
196
|
"X-Vault-Token": this.vaultToken,
|
|
191
197
|
"Content-Type": "application/json"
|
|
192
198
|
},
|
|
193
|
-
body: JSON.stringify(
|
|
199
|
+
body: JSON.stringify(body),
|
|
194
200
|
timeoutMs: this.requestTimeoutMs
|
|
195
201
|
});
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
202
|
+
if (res.ok) {
|
|
203
|
+
this.healthy = true;
|
|
204
|
+
return "ok";
|
|
205
|
+
}
|
|
206
|
+
if (typeof opts?.cas === "number" && res.status === 400) {
|
|
207
|
+
console.warn("\u26A0\uFE0F [encryption][kms] Vault write CAS conflict (concurrent DEK create)", { path, status: res.status });
|
|
208
|
+
return "conflict";
|
|
199
209
|
}
|
|
200
|
-
|
|
210
|
+
this.healthy = false;
|
|
211
|
+
console.warn("\u26A0\uFE0F [encryption][kms] Vault write failed", { path, status: res.status });
|
|
212
|
+
return "error";
|
|
201
213
|
} catch (err) {
|
|
202
214
|
this.healthy = false;
|
|
203
215
|
console.warn("\u26A0\uFE0F [encryption][kms] Vault write error", {
|
|
@@ -205,7 +217,7 @@ class HashicorpVaultKmsService {
|
|
|
205
217
|
error: err?.message || String(err),
|
|
206
218
|
timeoutMs: this.requestTimeoutMs
|
|
207
219
|
});
|
|
208
|
-
return
|
|
220
|
+
return "error";
|
|
209
221
|
}
|
|
210
222
|
}
|
|
211
223
|
buildKeyPath(tenantId) {
|
|
@@ -231,16 +243,32 @@ class HashicorpVaultKmsService {
|
|
|
231
243
|
return this.remember(dek);
|
|
232
244
|
}
|
|
233
245
|
async createTenantDek(tenantId) {
|
|
234
|
-
const key = generateDek();
|
|
235
246
|
const path = this.buildKeyPath(tenantId);
|
|
236
|
-
const
|
|
237
|
-
|
|
247
|
+
const existing = await this.readVault(path);
|
|
248
|
+
const existingKey = existing?.data?.data?.key;
|
|
249
|
+
if (existingKey) {
|
|
250
|
+
return this.remember({ tenantId, key: existingKey, fetchedAt: this.now() });
|
|
251
|
+
}
|
|
252
|
+
if (!this.healthy) return null;
|
|
253
|
+
const key = generateDek();
|
|
254
|
+
const outcome = await this.writeVault(path, key, { cas: 0 });
|
|
255
|
+
if (outcome === "ok") {
|
|
238
256
|
console.info("\u{1F511} [encryption][kms] Stored tenant DEK in Vault", { tenantId, path });
|
|
239
|
-
|
|
240
|
-
|
|
257
|
+
return this.remember({ tenantId, key, fetchedAt: this.now() });
|
|
258
|
+
}
|
|
259
|
+
if (outcome === "conflict") {
|
|
260
|
+
const winner = await this.readVault(path);
|
|
261
|
+
const winnerKey = winner?.data?.data?.key;
|
|
262
|
+
if (winnerKey) {
|
|
263
|
+
console.info("\u{1F511} [encryption][kms] Adopted concurrently-created tenant DEK", { tenantId, path });
|
|
264
|
+
return this.remember({ tenantId, key: winnerKey, fetchedAt: this.now() });
|
|
265
|
+
}
|
|
241
266
|
}
|
|
242
|
-
|
|
243
|
-
return
|
|
267
|
+
console.warn("\u26A0\uFE0F [encryption][kms] Failed to store tenant DEK in Vault", { tenantId, path });
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
invalidateDek(tenantId) {
|
|
271
|
+
this.cache.delete(tenantId);
|
|
244
272
|
}
|
|
245
273
|
}
|
|
246
274
|
let loggedDerivedKeyFallbackBanner = false;
|
|
@@ -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 { fetchWithTimeout, resolveTimeoutMs } from '../http/fetchWithTimeout'\n\nconst DEFAULT_VAULT_REQUEST_TIMEOUT_MS = 1_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\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}\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 console.warn('\u26A0\uFE0F [encryption][kms] Primary KMS failed, will try fallback', {\n error: (err as Error)?.message || String(err),\n })\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\ntype VaultClientOpts = {\n vaultAddr?: string\n vaultToken?: string\n mountPath?: string\n ttlMs?: number\n requestTimeoutMs?: number\n}\n\ntype VaultReadResponse = {\n data?: { data?: { key?: string; version?: number }; metadata?: Record<string, unknown> }\n}\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 healthy = true\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.debugEnabled = isEncryptionDebugEnabled()\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n if (this.debugEnabled) {\n console.warn('\u26A0\uFE0F [encryption][kms] 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 console.info('\uD83D\uDD10 [encryption][kms] Hashicorp Vault KMS enabled')\n }\n }\n }\n\n isHealthy(): boolean {\n return this.healthy\n }\n\n private now(): number {\n return Date.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 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 this.healthy = res.status < 500\n console.warn('\u26A0\uFE0F [encryption][kms] Vault read failed', { path, status: res.status })\n return null\n }\n if (this.debugEnabled) {\n console.info('\uD83D\uDD0D [encryption][kms] Vault read ok', { path })\n }\n return (await res.json()) as VaultReadResponse\n } catch (err) {\n this.healthy = false\n console.warn('\u26A0\uFE0F [encryption][kms] Vault read error', {\n path,\n error: (err as Error)?.message || String(err),\n timeoutMs: this.requestTimeoutMs,\n })\n return null\n }\n }\n\n private async writeVault(path: string, key: string): Promise<boolean> {\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n return false\n }\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({ data: { key } }),\n timeoutMs: this.requestTimeoutMs,\n })\n this.healthy = res.ok\n if (!res.ok) {\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write failed', { path, status: res.status })\n }\n return res.ok\n } catch (err) {\n this.healthy = false\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write error', {\n path,\n error: (err as Error)?.message || String(err),\n timeoutMs: this.requestTimeoutMs,\n })\n return false\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 console.warn('\u26A0\uFE0F [encryption][kms] 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 key = generateDek()\n const path = this.buildKeyPath(tenantId)\n const ok = await this.writeVault(path, key)\n if (ok) {\n console.info('\uD83D\uDD11 [encryption][kms] Stored tenant DEK in Vault', { tenantId, path })\n } else {\n console.warn('\u26A0\uFE0F [encryption][kms] Failed to store tenant DEK in Vault', { tenantId, path })\n }\n if (!ok) return null\n return this.remember({ tenantId, key, fetchedAt: this.now() })\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 console.warn(border)\n for (const line of body) {\n const padded = line.padEnd(width - 2, ' ')\n console.warn(`${redBg}${white} ${padded} ${reset}`)\n }\n console.warn(border)\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 console.warn(\n '\u26A0\uFE0F [encryption][kms] Vault not healthy or misconfigured (missing VAULT_ADDR/VAULT_TOKEN) and no fallback secret provided; falling back to noop KMS',\n )\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": "AAAA,OAAO,YAAY;AACnB,SAAS,aAAa,qBAAqB;AAC3C,SAAS,0BAA0B,qCAAqC;AACxE,SAAS,yBAAyB;AAClC,SAAS,kBAAkB,wBAAwB;AAEnD,MAAM,mCAAmC;AAEzC,SAAS,+BAAuC;AAC9C,QAAM,MAAM,QAAQ,IAAI;AACxB,QAAM,SAAS,MAAM,OAAO,SAAS,KAAK,EAAE,IAAI;AAChD,SAAO,iBAAiB,QAAQ,gCAAgC;AAClE;
|
|
4
|
+
"sourcesContent": ["import crypto from 'node:crypto'\nimport { generateDek, hashForLookup } from './aes'\nimport { isEncryptionDebugEnabled, isTenantDataEncryptionEnabled } from './toggles'\nimport { parseBooleanToken } from '../boolean'\nimport { fetchWithTimeout, resolveTimeoutMs } from '../http/fetchWithTimeout'\n\nconst DEFAULT_VAULT_REQUEST_TIMEOUT_MS = 1_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\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 console.warn('\u26A0\uFE0F [encryption][kms] Primary KMS failed, will try fallback', {\n error: (err as Error)?.message || String(err),\n })\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}\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 healthy = true\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.debugEnabled = isEncryptionDebugEnabled()\n if (!this.vaultAddr || !this.vaultToken) {\n this.healthy = false\n if (this.debugEnabled) {\n console.warn('\u26A0\uFE0F [encryption][kms] 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 console.info('\uD83D\uDD10 [encryption][kms] Hashicorp Vault KMS enabled')\n }\n }\n }\n\n isHealthy(): boolean {\n return this.healthy\n }\n\n private now(): number {\n return Date.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 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 this.healthy = res.status < 500\n console.warn('\u26A0\uFE0F [encryption][kms] Vault read failed', { path, status: res.status })\n return null\n }\n if (this.debugEnabled) {\n console.info('\uD83D\uDD0D [encryption][kms] Vault read ok', { path })\n }\n return (await res.json()) as VaultReadResponse\n } catch (err) {\n this.healthy = false\n console.warn('\u26A0\uFE0F [encryption][kms] Vault read error', {\n path,\n error: (err as Error)?.message || String(err),\n timeoutMs: this.requestTimeoutMs,\n })\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 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.healthy = true\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 don't flip `healthy`.\n if (typeof opts?.cas === 'number' && res.status === 400) {\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write CAS conflict (concurrent DEK create)', { path, status: res.status })\n return 'conflict'\n }\n this.healthy = false\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write failed', { path, status: res.status })\n return 'error'\n } catch (err) {\n this.healthy = false\n console.warn('\u26A0\uFE0F [encryption][kms] Vault write error', {\n path,\n error: (err as Error)?.message || String(err),\n timeoutMs: this.requestTimeoutMs,\n })\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 console.warn('\u26A0\uFE0F [encryption][kms] 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 console.info('\uD83D\uDD11 [encryption][kms] 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 console.info('\uD83D\uDD11 [encryption][kms] Adopted concurrently-created tenant DEK', { tenantId, path })\n return this.remember({ tenantId, key: winnerKey, fetchedAt: this.now() })\n }\n }\n console.warn('\u26A0\uFE0F [encryption][kms] 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 console.warn(border)\n for (const line of body) {\n const padded = line.padEnd(width - 2, ' ')\n console.warn(`${redBg}${white} ${padded} ${reset}`)\n }\n console.warn(border)\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 console.warn(\n '\u26A0\uFE0F [encryption][kms] Vault not healthy or misconfigured (missing VAULT_ADDR/VAULT_TOKEN) and no fallback secret provided; falling back to noop KMS',\n )\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
|
}
|