diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..4c5f206 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +.claude/ diff --git a/lefthook.yml b/lefthook.yml index a214edc..794655f 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -7,6 +7,9 @@ pre-commit: lint: glob: '*.{ts,tsx,js,json,md}' run: npm run lint + coverage: + glob: '*.{ts,tsx}' + run: npm run test:coverage test: glob: '*.{ts,tsx}' - run: npm test + run: npm run test diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 3534d49..6994a15 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -4,14 +4,17 @@ import { join } from 'node:path' vi.mock('node:fs', () => ({ readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + chmodSync: vi.fn(), })) vi.mock('node:os', () => ({ homedir: vi.fn(() => '/tmp/test-home'), })) -import { readFileSync } from 'node:fs' -import { loadConfig, parseConfig, resolveTilde } from '../core/config.js' +import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs' +import { loadConfig, parseConfig, resolveTilde, saveConfig, defaultConfig } from '../core/config.js' const mockReadFileSync = vi.mocked(readFileSync) @@ -63,7 +66,7 @@ describe('loadConfig', () => { const config = loadConfig() expect(config.mode).toBe('standalone') expect(config.server).toEqual({ host: '127.0.0.1', port: 2274 }) - expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.json')) + expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.enc.json')) expect(config.discord).toBeUndefined() expect(config.requireApproval).toEqual({}) expect(config.defaultRequireApproval).toBe(false) @@ -100,7 +103,7 @@ describe('parseConfig', () => { const config = parseConfig(minimalValid) expect(config.mode).toBe('standalone') expect(config.server).toEqual({ host: '127.0.0.1', port: 2274 }) - expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.json')) + expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.enc.json')) expect(config.discord).toBeUndefined() expect(config.requireApproval).toEqual({}) expect(config.defaultRequireApproval).toBe(false) @@ -173,7 +176,7 @@ describe('parseConfig', () => { it('uses default store.path when missing', () => { const config = parseConfig({}) - expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.json')) + expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.enc.json')) }) it('allows config with no discord section (all optional)', () => { @@ -289,4 +292,78 @@ describe('parseConfig', () => { it('throws if unlock is not an object', () => { expect(() => parseConfig({ unlock: 'bad' })).toThrow('unlock must be an object') }) + + it('throws if store.path is empty string', () => { + expect(() => parseConfig({ store: { path: '' } })).toThrow( + 'store.path must be a non-empty string', + ) + }) + + it('throws if store.path is non-string', () => { + expect(() => parseConfig({ store: { path: 123 } })).toThrow( + 'store.path must be a non-empty string', + ) + }) + + it('throws if store is not an object', () => { + expect(() => parseConfig({ store: 'bad' })).toThrow('store must be an object') + }) + + it('throws if server is not an object', () => { + expect(() => parseConfig({ server: 'bad' })).toThrow('server must be an object') + }) + + it('throws if server.host is empty string', () => { + expect(() => parseConfig({ server: { host: '' } })).toThrow( + 'server.host must be a non-empty string', + ) + }) + + it('throws if discord is not an object', () => { + expect(() => parseConfig({ discord: 'bad' })).toThrow('discord must be an object') + }) +}) + +describe('saveConfig', () => { + const mockWriteFileSync = vi.mocked(writeFileSync) + const mockMkdirSync = vi.mocked(mkdirSync) + const mockChmodSync = vi.mocked(chmodSync) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('writes config JSON to the specified path with 0600 permissions', () => { + const config = defaultConfig() + saveConfig(config, '/tmp/test-config/config.json') + + expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/test-config', { recursive: true }) + expect(mockWriteFileSync).toHaveBeenCalledWith( + '/tmp/test-config/config.json', + expect.any(String), + 'utf-8', + ) + expect(mockChmodSync).toHaveBeenCalledWith('/tmp/test-config/config.json', 0o600) + + // Verify JSON is valid and matches config + const writtenJson = mockWriteFileSync.mock.calls[0][1] as string + const parsed = JSON.parse(writtenJson) + expect(parsed.mode).toBe('standalone') + }) +}) + +describe('loadConfig edge cases', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('re-throws non-ENOENT errors from readFileSync', () => { + const err = new Error('EACCES') as NodeJS.ErrnoException + err.code = 'EACCES' + mockReadFileSync.mockImplementation(() => { + throw err + }) + + expect(() => loadConfig()).toThrow('EACCES') + }) }) diff --git a/src/__tests__/encrypted-store.test.ts b/src/__tests__/encrypted-store.test.ts index 3fe49de..0b6bc35 100644 --- a/src/__tests__/encrypted-store.test.ts +++ b/src/__tests__/encrypted-store.test.ts @@ -174,6 +174,62 @@ describe('EncryptedSecretStore', () => { expect(store.getByRef('by-ref-test').ref).toBe('by-ref-test') expect(store.getValueByRef('by-ref-test')).toBe('ref-value') }) + + it('getValueByRef throws for non-existent ref', async () => { + const store = await createStore() + store.add('existing-ref', 'val') + + expect(() => store.getValueByRef('no-such-ref')).toThrow( + 'Secret with ref "no-such-ref" not found', + ) + }) + }) + + describe('resolveRef', () => { + it('resolves by UUID and returns uuid + decrypted value', async () => { + const store = await createStore() + const uuid = store.add('resolve-ref-test', 'the-value') + + const result = store.resolveRef(uuid) + expect(result.uuid).toBe(uuid) + expect(result.value).toBe('the-value') + }) + + it('resolves by ref and returns uuid + decrypted value', async () => { + const store = await createStore() + const uuid = store.add('resolve-by-name', 'name-value') + + const result = store.resolveRef('resolve-by-name') + expect(result.uuid).toBe(uuid) + expect(result.value).toBe('name-value') + }) + + it('falls back to ref lookup when UUID is not found', async () => { + const store = await createStore() + const uuid = store.add('fallback-ref', 'fb-value') + + // Use a valid UUID format that does not exist in the store + // It should fall through UUID lookup and find by ref + const result = store.resolveRef('fallback-ref') + expect(result.uuid).toBe(uuid) + expect(result.value).toBe('fb-value') + }) + + it('throws when neither UUID nor ref matches', async () => { + const store = await createStore() + + expect(() => store.resolveRef('nonexistent')).toThrow( + 'Secret with ref "nonexistent" not found', + ) + }) + + it('throws when locked', async () => { + const store = await createStore() + store.add('locked-resolve', 'val') + store.lock() + + expect(() => store.resolveRef('locked-resolve')).toThrow('Store is locked') + }) }) describe('file format', () => { diff --git a/src/__tests__/grant.test.ts b/src/__tests__/grant.test.ts index 49580be..6e60d5c 100644 --- a/src/__tests__/grant.test.ts +++ b/src/__tests__/grant.test.ts @@ -344,6 +344,37 @@ describe('GrantManager', () => { }) }) + describe('getGrantByRequestId', () => { + it('returns grant matching the requestId', () => { + const manager = new GrantManager() + const request = makeApprovedRequest() + const grant = manager.createGrant(request) + + const found = manager.getGrantByRequestId(request.id) + expect(found).toBeDefined() + expect(found!.id).toBe(grant.id) + expect(found!.requestId).toBe(request.id) + }) + + it('returns a copy (not the original)', () => { + const manager = new GrantManager() + const request = makeApprovedRequest() + manager.createGrant(request) + + const found = manager.getGrantByRequestId(request.id) + if (found) { + found.used = true + } + const again = manager.getGrantByRequestId(request.id) + expect(again!.used).toBe(false) + }) + + it('returns undefined when no grant matches', () => { + const manager = new GrantManager() + expect(manager.getGrantByRequestId('nonexistent')).toBeUndefined() + }) + }) + describe('getGrantSecrets', () => { it('returns secretUuids array for valid grant', () => { const manager = new GrantManager() diff --git a/src/__tests__/integration/local-encrypted-flow.test.ts b/src/__tests__/integration/local-encrypted-flow.test.ts new file mode 100644 index 0000000..a4b1cc3 --- /dev/null +++ b/src/__tests__/integration/local-encrypted-flow.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import { EncryptedSecretStore } from '../../core/encrypted-store.js' +import { WorkflowEngine } from '../../core/workflow.js' +import { GrantManager } from '../../core/grant.js' +import { SecretInjector } from '../../core/injector.js' +import { UnlockSession } from '../../core/unlock-session.js' +import { createAccessRequest } from '../../core/request.js' +import type { SecretStore } from '../../core/secret-store.js' +import type { NotificationChannel } from '../../channels/channel.js' + +// Low-cost scrypt params for fast tests +const TEST_PARAMS = { N: 1024, r: 8, p: 1 } +const PASSWORD = 'integration-test-pw' + +function createMockChannel(response: 'approved' | 'denied' | 'timeout'): NotificationChannel { + return { + sendApprovalRequest: vi.fn().mockResolvedValue('msg-id-123'), + waitForResponse: vi.fn().mockResolvedValue(response), + sendNotification: vi.fn().mockResolvedValue(undefined), + } +} + +function buildWorkflowConfig( + overrides?: Partial<{ + requireApproval: Record + defaultRequireApproval: boolean + approvalTimeoutMs: number + }>, +) { + return { + requireApproval: {}, + defaultRequireApproval: true, + approvalTimeoutMs: 30_000, + ...overrides, + } +} + +function buildEngine(store: EncryptedSecretStore, channel: NotificationChannel) { + return new WorkflowEngine({ + store: store as unknown as SecretStore, + channel, + config: buildWorkflowConfig({ requireApproval: { prod: true } }), + }) +} + +function buildInjector(grantManager: GrantManager, store: EncryptedSecretStore) { + return new SecretInjector(grantManager, store as unknown as SecretStore) +} + +describe('Phase 1 Local Encrypted Flow', () => { + let tmpDir: string + let storePath: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), '2kc-integration-')) + storePath = join(tmpDir, 'secrets.enc.json') + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + async function initStore(): Promise { + const store = new EncryptedSecretStore(storePath) + await store.initialize(PASSWORD, TEST_PARAMS) + return store + } + + describe('happy path', () => { + it('init → unlock → add → request → approve → inject → verify env + redaction', async () => { + // 1. Init store and add secret + const store = await initStore() + const secretValue = 's3cr3t-value-42' + const uuid = store.add('api-key', secretValue, ['prod']) + + // 2. Create an access request + const request = createAccessRequest([uuid], 'integration test', 'task-001') + + // 3. Process request via WorkflowEngine with mocked approval channel + const mockChannel = createMockChannel('approved') + const engine = buildEngine(store, mockChannel) + const result = await engine.processRequest(request) + expect(result).toBe('approved') + + // 4. Create a grant + const grantManager = new GrantManager() + const grant = grantManager.createGrant(request) + expect(grant.secretUuids).toContain(uuid) + + // 5. Inject via SecretInjector — spawn real subprocess + const injector = buildInjector(grantManager, store) + const processResult = await injector.inject( + grant.id, + ['node', '-e', 'process.stdout.write(process.env.MY_SECRET ?? "")'], + { + envVarName: 'MY_SECRET', + }, + ) + + // 6. Secret value must NOT appear in stdout or stderr (redaction worked) + expect(processResult.stdout).not.toContain(secretValue) + expect(processResult.stdout).toContain('[REDACTED]') + expect(processResult.stderr).not.toContain(secretValue) + expect(processResult.exitCode).toBe(0) + }) + }) + + describe('locked store rejection', () => { + it('inject without unlock throws locked error', async () => { + // 1. Init store and add a secret while unlocked + const store = await initStore() + const uuid = store.add('locked-test-key', 'super-secret') + + // 2. Create approved request and grant + const request = createAccessRequest([uuid], 'test locked rejection', 'task-002') + request.status = 'approved' + const grantManager = new GrantManager() + const grant = grantManager.createGrant(request) + + // 3. Lock the store + store.lock() + expect(store.isUnlocked).toBe(false) + + // 4. Inject should fail because getValue() throws when locked + const injector = buildInjector(grantManager, store) + await expect( + injector.inject(grant.id, ['node', '-e', 'console.log("hi")'], { envVarName: 'MY_SECRET' }), + ).rejects.toThrow('Store is locked') + }) + }) + + describe('TTL expiry', () => { + it('unlock → advance past TTL → session is locked', () => { + vi.useFakeTimers() + + const session = new UnlockSession({ ttlMs: 5_000 }) + const fakeDek = Buffer.from('fake-dek-32-bytes-exactly-123456') + session.unlock(fakeDek) + expect(session.isUnlocked()).toBe(true) + + // Advance time past TTL + vi.advanceTimersByTime(6_000) + + expect(session.isUnlocked()).toBe(false) + }) + }) + + describe('wrong password', () => { + it('unlock with wrong password throws error', async () => { + // Init store with correct password + const store = await initStore() + store.lock() + expect(store.isUnlocked).toBe(false) + + // Attempt unlock with wrong password + await expect(store.unlock('completely-wrong-password')).rejects.toThrow('incorrect password') + }) + }) + + describe('approval denied', () => { + it('request → deny → grant creation fails', async () => { + // 1. Init store and add secret with approval-required tag + const store = await initStore() + const uuid = store.add('prod-key', 'prod-secret', ['prod']) + + // 2. Create request + const request = createAccessRequest([uuid], 'prod access needed', 'task-003') + + // 3. Process with denial channel + const mockChannel = createMockChannel('denied') + const engine = buildEngine(store, mockChannel) + const result = await engine.processRequest(request) + expect(result).toBe('denied') + expect(request.status).toBe('denied') + + // 4. createGrant should throw because status is 'denied' + const grantManager = new GrantManager() + expect(() => grantManager.createGrant(request)).toThrow( + 'Cannot create grant for request with status: denied', + ) + }) + }) + + describe('grant expiry', () => { + it('approve → advance past grant TTL → inject fails', async () => { + // 1. Init store and add secret + const store = await initStore() + const uuid = store.add('expiry-key', 'expiry-secret') + + // 2. Create request (minimum duration: 30 seconds) + const request = createAccessRequest([uuid], 'expiry test', 'task-004', 30) + request.status = 'approved' + + // 3. Switch to fake timers before creating the grant so expiry is deterministic + vi.useFakeTimers() + + // 4. Create grant (expires in 30s) + const grantManager = new GrantManager() + const grant = grantManager.createGrant(request) + + // 5. Advance past grant TTL + vi.advanceTimersByTime(31_000) + + // 6. Inject should fail with grant-invalid error + const injector = buildInjector(grantManager, store) + await expect( + injector.inject(grant.id, ['node', '-e', 'console.log("hi")'], { envVarName: 'MY_SECRET' }), + ).rejects.toThrow(`Grant is not valid: ${grant.id}`) + }) + }) + + describe('migration', () => { + it.skip('plaintext store → migrate → unlock → values match', async () => { + // This test is skipped until issue #57 implements auto-detection and + // in-place migration of plaintext stores in EncryptedSecretStore.unlock(). + // + // Expected flow: + // 1. Write a SecretsFile (plaintext format) to tmpDir/secrets.enc.json + // 2. Create EncryptedSecretStore pointing at that path + // 3. Call unlock(password) → expect auto-migration to encrypted format + // 4. Verify getValue() returns original value + // 5. Verify on-disk file is now in encrypted format (version: 1) + const plaintextStore = { + secrets: [ + { + uuid: '11111111-1111-1111-1111-111111111111', + ref: 'old-api-key', + value: 'plaintext-value', + tags: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + } + writeFileSync(storePath, JSON.stringify(plaintextStore, null, 2), 'utf-8') + + const store = new EncryptedSecretStore(storePath) + await store.unlock(PASSWORD) + + expect(store.isUnlocked).toBe(true) + expect(store.getValue('11111111-1111-1111-1111-111111111111')).toBe('plaintext-value') + }) + }) +}) diff --git a/src/__tests__/remote-service.test.ts b/src/__tests__/remote-service.test.ts index 1b1b5b3..1a72338 100644 --- a/src/__tests__/remote-service.test.ts +++ b/src/__tests__/remote-service.test.ts @@ -107,6 +107,21 @@ describe('RemoteService', () => { ) }) + it('resolve() calls GET /api/secrets/resolve/:refOrUuid', async () => { + const metadata = { uuid: 'x', ref: 'my-ref', tags: [] } + const fetchMock = mockFetchResponse(200, metadata) + globalThis.fetch = fetchMock + + const service = new RemoteService(makeConfig()) + const result = await service.secrets.resolve('my-ref') + + expect(result).toEqual(metadata) + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:2274/api/secrets/resolve/my-ref', + expect.objectContaining({ method: 'GET' }), + ) + }) + it('getMetadata() calls GET /api/secrets/:uuid', async () => { const metadata = { uuid: 'x', ref: 'n', tags: [] } const fetchMock = mockFetchResponse(200, metadata) diff --git a/src/__tests__/secret-store.test.ts b/src/__tests__/secret-store.test.ts index 43f0673..597e43c 100644 --- a/src/__tests__/secret-store.test.ts +++ b/src/__tests__/secret-store.test.ts @@ -246,6 +246,39 @@ describe('SecretStore', () => { }) }) + describe('resolveRef', () => { + it('resolves UUID to uuid + value', () => { + const uuid = store.add('rr-test', 'rr-value') + const result = store.resolveRef(uuid) + expect(result).toEqual({ uuid, value: 'rr-value' }) + }) + + it('resolves ref to uuid + value', () => { + const uuid = store.add('rr-by-ref', 'by-ref-val') + const result = store.resolveRef('rr-by-ref') + expect(result).toEqual({ uuid, value: 'by-ref-val' }) + }) + + it('falls back to ref when UUID not found', () => { + const uuid = store.add('fallback-test', 'fb-val') + // Pass a UUID that doesn't exist - should fall back to ref search + const result = store.resolveRef('fallback-test') + expect(result).toEqual({ uuid, value: 'fb-val' }) + }) + + it('throws when neither UUID nor ref found', () => { + expect(() => store.resolveRef('nonexistent')).toThrow( + 'Secret with ref "nonexistent" not found', + ) + }) + + it('throws when UUID format given but not found and no ref match', () => { + expect(() => store.resolveRef('00000000-0000-0000-0000-000000000000')).toThrow( + 'Secret with ref "00000000-0000-0000-0000-000000000000" not found', + ) + }) + }) + describe('file handling', () => { it('should create the JSON file on first write if it does not exist', () => { const newPath = join(tmpDir, 'new-secrets.json') diff --git a/src/__tests__/service.test.ts b/src/__tests__/service.test.ts index b28071d..caf0b07 100644 --- a/src/__tests__/service.test.ts +++ b/src/__tests__/service.test.ts @@ -1,8 +1,15 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' +import type { MockInstance } from 'vitest' import { resolveService, LocalService } from '../core/service.js' import { RemoteService } from '../core/remote-service.js' import { defaultConfig } from '../core/config.js' +import type { EncryptedSecretStore } from '../core/encrypted-store.js' +import type { UnlockSession } from '../core/unlock-session.js' +import type { GrantManager, AccessGrant } from '../core/grant.js' +import type { WorkflowEngine } from '../core/workflow.js' +import type { SecretInjector } from '../core/injector.js' +import type { RequestLog } from '../core/request.js' describe('resolveService', () => { it('returns LocalService for standalone mode', () => { @@ -20,50 +27,305 @@ describe('resolveService', () => { const service = resolveService(config) expect(service).toBeInstanceOf(RemoteService) }) + + it('throws when defaultRequireApproval is true and discord not configured', () => { + const config = { ...defaultConfig(), defaultRequireApproval: true } + expect(() => resolveService(config)).toThrow( + 'Discord must be configured when defaultRequireApproval is true', + ) + }) + + it('creates a noop channel when discord is not configured and approval not required', () => { + const config = { + ...defaultConfig(), + defaultRequireApproval: false, + discord: undefined, + } + const service = resolveService(config) + expect(service).toBeInstanceOf(LocalService) + }) + + it('creates LocalService with discord channel when discord is configured', () => { + const config = { + ...defaultConfig(), + discord: { + webhookUrl: 'https://discord.com/api/webhooks/123/abc', + botToken: 'bot-token', + channelId: '999888777', + }, + } + const service = resolveService(config) + expect(service).toBeInstanceOf(LocalService) + }) }) +function makeGrantMock(overrides?: Partial): AccessGrant { + return { + id: 'grant-id', + requestId: 'request-id', + secretUuids: ['secret-uuid'], + grantedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + used: false, + revokedAt: null, + ...overrides, + } +} + +function makeService() { + const store = { + list: vi.fn().mockReturnValue([{ uuid: 'u1', ref: 'my-secret', tags: [] }]), + add: vi.fn().mockReturnValue('new-uuid'), + remove: vi.fn().mockReturnValue(true), + getMetadata: vi.fn().mockReturnValue({ uuid: 'u1', ref: 'my-secret', tags: [] }), + resolve: vi.fn().mockReturnValue({ uuid: 'u1', ref: 'my-secret', tags: [] }), + getValue: vi.fn(), + getByRef: vi.fn(), + getValueByRef: vi.fn(), + resolveRef: vi.fn(), + lock: vi.fn(), + unlock: vi.fn(), + getDek: vi.fn().mockReturnValue(Buffer.alloc(32)), + } as unknown as EncryptedSecretStore + + const unlockSession = { + isUnlocked: vi.fn().mockReturnValue(true), + recordGrantUsage: vi.fn(), + on: vi.fn(), + off: vi.fn(), + unlock: vi.fn(), + lock: vi.fn(), + } as unknown as UnlockSession + + const grantManager = { + getGrantByRequestId: vi.fn().mockReturnValue(makeGrantMock()), + validateGrant: vi.fn().mockReturnValue(true), + createGrant: vi.fn().mockReturnValue(makeGrantMock()), + } as unknown as GrantManager + + const workflowEngine = { + processRequest: vi.fn().mockResolvedValue('approved'), + } as unknown as WorkflowEngine + + const injector = { + inject: vi.fn().mockResolvedValue({ exitCode: 0, stdout: 'ok', stderr: '' }), + } as unknown as SecretInjector + + const requestLog = { + add: vi.fn(), + } as unknown as RequestLog + + const startTime = Date.now() - 1000 + + const service = new LocalService({ + store, + unlockSession, + grantManager, + workflowEngine, + injector, + requestLog, + startTime, + }) + return { + service, + store, + unlockSession, + grantManager, + workflowEngine, + injector, + requestLog, + startTime, + } +} + describe('LocalService', () => { - it('health throws not implemented', async () => { - const service = new LocalService() - await expect(service.health()).rejects.toThrow('not implemented') + describe('health()', () => { + it('returns status:unlocked when session is unlocked', async () => { + const { service, unlockSession } = makeService() + ;(unlockSession.isUnlocked as MockInstance).mockReturnValue(true) + const result = await service.health() + expect(result.status).toBe('unlocked') + expect(typeof result.uptime).toBe('number') + }) + + it('returns status:locked when session is locked', async () => { + const { service, unlockSession } = makeService() + ;(unlockSession.isUnlocked as MockInstance).mockReturnValue(false) + const result = await service.health() + expect(result.status).toBe('locked') + }) + + it('includes uptime in ms based on startTime', async () => { + const { service, startTime } = makeService() + const result = await service.health() + expect(result.uptime).toBeGreaterThanOrEqual(1000) + expect(result.uptime).toBeLessThan(Date.now() - startTime + 100) + }) + }) + + describe('secrets.list()', () => { + it('returns list from store without requiring unlock', async () => { + const { service, store, unlockSession } = makeService() + ;(unlockSession.isUnlocked as MockInstance).mockReturnValue(false) + const result = await service.secrets.list() + expect(result).toEqual([{ uuid: 'u1', ref: 'my-secret', tags: [] }]) + expect(store.list).toHaveBeenCalledOnce() + }) }) - it('secrets.list throws not implemented', async () => { - const service = new LocalService() - await expect(service.secrets.list()).rejects.toThrow('not implemented') + describe('secrets.add()', () => { + it('adds secret when unlocked', async () => { + const { service, store } = makeService() + const result = await service.secrets.add('my-ref', 'my-value', ['tag1']) + expect(result).toEqual({ uuid: 'new-uuid' }) + expect(store.add).toHaveBeenCalledWith('my-ref', 'my-value', ['tag1']) + }) + + it('throws locked error when session is locked', async () => { + const { service, unlockSession } = makeService() + ;(unlockSession.isUnlocked as MockInstance).mockReturnValue(false) + await expect(service.secrets.add('ref', 'val')).rejects.toThrow( + 'Store is locked. Run `2kc unlock` first.', + ) + }) }) - it('secrets.add throws not implemented', async () => { - const service = new LocalService() - await expect(service.secrets.add('name', 'value')).rejects.toThrow('not implemented') + describe('secrets.remove()', () => { + it('removes secret from store', async () => { + const { service, store } = makeService() + await service.secrets.remove('u1') + expect(store.remove).toHaveBeenCalledWith('u1') + }) }) - it('secrets.remove throws not implemented', async () => { - const service = new LocalService() - await expect(service.secrets.remove('uuid')).rejects.toThrow('not implemented') + describe('secrets.getMetadata()', () => { + it('returns metadata without requiring unlock', async () => { + const { service, store, unlockSession } = makeService() + ;(unlockSession.isUnlocked as MockInstance).mockReturnValue(false) + const result = await service.secrets.getMetadata('u1') + expect(result).toEqual({ uuid: 'u1', ref: 'my-secret', tags: [] }) + expect(store.getMetadata).toHaveBeenCalledWith('u1') + }) }) - it('secrets.getMetadata throws not implemented', async () => { - const service = new LocalService() - await expect(service.secrets.getMetadata('uuid')).rejects.toThrow('not implemented') + describe('secrets.resolve()', () => { + it('resolves refOrUuid to metadata', async () => { + const { service, store } = makeService() + const result = await service.secrets.resolve('my-secret') + expect(result).toEqual({ uuid: 'u1', ref: 'my-secret', tags: [] }) + expect(store.resolve).toHaveBeenCalledWith('my-secret') + }) }) - it('requests.create throws not implemented', async () => { - const service = new LocalService() - await expect(service.requests.create('uuid', 'reason', 'task')).rejects.toThrow( - 'not implemented', - ) + describe('requests.create()', () => { + it('creates request, calls workflow, creates grant when approved', async () => { + const { service, workflowEngine, grantManager, requestLog } = makeService() + ;(workflowEngine.processRequest as MockInstance).mockImplementation(async (req) => { + req.status = 'approved' + return 'approved' + }) + const result = await service.requests.create(['u1'], 'need access', 'task-1', 300) + expect(result.status).toBe('approved') + expect(requestLog.add).toHaveBeenCalledWith(result) + expect(grantManager.createGrant).toHaveBeenCalledWith(result) + }) + + it('returns request with denied status when workflow denies', async () => { + const { service, workflowEngine, grantManager, requestLog } = makeService() + ;(workflowEngine.processRequest as MockInstance).mockImplementation(async (req) => { + req.status = 'denied' + return 'denied' + }) + const result = await service.requests.create(['u1'], 'need access', 'task-1', 300) + expect(result.status).toBe('denied') + expect(requestLog.add).toHaveBeenCalledWith(result) + expect(grantManager.createGrant).not.toHaveBeenCalled() + }) }) - it('grants.validate throws not implemented', async () => { - const service = new LocalService() - await expect(service.grants.validate('grantId')).rejects.toThrow('not implemented') + describe('grants.validate()', () => { + it('returns true for valid grant by requestId', async () => { + const { service, grantManager } = makeService() + ;(grantManager.getGrantByRequestId as MockInstance).mockReturnValue(makeGrantMock()) + ;(grantManager.validateGrant as MockInstance).mockReturnValue(true) + const result = await service.grants.validate('request-id') + expect(result).toBe(true) + }) + + it('returns false when no grant found for requestId', async () => { + const { service, grantManager } = makeService() + ;(grantManager.getGrantByRequestId as MockInstance).mockReturnValue(undefined) + const result = await service.grants.validate('unknown-request') + expect(result).toBe(false) + expect(grantManager.validateGrant).not.toHaveBeenCalled() + }) }) - it('inject throws not implemented', async () => { - const service = new LocalService() - await expect(service.inject('grantId', 'cmd', { envVarName: 'VAR' })).rejects.toThrow( - 'not implemented', - ) + describe('inject()', () => { + it('calls injector with parsed command and records grant usage', async () => { + const { service, injector, unlockSession, grantManager } = makeService() + const grant = makeGrantMock() + ;(grantManager.getGrantByRequestId as MockInstance).mockReturnValue(grant) + const result = await service.inject('request-id', 'echo hello', { envVarName: 'TOKEN' }) + expect(injector.inject).toHaveBeenCalledWith(grant.id, ['/bin/sh', '-c', 'echo hello'], { + envVarName: 'TOKEN', + }) + expect(unlockSession.recordGrantUsage).toHaveBeenCalledOnce() + expect(result).toEqual({ exitCode: 0, stdout: 'ok', stderr: '' }) + }) + + it('throws when session is locked', async () => { + const { service, unlockSession } = makeService() + ;(unlockSession.isUnlocked as MockInstance).mockReturnValue(false) + await expect(service.inject('request-id', 'echo hello')).rejects.toThrow( + 'Store is locked. Run `2kc unlock` first.', + ) + }) + + it('throws when no grant found for requestId', async () => { + const { service, grantManager } = makeService() + ;(grantManager.getGrantByRequestId as MockInstance).mockReturnValue(undefined) + await expect(service.inject('missing-request', 'echo hello')).rejects.toThrow( + 'No grant found for request: missing-request', + ) + }) + }) + + describe('unlock()', () => { + it('unlocks the store and passes DEK to session', async () => { + const { service, store, unlockSession } = makeService() + ;(store.unlock as MockInstance).mockResolvedValue(undefined) + ;(store.getDek as MockInstance).mockReturnValue(Buffer.alloc(32, 0xaa)) + + await service.unlock('test-password') + + expect(store.unlock).toHaveBeenCalledWith('test-password') + expect(unlockSession.unlock).toHaveBeenCalledWith(Buffer.alloc(32, 0xaa)) + }) + + it('throws when DEK is null after unlock', async () => { + const { service, store } = makeService() + ;(store.unlock as MockInstance).mockResolvedValue(undefined) + ;(store.getDek as MockInstance).mockReturnValue(null) + + await expect(service.unlock('pw')).rejects.toThrow('Failed to obtain DEK after unlock') + }) + }) + + describe('lock()', () => { + it('locks the session (store locks via event)', () => { + const { service, unlockSession } = makeService() + service.lock() + expect(unlockSession.lock).toHaveBeenCalled() + }) + }) + + describe('destroy()', () => { + it('removes the locked event listener from unlockSession', () => { + const { service, unlockSession } = makeService() + const onCall = (unlockSession.on as MockInstance).mock.calls[0] + service.destroy() + expect(unlockSession.off).toHaveBeenCalledWith('locked', onCall[1]) + }) }) }) diff --git a/src/__tests__/store-command.test.ts b/src/__tests__/store-command.test.ts new file mode 100644 index 0000000..45c6b84 --- /dev/null +++ b/src/__tests__/store-command.test.ts @@ -0,0 +1,217 @@ +/// + +import type { AppConfig } from '../core/config.js' + +// Mock readline so promptNewPassword resolves immediately +const mockQuestion = vi.fn() +const mockRlClose = vi.fn() + +vi.mock('node:readline', () => ({ + createInterface: vi.fn(() => ({ + question: mockQuestion, + close: mockRlClose, + })), +})) + +const mockLoadConfig = vi.fn<() => AppConfig>() +const mockSaveConfig = vi.fn() + +vi.mock('../core/config.js', () => ({ + loadConfig: (...args: unknown[]) => mockLoadConfig(...(args as [])), + saveConfig: (...args: unknown[]) => mockSaveConfig(...(args as [])), +})) + +const mockInitialize = vi.fn() +const mockAdd = vi.fn() + +vi.mock('../core/encrypted-store.js', () => ({ + EncryptedSecretStore: vi.fn(() => ({ + initialize: mockInitialize, + add: mockAdd, + })), +})) + +// Mock fs operations used by initStore/migrateStore +const mockExistsSync = vi.fn(() => false) +const mockUnlinkSync = vi.fn() +const mockReadFileSync = vi.fn() +const mockRenameSync = vi.fn() + +vi.mock('node:fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...(args as [])), + unlinkSync: (...args: unknown[]) => mockUnlinkSync(...(args as [])), + readFileSync: (...args: unknown[]) => mockReadFileSync(...(args as [])), + renameSync: (...args: unknown[]) => mockRenameSync(...(args as [])), +})) + +function createTestConfig(overrides?: Partial): AppConfig { + return { + mode: 'standalone', + server: { host: '127.0.0.1', port: 2274 }, + store: { path: '/tmp/.2kc/secrets.json' }, + unlock: { ttlMs: 900_000 }, + requireApproval: {}, + defaultRequireApproval: false, + approvalTimeoutMs: 300_000, + ...overrides, + } +} + +function mockPasswordPrompt(password: string) { + // promptNewPassword calls question twice (enter + confirm) + mockQuestion + .mockImplementationOnce((_prompt: string, cb: (answer: string) => void) => { + cb(password) + }) + .mockImplementationOnce((_prompt: string, cb: (answer: string) => void) => { + cb(password) + }) +} + +describe('store init command', () => { + let savedExitCode: number | undefined + + beforeEach(() => { + savedExitCode = process.exitCode + process.exitCode = undefined + vi.clearAllMocks() + mockExistsSync.mockReturnValue(false) + }) + + afterEach(() => { + process.exitCode = savedExitCode + }) + + it('initializes encrypted store and updates config', async () => { + const config = createTestConfig() + mockLoadConfig.mockReturnValue(config) + mockPasswordPrompt('my-password') + mockInitialize.mockResolvedValue(undefined) + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const { storeCommand } = await import('../cli/store.js') + await storeCommand.parseAsync(['init'], { from: 'user' }) + + expect(mockSaveConfig).toHaveBeenCalled() + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Initialized encrypted store')) + expect(process.exitCode).toBeUndefined() + + logSpy.mockRestore() + stderrSpy.mockRestore() + }) + + it('sets exitCode=1 when store is already encrypted', async () => { + const config = createTestConfig({ store: { path: '/tmp/.2kc/secrets.enc.json' } }) + mockLoadConfig.mockReturnValue(config) + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { storeCommand } = await import('../cli/store.js') + await storeCommand.parseAsync(['init'], { from: 'user' }) + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Store is already encrypted')) + expect(process.exitCode).toBe(1) + + errorSpy.mockRestore() + }) + + it('sets exitCode=1 when initialize throws', async () => { + const config = createTestConfig() + mockLoadConfig.mockReturnValue(config) + mockPasswordPrompt('pw') + mockInitialize.mockRejectedValue(new Error('disk full')) + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const { storeCommand } = await import('../cli/store.js') + await storeCommand.parseAsync(['init'], { from: 'user' }) + + expect(errorSpy).toHaveBeenCalledWith('Error: disk full') + expect(process.exitCode).toBe(1) + + errorSpy.mockRestore() + stderrSpy.mockRestore() + }) +}) + +describe('store migrate command', () => { + let savedExitCode: number | undefined + + beforeEach(() => { + savedExitCode = process.exitCode + process.exitCode = undefined + vi.clearAllMocks() + mockExistsSync.mockReturnValue(false) + }) + + afterEach(() => { + process.exitCode = savedExitCode + }) + + it('migrates plaintext store and updates config', async () => { + const config = createTestConfig() + mockLoadConfig.mockReturnValue(config) + mockPasswordPrompt('migrate-pw') + // existsSync: first call for plaintextPath (true), second for encryptedPath (false), + // third for .bak (false) + mockExistsSync + .mockReturnValueOnce(true) // plaintextPath exists + .mockReturnValueOnce(false) // encryptedPath doesn't exist + .mockReturnValueOnce(false) // .bak doesn't exist + mockReadFileSync.mockReturnValue( + JSON.stringify({ secrets: [{ ref: 'test', value: 'val', tags: [] }] }), + ) + mockInitialize.mockResolvedValue(undefined) + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const { storeCommand } = await import('../cli/store.js') + await storeCommand.parseAsync(['migrate'], { from: 'user' }) + + expect(mockSaveConfig).toHaveBeenCalled() + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Migrated')) + expect(process.exitCode).toBeUndefined() + + logSpy.mockRestore() + stderrSpy.mockRestore() + }) + + it('sets exitCode=1 when store is already encrypted', async () => { + const config = createTestConfig({ store: { path: '/tmp/.2kc/secrets.enc.json' } }) + mockLoadConfig.mockReturnValue(config) + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { storeCommand } = await import('../cli/store.js') + await storeCommand.parseAsync(['migrate'], { from: 'user' }) + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Store is already encrypted')) + expect(process.exitCode).toBe(1) + + errorSpy.mockRestore() + }) + + it('sets exitCode=1 on non-Error throws', async () => { + const config = createTestConfig() + mockLoadConfig.mockReturnValue(config) + mockPasswordPrompt('pw') + // plaintextPath does not exist - will cause an error + mockExistsSync.mockReturnValue(false) + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const { storeCommand } = await import('../cli/store.js') + await storeCommand.parseAsync(['migrate'], { from: 'user' }) + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Error:')) + expect(process.exitCode).toBe(1) + + errorSpy.mockRestore() + stderrSpy.mockRestore() + }) +}) diff --git a/src/__tests__/store-migration.test.ts b/src/__tests__/store-migration.test.ts new file mode 100644 index 0000000..32afe55 --- /dev/null +++ b/src/__tests__/store-migration.test.ts @@ -0,0 +1,219 @@ +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import { migrateStore, initStore } from '../cli/store.js' +import { EncryptedSecretStore } from '../core/encrypted-store.js' +import type { SecretsFile } from '../core/types.js' + +// Low-cost scrypt params for fast tests +const TEST_PARAMS = { N: 1024, r: 8, p: 1 } +const PASSWORD = 'migration-test-password' + +describe('migrateStore', () => { + let dir: string + let plaintextPath: string + let encryptedPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), '2kc-migrate-test-')) + plaintextPath = join(dir, 'secrets.json') + encryptedPath = join(dir, 'secrets.enc.json') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + function writePlaintext(secrets: SecretsFile['secrets']): void { + writeFileSync(plaintextPath, JSON.stringify({ secrets }), 'utf-8') + } + + it('encrypts plaintext secrets; decrypting yields original values', async () => { + writePlaintext([ + { + uuid: 'abc-123', + ref: 'my-ref', + value: 'original-value', + tags: ['prod'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + + await migrateStore(plaintextPath, encryptedPath, PASSWORD, { params: TEST_PARAMS }) + + const store = new EncryptedSecretStore(encryptedPath) + await store.unlock(PASSWORD) + expect(store.getValueByRef('my-ref')).toBe('original-value') + }) + + it('renames plaintext to .bak and removes original', async () => { + writePlaintext([]) + + await migrateStore(plaintextPath, encryptedPath, PASSWORD, { params: TEST_PARAMS }) + + expect(existsSync(plaintextPath)).toBe(false) + expect(existsSync(plaintextPath + '.bak')).toBe(true) + }) + + it('returns count of migrated secrets', async () => { + writePlaintext([ + { + uuid: 'uuid-1', + ref: 'secret-one', + value: 'value-one', + tags: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + uuid: 'uuid-2', + ref: 'secret-two', + value: 'value-two', + tags: ['tag-a'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + + const count = await migrateStore(plaintextPath, encryptedPath, PASSWORD, { + params: TEST_PARAMS, + }) + + expect(count).toBe(2) + }) + + it('throws if plaintext store does not exist', async () => { + await expect( + migrateStore(join(dir, 'nonexistent.json'), encryptedPath, PASSWORD, { + params: TEST_PARAMS, + }), + ).rejects.toThrow() + }) + + it('throws if encrypted store already exists (no --force)', async () => { + writePlaintext([]) + // Pre-create the encrypted store + await new EncryptedSecretStore(encryptedPath).initialize(PASSWORD, TEST_PARAMS) + + // Re-create plaintext so migrateStore has something to read + writePlaintext([]) + + await expect( + migrateStore(plaintextPath, encryptedPath, PASSWORD, { params: TEST_PARAMS }), + ).rejects.toThrow() + }) + + it('overwrites encrypted store with --force', async () => { + writePlaintext([ + { + uuid: 'force-uuid', + ref: 'force-ref', + value: 'force-value', + tags: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + // Pre-create the encrypted store + await new EncryptedSecretStore(encryptedPath).initialize(PASSWORD, TEST_PARAMS) + + // Restore plaintext (was not renamed since migrateStore wasn't called yet) + writePlaintext([ + { + uuid: 'force-uuid', + ref: 'force-ref', + value: 'force-value', + tags: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + + const count = await migrateStore(plaintextPath, encryptedPath, PASSWORD, { + force: true, + params: TEST_PARAMS, + }) + + expect(count).toBe(1) + const store = new EncryptedSecretStore(encryptedPath) + await store.unlock(PASSWORD) + expect(store.getValueByRef('force-ref')).toBe('force-value') + }) + + it('succeeds with empty plaintext store (0 secrets)', async () => { + writePlaintext([]) + + const count = await migrateStore(plaintextPath, encryptedPath, PASSWORD, { + params: TEST_PARAMS, + }) + + expect(count).toBe(0) + const store = new EncryptedSecretStore(encryptedPath) + await store.unlock(PASSWORD) + expect(store.list()).toHaveLength(0) + }) + + it('preserves tags after migration', async () => { + writePlaintext([ + { + uuid: 'tag-uuid', + ref: 'tagged-secret', + value: 'tagged-value', + tags: ['env:prod', 'team:backend'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + + await migrateStore(plaintextPath, encryptedPath, PASSWORD, { params: TEST_PARAMS }) + + const store = new EncryptedSecretStore(encryptedPath) + await store.unlock(PASSWORD) + const meta = store.getByRef('tagged-secret') + expect(meta.tags).toEqual(['env:prod', 'team:backend']) + }) +}) + +describe('initStore', () => { + let dir: string + let encryptedPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), '2kc-init-test-')) + encryptedPath = join(dir, 'secrets.enc.json') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('creates an encrypted store that can be unlocked', async () => { + await initStore(encryptedPath, PASSWORD, { params: TEST_PARAMS }) + + const store = new EncryptedSecretStore(encryptedPath) + await store.unlock(PASSWORD) + expect(store.isUnlocked).toBe(true) + expect(store.list()).toHaveLength(0) + }) + + it('throws if encrypted store already exists (no --force)', async () => { + await initStore(encryptedPath, PASSWORD, { params: TEST_PARAMS }) + + await expect(initStore(encryptedPath, PASSWORD, { params: TEST_PARAMS })).rejects.toThrow() + }) + + it('overwrites with --force', async () => { + await initStore(encryptedPath, PASSWORD, { params: TEST_PARAMS }) + + await expect( + initStore(encryptedPath, 'new-password', { force: true, params: TEST_PARAMS }), + ).resolves.not.toThrow() + + // Verify the new password works + const store = new EncryptedSecretStore(encryptedPath) + await store.unlock('new-password') + expect(store.isUnlocked).toBe(true) + }) +}) diff --git a/src/__tests__/unlock-command.test.ts b/src/__tests__/unlock-command.test.ts new file mode 100644 index 0000000..5a92047 --- /dev/null +++ b/src/__tests__/unlock-command.test.ts @@ -0,0 +1,326 @@ +/// + +import type { AppConfig } from '../core/config.js' + +const mockQuestion = vi.fn() +const mockRlClose = vi.fn() + +vi.mock('node:readline', () => ({ + createInterface: vi.fn(() => ({ + question: mockQuestion, + close: mockRlClose, + })), +})) + +const mockExistsSync = vi.fn<() => boolean>() +const mockReadFileSync = vi.fn<() => string>() + +vi.mock('node:fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...(args as [])), + readFileSync: (...args: unknown[]) => mockReadFileSync(...(args as [])), +})) + +const mockLoadConfig = vi.fn<() => AppConfig>() + +vi.mock('../core/config.js', () => ({ + loadConfig: (...args: unknown[]) => mockLoadConfig(...(args as [])), +})) + +const mockDeriveKek = vi.fn<() => Promise>() + +vi.mock('../core/kdf.js', () => ({ + deriveKek: (...args: unknown[]) => mockDeriveKek(...(args as [])), +})) + +const mockUnwrapDek = vi.fn<() => Buffer>() + +vi.mock('../core/crypto.js', () => ({ + unwrapDek: (...args: unknown[]) => mockUnwrapDek(...(args as [])), +})) + +const mockSessionUnlock = vi.fn<() => void>() +const mockSessionLock = vi.fn<() => void>() + +vi.mock('../core/unlock-session.js', () => ({ + UnlockSession: vi.fn(), +})) + +import { UnlockSession } from '../core/unlock-session.js' +const MockUnlockSession = vi.mocked(UnlockSession) + +const MOCK_STORE_FILE = { + version: 1, + kdf: { + algorithm: 'scrypt', + salt: Buffer.from('testsalt').toString('base64'), + params: { N: 1024, r: 8, p: 1 }, + }, + wrappedDek: { + ciphertext: 'aGVsbG8=', + nonce: 'bm9uY2U=', + tag: 'dGFn', + }, + secrets: [], +} + +function createTestConfig(): AppConfig { + return { + mode: 'standalone', + server: { host: '127.0.0.1', port: 2274 }, + store: { path: '~/.2kc/secrets.json' }, + unlock: { ttlMs: 900_000 }, + requireApproval: {}, + defaultRequireApproval: false, + approvalTimeoutMs: 300_000, + } +} + +describe('unlock command', () => { + let savedExitCode: number | undefined + + beforeEach(() => { + savedExitCode = process.exitCode + process.exitCode = undefined + vi.clearAllMocks() + + // Reset the mock session methods to fresh fns after clearAllMocks + MockUnlockSession.mockImplementation(function () { + return { + unlock: mockSessionUnlock, + lock: mockSessionLock, + isUnlocked: vi.fn(() => true), + getDek: vi.fn(() => null), + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + } + }) + }) + + afterEach(() => { + process.exitCode = savedExitCode + }) + + it('unlocks successfully with correct password', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFileSync.mockReturnValue(JSON.stringify(MOCK_STORE_FILE)) + + mockQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { + cb('correct-password') + }) + + const mockDek = Buffer.alloc(32, 0xde) + mockDeriveKek.mockResolvedValue(Buffer.alloc(32, 0xab)) + mockUnwrapDek.mockReturnValue(mockDek) + mockLoadConfig.mockReturnValue(createTestConfig()) + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const { unlockCommand } = await import('../cli/unlock.js') + await unlockCommand.parseAsync([], { from: 'user' }) + + expect(mockDeriveKek).toHaveBeenCalled() + expect(mockUnwrapDek).toHaveBeenCalled() + expect(MockUnlockSession).toHaveBeenCalledWith({ ttlMs: 900_000 }) + expect(mockSessionUnlock).toHaveBeenCalledWith(mockDek) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Store unlocked')) + expect(process.exitCode).toBeUndefined() + + logSpy.mockRestore() + }) + + it('prints error and sets exitCode=1 when password is wrong', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFileSync.mockReturnValue(JSON.stringify(MOCK_STORE_FILE)) + + mockQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { + cb('wrong-password') + }) + + mockDeriveKek.mockResolvedValue(Buffer.alloc(32, 0xab)) + mockUnwrapDek.mockImplementation(() => { + throw new Error('Unsupported state or unable to authenticate data') + }) + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { unlockCommand } = await import('../cli/unlock.js') + await unlockCommand.parseAsync([], { from: 'user' }) + + expect(errorSpy).toHaveBeenCalledWith('Incorrect password.') + expect(process.exitCode).toBe(1) + + errorSpy.mockRestore() + }) + + it('prints error and sets exitCode=1 when store file is missing', async () => { + mockExistsSync.mockReturnValue(false) + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { unlockCommand } = await import('../cli/unlock.js') + await unlockCommand.parseAsync([], { from: 'user' }) + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Encrypted store not found')) + expect(process.exitCode).toBe(1) + + errorSpy.mockRestore() + }) + + it('formats TTL in hours when ttlMs >= 3600000', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFileSync.mockReturnValue(JSON.stringify(MOCK_STORE_FILE)) + + mockQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { + cb('correct-password') + }) + + const mockDek = Buffer.alloc(32, 0xde) + mockDeriveKek.mockResolvedValue(Buffer.alloc(32, 0xab)) + mockUnwrapDek.mockReturnValue(mockDek) + mockLoadConfig.mockReturnValue({ + ...createTestConfig(), + unlock: { ttlMs: 7_200_000 }, + }) + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const { unlockCommand } = await import('../cli/unlock.js') + await unlockCommand.parseAsync([], { from: 'user' }) + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('2 hours')) + + logSpy.mockRestore() + }) + + it('formats TTL as "1 hour" (singular) for exactly 3600000ms', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFileSync.mockReturnValue(JSON.stringify(MOCK_STORE_FILE)) + + mockQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { + cb('correct-password') + }) + + const mockDek = Buffer.alloc(32, 0xde) + mockDeriveKek.mockResolvedValue(Buffer.alloc(32, 0xab)) + mockUnwrapDek.mockReturnValue(mockDek) + mockLoadConfig.mockReturnValue({ + ...createTestConfig(), + unlock: { ttlMs: 3_600_000 }, + }) + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const { unlockCommand } = await import('../cli/unlock.js') + await unlockCommand.parseAsync([], { from: 'user' }) + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('1 hour')) + + logSpy.mockRestore() + }) + + it('sets exitCode=1 when store file is malformed (bad version)', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFileSync.mockReturnValue(JSON.stringify({ version: 99, kdf: null, wrappedDek: null })) + + mockQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { + cb('password') + }) + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { unlockCommand } = await import('../cli/unlock.js') + await unlockCommand.parseAsync([], { from: 'user' }) + + expect(errorSpy).toHaveBeenCalledWith('Error: Malformed encrypted store file.') + expect(process.exitCode).toBe(1) + + errorSpy.mockRestore() + }) + + it('sets exitCode=1 when store file cannot be read (parse error)', async () => { + mockExistsSync.mockReturnValue(true) + mockReadFileSync.mockReturnValue('not valid json{{{') + + mockQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { + cb('password') + }) + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { unlockCommand } = await import('../cli/unlock.js') + await unlockCommand.parseAsync([], { from: 'user' }) + + expect(errorSpy).toHaveBeenCalledWith('Error: Failed to read encrypted store file.') + expect(process.exitCode).toBe(1) + + errorSpy.mockRestore() + }) +}) + +describe('lock command', () => { + let savedExitCode: number | undefined + + beforeEach(() => { + savedExitCode = process.exitCode + process.exitCode = undefined + vi.clearAllMocks() + }) + + afterEach(() => { + process.exitCode = savedExitCode + }) + + it('prints "Store locked."', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const { lockCommand } = await import('../cli/unlock.js') + await lockCommand.parseAsync([], { from: 'user' }) + + expect(logSpy).toHaveBeenCalledWith('Store locked.') + + logSpy.mockRestore() + }) +}) + +describe('status command', () => { + let savedExitCode: number | undefined + + beforeEach(() => { + savedExitCode = process.exitCode + process.exitCode = undefined + vi.clearAllMocks() + }) + + afterEach(() => { + process.exitCode = savedExitCode + }) + + it('prints "Encrypted store not found" when store file does not exist', async () => { + mockExistsSync.mockReturnValue(false) + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const { statusCommand } = await import('../cli/unlock.js') + await statusCommand.parseAsync([], { from: 'user' }) + + expect(logSpy).toHaveBeenCalledWith( + 'Encrypted store not found. Run store initialization first.', + ) + + logSpy.mockRestore() + }) + + it('prints "Store is locked." when store file exists', async () => { + mockExistsSync.mockReturnValue(true) + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const { statusCommand } = await import('../cli/unlock.js') + await statusCommand.parseAsync([], { from: 'user' }) + + expect(logSpy).toHaveBeenCalledWith('Store is locked.') + + logSpy.mockRestore() + }) +}) diff --git a/src/cli/index.ts b/src/cli/index.ts index 1637826..a216965 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,6 +9,8 @@ import { openclawCommand } from './openclaw.js' import { requestCommand } from './request.js' import { secretsCommand } from './secrets.js' import { serverCommand } from './server.js' +import { storeCommand } from './store.js' +import { unlockCommand, lockCommand, statusCommand } from './unlock.js' const pkg = JSON.parse( readFileSync(resolve(import.meta.dirname, '../../package.json'), 'utf-8'), @@ -26,5 +28,9 @@ program.addCommand(secretsCommand) program.addCommand(configCommand) program.addCommand(requestCommand) program.addCommand(serverCommand) +program.addCommand(storeCommand) +program.addCommand(unlockCommand) +program.addCommand(lockCommand) +program.addCommand(statusCommand) program.parse() diff --git a/src/cli/store.ts b/src/cli/store.ts new file mode 100644 index 0000000..b0f11b0 --- /dev/null +++ b/src/cli/store.ts @@ -0,0 +1,165 @@ +import { Command } from 'commander' +import { createInterface } from 'node:readline' +import { Writable } from 'node:stream' +import { timingSafeEqual } from 'node:crypto' +import { existsSync, readFileSync, unlinkSync, renameSync } from 'node:fs' + +import type { ScryptParams } from '../core/kdf.js' +import type { SecretsFile } from '../core/types.js' +import { loadConfig, saveConfig } from '../core/config.js' +import { EncryptedSecretStore } from '../core/encrypted-store.js' + +// --- Exported helpers (for unit testing) --- + +class MutedWritable extends Writable { + override _write(_chunk: Buffer, _encoding: BufferEncoding, callback: () => void): void { + callback() + } +} + +export async function promptNewPassword(): Promise { + const muted = new MutedWritable() + const rl = createInterface({ + input: process.stdin, + output: muted, + terminal: true, + }) + const question = (prompt: string): Promise => + new Promise((resolve) => { + process.stderr.write(prompt) + rl.question('', resolve) + }) + + try { + const first = await question('Enter new password: ') + process.stderr.write('\n') + const second = await question('Confirm password: ') + process.stderr.write('\n') + const ba = Buffer.from(first) + const bb = Buffer.from(second) + if (ba.length !== bb.length || !timingSafeEqual(ba, bb)) { + throw new Error('Passwords do not match') + } + return first + } finally { + rl.close() + } +} + +export async function initStore( + encryptedPath: string, + password: string, + opts: { force?: boolean; params?: ScryptParams } = {}, +): Promise { + if (existsSync(encryptedPath)) { + if (!opts.force) { + throw new Error( + `Encrypted store already exists at ${encryptedPath}. Use --force to overwrite.`, + ) + } + unlinkSync(encryptedPath) + } + await new EncryptedSecretStore(encryptedPath).initialize(password, opts.params) +} + +export async function migrateStore( + plaintextPath: string, + encryptedPath: string, + password: string, + opts: { force?: boolean; params?: ScryptParams } = {}, +): Promise { + if (!existsSync(plaintextPath)) { + throw new Error(`No plaintext store found at ${plaintextPath}`) + } + if (existsSync(encryptedPath)) { + if (!opts.force) { + throw new Error( + `Encrypted store already exists at ${encryptedPath}. Use --force to overwrite.`, + ) + } + unlinkSync(encryptedPath) + } + + const raw = JSON.parse(readFileSync(plaintextPath, 'utf-8')) as SecretsFile + if (!Array.isArray(raw?.secrets)) { + throw new Error('Invalid plaintext store format') + } + + const encStore = new EncryptedSecretStore(encryptedPath) + await encStore.initialize(password, opts.params) + try { + for (const entry of raw.secrets) { + encStore.add(entry.ref, entry.value, entry.tags) + } + } catch (err) { + unlinkSync(encryptedPath) + throw err + } + + const bakPath = plaintextPath + '.bak' + if (existsSync(bakPath)) { + unlinkSync(bakPath) + } + renameSync(plaintextPath, bakPath) + return raw.secrets.length +} + +function deriveEncryptedPath(plaintextPath: string): string { + if (!plaintextPath.endsWith('.json')) { + throw new Error('store.path must end in .json to derive encrypted path') + } + return plaintextPath.replace(/\.json$/, '.enc.json') +} + +// --- CLI commands --- + +const store = new Command('store').description('Manage the secret store') + +store + .command('init') + .description('Initialize a new encrypted secret store') + .option('--force', 'Overwrite existing encrypted store', false) + .action(async (opts: { force: boolean }) => { + try { + const config = loadConfig() + if (config.store.path.endsWith('.enc.json')) { + throw new Error('Store is already encrypted') + } + const encryptedPath = deriveEncryptedPath(config.store.path) + const password = await promptNewPassword() + await initStore(encryptedPath, password, { force: opts.force }) + config.store.path = encryptedPath + saveConfig(config) + console.log(`Initialized encrypted store at ${encryptedPath}`) + } catch (err: unknown) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`) + process.exitCode = 1 + } + }) + +store + .command('migrate') + .description('Migrate plaintext secrets.json to encrypted secrets.enc.json') + .option('--force', 'Overwrite existing encrypted store', false) + .action(async (opts: { force: boolean }) => { + try { + const config = loadConfig() + if (config.store.path.endsWith('.enc.json')) { + throw new Error('Store is already encrypted') + } + const plaintextPath = config.store.path + const encryptedPath = deriveEncryptedPath(plaintextPath) + const password = await promptNewPassword() + const count = await migrateStore(plaintextPath, encryptedPath, password, { + force: opts.force, + }) + config.store.path = encryptedPath + saveConfig(config) + console.log(`Migrated ${count} secret(s). Old store backed up to ${plaintextPath}.bak`) + } catch (err: unknown) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`) + process.exitCode = 1 + } + }) + +export { store as storeCommand } diff --git a/src/cli/unlock.ts b/src/cli/unlock.ts new file mode 100644 index 0000000..aee1592 --- /dev/null +++ b/src/cli/unlock.ts @@ -0,0 +1,119 @@ +import { Command } from 'commander' +import { createInterface } from 'node:readline' +import { Writable } from 'node:stream' +import { readFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' + +import { loadConfig } from '../core/config.js' +import { deriveKek } from '../core/kdf.js' +import { unwrapDek } from '../core/crypto.js' +import { UnlockSession } from '../core/unlock-session.js' +import type { EncryptedStoreFile } from '../core/encrypted-store.js' + +const ENCRYPTED_STORE_PATH = join(homedir(), '.2kc', 'secrets.enc.json') + +function promptPassword(): Promise { + return new Promise((resolve) => { + let muted = false + const mutableOutput = new Writable({ + write(_chunk, _encoding, callback) { + if (!muted) process.stderr.write(_chunk) + callback() + }, + }) + + const rl = createInterface({ + input: process.stdin, + output: mutableOutput, + terminal: true, + }) + + process.stderr.write('Password: ') + muted = true + + rl.question('', (answer) => { + muted = false + process.stderr.write('\n') + rl.close() + resolve(answer) + }) + }) +} + +function formatTtl(ms: number): string { + const seconds = Math.round(ms / 1000) + if (seconds < 60) { + return `${seconds} second${seconds === 1 ? '' : 's'}` + } + const minutes = Math.round(ms / 60_000) + if (minutes < 60) { + return `${minutes} minute${minutes === 1 ? '' : 's'}` + } + const hours = Math.round(ms / 3_600_000) + return `${hours} hour${hours === 1 ? '' : 's'}` +} + +const unlockCommand = new Command('unlock').description('Unlock the encrypted secret store') + +unlockCommand.action(async () => { + if (!existsSync(ENCRYPTED_STORE_PATH)) { + console.error('Error: Encrypted store not found. Run store initialization first.') + process.exitCode = 1 + return + } + + const password = await promptPassword() + + let store: EncryptedStoreFile + try { + const raw = JSON.parse(readFileSync(ENCRYPTED_STORE_PATH, 'utf-8')) as Record + if (raw.version !== 1 || !raw.kdf || !raw.wrappedDek) { + console.error('Error: Malformed encrypted store file.') + process.exitCode = 1 + return + } + store = raw as unknown as EncryptedStoreFile + } catch { + console.error('Error: Failed to read encrypted store file.') + process.exitCode = 1 + return + } + + let dek: Buffer + try { + const kek = await deriveKek(password, Buffer.from(store.kdf.salt, 'base64'), store.kdf.params) + dek = unwrapDek(kek, store.wrappedDek.ciphertext, store.wrappedDek.nonce, store.wrappedDek.tag) + } catch { + console.error('Incorrect password.') + process.exitCode = 1 + return + } + + const config = loadConfig() + const session = new UnlockSession(config.unlock) + session.unlock(dek) + dek.fill(0) + + console.log(`Store unlocked. Session expires in ${formatTtl(config.unlock.ttlMs)}.`) +}) + +const lockCommand = new Command('lock').description('Lock the encrypted secret store') + +lockCommand.action(() => { + console.log('Store locked.') +}) + +const statusCommand = new Command('status').description( + 'Show the lock status of the encrypted secret store', +) + +statusCommand.action(() => { + if (!existsSync(ENCRYPTED_STORE_PATH)) { + console.log('Encrypted store not found. Run store initialization first.') + return + } + console.log('Store is locked.') +}) + +export { unlockCommand, lockCommand, statusCommand } diff --git a/src/core/config.ts b/src/core/config.ts index c662795..a530d55 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -113,7 +113,7 @@ function parseServerConfig(raw: unknown): ServerConfig { } function parseStoreConfig(raw: unknown): StoreConfig { - const defaultPath = '~/.2kc/secrets.json' + const defaultPath = '~/.2kc/secrets.enc.json' if (raw === undefined || raw === null) return { path: resolveTilde(defaultPath) } if (!isRecord(raw)) { throw new Error('store must be an object') @@ -166,7 +166,7 @@ export function defaultConfig(): AppConfig { return { mode: 'standalone', server: { host: '127.0.0.1', port: 2274 }, - store: { path: resolveTilde('~/.2kc/secrets.json') }, + store: { path: resolveTilde('~/.2kc/secrets.enc.json') }, unlock: { ttlMs: 900_000 }, discord: undefined, requireApproval: {}, diff --git a/src/core/encrypted-store.ts b/src/core/encrypted-store.ts index 1b99e35..c3fe387 100644 --- a/src/core/encrypted-store.ts +++ b/src/core/encrypted-store.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path' import { homedir } from 'node:os' import type { SecretListItem, SecretMetadata } from './types.js' +import type { ISecretStore } from './secret-store.js' import type { EncryptedValue } from './crypto.js' import { generateDek, buildAad, encryptValue, decryptValue, wrapDek, unwrapDek } from './crypto.js' import { deriveKek, generateSalt, DEFAULT_SCRYPT_PARAMS } from './kdf.js' @@ -36,7 +37,7 @@ export interface EncryptedStoreFile { secrets: EncryptedSecretEntry[] } -export class EncryptedSecretStore { +export class EncryptedSecretStore implements ISecretStore { private readonly filePath: string private dek: Buffer | null = null @@ -49,6 +50,12 @@ export class EncryptedSecretStore { return this.dek !== null } + /** Returns a copy of the DEK currently held in memory, or null if locked. */ + getDek(): Buffer | null { + if (!this.dek) return null + return Buffer.from(this.dek) // return a copy to prevent callers from zeroing internal buffer + } + /** Lock the store by discarding the DEK from memory. */ lock(): void { if (this.dek) { diff --git a/src/core/grant.ts b/src/core/grant.ts index f8abf1d..b3c8789 100644 --- a/src/core/grant.ts +++ b/src/core/grant.ts @@ -1,4 +1,7 @@ import { randomUUID } from 'node:crypto' +import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { homedir } from 'node:os' import type { AccessRequest } from './request.js' export interface AccessGrant { @@ -11,8 +14,16 @@ export interface AccessGrant { revokedAt: string | null } +const DEFAULT_GRANTS_PATH = join(homedir(), '.2kc', 'grants.json') + export class GrantManager { private grants: Map = new Map() + private readonly grantsFilePath: string + + constructor(grantsFilePath: string = DEFAULT_GRANTS_PATH) { + this.grantsFilePath = grantsFilePath + this.load() + } createGrant(request: AccessRequest): AccessGrant { if (request.status !== 'approved') { @@ -29,6 +40,7 @@ export class GrantManager { revokedAt: null, } this.grants.set(grant.id, grant) + this.save() return grant } @@ -50,6 +62,7 @@ export class GrantManager { throw new Error(`Grant is not valid: ${grantId}`) } grant.used = true + this.save() } revokeGrant(grantId: string): void { @@ -61,6 +74,7 @@ export class GrantManager { throw new Error(`Grant already revoked: ${grantId}`) } grant.revokedAt = new Date().toISOString() + this.save() } cleanup(): void { @@ -84,4 +98,28 @@ export class GrantManager { if (!grant) return undefined return [...grant.secretUuids] } + + getGrantByRequestId(requestId: string): AccessGrant | undefined { + for (const grant of this.grants.values()) { + if (grant.requestId === requestId) return { ...grant } + } + return undefined + } + + private load(): void { + try { + const data = JSON.parse(readFileSync(this.grantsFilePath, 'utf-8')) as AccessGrant[] + for (const grant of data) this.grants.set(grant.id, grant) + } catch { + // File absent or corrupted — start with empty map + } + } + + private save(): void { + const dir = dirname(this.grantsFilePath) + mkdirSync(dir, { recursive: true }) + const grants = [...this.grants.values()] + writeFileSync(this.grantsFilePath, JSON.stringify(grants, null, 2), 'utf-8') + chmodSync(this.grantsFilePath, 0o600) + } } diff --git a/src/core/injector.ts b/src/core/injector.ts index 90332eb..53d0a4e 100644 --- a/src/core/injector.ts +++ b/src/core/injector.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process' import type { GrantManager } from './grant.js' -import type { SecretStore } from './secret-store.js' +import type { ISecretStore } from './secret-store.js' import type { ProcessResult } from './types.js' import { RedactTransform } from './redact.js' @@ -17,7 +17,7 @@ const PLACEHOLDER_RE = /^2k:\/\/(.+)$/ export class SecretInjector { constructor( private readonly grantManager: GrantManager, - private readonly secretStore: SecretStore, + private readonly secretStore: ISecretStore, ) {} async inject( diff --git a/src/core/secret-store.ts b/src/core/secret-store.ts index bd5570a..c087ae5 100644 --- a/src/core/secret-store.ts +++ b/src/core/secret-store.ts @@ -10,7 +10,19 @@ const DEFAULT_PATH = join(homedir(), '.2kc', 'secrets.json') const REF_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i -export class SecretStore { +export interface ISecretStore { + list(): SecretListItem[] + add(ref: string, value: string, tags?: string[]): string + remove(uuid: string): boolean + getMetadata(uuid: string): SecretMetadata + getValue(uuid: string): string + getByRef(ref: string): SecretMetadata + getValueByRef(ref: string): string + resolve(refOrUuid: string): SecretMetadata + resolveRef(refOrUuid: string): { uuid: string; value: string } +} + +export class SecretStore implements ISecretStore { private readonly filePath: string constructor(filePath: string = DEFAULT_PATH) { diff --git a/src/core/service.ts b/src/core/service.ts index 8a48175..b049b65 100644 --- a/src/core/service.ts +++ b/src/core/service.ts @@ -1,7 +1,16 @@ +import { join, dirname } from 'node:path' import type { AppConfig } from './config.js' import type { SecretListItem, SecretMetadata, ProcessResult } from './types.js' import type { AccessRequest } from './request.js' +import { createAccessRequest, RequestLog } from './request.js' +import type { NotificationChannel } from '../channels/channel.js' import { RemoteService } from './remote-service.js' +import { EncryptedSecretStore } from './encrypted-store.js' +import { UnlockSession } from './unlock-session.js' +import { GrantManager } from './grant.js' +import { WorkflowEngine } from './workflow.js' +import { SecretInjector } from './injector.js' +import { DiscordChannel } from '../channels/discord.js' // SecretSummary aliases existing SecretListItem shape export type SecretSummary = SecretListItem @@ -37,54 +46,103 @@ export interface Service { ): Promise } -function notImplemented(): never { - throw new Error('not implemented') +interface LocalServiceDeps { + store: EncryptedSecretStore + unlockSession: UnlockSession + grantManager: GrantManager + workflowEngine: WorkflowEngine + injector: SecretInjector + requestLog: RequestLog + startTime: number } export class LocalService implements Service { + private readonly onLocked: () => void + + constructor(private readonly deps: LocalServiceDeps) { + // When session auto-locks (TTL/idle/max-grants), also lock the encrypted store + this.onLocked = () => deps.store.lock() + deps.unlockSession.on('locked', this.onLocked) + } + + destroy(): void { + this.deps.unlockSession.off('locked', this.onLocked) + } + + // Called by `2kc unlock` CLI command — not on the Service interface + async unlock(password: string): Promise { + await this.deps.store.unlock(password) + const dek = this.deps.store.getDek() + if (!dek) throw new Error('Failed to obtain DEK after unlock') + this.deps.unlockSession.unlock(dek) + } + + // Called by `2kc lock` CLI command — not on the Service interface + lock(): void { + this.deps.unlockSession.lock() + // EncryptedSecretStore.lock() is called via the 'locked' event handler above + } + async health(): Promise<{ status: string; uptime?: number }> { - notImplemented() + return { + status: this.deps.unlockSession.isUnlocked() ? 'unlocked' : 'locked', + uptime: Date.now() - this.deps.startTime, + } } secrets: Service['secrets'] = { - async list() { - notImplemented() - }, - async add() { - notImplemented() - }, - async remove() { - notImplemented() - }, - async getMetadata() { - notImplemented() + list: async () => this.deps.store.list(), + + add: async (ref, value, tags) => { + if (!this.deps.unlockSession.isUnlocked()) { + throw new Error('Store is locked. Run `2kc unlock` first.') + } + const uuid = this.deps.store.add(ref, value, tags) + return { uuid } }, - async resolve() { - notImplemented() + + remove: async (uuid) => { + this.deps.store.remove(uuid) }, + + getMetadata: async (uuid) => this.deps.store.getMetadata(uuid), + + resolve: async (refOrUuid) => this.deps.store.resolve(refOrUuid), } requests: Service['requests'] = { - async create() { - notImplemented() + create: async (secretUuids, reason, taskRef, duration) => { + const request = createAccessRequest(secretUuids, reason, taskRef, duration) + this.deps.requestLog.add(request) + const outcome = await this.deps.workflowEngine.processRequest(request) + if (outcome === 'approved') { + this.deps.grantManager.createGrant(request) + } + return request }, } grants: Service['grants'] = { - async validate() { - notImplemented() + validate: async (requestId) => { + const grant = this.deps.grantManager.getGrantByRequestId(requestId) + if (!grant) return false + return this.deps.grantManager.validateGrant(grant.id) }, } async inject( - // eslint-disable-next-line @typescript-eslint/no-unused-vars requestId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars command: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars options?: { envVarName?: string }, ): Promise { - notImplemented() + if (!this.deps.unlockSession.isUnlocked()) { + throw new Error('Store is locked. Run `2kc unlock` first.') + } + const grant = this.deps.grantManager.getGrantByRequestId(requestId) + if (!grant) throw new Error(`No grant found for request: ${requestId}`) + const result = await this.deps.injector.inject(grant.id, ['/bin/sh', '-c', command], options) + this.deps.unlockSession.recordGrantUsage() + return result } } @@ -92,5 +150,42 @@ export function resolveService(config: AppConfig): Service { if (config.mode === 'client') { return new RemoteService(config.server) } - return new LocalService() + + const grantsPath = join(dirname(config.store.path), 'grants.json') + + const store = new EncryptedSecretStore(config.store.path) + const unlockSession = new UnlockSession(config.unlock) + const grantManager = new GrantManager(grantsPath) + + let channel: NotificationChannel + if (config.discord) { + channel = new DiscordChannel(config.discord) + } else if (config.defaultRequireApproval) { + throw new Error('Discord must be configured when defaultRequireApproval is true') + } else { + channel = { + async sendApprovalRequest() { + return 'noop' + }, + async waitForResponse() { + return 'approved' as const + }, + async sendNotification() {}, + } + } + + const workflowEngine = new WorkflowEngine({ store, channel, config }) + const injector = new SecretInjector(grantManager, store) + const requestLog = new RequestLog() + const startTime = Date.now() + + return new LocalService({ + store, + unlockSession, + grantManager, + workflowEngine, + injector, + requestLog, + startTime, + }) } diff --git a/src/core/workflow.ts b/src/core/workflow.ts index 2fdfba6..b4a6ac2 100644 --- a/src/core/workflow.ts +++ b/src/core/workflow.ts @@ -1,12 +1,12 @@ import type { NotificationChannel } from '../channels/channel.js' import type { AccessRequest } from './request.js' import type { AccessRequest as ChannelAccessRequest } from './types.js' -import type { SecretStore } from './secret-store.js' +import type { ISecretStore } from './secret-store.js' import type { SecretMetadata } from './types.js' import type { AppConfig } from './config.js' export interface WorkflowDeps { - store: SecretStore + store: ISecretStore channel: NotificationChannel config: Pick } @@ -40,7 +40,7 @@ function toChannelRequest( } export class WorkflowEngine { - private store: SecretStore + private store: ISecretStore private channel: NotificationChannel private config: WorkflowDeps['config']