@shipfox/api-secrets 2.0.0
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 +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +116 -0
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +38 -0
- package/dist/config.js.map +1 -0
- package/dist/core/crypto.d.ts +25 -0
- package/dist/core/crypto.d.ts.map +1 -0
- package/dist/core/crypto.js +85 -0
- package/dist/core/crypto.js.map +1 -0
- package/dist/core/dek-manager.d.ts +16 -0
- package/dist/core/dek-manager.d.ts.map +1 -0
- package/dist/core/dek-manager.js +85 -0
- package/dist/core/dek-manager.js.map +1 -0
- package/dist/core/errors.d.ts +53 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +86 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/fingerprint.d.ts +2 -0
- package/dist/core/fingerprint.d.ts.map +1 -0
- package/dist/core/fingerprint.js +9 -0
- package/dist/core/fingerprint.js.map +1 -0
- package/dist/core/index.d.ts +30 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +97 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/key-provider.d.ts +17 -0
- package/dist/core/key-provider.d.ts.map +1 -0
- package/dist/core/key-provider.js +45 -0
- package/dist/core/key-provider.js.map +1 -0
- package/dist/core/local-secret-store.d.ts +19 -0
- package/dist/core/local-secret-store.d.ts.map +1 -0
- package/dist/core/local-secret-store.js +40 -0
- package/dist/core/local-secret-store.js.map +1 -0
- package/dist/core/management.d.ts +37 -0
- package/dist/core/management.d.ts.map +1 -0
- package/dist/core/management.js +403 -0
- package/dist/core/management.js.map +1 -0
- package/dist/core/rotate-kek.d.ts +10 -0
- package/dist/core/rotate-kek.d.ts.map +1 -0
- package/dist/core/rotate-kek.js +94 -0
- package/dist/core/rotate-kek.js.map +1 -0
- package/dist/core/secret-store.d.ts +33 -0
- package/dist/core/secret-store.d.ts.map +1 -0
- package/dist/core/secret-store.js +209 -0
- package/dist/core/secret-store.js.map +1 -0
- package/dist/core/store-resolver.d.ts +18 -0
- package/dist/core/store-resolver.d.ts.map +1 -0
- package/dist/core/store-resolver.js +10 -0
- package/dist/core/store-resolver.js.map +1 -0
- package/dist/core/store-validation.d.ts +11 -0
- package/dist/core/store-validation.d.ts.map +1 -0
- package/dist/core/store-validation.js +30 -0
- package/dist/core/store-validation.js.map +1 -0
- package/dist/core/value-cipher.d.ts +10 -0
- package/dist/core/value-cipher.d.ts.map +1 -0
- package/dist/core/value-cipher.js +22 -0
- package/dist/core/value-cipher.js.map +1 -0
- package/dist/core/variable-store.d.ts +24 -0
- package/dist/core/variable-store.d.ts.map +1 -0
- package/dist/core/variable-store.js +194 -0
- package/dist/core/variable-store.js.map +1 -0
- package/dist/db/cap.d.ts +8 -0
- package/dist/db/cap.d.ts.map +1 -0
- package/dist/db/cap.js +39 -0
- package/dist/db/cap.js.map +1 -0
- package/dist/db/data-keys.d.ts +24 -0
- package/dist/db/data-keys.d.ts.map +1 -0
- package/dist/db/data-keys.js +61 -0
- package/dist/db/data-keys.js.map +1 -0
- package/dist/db/db.d.ts +1254 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +24 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/index.d.ts +13 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +15 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/management.d.ts +50 -0
- package/dist/db/management.d.ts.map +1 -0
- package/dist/db/management.js +127 -0
- package/dist/db/management.js.map +1 -0
- package/dist/db/schema/common.d.ts +3 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +8 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/data-keys.d.ts +103 -0
- package/dist/db/schema/data-keys.d.ts.map +1 -0
- package/dist/db/schema/data-keys.js +24 -0
- package/dist/db/schema/data-keys.js.map +1 -0
- package/dist/db/schema/outbox.d.ts +195 -0
- package/dist/db/schema/outbox.d.ts.map +1 -0
- package/dist/db/schema/outbox.js +5 -0
- package/dist/db/schema/outbox.js.map +1 -0
- package/dist/db/schema/values.d.ts +193 -0
- package/dist/db/schema/values.d.ts.map +1 -0
- package/dist/db/schema/values.js +43 -0
- package/dist/db/schema/values.js.map +1 -0
- package/dist/db/schema/variables.d.ts +175 -0
- package/dist/db/schema/variables.d.ts.map +1 -0
- package/dist/db/schema/variables.js +41 -0
- package/dist/db/schema/variables.js.map +1 -0
- package/dist/db/scope.d.ts +20 -0
- package/dist/db/scope.d.ts.map +1 -0
- package/dist/db/scope.js +19 -0
- package/dist/db/scope.js.map +1 -0
- package/dist/db/values.d.ts +33 -0
- package/dist/db/values.d.ts.map +1 -0
- package/dist/db/values.js +119 -0
- package/dist/db/values.js.map +1 -0
- package/dist/db/variables.d.ts +32 -0
- package/dist/db/variables.d.ts.map +1 -0
- package/dist/db/variables.js +117 -0
- package/dist/db/variables.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +3 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +4 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +39 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +151 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/metrics/service.d.ts +2 -0
- package/dist/metrics/service.d.ts.map +1 -0
- package/dist/metrics/service.js +21 -0
- package/dist/metrics/service.js.map +1 -0
- package/dist/presentation/dto/index.d.ts +2 -0
- package/dist/presentation/dto/index.d.ts.map +1 -0
- package/dist/presentation/dto/index.js +3 -0
- package/dist/presentation/dto/index.js.map +1 -0
- package/dist/presentation/dto/management.d.ts +7 -0
- package/dist/presentation/dto/management.d.ts.map +1 -0
- package/dist/presentation/dto/management.js +32 -0
- package/dist/presentation/dto/management.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-secret.d.ts +2 -0
- package/dist/presentation/e2eRoutes/create-secret.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-secret.js +34 -0
- package/dist/presentation/e2eRoutes/create-secret.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-variable.d.ts +2 -0
- package/dist/presentation/e2eRoutes/create-variable.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-variable.js +34 -0
- package/dist/presentation/e2eRoutes/create-variable.js.map +1 -0
- package/dist/presentation/e2eRoutes/index.d.ts +3 -0
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/index.js +11 -0
- package/dist/presentation/e2eRoutes/index.js.map +1 -0
- package/dist/presentation/routes/auth.d.ts +16 -0
- package/dist/presentation/routes/auth.d.ts.map +1 -0
- package/dist/presentation/routes/auth.js +42 -0
- package/dist/presentation/routes/auth.js.map +1 -0
- package/dist/presentation/routes/batch-secrets.d.ts +2 -0
- package/dist/presentation/routes/batch-secrets.d.ts.map +1 -0
- package/dist/presentation/routes/batch-secrets.js +44 -0
- package/dist/presentation/routes/batch-secrets.js.map +1 -0
- package/dist/presentation/routes/batch-variables.d.ts +2 -0
- package/dist/presentation/routes/batch-variables.d.ts.map +1 -0
- package/dist/presentation/routes/batch-variables.js +44 -0
- package/dist/presentation/routes/batch-variables.js.map +1 -0
- package/dist/presentation/routes/cursor.d.ts +3 -0
- package/dist/presentation/routes/cursor.d.ts.map +1 -0
- package/dist/presentation/routes/cursor.js +19 -0
- package/dist/presentation/routes/cursor.js.map +1 -0
- package/dist/presentation/routes/delete-secret.d.ts +2 -0
- package/dist/presentation/routes/delete-secret.d.ts.map +1 -0
- package/dist/presentation/routes/delete-secret.js +40 -0
- package/dist/presentation/routes/delete-secret.js.map +1 -0
- package/dist/presentation/routes/delete-variable.d.ts +2 -0
- package/dist/presentation/routes/delete-variable.d.ts.map +1 -0
- package/dist/presentation/routes/delete-variable.js +40 -0
- package/dist/presentation/routes/delete-variable.js.map +1 -0
- package/dist/presentation/routes/errors.d.ts +2 -0
- package/dist/presentation/routes/errors.d.ts.map +1 -0
- package/dist/presentation/routes/errors.js +73 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/get-variable.d.ts +2 -0
- package/dist/presentation/routes/get-variable.d.ts.map +1 -0
- package/dist/presentation/routes/get-variable.js +42 -0
- package/dist/presentation/routes/get-variable.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +3 -0
- package/dist/presentation/routes/index.d.ts.map +1 -0
- package/dist/presentation/routes/index.js +29 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/list-secrets.d.ts +2 -0
- package/dist/presentation/routes/list-secrets.d.ts.map +1 -0
- package/dist/presentation/routes/list-secrets.js +50 -0
- package/dist/presentation/routes/list-secrets.js.map +1 -0
- package/dist/presentation/routes/list-variables.d.ts +2 -0
- package/dist/presentation/routes/list-variables.d.ts.map +1 -0
- package/dist/presentation/routes/list-variables.js +50 -0
- package/dist/presentation/routes/list-variables.js.map +1 -0
- package/dist/presentation/routes/put-secret.d.ts +2 -0
- package/dist/presentation/routes/put-secret.d.ts.map +1 -0
- package/dist/presentation/routes/put-secret.js +52 -0
- package/dist/presentation/routes/put-secret.js.map +1 -0
- package/dist/presentation/routes/put-variable.d.ts +2 -0
- package/dist/presentation/routes/put-variable.d.ts.map +1 -0
- package/dist/presentation/routes/put-variable.js +52 -0
- package/dist/presentation/routes/put-variable.js.map +1 -0
- package/dist/presentation/routes/warnings.d.ts +5 -0
- package/dist/presentation/routes/warnings.d.ts.map +1 -0
- package/dist/presentation/routes/warnings.js +16 -0
- package/dist/presentation/routes/warnings.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_lowly_ogun.sql +59 -0
- package/drizzle/meta/0000_snapshot.json +547 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +77 -0
- package/src/config.test.ts +41 -0
- package/src/config.ts +41 -0
- package/src/core/crypto.test.ts +115 -0
- package/src/core/crypto.ts +105 -0
- package/src/core/dek-manager.test.ts +120 -0
- package/src/core/dek-manager.ts +98 -0
- package/src/core/errors.ts +100 -0
- package/src/core/fingerprint.test.ts +27 -0
- package/src/core/fingerprint.ts +14 -0
- package/src/core/index.ts +153 -0
- package/src/core/key-provider.test.ts +44 -0
- package/src/core/key-provider.ts +70 -0
- package/src/core/local-secret-store.ts +49 -0
- package/src/core/management.test.ts +37 -0
- package/src/core/management.ts +503 -0
- package/src/core/rotate-kek.test.ts +183 -0
- package/src/core/rotate-kek.ts +100 -0
- package/src/core/secret-store.test.ts +301 -0
- package/src/core/secret-store.ts +251 -0
- package/src/core/store-resolver.test.ts +20 -0
- package/src/core/store-resolver.ts +28 -0
- package/src/core/store-validation.ts +43 -0
- package/src/core/value-cipher.ts +26 -0
- package/src/core/variable-store.test.ts +106 -0
- package/src/core/variable-store.ts +232 -0
- package/src/db/cap.ts +58 -0
- package/src/db/data-keys.ts +99 -0
- package/src/db/db.ts +26 -0
- package/src/db/index.ts +45 -0
- package/src/db/management.ts +252 -0
- package/src/db/schema/common.ts +8 -0
- package/src/db/schema/data-keys.ts +31 -0
- package/src/db/schema/outbox.ts +4 -0
- package/src/db/schema/values.ts +74 -0
- package/src/db/schema/variables.ts +71 -0
- package/src/db/scope.ts +42 -0
- package/src/db/values.ts +191 -0
- package/src/db/variables.ts +187 -0
- package/src/index.test.ts +11 -0
- package/src/index.ts +50 -0
- package/src/metrics/index.ts +19 -0
- package/src/metrics/instance.ts +219 -0
- package/src/metrics/service.ts +19 -0
- package/src/presentation/dto/index.ts +1 -0
- package/src/presentation/dto/management.ts +36 -0
- package/src/presentation/e2eRoutes/create-secret.ts +31 -0
- package/src/presentation/e2eRoutes/create-variable.ts +34 -0
- package/src/presentation/e2eRoutes/index.test.ts +94 -0
- package/src/presentation/e2eRoutes/index.ts +8 -0
- package/src/presentation/routes/auth.ts +50 -0
- package/src/presentation/routes/batch-secrets.ts +33 -0
- package/src/presentation/routes/batch-variables.ts +33 -0
- package/src/presentation/routes/cursor.ts +23 -0
- package/src/presentation/routes/delete-secret.ts +26 -0
- package/src/presentation/routes/delete-variable.ts +26 -0
- package/src/presentation/routes/errors.ts +63 -0
- package/src/presentation/routes/get-variable.ts +32 -0
- package/src/presentation/routes/index.ts +29 -0
- package/src/presentation/routes/list-secrets.ts +41 -0
- package/src/presentation/routes/list-variables.ts +41 -0
- package/src/presentation/routes/management.test.ts +452 -0
- package/src/presentation/routes/put-secret.ts +39 -0
- package/src/presentation/routes/put-variable.ts +39 -0
- package/src/presentation/routes/warnings.ts +23 -0
- package/test/env.ts +8 -0
- package/test/globalSetup.ts +18 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +7 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +12 -0
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shipfox/api-secrets",
|
|
3
|
+
"license": "MIT",
|
|
4
|
+
"version": "2.0.0",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
8
|
+
"directory": "libs/api/secrets"
|
|
9
|
+
},
|
|
10
|
+
"private": false,
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"imports": {
|
|
15
|
+
"#test/*": "./test/*",
|
|
16
|
+
"#*": {
|
|
17
|
+
"workspace-source": "./src/*",
|
|
18
|
+
"development": "./src/*",
|
|
19
|
+
"default": "./dist/*"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"development": {
|
|
25
|
+
"types": "./src/index.ts",
|
|
26
|
+
"default": "./src/index.ts"
|
|
27
|
+
},
|
|
28
|
+
"default": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"./core/errors": {
|
|
34
|
+
"development": {
|
|
35
|
+
"types": "./src/core/errors.ts",
|
|
36
|
+
"default": "./src/core/errors.ts"
|
|
37
|
+
},
|
|
38
|
+
"default": {
|
|
39
|
+
"types": "./dist/core/errors.d.ts",
|
|
40
|
+
"default": "./dist/core/errors.js"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"drizzle-orm": "^0.45.2",
|
|
46
|
+
"zod": "^4.4.3",
|
|
47
|
+
"@shipfox/api-auth-context": "2.0.0",
|
|
48
|
+
"@shipfox/config": "1.2.1",
|
|
49
|
+
"@shipfox/api-projects": "2.0.0",
|
|
50
|
+
"@shipfox/api-secrets-dto": "2.0.0",
|
|
51
|
+
"@shipfox/node-drizzle": "0.2.1",
|
|
52
|
+
"@shipfox/node-fastify": "0.2.1",
|
|
53
|
+
"@shipfox/node-module": "0.2.0",
|
|
54
|
+
"@shipfox/node-opentelemetry": "0.5.0",
|
|
55
|
+
"@shipfox/node-outbox": "0.2.1",
|
|
56
|
+
"@shipfox/node-postgres": "0.4.1"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/pg": "^8.15.5",
|
|
60
|
+
"drizzle-kit": "^0.31.10",
|
|
61
|
+
"fastify": "^5.3.3",
|
|
62
|
+
"@shipfox/biome": "1.8.1",
|
|
63
|
+
"@shipfox/swc": "1.2.5",
|
|
64
|
+
"@shipfox/typescript": "1.1.6",
|
|
65
|
+
"@shipfox/vitest": "1.2.2",
|
|
66
|
+
"@shipfox/ts-config": "1.3.8"
|
|
67
|
+
},
|
|
68
|
+
"scripts": {
|
|
69
|
+
"build": "shipfox-swc",
|
|
70
|
+
"check": "shipfox-biome-check",
|
|
71
|
+
"check:fix": "shipfox-biome-check --write",
|
|
72
|
+
"test": "shipfox-vitest-run",
|
|
73
|
+
"test:watch": "shipfox-vitest-watch",
|
|
74
|
+
"type": "shipfox-tsc-check",
|
|
75
|
+
"type:emit": "shipfox-tsc-emit"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {vi} from '@shipfox/vitest/vi';
|
|
2
|
+
|
|
3
|
+
const VALID_KEY = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
|
4
|
+
|
|
5
|
+
describe('secrets config', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.unstubAllEnvs();
|
|
8
|
+
vi.resetModules();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it.each(['not-base64', `${VALID_KEY}\n`])('rejects malformed KEKs: %s', async (value) => {
|
|
12
|
+
vi.stubEnv('SECRETS_ENCRYPTION_KEK', value);
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
|
|
15
|
+
await expect(import('./config.js')).rejects.toThrow('SECRETS_ENCRYPTION_KEK');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('accepts current and previous KEKs', async () => {
|
|
19
|
+
vi.stubEnv('SECRETS_ENCRYPTION_KEK', VALID_KEY);
|
|
20
|
+
vi.stubEnv('SECRETS_ENCRYPTION_KEK_PREVIOUS', 'MTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWY=');
|
|
21
|
+
vi.resetModules();
|
|
22
|
+
|
|
23
|
+
const {config} = await import('./config.js');
|
|
24
|
+
|
|
25
|
+
expect(config.SECRETS_ENCRYPTION_KEK).toBe(VALID_KEY);
|
|
26
|
+
expect(config.SECRETS_ENCRYPTION_KEK_PREVIOUS).toBe(
|
|
27
|
+
'MTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWY=',
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it.each([
|
|
32
|
+
['SECRETS_MAX_PER_WORKSPACE', '0'],
|
|
33
|
+
['SECRETS_SHORT_VALUE_WARN_LENGTH', '0'],
|
|
34
|
+
])('rejects invalid numeric config %s=%s', async (name, value) => {
|
|
35
|
+
vi.stubEnv('SECRETS_ENCRYPTION_KEK', VALID_KEY);
|
|
36
|
+
vi.stubEnv(name, value);
|
|
37
|
+
vi.resetModules();
|
|
38
|
+
|
|
39
|
+
await expect(import('./config.js')).rejects.toThrow(name);
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {SECRETS_MAX_LIST_LIMIT} from '@shipfox/api-secrets-dto';
|
|
2
|
+
import {createConfig, num, str} from '@shipfox/config';
|
|
3
|
+
import {decodeBase64Key} from '#core/crypto.js';
|
|
4
|
+
|
|
5
|
+
export const MAX_VALUE_BYTES = 64 * 1024;
|
|
6
|
+
|
|
7
|
+
export const config = createConfig({
|
|
8
|
+
SECRETS_ENCRYPTION_KEK: str({
|
|
9
|
+
desc: 'Master key used to protect all stored secrets. Required. Generate a unique value per environment with openssl rand -base64 32 and provide it from a secret manager. The committed .env value is only for local development. Losing this key makes stored secrets unrecoverable. To rotate it, set SECRETS_ENCRYPTION_KEK_PREVIOUS to the old value during the rotation window.',
|
|
10
|
+
}),
|
|
11
|
+
SECRETS_ENCRYPTION_KEK_PREVIOUS: str({
|
|
12
|
+
desc: 'Previous master key used only while rotating stored secret data keys. Optional. Set it to the old SECRETS_ENCRYPTION_KEK value until rotation has completed.',
|
|
13
|
+
default: undefined,
|
|
14
|
+
}),
|
|
15
|
+
SECRETS_MAX_PER_WORKSPACE: num({
|
|
16
|
+
desc: 'Maximum number of secrets and variables allowed per workspace across all project scopes and namespaces.',
|
|
17
|
+
default: 10000,
|
|
18
|
+
}),
|
|
19
|
+
SECRETS_SHORT_VALUE_WARN_LENGTH: num({
|
|
20
|
+
desc: 'Secret values shorter than this many characters are classified for a write warning by management routes.',
|
|
21
|
+
default: 12,
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
decodeBase64Key(config.SECRETS_ENCRYPTION_KEK, 'SECRETS_ENCRYPTION_KEK');
|
|
26
|
+
if (config.SECRETS_ENCRYPTION_KEK_PREVIOUS) {
|
|
27
|
+
decodeBase64Key(config.SECRETS_ENCRYPTION_KEK_PREVIOUS, 'SECRETS_ENCRYPTION_KEK_PREVIOUS');
|
|
28
|
+
}
|
|
29
|
+
if (config.SECRETS_MAX_PER_WORKSPACE < 1) {
|
|
30
|
+
throw new Error('SECRETS_MAX_PER_WORKSPACE must be greater than 0.');
|
|
31
|
+
}
|
|
32
|
+
if (config.SECRETS_MAX_PER_WORKSPACE > SECRETS_MAX_LIST_LIMIT) {
|
|
33
|
+
// The settings UI lists the whole bounded set in one call (limit = SECRETS_MAX_LIST_LIMIT).
|
|
34
|
+
// A cap above that would let the list silently truncate, so fail fast instead.
|
|
35
|
+
throw new Error(
|
|
36
|
+
`SECRETS_MAX_PER_WORKSPACE (${config.SECRETS_MAX_PER_WORKSPACE}) cannot exceed SECRETS_MAX_LIST_LIMIT (${SECRETS_MAX_LIST_LIMIT}).`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (config.SECRETS_SHORT_VALUE_WARN_LENGTH < 1) {
|
|
40
|
+
throw new Error('SECRETS_SHORT_VALUE_WARN_LENGTH must be greater than 0.');
|
|
41
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import {describe, expect, it} from '@shipfox/vitest/vi';
|
|
3
|
+
import {aadForValue, aesGcmOpen, aesGcmSeal, decodeBase64Key} from './crypto.js';
|
|
4
|
+
import {SecretDecryptionError} from './errors.js';
|
|
5
|
+
|
|
6
|
+
const V1_PREFIX_PATTERN = /^v1:/;
|
|
7
|
+
const STRIP_WHITESPACE_PATTERN = /Strip whitespace/;
|
|
8
|
+
const BASE64_PADDING_SUFFIX = /=+$/;
|
|
9
|
+
|
|
10
|
+
describe('AES-GCM secret crypto', () => {
|
|
11
|
+
it('round-trips with non-deterministic ciphertext', () => {
|
|
12
|
+
const key = crypto.randomBytes(32);
|
|
13
|
+
const aad = aadForValue({
|
|
14
|
+
workspaceId: crypto.randomUUID(),
|
|
15
|
+
scope: {projectId: null},
|
|
16
|
+
namespace: '',
|
|
17
|
+
key: 'API_KEY',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const first = aesGcmSeal({key, plaintext: Buffer.from('secret'), aad});
|
|
21
|
+
const second = aesGcmSeal({key, plaintext: Buffer.from('secret'), aad});
|
|
22
|
+
const opened = aesGcmOpen({key, encoded: first, aad});
|
|
23
|
+
|
|
24
|
+
expect(first).toMatch(V1_PREFIX_PATTERN);
|
|
25
|
+
expect(first).not.toContain('secret');
|
|
26
|
+
expect(first).not.toBe(second);
|
|
27
|
+
expect(opened.toString('utf8')).toBe('secret');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects tampered ciphertext and wrong AAD', () => {
|
|
31
|
+
const key = crypto.randomBytes(32);
|
|
32
|
+
const aad = aadForValue({
|
|
33
|
+
workspaceId: crypto.randomUUID(),
|
|
34
|
+
scope: {projectId: '2df9f89a-98a0-4a4a-8d7d-8f356207b449'},
|
|
35
|
+
namespace: 'system/agent/model-provider/openai',
|
|
36
|
+
key: 'API_KEY',
|
|
37
|
+
});
|
|
38
|
+
const encoded = aesGcmSeal({key, plaintext: Buffer.from('secret'), aad});
|
|
39
|
+
|
|
40
|
+
expect(() => aesGcmOpen({key, encoded: `${encoded.slice(0, -1)}A`, aad})).toThrow(
|
|
41
|
+
SecretDecryptionError,
|
|
42
|
+
);
|
|
43
|
+
expect(() =>
|
|
44
|
+
aesGcmOpen({
|
|
45
|
+
key,
|
|
46
|
+
encoded,
|
|
47
|
+
aad: aadForValue({
|
|
48
|
+
workspaceId: crypto.randomUUID(),
|
|
49
|
+
scope: {projectId: '2df9f89a-98a0-4a4a-8d7d-8f356207b449'},
|
|
50
|
+
namespace: 'system/agent/model-provider/openai',
|
|
51
|
+
key: 'API_KEY',
|
|
52
|
+
}),
|
|
53
|
+
}),
|
|
54
|
+
).toThrow(SecretDecryptionError);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('binds ciphertext to namespace and key', () => {
|
|
58
|
+
const key = crypto.randomBytes(32);
|
|
59
|
+
const workspaceId = crypto.randomUUID();
|
|
60
|
+
const scope = {projectId: crypto.randomUUID()};
|
|
61
|
+
const aad = aadForValue({
|
|
62
|
+
workspaceId,
|
|
63
|
+
scope,
|
|
64
|
+
namespace: 'system/agent/model-provider/openai',
|
|
65
|
+
key: 'API_KEY',
|
|
66
|
+
});
|
|
67
|
+
const encoded = aesGcmSeal({key, plaintext: Buffer.from('secret'), aad});
|
|
68
|
+
|
|
69
|
+
expect(() =>
|
|
70
|
+
aesGcmOpen({
|
|
71
|
+
key,
|
|
72
|
+
encoded,
|
|
73
|
+
aad: aadForValue({
|
|
74
|
+
workspaceId,
|
|
75
|
+
scope,
|
|
76
|
+
namespace: 'system/agent/model-provider/anthropic',
|
|
77
|
+
key: 'API_KEY',
|
|
78
|
+
}),
|
|
79
|
+
}),
|
|
80
|
+
).toThrow(SecretDecryptionError);
|
|
81
|
+
expect(() =>
|
|
82
|
+
aesGcmOpen({
|
|
83
|
+
key,
|
|
84
|
+
encoded,
|
|
85
|
+
aad: aadForValue({
|
|
86
|
+
workspaceId,
|
|
87
|
+
scope,
|
|
88
|
+
namespace: 'system/agent/model-provider/openai',
|
|
89
|
+
key: 'OTHER_KEY',
|
|
90
|
+
}),
|
|
91
|
+
}),
|
|
92
|
+
).toThrow(SecretDecryptionError);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('rejects too-short and non-canonical encodings', () => {
|
|
96
|
+
const key = crypto.randomBytes(32);
|
|
97
|
+
|
|
98
|
+
expect(() => aesGcmOpen({key, encoded: 'v1:AA==', aad: 'aad'})).toThrow(SecretDecryptionError);
|
|
99
|
+
expect(() => aesGcmOpen({key, encoded: 'v1:AA==\n', aad: 'aad'})).toThrow(
|
|
100
|
+
SecretDecryptionError,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('strictly validates base64 keys', () => {
|
|
105
|
+
const encoded = Buffer.alloc(32, 1).toString('base64');
|
|
106
|
+
const key = decodeBase64Key(encoded, 'TEST_KEY');
|
|
107
|
+
|
|
108
|
+
expect(key).toHaveLength(32);
|
|
109
|
+
expect(() => decodeBase64Key(encoded.replace(BASE64_PADDING_SUFFIX, ''), 'TEST_KEY')).toThrow(
|
|
110
|
+
STRIP_WHITESPACE_PATTERN,
|
|
111
|
+
);
|
|
112
|
+
expect(() => decodeBase64Key(`${encoded}=`, 'TEST_KEY')).toThrow(STRIP_WHITESPACE_PATTERN);
|
|
113
|
+
expect(() => decodeBase64Key(`${encoded}\n`, 'TEST_KEY')).toThrow(STRIP_WHITESPACE_PATTERN);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import {KekConfigurationError, SecretDecryptionError} from './errors.js';
|
|
3
|
+
|
|
4
|
+
const CIPHER = 'aes-256-gcm';
|
|
5
|
+
const ENCODED_PREFIX = 'v1:';
|
|
6
|
+
const IV_BYTES = 12;
|
|
7
|
+
const AUTH_TAG_BYTES = 16;
|
|
8
|
+
const KEY_BYTES = 32;
|
|
9
|
+
const BASE64_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
10
|
+
const BASE64_PADDING_SUFFIX = /=+$/;
|
|
11
|
+
|
|
12
|
+
export interface AesGcmSealParams {
|
|
13
|
+
key: Buffer;
|
|
14
|
+
plaintext: Buffer;
|
|
15
|
+
aad: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AesGcmOpenParams {
|
|
19
|
+
key: Buffer;
|
|
20
|
+
encoded: string;
|
|
21
|
+
aad: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SecretScope {
|
|
25
|
+
projectId?: string | null | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SecretValueAadParams {
|
|
29
|
+
workspaceId: string;
|
|
30
|
+
scope?: SecretScope | undefined;
|
|
31
|
+
namespace: string;
|
|
32
|
+
key: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function aesGcmSeal(params: AesGcmSealParams): string {
|
|
36
|
+
const iv = crypto.randomBytes(IV_BYTES);
|
|
37
|
+
const cipher = crypto.createCipheriv(CIPHER, params.key, iv);
|
|
38
|
+
cipher.setAAD(Buffer.from(params.aad, 'utf8'));
|
|
39
|
+
|
|
40
|
+
const ciphertext = Buffer.concat([cipher.update(params.plaintext), cipher.final()]);
|
|
41
|
+
const authTag = cipher.getAuthTag();
|
|
42
|
+
|
|
43
|
+
return `${ENCODED_PREFIX}${Buffer.concat([iv, authTag, ciphertext]).toString('base64')}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function aesGcmOpen(params: AesGcmOpenParams): Buffer {
|
|
47
|
+
if (!params.encoded.startsWith(ENCODED_PREFIX)) throw new SecretDecryptionError();
|
|
48
|
+
|
|
49
|
+
const encodedPayload = params.encoded.slice(ENCODED_PREFIX.length);
|
|
50
|
+
const payload = Buffer.from(encodedPayload, 'base64');
|
|
51
|
+
const canonical = payload.toString('base64');
|
|
52
|
+
if (
|
|
53
|
+
!BASE64_KEY_PATTERN.test(encodedPayload) ||
|
|
54
|
+
canonical.replace(BASE64_PADDING_SUFFIX, '') !==
|
|
55
|
+
encodedPayload.replace(BASE64_PADDING_SUFFIX, '')
|
|
56
|
+
) {
|
|
57
|
+
throw new SecretDecryptionError();
|
|
58
|
+
}
|
|
59
|
+
if (payload.length < IV_BYTES + AUTH_TAG_BYTES) throw new SecretDecryptionError();
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const iv = payload.subarray(0, IV_BYTES);
|
|
63
|
+
const authTag = payload.subarray(IV_BYTES, IV_BYTES + AUTH_TAG_BYTES);
|
|
64
|
+
const ciphertext = payload.subarray(IV_BYTES + AUTH_TAG_BYTES);
|
|
65
|
+
const decipher = crypto.createDecipheriv(CIPHER, params.key, iv);
|
|
66
|
+
decipher.setAAD(Buffer.from(params.aad, 'utf8'));
|
|
67
|
+
decipher.setAuthTag(authTag);
|
|
68
|
+
|
|
69
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof SecretDecryptionError) throw error;
|
|
72
|
+
throw new SecretDecryptionError();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function decodeBase64Key(encoded: string | undefined, label: string): Buffer {
|
|
77
|
+
if (!encoded) {
|
|
78
|
+
throw new KekConfigurationError(
|
|
79
|
+
`${label} is required and must be a base64-encoded 32-byte key. Generate one with openssl rand -base64 32.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const key = Buffer.from(encoded, 'base64');
|
|
84
|
+
if (key.length !== KEY_BYTES || !isCanonicalBase64Key(encoded, key)) {
|
|
85
|
+
throw new KekConfigurationError(
|
|
86
|
+
`${label} must be a canonical base64-encoded 32-byte key. Strip whitespace and generate a new value with openssl rand -base64 32 if needed.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return key;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function aadForDek(workspaceId: string, kekVersion: string): string {
|
|
94
|
+
return JSON.stringify([workspaceId, kekVersion]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function aadForValue(params: SecretValueAadParams): string {
|
|
98
|
+
const projectId = params.scope?.projectId ?? null;
|
|
99
|
+
const scopeTuple = projectId !== null ? ['project', projectId] : ['workspace'];
|
|
100
|
+
return JSON.stringify([params.workspaceId, scopeTuple, params.namespace, params.key]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isCanonicalBase64Key(encoded: string, key: Buffer): boolean {
|
|
104
|
+
return BASE64_KEY_PATTERN.test(encoded) && key.toString('base64') === encoded;
|
|
105
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import {describe, expect, it} from '@shipfox/vitest/vi';
|
|
3
|
+
import {eq} from 'drizzle-orm';
|
|
4
|
+
import {db, insertDataKeyIfAbsent, secretDataKeys} from '#db/index.js';
|
|
5
|
+
import {DekManager} from './dek-manager.js';
|
|
6
|
+
import {createLocalKeyProvider} from './key-provider.js';
|
|
7
|
+
|
|
8
|
+
describe('DekManager', () => {
|
|
9
|
+
it('persists one data key across concurrent first use', async () => {
|
|
10
|
+
const workspaceId = crypto.randomUUID();
|
|
11
|
+
const manager = new DekManager(createLocalKeyProvider({currentKek: crypto.randomBytes(32)}), {
|
|
12
|
+
maxEntries: 10,
|
|
13
|
+
ttlMs: 60_000,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const [first, second] = await Promise.all([
|
|
17
|
+
manager.getPlaintextDek(workspaceId),
|
|
18
|
+
manager.getPlaintextDek(workspaceId),
|
|
19
|
+
]);
|
|
20
|
+
const rows = await db()
|
|
21
|
+
.select()
|
|
22
|
+
.from(secretDataKeys)
|
|
23
|
+
.where(eq(secretDataKeys.workspaceId, workspaceId));
|
|
24
|
+
|
|
25
|
+
expect(rows).toHaveLength(1);
|
|
26
|
+
expect(first.equals(second)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('reports whether a first-use data key insert won the race', async () => {
|
|
30
|
+
const workspaceId = crypto.randomUUID();
|
|
31
|
+
const keyProvider = createLocalKeyProvider({currentKek: crypto.randomBytes(32)});
|
|
32
|
+
const first = keyProvider.wrapDek(workspaceId, crypto.randomBytes(32));
|
|
33
|
+
const second = keyProvider.wrapDek(workspaceId, crypto.randomBytes(32));
|
|
34
|
+
|
|
35
|
+
const inserted = await insertDataKeyIfAbsent({
|
|
36
|
+
workspaceId,
|
|
37
|
+
wrappedDek: first.wrappedDek,
|
|
38
|
+
kekVersion: first.kekVersion,
|
|
39
|
+
});
|
|
40
|
+
const skipped = await insertDataKeyIfAbsent({
|
|
41
|
+
workspaceId,
|
|
42
|
+
wrappedDek: second.wrappedDek,
|
|
43
|
+
kekVersion: second.kekVersion,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(inserted).toBe(true);
|
|
47
|
+
expect(skipped).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('re-reads persisted data keys on cold start', async () => {
|
|
51
|
+
const workspaceId = crypto.randomUUID();
|
|
52
|
+
const keyProvider = createLocalKeyProvider({currentKek: crypto.randomBytes(32)});
|
|
53
|
+
const firstManager = new DekManager(keyProvider, {maxEntries: 10, ttlMs: 60_000});
|
|
54
|
+
const secondManager = new DekManager(keyProvider, {maxEntries: 10, ttlMs: 60_000});
|
|
55
|
+
|
|
56
|
+
const first = await firstManager.getPlaintextDek(workspaceId);
|
|
57
|
+
const second = await secondManager.getPlaintextDek(workspaceId);
|
|
58
|
+
|
|
59
|
+
expect(second.equals(first)).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('serves cache hits without reading the persisted data key', async () => {
|
|
63
|
+
const workspaceId = crypto.randomUUID();
|
|
64
|
+
const manager = new DekManager(createLocalKeyProvider({currentKek: crypto.randomBytes(32)}), {
|
|
65
|
+
maxEntries: 10,
|
|
66
|
+
ttlMs: 60_000,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const first = await manager.getPlaintextDek(workspaceId);
|
|
70
|
+
await db().delete(secretDataKeys).where(eq(secretDataKeys.workspaceId, workspaceId));
|
|
71
|
+
const second = await manager.getPlaintextDek(workspaceId);
|
|
72
|
+
|
|
73
|
+
expect(second.equals(first)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns defensive copies of cached data keys', async () => {
|
|
77
|
+
const workspaceId = crypto.randomUUID();
|
|
78
|
+
const manager = new DekManager(createLocalKeyProvider({currentKek: crypto.randomBytes(32)}), {
|
|
79
|
+
maxEntries: 10,
|
|
80
|
+
ttlMs: 60_000,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const first = await manager.getPlaintextDek(workspaceId);
|
|
84
|
+
const expected = Buffer.from(first);
|
|
85
|
+
first.fill(0);
|
|
86
|
+
const second = await manager.getPlaintextDek(workspaceId);
|
|
87
|
+
|
|
88
|
+
expect(second.equals(expected)).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('refreshes expired cache entries from storage', async () => {
|
|
92
|
+
const workspaceId = crypto.randomUUID();
|
|
93
|
+
const manager = new DekManager(createLocalKeyProvider({currentKek: crypto.randomBytes(32)}), {
|
|
94
|
+
maxEntries: 10,
|
|
95
|
+
ttlMs: -1,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const first = await manager.getPlaintextDek(workspaceId);
|
|
99
|
+
await db().delete(secretDataKeys).where(eq(secretDataKeys.workspaceId, workspaceId));
|
|
100
|
+
const second = await manager.getPlaintextDek(workspaceId);
|
|
101
|
+
|
|
102
|
+
expect(second.equals(first)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('evicts the least recently used cache entry when full', async () => {
|
|
106
|
+
const evictedWorkspaceId = crypto.randomUUID();
|
|
107
|
+
const otherWorkspaceId = crypto.randomUUID();
|
|
108
|
+
const manager = new DekManager(createLocalKeyProvider({currentKek: crypto.randomBytes(32)}), {
|
|
109
|
+
maxEntries: 1,
|
|
110
|
+
ttlMs: 60_000,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const first = await manager.getPlaintextDek(evictedWorkspaceId);
|
|
114
|
+
await manager.getPlaintextDek(otherWorkspaceId);
|
|
115
|
+
await db().delete(secretDataKeys).where(eq(secretDataKeys.workspaceId, evictedWorkspaceId));
|
|
116
|
+
const second = await manager.getPlaintextDek(evictedWorkspaceId);
|
|
117
|
+
|
|
118
|
+
expect(second.equals(first)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import {getDataKey, insertDataKeyIfAbsent} from '#db/index.js';
|
|
3
|
+
import {classifyDekAccessError, recordSecretsDekAccess} from '#metrics/instance.js';
|
|
4
|
+
import type {KeyProvider} from './key-provider.js';
|
|
5
|
+
|
|
6
|
+
const DEK_BYTES = 32;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Plaintext DEKs live in memory by design so hot secret reads avoid unwrapping on
|
|
10
|
+
* every access. Node cannot reliably zeroize Buffers, so residency is bounded by
|
|
11
|
+
* LRU size and lazy TTL instead of pretending wipes are a complete mitigation.
|
|
12
|
+
*/
|
|
13
|
+
export class DekManager {
|
|
14
|
+
readonly #cache = new Map<string, {dek: Buffer; expiresAt: number}>();
|
|
15
|
+
readonly #keyProvider: KeyProvider;
|
|
16
|
+
readonly #options: {maxEntries: number; ttlMs: number};
|
|
17
|
+
|
|
18
|
+
constructor(keyProvider: KeyProvider, options: {maxEntries: number; ttlMs: number}) {
|
|
19
|
+
this.#keyProvider = keyProvider;
|
|
20
|
+
this.#options = options;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async getPlaintextDek(workspaceId: string): Promise<Buffer> {
|
|
24
|
+
const startedAt = Date.now();
|
|
25
|
+
try {
|
|
26
|
+
const cached = this.#cache.get(workspaceId);
|
|
27
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
28
|
+
this.#cache.delete(workspaceId);
|
|
29
|
+
this.#cache.set(workspaceId, cached);
|
|
30
|
+
recordSecretsDekAccess({outcome: 'cache_hit', durationMs: Date.now() - startedAt});
|
|
31
|
+
return Buffer.from(cached.dek);
|
|
32
|
+
}
|
|
33
|
+
const hadExpiredCache = Boolean(cached);
|
|
34
|
+
if (cached) this.#cache.delete(workspaceId);
|
|
35
|
+
|
|
36
|
+
const existing = await getDataKey(workspaceId);
|
|
37
|
+
if (existing) {
|
|
38
|
+
const dek = this.#keyProvider.unwrapDek(
|
|
39
|
+
workspaceId,
|
|
40
|
+
existing.wrappedDek,
|
|
41
|
+
existing.kekVersion,
|
|
42
|
+
);
|
|
43
|
+
this.#set(workspaceId, dek);
|
|
44
|
+
recordSecretsDekAccess({
|
|
45
|
+
outcome: hadExpiredCache ? 'cache_expired' : 'db_unwrapped',
|
|
46
|
+
durationMs: Date.now() - startedAt,
|
|
47
|
+
});
|
|
48
|
+
return Buffer.from(dek);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const generatedDek = crypto.randomBytes(DEK_BYTES);
|
|
52
|
+
const wrapped = this.#keyProvider.wrapDek(workspaceId, generatedDek);
|
|
53
|
+
const inserted = await insertDataKeyIfAbsent({
|
|
54
|
+
workspaceId,
|
|
55
|
+
wrappedDek: wrapped.wrappedDek,
|
|
56
|
+
kekVersion: wrapped.kekVersion,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// The DEK row commits before value writes. If concurrent first-use inserts race,
|
|
60
|
+
// the primary key decides the winner and every caller re-reads the persisted row.
|
|
61
|
+
const persisted = await getDataKey(workspaceId);
|
|
62
|
+
if (!persisted) throw new Error(`Data key was not persisted for workspace ${workspaceId}`);
|
|
63
|
+
const dek = this.#keyProvider.unwrapDek(
|
|
64
|
+
workspaceId,
|
|
65
|
+
persisted.wrappedDek,
|
|
66
|
+
persisted.kekVersion,
|
|
67
|
+
);
|
|
68
|
+
this.#set(workspaceId, dek);
|
|
69
|
+
recordSecretsDekAccess({
|
|
70
|
+
outcome: inserted ? 'generated' : 'db_unwrapped',
|
|
71
|
+
durationMs: Date.now() - startedAt,
|
|
72
|
+
});
|
|
73
|
+
return Buffer.from(dek);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
recordSecretsDekAccess({
|
|
76
|
+
outcome: classifyDekAccessError(error),
|
|
77
|
+
durationMs: Date.now() - startedAt,
|
|
78
|
+
});
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
invalidate(workspaceId: string): void {
|
|
84
|
+
this.#cache.delete(workspaceId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#set(workspaceId: string, dek: Buffer): void {
|
|
88
|
+
this.#cache.set(workspaceId, {
|
|
89
|
+
dek: Buffer.from(dek),
|
|
90
|
+
expiresAt: Date.now() + this.#options.ttlMs,
|
|
91
|
+
});
|
|
92
|
+
while (this.#cache.size > this.#options.maxEntries) {
|
|
93
|
+
const oldest = this.#cache.keys().next().value as string | undefined;
|
|
94
|
+
if (!oldest) break;
|
|
95
|
+
this.#cache.delete(oldest);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|