diff --git a/backend/package-lock.json b/backend/package-lock.json index 90ab0600..6c98c776 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "markdown-it": "^14.1.1", "multer": "^2.0.2", "openai": "^6.36.0", + "sodium-native": "^5.1.0", "uuid": "^9.0.1", "zod": "^4.4.3" }, @@ -2770,6 +2771,20 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", @@ -2879,6 +2894,135 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bare-addon-resolve": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.0.tgz", + "integrity": "sha512-sSd0jieRJlDaODOzj0oe0RjFVC1QI0ZIjGIdPkbrTXsdVVtENg14c+lHHAhHwmWCZ2nQlMhy8jA3Y5LYPc/isA==", + "license": "Apache-2.0", + "dependencies": { + "bare-module-resolve": "^1.10.0", + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-ansi-escapes": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/bare-ansi-escapes/-/bare-ansi-escapes-2.2.3.tgz", + "integrity": "sha512-02ES4/E2RbrtZSnHJ9LntBhYkLA6lPpSEeP8iqS3MccBIVhVBlEmruF1I7HZqx5Q8aiTeYfQVeqmrU9YO2yYoQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-stream": "^2.6.5" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-assert": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.2.0.tgz", + "integrity": "sha512-c6uvgvTJBspTDxtVnPgrBKmLgcpW3Fp72NVKDLg6oT4QjQbhGtvrkHMhGYMK1sh4vjBHOBmuUalyt9hSzV37fQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-inspect": "^3.1.2" + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-inspect": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/bare-inspect/-/bare-inspect-3.1.4.tgz", + "integrity": "sha512-jfW5KRA84o3REpI6Vr4nbvMn+hqVAw8GU1mMdRwUsY5yJovQamxYeKGVKGqdzs+8ZbG4jRzGUXP/3Ji/DnqfPg==", + "license": "Apache-2.0", + "dependencies": { + "bare-ansi-escapes": "^2.1.0", + "bare-type": "^1.0.0" + }, + "engines": { + "bare": ">=1.18.0" + } + }, + "node_modules/bare-module-resolve": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.2.tgz", + "integrity": "sha512-j+hiD5k99qec4KjJvYsI67q5AOBifmy9JG3oeMVxTmvrhn2sIdp8StrUvZu4YNgwTpO+NhniQG16N1ETDe1k5w==", + "license": "Apache-2.0", + "dependencies": { + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-semver": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.3.tgz", + "integrity": "sha512-HS/A30bi2+PiRJfU6R4+Kp+6KeLSCSByjYM2iiobOKzLAvtu1CT+S8xWfiU7wz0erknjkUoC+yXy108tzIuP5Q==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", + "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bare-type/-/bare-type-1.1.0.tgz", + "integrity": "sha512-LdtnnEEYldOc87Dr4GpsKnStStZk3zfgoEMXy8yvEZkXrcCv9RtYDrUYWFsBQHtaB0s1EUWmcvS6XmEZYIj3Bw==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.2.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3779,6 +3923,15 @@ "node": ">= 0.6" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -3941,6 +4094,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -6502,6 +6661,18 @@ "node": ">= 6" } }, + "node_modules/require-addon": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz", + "integrity": "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==", + "license": "Apache-2.0", + "dependencies": { + "bare-addon-resolve": "^1.3.0" + }, + "engines": { + "bare": ">=1.10.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6812,6 +6983,20 @@ "node": ">=8" } }, + "node_modules/sodium-native": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", + "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", + "license": "MIT", + "dependencies": { + "bare-assert": "^1.2.0", + "require-addon": "^1.1.0", + "which-runtime": "^1.2.1" + }, + "engines": { + "bare": ">=1.16.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6870,6 +7055,17 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -7077,6 +7273,15 @@ "node": ">=6" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -7145,6 +7350,15 @@ "node": "*" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7521,6 +7735,12 @@ "node": ">= 8" } }, + "node_modules/which-runtime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/which-runtime/-/which-runtime-1.3.2.tgz", + "integrity": "sha512-5kwCfWml7+b2NO7KrLMhYihjRx0teKkd3yGp1Xk5Vaf2JGdSh+rgVhEALAD9c/59dP+YwJHXoEO7e8QPy7gOkw==", + "license": "Apache-2.0" + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 3406d8a2..f62af94e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,7 +41,7 @@ "prepack": "npm run build", "typecheck": "tsc --noEmit", "test": "jest", - "test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/agentv3/__tests__/analysisPatternMemory.test.ts src/agentv3/__tests__/claudeRuntimeRuntimeSnapshots.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts src/services/__tests__/enterpriseDb.test.ts src/services/__tests__/enterpriseSchema.test.ts src/services/__tests__/enterpriseRepository.test.ts src/services/__tests__/enterpriseKnowledgeScope.test.ts src/services/__tests__/enterpriseMigration.test.ts src/services/__tests__/runtimeSnapshotStore.test.ts src/services/providerManager/__tests__/enterpriseProviderStore.test.ts src/routes/__tests__/enterpriseTraceMetadataRoutes.test.ts src/routes/__tests__/enterpriseReportRoutes.test.ts", + "test:core": "jest --runInBand --forceExit src/agent/communication/__tests__/agentMessageBus.test.ts src/agent/core/executors/__tests__/strategyExecutor.test.ts src/agent/core/executors/__tests__/hypothesisExecutor.test.ts src/agent/context/__tests__/enhancedSessionContext.test.ts src/tests/adbTools.test.ts src/services/__tests__/sessionLogger.test.ts src/services/__tests__/traceAnalysisSkillConfig.test.ts src/agent/agents/domain/__tests__/registry.test.ts src/agentv3/__tests__/sqlIncludeInjector.test.ts src/agentv3/__tests__/analysisPatternMemory.test.ts src/agentv3/__tests__/claudeRuntimeRuntimeSnapshots.test.ts src/middleware/__tests__/auth.test.ts src/services/__tests__/rbac.test.ts src/routes/__tests__/agentRoutesRbac.test.ts src/routes/__tests__/ownerGuardRoutes.test.ts src/routes/__tests__/requestContextRouteCoverage.test.ts src/middleware/__tests__/legacyApiCompatibility.test.ts src/services/__tests__/enterpriseDb.test.ts src/services/__tests__/enterpriseSchema.test.ts src/services/__tests__/enterpriseRepository.test.ts src/services/__tests__/enterpriseKnowledgeScope.test.ts src/services/__tests__/enterpriseMigration.test.ts src/services/__tests__/runtimeSnapshotStore.test.ts src/services/providerManager/__tests__/localSecretStore.test.ts src/services/providerManager/__tests__/enterpriseProviderStore.test.ts src/routes/__tests__/enterpriseTraceMetadataRoutes.test.ts src/routes/__tests__/enterpriseReportRoutes.test.ts", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:unit": "jest --testPathPatterns=src/tests", @@ -97,6 +97,7 @@ "markdown-it": "^14.1.1", "multer": "^2.0.2", "openai": "^6.36.0", + "sodium-native": "^5.1.0", "uuid": "^9.0.1", "zod": "^4.4.3" }, diff --git a/backend/src/routes/providerRoutes.ts b/backend/src/routes/providerRoutes.ts index 793412aa..acf5bdeb 100644 --- a/backend/src/routes/providerRoutes.ts +++ b/backend/src/routes/providerRoutes.ts @@ -117,6 +117,18 @@ router.post('/:id/runtime', (req, res) => { } }); +router.post('/:id/rotate-secret', (req, res) => { + try { + const svc = getProviderService(); + const scope = providerScopeForRequest(req); + const secretVersion = svc.rotateSecret(req.params.id, scope); + res.json({ success: true, secretVersion, provider: svc.get(req.params.id, scope) }); + } catch (err: any) { + const status = err.message.includes('not found') ? 404 : 400; + res.status(status).json({ success: false, error: err.message }); + } +}); + router.post('/:id/test', async (req, res) => { const svc = getProviderService(); const provider = svc.getRaw(req.params.id, providerScopeForRequest(req)); diff --git a/backend/src/services/providerManager/__tests__/enterpriseProviderStore.test.ts b/backend/src/services/providerManager/__tests__/enterpriseProviderStore.test.ts index 84043b7e..cebdfd02 100644 --- a/backend/src/services/providerManager/__tests__/enterpriseProviderStore.test.ts +++ b/backend/src/services/providerManager/__tests__/enterpriseProviderStore.test.ts @@ -7,7 +7,11 @@ import os from 'os'; import path from 'path'; import { ENTERPRISE_FEATURE_FLAG_ENV } from '../../../config'; import { ENTERPRISE_DB_PATH_ENV, openEnterpriseDb } from '../../enterpriseDb'; -import { SECRET_STORE_DIR_ENV } from '../localSecretStore'; +import { listEnterpriseAuditEvents } from '../../enterpriseAuditService'; +import { + SECRET_STORE_DIR_ENV, + SECRET_STORE_MASTER_KEY_ENV, +} from '../localSecretStore'; import { ProviderService } from '../providerService'; import type { ProviderCreateInput, ProviderScope } from '../types'; @@ -15,6 +19,7 @@ const originalEnv = { enterprise: process.env[ENTERPRISE_FEATURE_FLAG_ENV], enterpriseDbPath: process.env[ENTERPRISE_DB_PATH_ENV], secretStoreDir: process.env[SECRET_STORE_DIR_ENV], + secretStoreMasterKey: process.env[SECRET_STORE_MASTER_KEY_ENV], }; interface ProviderCredentialRow { @@ -80,6 +85,7 @@ beforeEach(async () => { process.env[ENTERPRISE_FEATURE_FLAG_ENV] = 'true'; process.env[ENTERPRISE_DB_PATH_ENV] = dbPath; process.env[SECRET_STORE_DIR_ENV] = secretDir; + process.env[SECRET_STORE_MASTER_KEY_ENV] = Buffer.alloc(32, 3).toString('base64'); svc = new ProviderService(path.join(tmpDir, 'providers.json')); }); @@ -87,6 +93,7 @@ afterEach(async () => { restoreEnvValue(ENTERPRISE_FEATURE_FLAG_ENV, originalEnv.enterprise); restoreEnvValue(ENTERPRISE_DB_PATH_ENV, originalEnv.enterpriseDbPath); restoreEnvValue(SECRET_STORE_DIR_ENV, originalEnv.secretStoreDir); + restoreEnvValue(SECRET_STORE_MASTER_KEY_ENV, originalEnv.secretStoreMasterKey); if (tmpDir) { await fs.rm(tmpDir, { recursive: true, force: true }); tmpDir = undefined; @@ -110,11 +117,13 @@ describe('enterprise provider store', () => { expect(row.policy_json).toContain('openaiBaseUrl'); expect(row.policy_json).not.toContain('sk-enterprise-secret-a'); expect(row.secret_ref).toMatch(/^secret:provider:/); + expect(JSON.parse(row.policy_json).secretVersion).toBe(2); const providerJsonPath = path.join(tmpDir!, 'providers.json'); await expect(fs.access(providerJsonPath)).rejects.toBeTruthy(); const secretFile = await fs.readFile(path.join(secretDir, 'provider-secrets.enc.json'), 'utf-8'); + expect(secretFile).toContain('libsodium-secretbox'); expect(secretFile).not.toContain('sk-enterprise-secret-a'); expect(svc.getEnvForProvider(provider.id, scope('user-a'))!.OPENAI_API_KEY) .toBe('sk-enterprise-secret-a'); @@ -140,4 +149,31 @@ describe('enterprise provider store', () => { expect(svc.getEffectiveEnv(scope('user-a'))!.OPENAI_API_KEY).toBe('sk-user-a'); expect(svc.getEffectiveEnv(scope('user-b'))!.OPENAI_API_KEY).toBe('sk-user-b'); }); + + it('rotates provider secrets and audits secret lifecycle operations', () => { + const provider = svc.create(input, scope('user-a')); + expect(svc.getEnvForProvider(provider.id, scope('user-a'))!.OPENAI_API_KEY) + .toBe('sk-enterprise-secret-a'); + + expect(svc.rotateSecret(provider.id, scope('user-a'))).toBe(2); + expect(svc.getEnvForProvider(provider.id, scope('user-a'))!.OPENAI_API_KEY) + .toBe('sk-enterprise-secret-a'); + + const row = readProviderRows()[0]; + expect(JSON.parse(row.policy_json).secretVersion).toBe(2); + + const db = openEnterpriseDb(dbPath); + try { + const actions = listEnterpriseAuditEvents(db) + .filter(event => event.resource_type === 'provider_secret') + .map(event => event.action); + expect(actions).toEqual(expect.arrayContaining([ + 'provider.secret.create', + 'provider.secret.read', + 'provider.secret.rotate', + ])); + } finally { + db.close(); + } + }); }); diff --git a/backend/src/services/providerManager/__tests__/localSecretStore.test.ts b/backend/src/services/providerManager/__tests__/localSecretStore.test.ts new file mode 100644 index 00000000..28183eca --- /dev/null +++ b/backend/src/services/providerManager/__tests__/localSecretStore.test.ts @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2024-2026 Gracker (Chris) +// This file is part of SmartPerfetto. See LICENSE for details. + +import fs from 'fs/promises'; +import crypto from 'crypto'; +import os from 'os'; +import path from 'path'; +import { + LocalEncryptedSecretStore, + SECRET_STORE_MASTER_KEY_ENV, +} from '../localSecretStore'; + +const originalMasterKey = process.env[SECRET_STORE_MASTER_KEY_ENV]; + +function restoreEnvValue(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +describe('LocalEncryptedSecretStore', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartperfetto-secret-store-')); + process.env[SECRET_STORE_MASTER_KEY_ENV] = Buffer.alloc(32, 7).toString('base64'); + }); + + afterEach(async () => { + restoreEnvValue(SECRET_STORE_MASTER_KEY_ENV, originalMasterKey); + await fs.rm(tmpDir, {recursive: true, force: true}); + }); + + it('encrypts provider secrets with libsodium secretbox and never writes plaintext', async () => { + const store = new LocalEncryptedSecretStore(tmpDir); + expect(store.info()).toEqual(expect.objectContaining({ + algorithm: 'libsodium-secretbox', + masterKeySource: 'env', + })); + + expect(store.put('secret:provider:test', { + openaiApiKey: 'sk-secret-value', + openaiBaseUrl: 'not-sensitive-but-ignored-by-caller', + empty: '', + })).toBe(1); + + const raw = await fs.readFile(path.join(tmpDir, 'provider-secrets.enc.json'), 'utf-8'); + expect(raw).toContain('libsodium-secretbox'); + expect(raw).not.toContain('sk-secret-value'); + expect(raw).not.toContain('not-sensitive-but-ignored-by-caller'); + await expect(fs.access(path.join(tmpDir, '.master-key'))).rejects.toBeTruthy(); + + expect(store.get('secret:provider:test')).toEqual({ + openaiApiKey: 'sk-secret-value', + openaiBaseUrl: 'not-sensitive-but-ignored-by-caller', + }); + }); + + it('rotates ciphertext and version without changing the decrypted secret', async () => { + const store = new LocalEncryptedSecretStore(tmpDir); + store.put('secret:provider:test', {openaiApiKey: 'sk-secret-value'}); + const before = await fs.readFile(path.join(tmpDir, 'provider-secrets.enc.json'), 'utf-8'); + + expect(store.rotate('secret:provider:test')).toBe(2); + const after = await fs.readFile(path.join(tmpDir, 'provider-secrets.enc.json'), 'utf-8'); + + expect(after).not.toEqual(before); + expect(store.getVersion('secret:provider:test')).toBe(2); + expect(store.get('secret:provider:test')).toEqual({ + openaiApiKey: 'sk-secret-value', + }); + }); + + it('migrates legacy AES-GCM secret files to libsodium on read', async () => { + const key = Buffer.alloc(32, 7); + const iv = Buffer.alloc(12, 4); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const ciphertext = Buffer.concat([ + cipher.update(JSON.stringify({openaiApiKey: 'sk-legacy-secret'}), 'utf-8'), + cipher.final(), + ]); + await fs.mkdir(tmpDir, {recursive: true}); + await fs.writeFile(path.join(tmpDir, 'provider-secrets.enc.json'), JSON.stringify({ + version: 1, + entries: { + 'secret:provider:legacy': { + version: 3, + algorithm: 'aes-256-gcm', + iv: iv.toString('base64'), + tag: cipher.getAuthTag().toString('base64'), + ciphertext: ciphertext.toString('base64'), + updatedAt: 123, + }, + }, + }), 'utf-8'); + + const store = new LocalEncryptedSecretStore(tmpDir); + + expect(store.get('secret:provider:legacy')).toEqual({ + openaiApiKey: 'sk-legacy-secret', + }); + expect(store.getVersion('secret:provider:legacy')).toBe(3); + const migratedRaw = await fs.readFile(path.join(tmpDir, 'provider-secrets.enc.json'), 'utf-8'); + expect(JSON.parse(migratedRaw).version).toBe(2); + expect(migratedRaw).toContain('libsodium-secretbox'); + expect(migratedRaw).not.toContain('sk-legacy-secret'); + }); + + it('fails closed when the configured master key changes', () => { + const store = new LocalEncryptedSecretStore(tmpDir); + store.put('secret:provider:test', {openaiApiKey: 'sk-secret-value'}); + + process.env[SECRET_STORE_MASTER_KEY_ENV] = Buffer.alloc(32, 9).toString('base64'); + const wrongKeyStore = new LocalEncryptedSecretStore(tmpDir); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(wrongKeyStore.get('secret:provider:test')).toEqual({}); + warnSpy.mockRestore(); + }); +}); diff --git a/backend/src/services/providerManager/localSecretStore.ts b/backend/src/services/providerManager/localSecretStore.ts index 9be9846d..f5f78a33 100644 --- a/backend/src/services/providerManager/localSecretStore.ts +++ b/backend/src/services/providerManager/localSecretStore.ts @@ -3,13 +3,42 @@ // This file is part of SmartPerfetto. See LICENSE for details. import crypto from 'crypto'; +import { execFileSync } from 'child_process'; import fs from 'fs'; import path from 'path'; +const sodium = require('sodium-native') as { + crypto_secretbox_easy: (ciphertext: Buffer, message: Buffer, nonce: Buffer, key: Buffer) => void; + crypto_secretbox_open_easy: (message: Buffer, ciphertext: Buffer, nonce: Buffer, key: Buffer) => boolean; + crypto_secretbox_KEYBYTES: number; + crypto_secretbox_NONCEBYTES: number; + crypto_secretbox_MACBYTES: number; + randombytes_buf: (buffer: Buffer) => void; +}; + export const SECRET_STORE_DIR_ENV = 'SMARTPERFETTO_SECRET_STORE_DIR'; export const SECRET_STORE_MASTER_KEY_ENV = 'SMARTPERFETTO_SECRET_STORE_MASTER_KEY'; +export const SECRET_STORE_KEYRING_SERVICE_ENV = 'SMARTPERFETTO_SECRET_STORE_KEYRING_SERVICE'; +export const SECRET_STORE_KEYRING_ACCOUNT_ENV = 'SMARTPERFETTO_SECRET_STORE_KEYRING_ACCOUNT'; +export const SECRET_STORE_ALLOW_LOCAL_MASTER_KEY_ENV = 'SMARTPERFETTO_SECRET_STORE_ALLOW_LOCAL_MASTER_KEY'; + +type SecretAlgorithm = 'libsodium-secretbox'; +type MasterKeySource = 'env' | 'keyring' | 'local-dev-file'; interface EncryptedSecretEntry { + version: number; + algorithm: SecretAlgorithm; + nonce: string; + ciphertext: string; + updatedAt: number; +} + +interface EncryptedSecretFile { + version: 2; + entries: Record; +} + +interface LegacyEncryptedSecretEntry { version: number; algorithm: 'aes-256-gcm'; iv: string; @@ -18,9 +47,15 @@ interface EncryptedSecretEntry { updatedAt: number; } -interface EncryptedSecretFile { +interface LegacyEncryptedSecretFile { version: 1; - entries: Record; + entries: Record; +} + +export interface SecretStoreInfo { + filePath: string; + masterKeySource: MasterKeySource; + algorithm: SecretAlgorithm; } function resolveSecretStoreDir(): string { @@ -37,46 +72,217 @@ function decodeMasterKey(raw: string): Buffer { } try { const decoded = Buffer.from(trimmed, 'base64'); - if (decoded.length === 32) return decoded; + if (decoded.length === sodium.crypto_secretbox_KEYBYTES) return decoded; } catch { // Fall through to passphrase hashing. } return crypto.createHash('sha256').update(trimmed, 'utf8').digest(); } -function readOrCreateLocalMasterKey(dir: string): Buffer { - const keyPath = path.join(dir, '.master-key'); - if (fs.existsSync(keyPath)) { - return decodeMasterKey(fs.readFileSync(keyPath, 'utf-8')); +function encodeMasterKey(key: Buffer): string { + return key.toString('base64'); +} + +function randomMasterKey(): Buffer { + const key = Buffer.alloc(sodium.crypto_secretbox_KEYBYTES); + sodium.randombytes_buf(key); + return key; +} + +function randomNonce(): Buffer { + const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES); + sodium.randombytes_buf(nonce); + return nonce; +} + +function keyringService(): string { + return process.env[SECRET_STORE_KEYRING_SERVICE_ENV]?.trim() || 'SmartPerfetto SecretStore'; +} + +function keyringAccount(): string { + return process.env[SECRET_STORE_KEYRING_ACCOUNT_ENV]?.trim() || 'provider-master-key'; +} + +function readMacosKeyring(service: string, account: string): string | null { + try { + return execFileSync('/usr/bin/security', [ + 'find-generic-password', + '-s', + service, + '-a', + account, + '-w', + ], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return null; } - const key = crypto.randomBytes(32); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(keyPath, key.toString('base64'), { mode: 0o600 }); +} + +function writeMacosKeyring(service: string, account: string, secret: string): void { + execFileSync('/usr/bin/security', [ + 'add-generic-password', + '-U', + '-s', + service, + '-a', + account, + '-w', + secret, + ], {stdio: 'ignore'}); +} + +function readLinuxKeyring(service: string, account: string): string | null { + try { + return execFileSync('secret-tool', [ + 'lookup', + 'service', + service, + 'account', + account, + ], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return null; + } +} + +function writeLinuxKeyring(service: string, account: string, secret: string): void { + execFileSync('secret-tool', [ + 'store', + '--label', + service, + 'service', + service, + 'account', + account, + ], { + input: secret, + stdio: ['pipe', 'ignore', 'ignore'], + }); +} + +function readKeyringSecret(): string | null { + const service = keyringService(); + const account = keyringAccount(); + if (process.platform === 'darwin') return readMacosKeyring(service, account); + if (process.platform === 'linux') return readLinuxKeyring(service, account); + return null; +} + +function writeKeyringSecret(secret: string): void { + const service = keyringService(); + const account = keyringAccount(); + if (process.platform === 'darwin') { + writeMacosKeyring(service, account, secret); + return; + } + if (process.platform === 'linux') { + writeLinuxKeyring(service, account, secret); + return; + } + throw new Error(`OS keyring is not supported on platform ${process.platform}`); +} + +function readLegacyLocalMasterKey(dir: string): Buffer | null { + const keyPath = path.join(dir, '.master-key'); + if (!fs.existsSync(keyPath)) return null; + return decodeMasterKey(fs.readFileSync(keyPath, 'utf-8')); +} + +function readOrCreateLocalDevMasterKey(dir: string): Buffer { + const existing = readLegacyLocalMasterKey(dir); + if (existing) return existing; + const key = randomMasterKey(); + const keyPath = path.join(dir, '.master-key'); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync(keyPath, encodeMasterKey(key), {mode: 0o600}); try { fs.chmodSync(keyPath, 0o600); } catch { /* Windows */ } return key; } -function resolveMasterKey(dir: string): Buffer { +function localMasterKeyFallbackAllowed(): boolean { + const configured = process.env[SECRET_STORE_ALLOW_LOCAL_MASTER_KEY_ENV]; + if (!configured) return process.env.NODE_ENV === 'test'; + return ['1', 'true', 'yes', 'on', 'enabled'].includes(configured.trim().toLowerCase()); +} + +function resolveMasterKey(dir: string): {key: Buffer; source: MasterKeySource} { const configured = process.env[SECRET_STORE_MASTER_KEY_ENV]; if (configured && configured.trim().length > 0) { - return decodeMasterKey(configured); + return {key: decodeMasterKey(configured), source: 'env'}; + } + + const keyringSecret = readKeyringSecret(); + if (keyringSecret) { + return {key: decodeMasterKey(keyringSecret), source: 'keyring'}; + } + + const legacyLocalKey = readLegacyLocalMasterKey(dir); + if (legacyLocalKey) { + try { + writeKeyringSecret(encodeMasterKey(legacyLocalKey)); + return {key: legacyLocalKey, source: 'keyring'}; + } catch { + if (localMasterKeyFallbackAllowed()) { + return {key: legacyLocalKey, source: 'local-dev-file'}; + } + throw new Error( + `SecretStore master key exists only in local file ${path.join(dir, '.master-key')}; OS keyring is unavailable. Set ${SECRET_STORE_MASTER_KEY_ENV} for tests/dev or configure OS keyring.`, + ); + } + } + + const newKey = randomMasterKey(); + try { + writeKeyringSecret(encodeMasterKey(newKey)); + return {key: newKey, source: 'keyring'}; + } catch { + if (localMasterKeyFallbackAllowed()) { + return {key: readOrCreateLocalDevMasterKey(dir), source: 'local-dev-file'}; + } + throw new Error( + `OS keyring is unavailable for SecretStore master key. Set ${SECRET_STORE_MASTER_KEY_ENV} for tests/dev or install/configure an OS keyring provider.`, + ); } - return readOrCreateLocalMasterKey(dir); } function emptySecretFile(): EncryptedSecretFile { - return { version: 1, entries: {} }; + return {version: 2, entries: {}}; +} + +function normalizeSecretObject(value: Record): Record { + return Object.fromEntries( + Object.entries(value) + .filter(([, v]) => typeof v === 'string' && v.length > 0) + .sort(([a], [b]) => a.localeCompare(b)), + ); } export class LocalEncryptedSecretStore { private readonly dir: string; private readonly filePath: string; private readonly key: Buffer; + private readonly masterKeySource: MasterKeySource; constructor(dir: string = resolveSecretStoreDir()) { this.dir = dir; this.filePath = path.join(dir, 'provider-secrets.enc.json'); - this.key = resolveMasterKey(dir); + const resolved = resolveMasterKey(dir); + this.key = resolved.key; + this.masterKeySource = resolved.source; + } + + info(): SecretStoreInfo { + return { + filePath: this.filePath, + masterKeySource: this.masterKeySource, + algorithm: 'libsodium-secretbox', + }; } get(ref: string): Record { @@ -84,17 +290,19 @@ export class LocalEncryptedSecretStore { const entry = file.entries[ref]; if (!entry) return {}; try { - const decipher = crypto.createDecipheriv( - entry.algorithm, + const ciphertext = Buffer.from(entry.ciphertext, 'base64'); + if (ciphertext.length < sodium.crypto_secretbox_MACBYTES) { + throw new Error('ciphertext too short'); + } + const plaintext = Buffer.alloc(ciphertext.length - sodium.crypto_secretbox_MACBYTES); + const ok = sodium.crypto_secretbox_open_easy( + plaintext, + ciphertext, + Buffer.from(entry.nonce, 'base64'), this.key, - Buffer.from(entry.iv, 'base64'), ); - decipher.setAuthTag(Buffer.from(entry.tag, 'base64')); - const plaintext = Buffer.concat([ - decipher.update(Buffer.from(entry.ciphertext, 'base64')), - decipher.final(), - ]).toString('utf-8'); - const parsed = JSON.parse(plaintext); + if (!ok) throw new Error('secretbox authentication failed'); + const parsed = JSON.parse(plaintext.toString('utf-8')); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}; @@ -105,25 +313,16 @@ export class LocalEncryptedSecretStore { } put(ref: string, value: Record): number { - const file = this.readFile(); - const previous = file.entries[ref]; - const iv = crypto.randomBytes(12); - const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv); - const ciphertext = Buffer.concat([ - cipher.update(JSON.stringify(value), 'utf-8'), - cipher.final(), - ]); - const version = (previous?.version ?? 0) + 1; - file.entries[ref] = { - version, - algorithm: 'aes-256-gcm', - iv: iv.toString('base64'), - tag: cipher.getAuthTag().toString('base64'), - ciphertext: ciphertext.toString('base64'), - updatedAt: Date.now(), - }; - this.writeFile(file); - return version; + return this.writeEncrypted(ref, value); + } + + rotate(ref: string, value?: Record): number { + const next = value ?? this.get(ref); + return this.writeEncrypted(ref, next); + } + + getVersion(ref: string): number | undefined { + return this.readFile().entries[ref]?.version; } delete(ref: string): boolean { @@ -135,22 +334,84 @@ export class LocalEncryptedSecretStore { return true; } + private writeEncrypted(ref: string, value: Record): number { + const file = this.readFile(); + const previous = file.entries[ref]; + const version = (previous?.version ?? 0) + 1; + file.entries[ref] = this.encryptEntry( + Buffer.from(JSON.stringify(normalizeSecretObject(value)), 'utf-8'), + version, + Date.now(), + ); + this.writeFile(file); + return version; + } + private readFile(): EncryptedSecretFile { if (!fs.existsSync(this.filePath)) return emptySecretFile(); try { const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf-8')); - return parsed && parsed.version === 1 && parsed.entries && typeof parsed.entries === 'object' - ? parsed as EncryptedSecretFile - : emptySecretFile(); + if (parsed && parsed.version === 2 && parsed.entries && typeof parsed.entries === 'object') { + return parsed as EncryptedSecretFile; + } + if (parsed && parsed.version === 1 && parsed.entries && typeof parsed.entries === 'object') { + const migrated = this.migrateLegacyFile(parsed as LegacyEncryptedSecretFile); + this.writeFile(migrated); + return migrated; + } + return emptySecretFile(); } catch { return emptySecretFile(); } } + private encryptEntry( + plaintext: Buffer, + version: number, + updatedAt: number, + ): EncryptedSecretEntry { + const nonce = randomNonce(); + const ciphertext = Buffer.alloc(plaintext.length + sodium.crypto_secretbox_MACBYTES); + sodium.crypto_secretbox_easy(ciphertext, plaintext, nonce, this.key); + return { + version, + algorithm: 'libsodium-secretbox', + nonce: nonce.toString('base64'), + ciphertext: ciphertext.toString('base64'), + updatedAt, + }; + } + + private migrateLegacyFile(file: LegacyEncryptedSecretFile): EncryptedSecretFile { + const migrated = emptySecretFile(); + for (const [ref, entry] of Object.entries(file.entries)) { + try { + const decipher = crypto.createDecipheriv( + entry.algorithm, + this.key, + Buffer.from(entry.iv, 'base64'), + ); + decipher.setAuthTag(Buffer.from(entry.tag, 'base64')); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(entry.ciphertext, 'base64')), + decipher.final(), + ]); + migrated.entries[ref] = this.encryptEntry( + plaintext, + entry.version, + entry.updatedAt, + ); + } catch (err) { + console.warn('[LocalSecretStore] Failed to migrate legacy AES secret:', (err as Error).message); + } + } + return migrated; + } + private writeFile(file: EncryptedSecretFile): void { - fs.mkdirSync(this.dir, { recursive: true }); + fs.mkdirSync(this.dir, {recursive: true}); const tmp = `${this.filePath}.tmp`; - fs.writeFileSync(tmp, JSON.stringify(file, null, 2), { mode: 0o600 }); + fs.writeFileSync(tmp, JSON.stringify(file, null, 2), {mode: 0o600}); fs.renameSync(tmp, this.filePath); try { fs.chmodSync(this.filePath, 0o600); } catch { /* Windows */ } } diff --git a/backend/src/services/providerManager/providerService.ts b/backend/src/services/providerManager/providerService.ts index d597a3a5..409c83d6 100644 --- a/backend/src/services/providerManager/providerService.ts +++ b/backend/src/services/providerManager/providerService.ts @@ -189,6 +189,16 @@ export class ProviderService { this.store.delete(id, scope); } + rotateSecret(id: string, scope?: ProviderScope): number { + const existing = this.store.get(id, scope); + if (!existing) throw new Error(`Provider not found: ${id}`); + const version = this.store.rotateSecret(id, scope); + if (version === undefined) { + throw new Error('Secret rotation is only available for the enterprise provider store'); + } + return version; + } + activate(id: string, scope?: ProviderScope): void { const target = this.store.get(id, scope); if (!target) throw new Error(`Provider not found: ${id}`); diff --git a/backend/src/services/providerManager/providerStore.ts b/backend/src/services/providerManager/providerStore.ts index 6c4520eb..1b507891 100644 --- a/backend/src/services/providerManager/providerStore.ts +++ b/backend/src/services/providerManager/providerStore.ts @@ -3,12 +3,14 @@ import * as fs from 'fs'; import * as path from 'path'; +import crypto from 'crypto'; import { openEnterpriseDb } from '../enterpriseDb'; import { enterpriseDbReadAuthorityEnabled, enterpriseDbWritesEnabled, legacyFilesystemWritesEnabled, } from '../enterpriseMigration'; +import { recordEnterpriseAuditEvent } from '../enterpriseAuditService'; import type { ProviderConfig, ProviderConnection, ProviderScope } from './types'; import { LocalEncryptedSecretStore } from './localSecretStore'; @@ -246,6 +248,11 @@ export class ProviderStore { return deleted; } + rotateSecret(id: string, scope?: ProviderScope): number | undefined { + if (!enterpriseProviderDbWritesEnabled()) return undefined; + return this.rotateEnterpriseSecret(id, scope); + } + private getSecretStore(): LocalEncryptedSecretStore { if (!this.secretStore) { this.secretStore = new LocalEncryptedSecretStore(); @@ -344,6 +351,17 @@ export class ProviderStore { existing?.created_at ?? toEpochMs(provider.createdAt), toEpochMs(provider.updatedAt), ); + this.recordProviderSecretAudit(db, { + action: existing ? 'provider.secret.write' : 'provider.secret.create', + row: { + id: provider.id, + tenant_id: resolved.tenantId, + workspace_id: workspaceId, + owner_user_id: ownerUserId, + secret_ref: secretRef, + }, + secretVersion, + }); } finally { db.close(); } @@ -361,6 +379,11 @@ export class ProviderStore { `).run({ ...resolved, id }); if (result.changes > 0) { this.getSecretStore().delete(row.secret_ref); + this.recordProviderSecretAudit(db, { + action: 'provider.secret.delete', + row, + secretVersion: this.readSecretVersionFromPolicy(row), + }); } return result.changes > 0; } finally { @@ -368,6 +391,38 @@ export class ProviderStore { } } + private rotateEnterpriseSecret(id: string, scope?: ProviderScope): number | undefined { + const resolved = resolveProviderScope(scope); + const row = this.getEnterpriseRowById(id, resolved); + if (!row) return undefined; + const secretVersion = this.getSecretStore().rotate(row.secret_ref); + const policy = { + ...parseJsonObject(row.policy_json), + secretVersion, + }; + const db = openEnterpriseDb(); + try { + db.prepare(` + UPDATE provider_credentials + SET policy_json = @policyJson, updated_at = @updatedAt + WHERE id = @id AND ${accessibleProviderWhere()} + `).run({ + ...resolved, + id, + policyJson: JSON.stringify(policy), + updatedAt: Date.now(), + }); + this.recordProviderSecretAudit(db, { + action: 'provider.secret.rotate', + row, + secretVersion, + }); + return secretVersion; + } finally { + db.close(); + } + } + private getEnterpriseRowById(id: string, scope: Required): ProviderCredentialRow | undefined { const db = openEnterpriseDb(); try { @@ -388,6 +443,11 @@ export class ProviderStore { if (typeof models.primary !== 'string' || typeof models.light !== 'string') { return null; } + this.recordProviderSecretAudit(undefined, { + action: 'provider.secret.read', + row, + secretVersion: policy.secretVersion, + }); const secretConnection = this.getSecretStore().get(row.secret_ref); const connection = mergeConnectionSecrets(policy.connection, secretConnection); return { @@ -418,4 +478,52 @@ export class ProviderStore { fs.renameSync(tmp, this.filePath); try { fs.chmodSync(this.filePath, 0o600); } catch { /* Windows */ } } + + private readSecretVersionFromPolicy(row: ProviderCredentialRow): number | undefined { + const policy = parseJsonObject(row.policy_json) as ProviderPolicyJson; + return policy.secretVersion; + } + + private recordProviderSecretAudit( + db: ReturnType | undefined, + input: { + action: string; + row: Pick; + secretVersion?: number; + }, + ): void { + const record = (targetDb: ReturnType) => { + recordEnterpriseAuditEvent(targetDb, { + tenantId: input.row.tenant_id, + workspaceId: input.row.workspace_id ?? undefined, + actorUserId: input.row.owner_user_id ?? undefined, + action: input.action, + resourceType: 'provider_secret', + resourceId: input.row.id, + metadata: { + secretRefHash: hashSecretRef(input.row.secret_ref), + secretVersion: input.secretVersion, + secretStore: this.getSecretStore().info(), + }, + }); + }; + try { + if (db) { + record(db); + return; + } + const auditDb = openEnterpriseDb(); + try { + record(auditDb); + } finally { + auditDb.close(); + } + } catch (err) { + console.warn('[ProviderStore] Failed to record provider secret audit:', (err as Error).message); + } + } +} + +function hashSecretRef(secretRef: string): string { + return `sha256:${crypto.createHash('sha256').update(secretRef).digest('hex')}`; } diff --git a/docs/features/enterprise-multi-tenant/README.md b/docs/features/enterprise-multi-tenant/README.md index 02a17b11..c6f9d1ef 100644 --- a/docs/features/enterprise-multi-tenant/README.md +++ b/docs/features/enterprise-multi-tenant/README.md @@ -47,7 +47,7 @@ - [x] 3.6 provider 从 `data/providers.json` 迁到 DB metadata + encrypted SecretStore - [x] 3.7 Memory / RAG / Case / Baseline 表加 scope(§14.1,先 filter 后语义召回) - [x] 3.8 双写 → 切读 → 退役 三阶段(§17),每阶段都能回滚;准备 filesystem + DB snapshot -- [ ] 3.9 SecretStore:libsodium 加密 + OS keyring 解 master key + secret rotation + 读取审计 +- [x] 3.9 SecretStore:libsodium 加密 + OS keyring 解 master key + secret rotation + 读取审计 - [ ] 3.10 集成测试:backend restart 后 session/report/trace metadata 可恢复 ### 0.4 主线 C:运行时隔离(§18 + §11) @@ -838,6 +838,13 @@ user override - 支持 secret rotation。 - 未来可把 `SecretStore` 实现替换为 Vault/KMS。 +当前实现: + +- secret file 使用 libsodium `crypto_secretbox` 加密,文件只保存 nonce/ciphertext/version。 +- master key 优先来自 OS keyring;CI/headless 环境可用 `SMARTPERFETTO_SECRET_STORE_MASTER_KEY` 注入,测试/dev 可显式打开本地 fallback。 +- provider secret 的 create/write/read/delete/rotate 写入 `audit_events`,metadata 只记录 `secretRef` hash、version 和 SecretStore 非敏感信息。 +- rotation 入口为 `POST /api/v1/providers/:id/rotate-secret`。 + ## 14. Memory、Report、Skill 与 Legacy AI ### 14.1 Memory/RAG/Case/Baseline