From 5b5ce285421b9132ee34a15147ca8fe1470530a0 Mon Sep 17 00:00:00 2001 From: Helix Date: Wed, 25 Feb 2026 17:36:20 -0800 Subject: [PATCH 01/25] fixes #59 --- src/__tests__/routes.test.ts | 420 +++++++++++++++++++++++++++++++++++ src/__tests__/server.test.ts | 60 ++++- src/server/app.ts | 16 +- src/server/index.ts | 9 +- src/server/routes.ts | 197 ++++++++++++++++ 5 files changed, 688 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/routes.test.ts create mode 100644 src/server/routes.ts diff --git a/src/__tests__/routes.test.ts b/src/__tests__/routes.test.ts new file mode 100644 index 0000000..e8c07db --- /dev/null +++ b/src/__tests__/routes.test.ts @@ -0,0 +1,420 @@ +/// + +import { createServer } from '../server/app.js' +import type { Service } from '../core/service.js' + +const TEST_TOKEN = 'test-token' +const authHeaders = { Authorization: `Bearer ${TEST_TOKEN}` } +const TEST_UUID = '550e8400-e29b-41d4-a716-446655440000' +const MISSING_UUID = '550e8400-e29b-41d4-a716-446655440001' + +function makeMockService(): Service { + return { + health: vi.fn().mockResolvedValue({ status: 'unlocked' }), + secrets: { + list: vi.fn().mockResolvedValue([]), + add: vi.fn().mockResolvedValue({ uuid: TEST_UUID }), + remove: vi.fn().mockResolvedValue(undefined), + getMetadata: vi.fn().mockResolvedValue({ uuid: TEST_UUID, ref: 'MY_SECRET', tags: [] }), + resolve: vi.fn().mockResolvedValue({ uuid: TEST_UUID, ref: 'MY_SECRET', tags: [] }), + }, + requests: { + create: vi.fn().mockResolvedValue({ + id: 'req-123', + secretUuids: ['test-uuid'], + reason: 'testing', + taskRef: 'task-1', + durationSeconds: 300, + requestedAt: new Date().toISOString(), + status: 'pending', + }), + }, + grants: { + validate: vi.fn().mockResolvedValue(true), + }, + inject: vi.fn().mockResolvedValue({ exitCode: 0, stdout: 'output', stderr: '' }), + } as unknown as Service +} + +describe('API Routes', () => { + describe('GET /api/secrets', () => { + it('returns 200 with secret list', async () => { + const service = makeMockService() + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: '/api/secrets', + headers: authHeaders, + }) + + expect(response.statusCode).toBe(200) + expect(JSON.parse(response.body)).toEqual([]) + expect(service.secrets.list).toHaveBeenCalled() + + await server.close() + }) + + it('returns 401 without auth header', async () => { + const server = createServer(makeMockService(), TEST_TOKEN) + const response = await server.inject({ method: 'GET', url: '/api/secrets' }) + + expect(response.statusCode).toBe(401) + + await server.close() + }) + }) + + describe('POST /api/secrets', () => { + it('returns 201 with uuid on success', async () => { + const service = makeMockService() + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'POST', + url: '/api/secrets', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + payload: { ref: 'MY_SECRET', value: 'supersecret', tags: ['prod'] }, + }) + + expect(response.statusCode).toBe(201) + expect(JSON.parse(response.body)).toEqual({ uuid: TEST_UUID }) + expect(service.secrets.add).toHaveBeenCalledWith('MY_SECRET', 'supersecret', ['prod']) + + await server.close() + }) + + it('returns 400 on missing ref', async () => { + const server = createServer(makeMockService(), TEST_TOKEN) + const response = await server.inject({ + method: 'POST', + url: '/api/secrets', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + payload: { value: 'supersecret' }, + }) + + expect(response.statusCode).toBe(400) + + await server.close() + }) + + it('returns 409 when ref already exists', async () => { + const service = makeMockService() + ;(service.secrets.add as ReturnType).mockRejectedValue( + new Error('already exists'), + ) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'POST', + url: '/api/secrets', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + payload: { ref: 'MY_SECRET', value: 'val' }, + }) + + expect(response.statusCode).toBe(409) + + await server.close() + }) + + it('returns 403 when store is locked', async () => { + const service = makeMockService() + ;(service.secrets.add as ReturnType).mockRejectedValue( + new Error('Store is locked'), + ) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'POST', + url: '/api/secrets', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + payload: { ref: 'MY_SECRET', value: 'val' }, + }) + + expect(response.statusCode).toBe(403) + + await server.close() + }) + }) + + describe('DELETE /api/secrets/:uuid', () => { + it('returns 204 on success', async () => { + const service = makeMockService() + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'DELETE', + url: `/api/secrets/${TEST_UUID}`, + headers: authHeaders, + }) + + expect(response.statusCode).toBe(204) + expect(service.secrets.remove).toHaveBeenCalledWith(TEST_UUID) + + await server.close() + }) + + it('returns 404 when secret not found', async () => { + const service = makeMockService() + ;(service.secrets.remove as ReturnType).mockRejectedValue( + new Error('not found'), + ) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'DELETE', + url: `/api/secrets/${MISSING_UUID}`, + headers: authHeaders, + }) + + expect(response.statusCode).toBe(404) + + await server.close() + }) + }) + + describe('GET /api/secrets/:uuid', () => { + it('returns 200 with metadata', async () => { + const service = makeMockService() + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: `/api/secrets/${TEST_UUID}`, + headers: authHeaders, + }) + + expect(response.statusCode).toBe(200) + const body = JSON.parse(response.body) + expect(body.uuid).toBe(TEST_UUID) + expect(service.secrets.getMetadata).toHaveBeenCalledWith(TEST_UUID) + + await server.close() + }) + + it('returns 404 when not found', async () => { + const service = makeMockService() + ;(service.secrets.getMetadata as ReturnType).mockRejectedValue( + new Error('not found'), + ) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: `/api/secrets/${MISSING_UUID}`, + headers: authHeaders, + }) + + expect(response.statusCode).toBe(404) + + await server.close() + }) + }) + + describe('GET /api/secrets/resolve/:refOrUuid', () => { + it('returns 200 with resolved metadata', async () => { + const service = makeMockService() + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: '/api/secrets/resolve/MY_SECRET', + headers: authHeaders, + }) + + expect(response.statusCode).toBe(200) + const body = JSON.parse(response.body) + expect(body.ref).toBe('MY_SECRET') + expect(service.secrets.resolve).toHaveBeenCalledWith('MY_SECRET') + + await server.close() + }) + + it('returns 404 when not found', async () => { + const service = makeMockService() + ;(service.secrets.resolve as ReturnType).mockRejectedValue( + new Error('not found'), + ) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: '/api/secrets/resolve/MISSING', + headers: authHeaders, + }) + + expect(response.statusCode).toBe(404) + + await server.close() + }) + }) + + describe('POST /api/requests', () => { + it('returns 201 with access request', async () => { + const service = makeMockService() + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'POST', + url: '/api/requests', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + payload: { secretUuids: ['test-uuid'], reason: 'testing', taskRef: 'task-1' }, + }) + + expect(response.statusCode).toBe(201) + const body = JSON.parse(response.body) + expect(body.id).toBe('req-123') + expect(service.requests.create).toHaveBeenCalledWith( + ['test-uuid'], + 'testing', + 'task-1', + undefined, + ) + + await server.close() + }) + + it('returns 400 on missing required fields', async () => { + const server = createServer(makeMockService(), TEST_TOKEN) + const response = await server.inject({ + method: 'POST', + url: '/api/requests', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + payload: { secretUuids: ['test-uuid'] }, + }) + + expect(response.statusCode).toBe(400) + + await server.close() + }) + }) + + describe('GET /api/grants/:requestId', () => { + it('returns 200 with boolean result', async () => { + const service = makeMockService() + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: '/api/grants/req-123', + headers: authHeaders, + }) + + expect(response.statusCode).toBe(200) + expect(JSON.parse(response.body)).toBe(true) + expect(service.grants.validate).toHaveBeenCalledWith('req-123') + + await server.close() + }) + }) + + describe('error handling edge cases', () => { + it('returns 500 for errors with no matching keyword', async () => { + const service = makeMockService() + ;(service.secrets.list as ReturnType).mockRejectedValue( + new Error('something went wrong'), + ) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: '/api/secrets', + headers: authHeaders, + }) + + expect(response.statusCode).toBe(500) + + await server.close() + }) + + it('returns 400 for errors containing "must"', async () => { + const service = makeMockService() + ;(service.secrets.add as ReturnType).mockRejectedValue( + new Error('ref must not be empty'), + ) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'POST', + url: '/api/secrets', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + payload: { ref: 'MY_SECRET', value: 'val' }, + }) + + expect(response.statusCode).toBe(400) + + await server.close() + }) + + it('returns 500 when a non-Error value is thrown', async () => { + const service = makeMockService() + ;(service.secrets.list as ReturnType).mockRejectedValue('raw string error') + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: '/api/secrets', + headers: authHeaders, + }) + + expect(response.statusCode).toBe(500) + + await server.close() + }) + }) + + describe('POST /api/inject', () => { + it('returns 200 with process result', async () => { + const service = makeMockService() + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'POST', + url: '/api/inject', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + payload: { requestId: 'req-123', command: 'echo hello' }, + }) + + expect(response.statusCode).toBe(200) + const body = JSON.parse(response.body) + expect(body.exitCode).toBe(0) + expect(service.inject).toHaveBeenCalledWith('req-123', 'echo hello', undefined) + + await server.close() + }) + + it('passes envVarName when provided', async () => { + const service = makeMockService() + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'POST', + url: '/api/inject', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + payload: { requestId: 'req-123', command: 'echo hello', envVarName: 'MY_VAR' }, + }) + + expect(response.statusCode).toBe(200) + expect(service.inject).toHaveBeenCalledWith('req-123', 'echo hello', { envVarName: 'MY_VAR' }) + + await server.close() + }) + + it('returns 403 when store is locked', async () => { + const service = makeMockService() + ;(service.inject as ReturnType).mockRejectedValue(new Error('Store is locked')) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'POST', + url: '/api/inject', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + payload: { requestId: 'req-123', command: 'echo hello' }, + }) + + expect(response.statusCode).toBe(403) + + await server.close() + }) + + it('returns 404 when no grant found', async () => { + const service = makeMockService() + ;(service.inject as ReturnType).mockRejectedValue( + new Error('No grant found for request'), + ) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'POST', + url: '/api/inject', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + payload: { requestId: 'req-123', command: 'echo hello' }, + }) + + expect(response.statusCode).toBe(404) + + await server.close() + }) + }) +}) diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index a5a6545..faf5c24 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -2,6 +2,21 @@ import { createServer, startServer } from '../server/app.js' import { defaultConfig } from '../core/config.js' +import type { Service } from '../core/service.js' + +const mockService = { + health: vi.fn(), + secrets: { + list: vi.fn(), + add: vi.fn(), + remove: vi.fn(), + getMetadata: vi.fn(), + resolve: vi.fn(), + }, + requests: { create: vi.fn() }, + grants: { validate: vi.fn() }, + inject: vi.fn(), +} as unknown as Service describe('HTTP Server', () => { const config = { @@ -11,7 +26,7 @@ describe('HTTP Server', () => { describe('GET /health', () => { it('returns 200 with status ok and uptime', async () => { - const server = createServer() + const server = createServer(mockService, 'test-token') const response = await server.inject({ method: 'GET', url: '/health' }) expect(response.statusCode).toBe(200) @@ -25,8 +40,12 @@ describe('HTTP Server', () => { describe('404 handling', () => { it('returns 404 JSON for unknown routes', async () => { - const server = createServer() - const response = await server.inject({ method: 'GET', url: '/nonexistent' }) + const server = createServer(mockService, 'test-token') + const response = await server.inject({ + method: 'GET', + url: '/nonexistent', + headers: { Authorization: 'Bearer test-token' }, + }) expect(response.statusCode).toBe(404) const body = JSON.parse(response.body) @@ -39,14 +58,18 @@ describe('HTTP Server', () => { describe('error handling', () => { it('returns 500 with generic message for internal errors', async () => { - const server = createServer() + const server = createServer(mockService, 'test-token') // Register a route that throws an internal error server.get('/test-internal-error', async () => { throw new Error('Internal failure') }) - const response = await server.inject({ method: 'GET', url: '/test-internal-error' }) + const response = await server.inject({ + method: 'GET', + url: '/test-internal-error', + headers: { Authorization: 'Bearer test-token' }, + }) expect(response.statusCode).toBe(500) const body = JSON.parse(response.body) @@ -57,7 +80,7 @@ describe('HTTP Server', () => { }) it('returns 4xx with error message for client errors', async () => { - const server = createServer() + const server = createServer(mockService, 'test-token') // Register a route that throws a 400 error server.get('/test-client-error', async () => { @@ -66,7 +89,11 @@ describe('HTTP Server', () => { throw err }) - const response = await server.inject({ method: 'GET', url: '/test-client-error' }) + const response = await server.inject({ + method: 'GET', + url: '/test-client-error', + headers: { Authorization: 'Bearer test-token' }, + }) expect(response.statusCode).toBe(400) const body = JSON.parse(response.body) @@ -79,7 +106,7 @@ describe('HTTP Server', () => { describe('graceful shutdown', () => { it('closes the server without error', async () => { - const server = createServer() + const server = createServer(mockService, 'test-token') await server.listen({ host: config.server.host, port: config.server.port }) await expect(server.close()).resolves.toBeUndefined() @@ -93,7 +120,7 @@ describe('HTTP Server', () => { SIGTERM: process.listenerCount('SIGTERM'), } - const server = await startServer(config) + const server = await startServer(config, mockService, 'test-token') // Signal handlers should have been added expect(process.listenerCount('SIGINT')).toBe(originalListeners.SIGINT + 1) @@ -107,12 +134,25 @@ describe('HTTP Server', () => { }) it('returns a listening server', async () => { - const server = await startServer(config) + const server = await startServer(config, mockService, 'test-token') const response = await server.inject({ method: 'GET', url: '/health' }) expect(response.statusCode).toBe(200) await server.close() }) + + it('closes the server and exits on SIGINT', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never) + + await startServer(config, mockService, 'test-token') + + process.emit('SIGINT') + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(exitSpy).toHaveBeenCalledWith(0) + exitSpy.mockRestore() + }) }) }) diff --git a/src/server/app.ts b/src/server/app.ts index a5a9dbc..d4299f9 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,13 +1,19 @@ import Fastify, { type FastifyError, type FastifyInstance } from 'fastify' import type { AppConfig } from '../core/config.js' +import type { Service } from '../core/service.js' +import { bearerAuthPlugin } from './auth.js' +import { routePlugin } from './routes.js' -export function createServer(): FastifyInstance { +export function createServer(service: Service, authToken: string): FastifyInstance { const server = Fastify({ logger: { level: 'info', }, }) + server.register(bearerAuthPlugin, { authToken }) + server.register(routePlugin, { service }) + server.get('/health', async () => { return { status: 'ok', uptime: process.uptime() } }) @@ -25,8 +31,12 @@ export function createServer(): FastifyInstance { return server } -export async function startServer(config: AppConfig): Promise { - const server = createServer() +export async function startServer( + config: AppConfig, + service: Service, + authToken: string, +): Promise { + const server = createServer(service, authToken) const shutdown = async () => { server.log.info('Shutting down server...') diff --git a/src/server/index.ts b/src/server/index.ts index f056b69..c29e0d5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,9 +1,16 @@ import { loadConfig } from '../core/config.js' +import { resolveService } from '../core/service.js' import { startServer } from './app.js' try { const config = loadConfig() - await startServer(config) + const service = resolveService(config) + const authToken = config.server.authToken + if (!authToken) { + console.error('server.authToken is required. Run 2kc server token generate.') + process.exit(1) + } + await startServer(config, service, authToken) } catch (error) { console.error('Failed to start server:', error instanceof Error ? error.message : error) process.exit(1) diff --git a/src/server/routes.ts b/src/server/routes.ts new file mode 100644 index 0000000..afa453a --- /dev/null +++ b/src/server/routes.ts @@ -0,0 +1,197 @@ +import fp from 'fastify-plugin' +import type { FastifyInstance } from 'fastify' +import type { Service } from '../core/service.js' + +function mapErrorToStatus(error: Error): number { + const msg = error.message.toLowerCase() + if (msg.includes('locked')) return 403 + if (msg.includes('not found') || msg.includes('no grant found')) return 404 + if (msg.includes('already exists')) return 409 + if (msg.includes('must') || msg.includes('required')) return 400 + return 500 +} + +function mapStatusToMessage(status: number): string { + if (status === 404) return 'Resource not found' + if (status === 403) return 'Access denied' + if (status === 409) return 'Conflict' + if (status === 400) return 'Bad request' + return 'Internal Server Error' +} + +function handleError(error: unknown): never { + if (error instanceof Error) { + const status = mapErrorToStatus(error) + ;(error as Error & { statusCode?: number }).statusCode = status + if (status < 500) { + error.message = mapStatusToMessage(status) + } + throw error + } + throw Object.assign(new Error(String(error)), { statusCode: 500 }) +} + +export interface RoutePluginOptions { + service: Service +} + +export const routePlugin = fp( + async (fastify: FastifyInstance, opts: RoutePluginOptions) => { + const { service } = opts + + // GET /api/secrets — list secrets (metadata only) + fastify.get('/api/secrets', async () => service.secrets.list().catch(handleError)) + + // POST /api/secrets — add a secret + fastify.post<{ Body: { ref: string; value: string; tags?: string[] } }>( + '/api/secrets', + { + schema: { + body: { + type: 'object', + required: ['ref', 'value'], + properties: { + ref: { type: 'string' }, + value: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + async (request, reply) => { + const { ref, value, tags } = request.body + const result = await service.secrets.add(ref, value, tags).catch(handleError) + return reply.code(201).send(result) + }, + ) + + // DELETE /api/secrets/:uuid — remove a secret + fastify.delete<{ Params: { uuid: string } }>( + '/api/secrets/:uuid', + { + schema: { + params: { + type: 'object', + required: ['uuid'], + properties: { + uuid: { type: 'string', format: 'uuid' }, + }, + }, + }, + }, + async (request, reply) => { + await service.secrets.remove(request.params.uuid).catch(handleError) + return reply.code(204).send() + }, + ) + + // GET /api/secrets/resolve/:refOrUuid — resolve metadata (registered before /:uuid to take priority) + fastify.get<{ Params: { refOrUuid: string } }>( + '/api/secrets/resolve/:refOrUuid', + { + schema: { + params: { + type: 'object', + required: ['refOrUuid'], + properties: { + refOrUuid: { type: 'string', minLength: 1 }, + }, + }, + }, + }, + async (request) => service.secrets.resolve(request.params.refOrUuid).catch(handleError), + ) + + // GET /api/secrets/:uuid — get metadata by uuid + fastify.get<{ Params: { uuid: string } }>( + '/api/secrets/:uuid', + { + schema: { + params: { + type: 'object', + required: ['uuid'], + properties: { + uuid: { type: 'string', format: 'uuid' }, + }, + }, + }, + }, + async (request) => service.secrets.getMetadata(request.params.uuid).catch(handleError), + ) + + // POST /api/requests — create access request + fastify.post<{ + Body: { secretUuids: string[]; reason: string; taskRef: string; duration?: number } + }>( + '/api/requests', + { + schema: { + body: { + type: 'object', + required: ['secretUuids', 'reason', 'taskRef'], + properties: { + secretUuids: { type: 'array', items: { type: 'string' } }, + reason: { type: 'string' }, + taskRef: { type: 'string' }, + duration: { type: 'number' }, + }, + }, + }, + }, + async (request, reply) => { + const { secretUuids, reason, taskRef, duration } = request.body + const result = await service.requests + .create(secretUuids, reason, taskRef, duration) + .catch(handleError) + return reply.code(201).send(result) + }, + ) + + // GET /api/grants/:requestId — validate grant status + fastify.get<{ Params: { requestId: string } }>( + '/api/grants/:requestId', + { + schema: { + params: { + type: 'object', + required: ['requestId'], + properties: { + requestId: { type: 'string', minLength: 1 }, + }, + }, + }, + }, + async (request) => service.grants.validate(request.params.requestId).catch(handleError), + ) + + // POST /api/inject — resolve secrets for injection + // + // Security boundary: authenticated callers are trusted to run commands. + // The command parameter is intentionally user-supplied — this endpoint exists + // to inject secrets into arbitrary commands. The auth token is the sole + // security boundary; callers with a valid token are assumed authorized. + fastify.post<{ Body: { requestId: string; command: string; envVarName?: string } }>( + '/api/inject', + { + schema: { + body: { + type: 'object', + required: ['requestId', 'command'], + properties: { + requestId: { type: 'string' }, + command: { type: 'string' }, + envVarName: { type: 'string' }, + }, + }, + }, + }, + async (request) => { + const { requestId, command, envVarName } = request.body + return service + .inject(requestId, command, envVarName != null ? { envVarName } : undefined) + .catch(handleError) + }, + ) + }, + { name: 'routes' }, +) From 3652f94ad68f14513e53ec0f26027a4874546b8e Mon Sep 17 00:00:00 2001 From: Helix Date: Wed, 25 Feb 2026 18:04:59 -0800 Subject: [PATCH 02/25] fixes #60 Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 10 +++ package.json | 1 + src/__tests__/auth.test.ts | 144 ++++++++++++++++++++++++++++++++++- src/__tests__/config.test.ts | 22 ++++++ src/__tests__/server.test.ts | 8 +- src/core/config.ts | 8 ++ src/server/app.ts | 28 +++++-- src/server/auth.ts | 40 +++++++++- src/server/index.ts | 7 +- 9 files changed, 246 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index da90854..2bd3248 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "commander": "^13.1.0", "fastify": "^5.7.4", "fastify-plugin": "^5.1.0", + "jose": "^6.1.3", "uuid": "^13.0.0" }, "bin": { @@ -2942,6 +2943,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", diff --git a/package.json b/package.json index 568f4e4..7b69ff8 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "commander": "^13.1.0", "fastify": "^5.7.4", "fastify-plugin": "^5.1.0", + "jose": "^6.1.3", "uuid": "^13.0.0" }, "devDependencies": { diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index c3f728e..89499ce 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -1,14 +1,23 @@ /// +import { randomBytes } from 'node:crypto' + import Fastify from 'fastify' +import { SignJWT } from 'jose' import { bearerAuthPlugin, validateAuthToken } from '../server/auth.js' const TEST_TOKEN = 'test-secret-token-12345' - -function buildApp(token: string = TEST_TOKEN) { +const TEST_SESSION_SECRET = randomBytes(32) +const TEST_SESSION_TTL_MS = 3_600_000 + +function buildApp( + token: string = TEST_TOKEN, + sessionSecret: Uint8Array = TEST_SESSION_SECRET, + sessionTtlMs: number = TEST_SESSION_TTL_MS, +) { const app = Fastify() - app.register(bearerAuthPlugin, { authToken: token }) + app.register(bearerAuthPlugin, { authToken: token, sessionSecret, sessionTtlMs }) app.get('/health', async () => { return { status: 'ok' } @@ -93,12 +102,139 @@ describe('bearerAuthPlugin', () => { it('throws if authToken is not provided', async () => { const app = Fastify() - app.register(bearerAuthPlugin, { authToken: '' }) + app.register(bearerAuthPlugin, { + authToken: '', + sessionSecret: TEST_SESSION_SECRET, + sessionTtlMs: TEST_SESSION_TTL_MS, + }) await expect(app.ready()).rejects.toThrow('server.authToken must be configured') }) }) +describe('session JWT auth', () => { + it('POST /api/auth/login with valid token returns sessionToken and expiresAt', async () => { + const app = buildApp() + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + headers: { 'content-type': 'application/json' }, + payload: { token: TEST_TOKEN }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json() + expect(typeof body.sessionToken).toBe('string') + expect(typeof body.expiresAt).toBe('string') + expect(new Date(body.expiresAt).getTime()).toBeGreaterThan(Date.now()) + }) + + it('POST /api/auth/login with invalid token returns 401', async () => { + const app = buildApp() + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + headers: { 'content-type': 'application/json' }, + payload: { token: 'wrong-token' }, + }) + + expect(response.statusCode).toBe(401) + expect(response.json()).toEqual({ error: 'Invalid or missing auth token' }) + }) + + it('POST /api/auth/login with missing token field returns 401', async () => { + const app = buildApp() + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + headers: { 'content-type': 'application/json' }, + payload: {}, + }) + + expect(response.statusCode).toBe(401) + expect(response.json()).toEqual({ error: 'Invalid or missing auth token' }) + }) + + it('request with valid session JWT passes auth', async () => { + const app = buildApp() + + // First, get a session token via login + const loginResponse = await app.inject({ + method: 'POST', + url: '/api/auth/login', + headers: { 'content-type': 'application/json' }, + payload: { token: TEST_TOKEN }, + }) + const { sessionToken } = loginResponse.json() + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { authorization: `Bearer ${sessionToken}` }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ data: 'protected' }) + }) + + it('request with expired session JWT returns 401', async () => { + const app = buildApp() + + const now = Math.floor(Date.now() / 1000) + const expiredToken = await new SignJWT({ sub: 'client' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(now - 7200) + .setExpirationTime(now - 3600) + .sign(TEST_SESSION_SECRET) + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { authorization: `Bearer ${expiredToken}` }, + }) + + expect(response.statusCode).toBe(401) + expect(response.json()).toEqual({ error: 'Invalid or missing auth token' }) + }) + + it('request with JWT signed by wrong secret returns 401', async () => { + const app = buildApp() + + const wrongSecret = randomBytes(32) + const now = Math.floor(Date.now() / 1000) + const badToken = await new SignJWT({ sub: 'client' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(now) + .setExpirationTime(now + 3600) + .sign(wrongSecret) + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { authorization: `Bearer ${badToken}` }, + }) + + expect(response.statusCode).toBe(401) + expect(response.json()).toEqual({ error: 'Invalid or missing auth token' }) + }) + + it('request with static bearer token still passes (backward compat)', async () => { + const app = buildApp() + + const response = await app.inject({ + method: 'GET', + url: '/test', + headers: { authorization: `Bearer ${TEST_TOKEN}` }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ data: 'protected' }) + }) +}) + describe('validateAuthToken', () => { it('returns true for matching tokens', () => { expect(validateAuthToken('abc123', 'abc123')).toBe(true) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 6994a15..20a2390 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -313,6 +313,28 @@ describe('parseConfig', () => { expect(() => parseConfig({ server: 'bad' })).toThrow('server must be an object') }) + it('parses server.sessionTtlMs when provided', () => { + const config = parseConfig({ server: { sessionTtlMs: 1800000 } }) + expect(config.server.sessionTtlMs).toBe(1800000) + }) + + it('defaults server.sessionTtlMs to undefined (applied at runtime)', () => { + const config = parseConfig({}) + expect(config.server.sessionTtlMs).toBeUndefined() + }) + + it('throws on server.sessionTtlMs below minimum (1000ms)', () => { + expect(() => parseConfig({ server: { sessionTtlMs: 0 } })).toThrow( + 'server.sessionTtlMs must be at least 1000ms', + ) + expect(() => parseConfig({ server: { sessionTtlMs: -1 } })).toThrow( + 'server.sessionTtlMs must be at least 1000ms', + ) + expect(() => parseConfig({ server: { sessionTtlMs: 999 } })).toThrow( + 'server.sessionTtlMs must be at least 1000ms', + ) + }) + it('throws if server.host is empty string', () => { expect(() => parseConfig({ server: { host: '' } })).toThrow( 'server.host must be a non-empty string', diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index faf5c24..79f9583 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -21,7 +21,7 @@ const mockService = { describe('HTTP Server', () => { const config = { ...defaultConfig(), - server: { host: '127.0.0.1', port: 0 }, + server: { host: '127.0.0.1', port: 0, authToken: 'test-token' }, } describe('GET /health', () => { @@ -120,7 +120,7 @@ describe('HTTP Server', () => { SIGTERM: process.listenerCount('SIGTERM'), } - const server = await startServer(config, mockService, 'test-token') + const server = await startServer(config, mockService) // Signal handlers should have been added expect(process.listenerCount('SIGINT')).toBe(originalListeners.SIGINT + 1) @@ -134,7 +134,7 @@ describe('HTTP Server', () => { }) it('returns a listening server', async () => { - const server = await startServer(config, mockService, 'test-token') + const server = await startServer(config, mockService) const response = await server.inject({ method: 'GET', url: '/health' }) expect(response.statusCode).toBe(200) @@ -145,7 +145,7 @@ describe('HTTP Server', () => { it('closes the server and exits on SIGINT', async () => { const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never) - await startServer(config, mockService, 'test-token') + await startServer(config, mockService) process.emit('SIGINT') diff --git a/src/core/config.ts b/src/core/config.ts index a530d55..f933451 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -12,6 +12,7 @@ export interface ServerConfig { host: string port: number authToken?: string + sessionTtlMs?: number } export interface StoreConfig { @@ -109,6 +110,13 @@ function parseServerConfig(raw: unknown): ServerConfig { result.authToken = raw.authToken } + if (raw.sessionTtlMs !== undefined) { + if (typeof raw.sessionTtlMs !== 'number' || raw.sessionTtlMs < 1000) { + throw new Error('server.sessionTtlMs must be at least 1000ms') + } + result.sessionTtlMs = raw.sessionTtlMs + } + return result } diff --git a/src/server/app.ts b/src/server/app.ts index d4299f9..56eb4dc 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -1,17 +1,29 @@ +import { randomBytes } from 'node:crypto' + import Fastify, { type FastifyError, type FastifyInstance } from 'fastify' + import type { AppConfig } from '../core/config.js' import type { Service } from '../core/service.js' import { bearerAuthPlugin } from './auth.js' import { routePlugin } from './routes.js' export function createServer(service: Service, authToken: string): FastifyInstance { + // sessionSecret is regenerated per server instance — sessions intentionally + // invalidate on server restart (ephemeral by design). + const sessionSecret = randomBytes(32) + const sessionTtlMs = 3_600_000 + const server = Fastify({ logger: { level: 'info', }, }) - server.register(bearerAuthPlugin, { authToken }) + server.register(bearerAuthPlugin, { + authToken, + sessionSecret, + sessionTtlMs, + }) server.register(routePlugin, { service }) server.get('/health', async () => { @@ -31,12 +43,14 @@ export function createServer(service: Service, authToken: string): FastifyInstan return server } -export async function startServer( - config: AppConfig, - service: Service, - authToken: string, -): Promise { - const server = createServer(service, authToken) +export async function startServer(config: AppConfig, service: Service): Promise { + if (!config.server.authToken) { + throw new Error( + 'server.authToken must be configured. Run `2kc server token generate` to create one.', + ) + } + + const server = createServer(service, config.server.authToken) const shutdown = async () => { server.log.info('Shutting down server...') diff --git a/src/server/auth.ts b/src/server/auth.ts index 3ab44a3..81f0ef2 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -2,6 +2,7 @@ import { timingSafeEqual, randomBytes } from 'node:crypto' import fp from 'fastify-plugin' import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import { SignJWT, jwtVerify } from 'jose' export function validateAuthToken(expected: string, provided: string): boolean { const expectedBuf = Buffer.from(expected, 'utf-8') @@ -19,6 +20,8 @@ export function validateAuthToken(expected: string, provided: string): boolean { export interface BearerAuthOptions { authToken: string + sessionSecret: Uint8Array + sessionTtlMs: number } export const bearerAuthPlugin = fp( @@ -29,10 +32,34 @@ export const bearerAuthPlugin = fp( ) } + fastify.post('/api/auth/login', async (request: FastifyRequest, reply: FastifyReply) => { + const body = request.body as { token?: unknown } + const provided = body?.token + + if (typeof provided !== 'string' || !validateAuthToken(opts.authToken, provided)) { + return reply.code(401).send({ error: 'Invalid or missing auth token' }) + } + + const now = Math.floor(Date.now() / 1000) + const exp = now + Math.floor(opts.sessionTtlMs / 1000) + const expiresAt = new Date(Date.now() + opts.sessionTtlMs).toISOString() + + const sessionToken = await new SignJWT({ sub: 'client' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(now) + .setExpirationTime(exp) + .sign(opts.sessionSecret) + + return reply.code(200).send({ sessionToken, expiresAt }) + }) + fastify.addHook('preHandler', async (request: FastifyRequest, reply: FastifyReply) => { if (request.method === 'GET' && request.routeOptions.url === '/health') { return } + if (request.method === 'POST' && request.routeOptions.url === '/api/auth/login') { + return + } const authHeader = request.headers.authorization if (!authHeader || !authHeader.startsWith('Bearer ')) { @@ -40,7 +67,18 @@ export const bearerAuthPlugin = fp( } const token = authHeader.slice(7) - if (!validateAuthToken(opts.authToken, token)) { + + // Accept static bearer token (backward compat) — skip if token looks like a JWT + // (contains dots) since validateAuthToken would always fail and wastes allocation + if (!token.includes('.') && validateAuthToken(opts.authToken, token)) { + return + } + + // Accept valid session JWT + try { + await jwtVerify(token, opts.sessionSecret) + return + } catch { return reply.code(401).send({ error: 'Invalid or missing auth token' }) } }) diff --git a/src/server/index.ts b/src/server/index.ts index c29e0d5..9b19778 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,12 +5,7 @@ import { startServer } from './app.js' try { const config = loadConfig() const service = resolveService(config) - const authToken = config.server.authToken - if (!authToken) { - console.error('server.authToken is required. Run 2kc server token generate.') - process.exit(1) - } - await startServer(config, service, authToken) + await startServer(config, service) } catch (error) { console.error('Failed to start server:', error instanceof Error ? error.message : error) process.exit(1) From 3dd96d268eebb54e64ccbc127f7cad3abea2fcb3 Mon Sep 17 00:00:00 2001 From: Helix Date: Wed, 25 Feb 2026 21:19:38 -0800 Subject: [PATCH 03/25] fixes #61 --- src/__tests__/grant.test.ts | 100 +++--- .../integration/local-encrypted-flow.test.ts | 8 +- src/__tests__/service.test.ts | 22 +- src/__tests__/signed-grant.test.ts | 308 ++++++++++++++++++ src/cli/request.ts | 2 +- src/cli/secrets.ts | 6 +- src/core/grant.ts | 14 +- src/core/key-manager.ts | 52 +++ src/core/service.ts | 9 +- src/core/signed-grant.ts | 67 ++++ src/server/index.ts | 2 +- 11 files changed, 514 insertions(+), 76 deletions(-) create mode 100644 src/__tests__/signed-grant.test.ts create mode 100644 src/core/key-manager.ts create mode 100644 src/core/signed-grant.ts diff --git a/src/__tests__/grant.test.ts b/src/__tests__/grant.test.ts index 6e60d5c..f870d0c 100644 --- a/src/__tests__/grant.test.ts +++ b/src/__tests__/grant.test.ts @@ -25,44 +25,44 @@ function makePendingRequest(durationSeconds = 300) { describe('GrantManager', () => { describe('createGrant', () => { - it('creates a grant with a UUID id', () => { + it('creates a grant with a UUID id', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) }) - it('sets requestId to request.id', () => { + it('sets requestId to request.id', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.requestId).toBe(request.id) }) - it('sets secretUuids from request.secretUuids', () => { + it('sets secretUuids from request.secretUuids', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.secretUuids).toEqual(request.secretUuids) }) - it('sets used to false and revokedAt to null', () => { + it('sets used to false and revokedAt to null', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.used).toBe(false) expect(grant.revokedAt).toBeNull() }) - it('throws if request is not approved', () => { + it('throws if request is not approved', async () => { const manager = new GrantManager() const request = makePendingRequest() - expect(() => manager.createGrant(request)).toThrow( + await expect(manager.createGrant(request)).rejects.toThrow( 'Cannot create grant for request with status: pending', ) }) @@ -77,25 +77,25 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('sets grantedAt to current ISO timestamp', () => { + it('sets grantedAt to current ISO timestamp', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.grantedAt).toBe('2026-01-15T10:00:00.000Z') }) - it('sets expiresAt to grantedAt + durationSeconds', () => { + it('sets expiresAt to grantedAt + durationSeconds', async () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.expiresAt).toBe('2026-01-15T10:05:00.000Z') }) }) describe('batch', () => { - it('copies secretUuids array from request', () => { + it('copies secretUuids array from request', async () => { const manager = new GrantManager() const request = createAccessRequest( ['uuid-1', 'uuid-2', 'uuid-3'], @@ -103,17 +103,17 @@ describe('GrantManager', () => { 'TASK-1', ) request.status = 'approved' - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.secretUuids).toEqual(['uuid-1', 'uuid-2', 'uuid-3']) }) - it('preserves all UUIDs in the array', () => { + it('preserves all UUIDs in the array', async () => { const manager = new GrantManager() const uuids = ['a', 'b', 'c', 'd', 'e'] const request = createAccessRequest(uuids, 'batch access', 'TASK-1') request.status = 'approved' - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.secretUuids).toHaveLength(5) expect(grant.secretUuids).toEqual(uuids) @@ -122,10 +122,10 @@ describe('GrantManager', () => { }) describe('validateGrant', () => { - it('returns true for valid, unexpired, unused, unrevoked grant', () => { + it('returns true for valid, unexpired, unused, unrevoked grant', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(manager.validateGrant(grant.id)).toBe(true) }) @@ -136,20 +136,20 @@ describe('GrantManager', () => { expect(manager.validateGrant('nonexistent')).toBe(false) }) - it('returns false for used grant', () => { + it('returns false for used grant', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) manager.markUsed(grant.id) expect(manager.validateGrant(grant.id)).toBe(false) }) - it('returns false for revoked grant', () => { + it('returns false for revoked grant', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) manager.revokeGrant(grant.id) @@ -166,10 +166,10 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('returns false for expired grant', () => { + it('returns false for expired grant', async () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) // Advance past expiry vi.setSystemTime(new Date('2026-01-15T10:05:00.001Z')) @@ -180,10 +180,10 @@ describe('GrantManager', () => { }) describe('markUsed', () => { - it('marks grant as used', () => { + it('marks grant as used', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) manager.markUsed(grant.id) @@ -197,20 +197,20 @@ describe('GrantManager', () => { expect(() => manager.markUsed('nonexistent')).toThrow('Grant not found: nonexistent') }) - it('throws if grant already used', () => { + it('throws if grant already used', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) manager.markUsed(grant.id) expect(() => manager.markUsed(grant.id)).toThrow(`Grant is not valid: ${grant.id}`) }) - it('throws if grant revoked', () => { + it('throws if grant revoked', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) manager.revokeGrant(grant.id) @@ -227,10 +227,10 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('throws if grant expired', () => { + it('throws if grant expired', async () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) vi.setSystemTime(new Date('2026-01-15T10:05:00.001Z')) @@ -246,10 +246,10 @@ describe('GrantManager', () => { expect(() => manager.revokeGrant('nonexistent')).toThrow('Grant not found: nonexistent') }) - it('throws if grant already revoked', () => { + it('throws if grant already revoked', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) manager.revokeGrant(grant.id) @@ -266,10 +266,10 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('sets revokedAt timestamp', () => { + it('sets revokedAt timestamp', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) vi.setSystemTime(new Date('2026-01-15T10:01:00.000Z')) manager.revokeGrant(grant.id) @@ -297,10 +297,10 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('removes expired grants from memory', () => { + it('removes expired grants from memory', async () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) vi.setSystemTime(new Date('2026-01-15T10:05:00.001Z')) manager.cleanup() @@ -308,10 +308,10 @@ describe('GrantManager', () => { expect(manager.getGrant(grant.id)).toBeUndefined() }) - it('keeps unexpired grants', () => { + it('keeps unexpired grants', async () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) vi.setSystemTime(new Date('2026-01-15T10:04:00.000Z')) manager.cleanup() @@ -322,10 +322,10 @@ describe('GrantManager', () => { }) describe('getGrant', () => { - it('returns a copy of the grant', () => { + it('returns a copy of the grant', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) const retrieved = manager.getGrant(grant.id) expect(retrieved).toEqual(grant) @@ -345,10 +345,10 @@ describe('GrantManager', () => { }) describe('getGrantByRequestId', () => { - it('returns grant matching the requestId', () => { + it('returns grant matching the requestId', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) const found = manager.getGrantByRequestId(request.id) expect(found).toBeDefined() @@ -356,10 +356,10 @@ describe('GrantManager', () => { expect(found!.requestId).toBe(request.id) }) - it('returns a copy (not the original)', () => { + it('returns a copy (not the original)', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - manager.createGrant(request) + await manager.createGrant(request) const found = manager.getGrantByRequestId(request.id) if (found) { @@ -376,11 +376,11 @@ describe('GrantManager', () => { }) describe('getGrantSecrets', () => { - it('returns secretUuids array for valid grant', () => { + it('returns secretUuids array for valid grant', async () => { const manager = new GrantManager() const request = createAccessRequest(['uuid-1', 'uuid-2'], 'reason', 'TASK-1') request.status = 'approved' - const grant = manager.createGrant(request) + const { grant } = await manager.createGrant(request) const secrets = manager.getGrantSecrets(grant.id) expect(secrets).toEqual(['uuid-1', 'uuid-2']) diff --git a/src/__tests__/integration/local-encrypted-flow.test.ts b/src/__tests__/integration/local-encrypted-flow.test.ts index a4b1cc3..780dcdc 100644 --- a/src/__tests__/integration/local-encrypted-flow.test.ts +++ b/src/__tests__/integration/local-encrypted-flow.test.ts @@ -89,7 +89,7 @@ describe('Phase 1 Local Encrypted Flow', () => { // 4. Create a grant const grantManager = new GrantManager() - const grant = grantManager.createGrant(request) + const { grant } = await grantManager.createGrant(request) expect(grant.secretUuids).toContain(uuid) // 5. Inject via SecretInjector — spawn real subprocess @@ -120,7 +120,7 @@ describe('Phase 1 Local Encrypted Flow', () => { const request = createAccessRequest([uuid], 'test locked rejection', 'task-002') request.status = 'approved' const grantManager = new GrantManager() - const grant = grantManager.createGrant(request) + const { grant } = await grantManager.createGrant(request) // 3. Lock the store store.lock() @@ -180,7 +180,7 @@ describe('Phase 1 Local Encrypted Flow', () => { // 4. createGrant should throw because status is 'denied' const grantManager = new GrantManager() - expect(() => grantManager.createGrant(request)).toThrow( + await expect(grantManager.createGrant(request)).rejects.toThrow( 'Cannot create grant for request with status: denied', ) }) @@ -201,7 +201,7 @@ describe('Phase 1 Local Encrypted Flow', () => { // 4. Create grant (expires in 30s) const grantManager = new GrantManager() - const grant = grantManager.createGrant(request) + const { grant } = await grantManager.createGrant(request) // 5. Advance past grant TTL vi.advanceTimersByTime(31_000) diff --git a/src/__tests__/service.test.ts b/src/__tests__/service.test.ts index caf0b07..17cfb80 100644 --- a/src/__tests__/service.test.ts +++ b/src/__tests__/service.test.ts @@ -12,40 +12,40 @@ import type { SecretInjector } from '../core/injector.js' import type { RequestLog } from '../core/request.js' describe('resolveService', () => { - it('returns LocalService for standalone mode', () => { + it('returns LocalService for standalone mode', async () => { const config = defaultConfig() - const service = resolveService(config) + const service = await resolveService(config) expect(service).toBeInstanceOf(LocalService) }) - it('returns RemoteService for client mode', () => { + it('returns RemoteService for client mode', async () => { const config = { ...defaultConfig(), mode: 'client' as const, server: { host: '127.0.0.1', port: 2274, authToken: 'test-token' }, } - const service = resolveService(config) + const service = await resolveService(config) expect(service).toBeInstanceOf(RemoteService) }) - it('throws when defaultRequireApproval is true and discord not configured', () => { + it('throws when defaultRequireApproval is true and discord not configured', async () => { const config = { ...defaultConfig(), defaultRequireApproval: true } - expect(() => resolveService(config)).toThrow( + await expect(resolveService(config)).rejects.toThrow( 'Discord must be configured when defaultRequireApproval is true', ) }) - it('creates a noop channel when discord is not configured and approval not required', () => { + it('creates a noop channel when discord is not configured and approval not required', async () => { const config = { ...defaultConfig(), defaultRequireApproval: false, discord: undefined, } - const service = resolveService(config) + const service = await resolveService(config) expect(service).toBeInstanceOf(LocalService) }) - it('creates LocalService with discord channel when discord is configured', () => { + it('creates LocalService with discord channel when discord is configured', async () => { const config = { ...defaultConfig(), discord: { @@ -54,7 +54,7 @@ describe('resolveService', () => { channelId: '999888777', }, } - const service = resolveService(config) + const service = await resolveService(config) expect(service).toBeInstanceOf(LocalService) }) }) @@ -100,7 +100,7 @@ function makeService() { const grantManager = { getGrantByRequestId: vi.fn().mockReturnValue(makeGrantMock()), validateGrant: vi.fn().mockReturnValue(true), - createGrant: vi.fn().mockReturnValue(makeGrantMock()), + createGrant: vi.fn().mockResolvedValue({ grant: makeGrantMock(), jws: null }), } as unknown as GrantManager const workflowEngine = { diff --git a/src/__tests__/signed-grant.test.ts b/src/__tests__/signed-grant.test.ts new file mode 100644 index 0000000..8dc7e64 --- /dev/null +++ b/src/__tests__/signed-grant.test.ts @@ -0,0 +1,308 @@ +/// +import { generateKeyPair } from 'jose' +import { randomUUID } from 'node:crypto' +import { mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { tmpdir } from 'node:os' +import { signGrant, verifyGrant } from '../core/signed-grant.js' +import { loadOrGenerateKeyPair } from '../core/key-manager.js' +import type { AccessGrant } from '../core/grant.js' + +function makeGrant(overrides: Partial = {}): AccessGrant { + const now = Date.now() + return { + id: randomUUID(), + requestId: randomUUID(), + secretUuids: [randomUUID(), randomUUID()], + grantedAt: new Date(now).toISOString(), + expiresAt: new Date(now + 3600 * 1000).toISOString(), + used: false, + revokedAt: null, + ...overrides, + } +} + +describe('signGrant / verifyGrant', () => { + let publicKey: CryptoKey + let privateKey: CryptoKey + + beforeAll(async () => { + const keys = await generateKeyPair('EdDSA') + publicKey = keys.publicKey + privateKey = keys.privateKey + }) + + describe('round-trip', () => { + it('verifyGrant returns decoded payload matching original grant', async () => { + const grant = makeGrant() + const jws = await signGrant(grant, privateKey) + const payload = await verifyGrant(jws, publicKey) + + expect(payload.id).toBe(grant.id) + expect(payload.requestId).toBe(grant.requestId) + expect(payload.secretUuids).toEqual(grant.secretUuids) + }) + + it('maps jti → id, sub → requestId, secretUuids, grantedAt, expiresAt', async () => { + const grant = makeGrant() + const jws = await signGrant(grant, privateKey) + const payload = await verifyGrant(jws, publicKey) + + expect(payload.id).toBe(grant.id) + expect(payload.requestId).toBe(grant.requestId) + expect(payload.secretUuids).toEqual(grant.secretUuids) + // grantedAt/expiresAt are reconstructed from iat/exp (second precision — within 1s) + expect( + Math.abs(new Date(payload.grantedAt).getTime() - new Date(grant.grantedAt).getTime()), + ).toBeLessThan(1000) + expect( + Math.abs(new Date(payload.expiresAt).getTime() - new Date(grant.expiresAt).getTime()), + ).toBeLessThan(1000) + }) + + it('commandHash present in payload when provided to signGrant', async () => { + const grant = makeGrant() + const hash = 'sha256:abc123' + const jws = await signGrant(grant, privateKey, hash) + const payload = await verifyGrant(jws, publicKey) + + expect(payload.commandHash).toBe(hash) + }) + + it('commandHash absent when not provided to signGrant', async () => { + const grant = makeGrant() + const jws = await signGrant(grant, privateKey) + const payload = await verifyGrant(jws, publicKey) + + expect(payload.commandHash).toBeUndefined() + }) + }) + + describe('tamper rejection', () => { + it('throws when compact JWS payload is base64-modified after signing', async () => { + const grant = makeGrant() + const jws = await signGrant(grant, privateKey) + + // A compact JWS has three parts: header.payload.signature + const parts = jws.split('.') + // Modify the payload part by appending a character + parts[1] = parts[1] + 'X' + const tampered = parts.join('.') + + await expect(verifyGrant(tampered, publicKey)).rejects.toThrow() + }) + + it('throws when signed with a different private key', async () => { + const grant = makeGrant() + const { privateKey: otherPrivateKey } = await generateKeyPair('EdDSA') + const jws = await signGrant(grant, otherPrivateKey) + + await expect(verifyGrant(jws, publicKey)).rejects.toThrow() + }) + }) + + describe('expiry', () => { + it('throws when grant expiresAt is in the past (expired JWS)', async () => { + const expiredGrant = makeGrant({ + grantedAt: new Date(Date.now() - 7200 * 1000).toISOString(), + expiresAt: new Date(Date.now() - 3600 * 1000).toISOString(), + }) + const jws = await signGrant(expiredGrant, privateKey) + + await expect(verifyGrant(jws, publicKey)).rejects.toThrow() + }) + }) + + describe('commandHash', () => { + it('round-trips commandHash value correctly', async () => { + const grant = makeGrant() + const hash = 'sha256:deadbeef1234567890' + const jws = await signGrant(grant, privateKey, hash) + const payload = await verifyGrant(jws, publicKey) + + expect(payload.commandHash).toBe(hash) + }) + + it('verifyGrant returns undefined commandHash when not included', async () => { + const grant = makeGrant() + const jws = await signGrant(grant, privateKey) + const payload = await verifyGrant(jws, publicKey) + + expect('commandHash' in payload).toBe(false) + }) + }) +}) + +describe('loadOrGenerateKeyPair', () => { + const tempPaths: string[] = [] + + afterEach(() => { + for (const p of tempPaths) { + try { + rmSync(p, { force: true }) + } catch { + // ignore cleanup errors + } + } + tempPaths.length = 0 + }) + + function tempKeyPath(): string { + const p = join(tmpdir(), `2kc-test-${randomUUID()}`, 'server-keys.json') + tempPaths.push(p) + return p + } + + it('generates a new keypair and writes server-keys.json when file absent', async () => { + const keyPath = tempKeyPath() + await loadOrGenerateKeyPair(keyPath) + + const stat = statSync(keyPath) + expect(stat.isFile()).toBe(true) + }) + + it('sets 0o600 permissions on the generated key file', async () => { + const keyPath = tempKeyPath() + await loadOrGenerateKeyPair(keyPath) + + const stat = statSync(keyPath) + + const mode = stat.mode & 0o777 + expect(mode).toBe(0o600) + }) + + it('loads existing keypair from file without regenerating (idempotent)', async () => { + const keyPath = tempKeyPath() + const first = await loadOrGenerateKeyPair(keyPath) + const second = await loadOrGenerateKeyPair(keyPath) + + // Both calls should return keys that can sign/verify with each other + const grant = makeGrant() + const jws = await signGrant(grant, first.privateKey) + const payload = await verifyGrant(jws, second.publicKey) + expect(payload.id).toBe(grant.id) + }) + + it('generated keys produce a valid sign/verify round-trip', async () => { + const keyPath = tempKeyPath() + const { publicKey, privateKey } = await loadOrGenerateKeyPair(keyPath) + + const grant = makeGrant() + const jws = await signGrant(grant, privateKey) + const payload = await verifyGrant(jws, publicKey) + + expect(payload.id).toBe(grant.id) + expect(payload.secretUuids).toEqual(grant.secretUuids) + }) + + it('throws with clear error when key file contains invalid JSON', async () => { + const keyPath = tempKeyPath() + // Write invalid JSON to the key file + mkdirSync(dirname(keyPath), { recursive: true }) + writeFileSync(keyPath, 'not valid json', 'utf-8') + + await expect(loadOrGenerateKeyPair(keyPath)).rejects.toThrow() + }) + + it('throws when key file contains valid JSON but wrong key format', async () => { + const keyPath = tempKeyPath() + mkdirSync(dirname(keyPath), { recursive: true }) + writeFileSync( + keyPath, + JSON.stringify({ + publicKey: { kty: 'RSA', n: 'abc' }, + privateKey: { kty: 'RSA', n: 'abc' }, + }), + 'utf-8', + ) + + await expect(loadOrGenerateKeyPair(keyPath)).rejects.toThrow( + 'Key file contains invalid key format', + ) + }) +}) + +describe('verifyGrant claim validation', () => { + let publicKey: CryptoKey + let privateKey: CryptoKey + + beforeAll(async () => { + const keys = await generateKeyPair('EdDSA') + publicKey = keys.publicKey + privateKey = keys.privateKey + }) + + it('throws when jti claim is missing', async () => { + const { SignJWT } = await import('jose') + const jws = await new SignJWT({ secretUuids: ['a'] }) + .setProtectedHeader({ alg: 'EdDSA' }) + .setSubject('req-1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + await expect(verifyGrant(jws, publicKey)).rejects.toThrow('Missing jti claim') + }) + + it('throws when sub claim is missing', async () => { + const { SignJWT } = await import('jose') + const jws = await new SignJWT({ secretUuids: ['a'] }) + .setProtectedHeader({ alg: 'EdDSA' }) + .setJti('grant-1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + await expect(verifyGrant(jws, publicKey)).rejects.toThrow('Missing sub claim') + }) + + it('throws when secretUuids claim is missing', async () => { + const { SignJWT } = await import('jose') + const jws = await new SignJWT({}) + .setProtectedHeader({ alg: 'EdDSA' }) + .setJti('grant-1') + .setSubject('req-1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + await expect(verifyGrant(jws, publicKey)).rejects.toThrow('Missing or invalid secretUuids') + }) + + it('throws when iat claim is missing', async () => { + const { SignJWT } = await import('jose') + const jws = await new SignJWT({ secretUuids: ['a'] }) + .setProtectedHeader({ alg: 'EdDSA' }) + .setJti('grant-1') + .setSubject('req-1') + .setExpirationTime('1h') + .sign(privateKey) + + await expect(verifyGrant(jws, publicKey)).rejects.toThrow('Missing iat claim') + }) + + it('throws when exp claim is missing', async () => { + const { SignJWT } = await import('jose') + const jws = await new SignJWT({ secretUuids: ['a'] }) + .setProtectedHeader({ alg: 'EdDSA' }) + .setJti('grant-1') + .setSubject('req-1') + .setIssuedAt() + .sign(privateKey) + + await expect(verifyGrant(jws, publicKey)).rejects.toThrow('Missing exp claim') + }) + + it('throws when secretUuids contains non-string elements', async () => { + const { SignJWT } = await import('jose') + const jws = await new SignJWT({ secretUuids: [123, true] }) + .setProtectedHeader({ alg: 'EdDSA' }) + .setJti('grant-1') + .setSubject('req-1') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey) + + await expect(verifyGrant(jws, publicKey)).rejects.toThrow('Missing or invalid secretUuids') + }) +}) diff --git a/src/cli/request.ts b/src/cli/request.ts index 0b0ddb6..41c7cab 100644 --- a/src/cli/request.ts +++ b/src/cli/request.ts @@ -25,7 +25,7 @@ const request = new Command('request') try { // 1. Load config and resolve service const config = loadConfig() - const service = resolveService(config) + const service = await resolveService(config) // 2. Parse and validate duration const durationSeconds = parseInt(opts.duration, 10) diff --git a/src/cli/secrets.ts b/src/cli/secrets.ts index c1f115e..85b9dd5 100644 --- a/src/cli/secrets.ts +++ b/src/cli/secrets.ts @@ -56,7 +56,7 @@ secrets process.exitCode = 1 return } - const service = resolveService(loadConfig()) + const service = await resolveService(loadConfig()) const result = await service.secrets.add(opts.ref, value, opts.tags) console.log(result.uuid) }) @@ -66,7 +66,7 @@ secrets .description('List all secrets (UUIDs, refs, and tags)') .action(async () => { try { - const service = resolveService(loadConfig()) + const service = await resolveService(loadConfig()) const items = await service.secrets.list() console.log(JSON.stringify(items, null, 2)) } catch (err: unknown) { @@ -81,7 +81,7 @@ secrets .argument('', 'Ref or UUID of the secret to remove') .action(async (refOrUuid: string) => { try { - const service = resolveService(loadConfig()) + const service = await resolveService(loadConfig()) const metadata = await service.secrets.resolve(refOrUuid) await service.secrets.remove(metadata.uuid) console.log('Removed') diff --git a/src/core/grant.ts b/src/core/grant.ts index b3c8789..4c353bd 100644 --- a/src/core/grant.ts +++ b/src/core/grant.ts @@ -3,6 +3,7 @@ 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' +import { signGrant } from './signed-grant.js' export interface AccessGrant { id: string @@ -20,12 +21,15 @@ export class GrantManager { private grants: Map = new Map() private readonly grantsFilePath: string - constructor(grantsFilePath: string = DEFAULT_GRANTS_PATH) { + constructor( + grantsFilePath: string = DEFAULT_GRANTS_PATH, + private readonly signingKey: CryptoKey | null = null, + ) { this.grantsFilePath = grantsFilePath this.load() } - createGrant(request: AccessRequest): AccessGrant { + async createGrant(request: AccessRequest): Promise<{ grant: AccessGrant; jws: string | null }> { if (request.status !== 'approved') { throw new Error(`Cannot create grant for request with status: ${request.status}`) } @@ -40,8 +44,12 @@ export class GrantManager { revokedAt: null, } this.grants.set(grant.id, grant) + let jws: string | null = null + if (this.signingKey) { + jws = await signGrant(grant, this.signingKey) + } this.save() - return grant + return { grant, jws } } validateGrant(grantId: string): boolean { diff --git a/src/core/key-manager.ts b/src/core/key-manager.ts new file mode 100644 index 0000000..b6f6543 --- /dev/null +++ b/src/core/key-manager.ts @@ -0,0 +1,52 @@ +import { generateKeyPair, exportJWK, importJWK } from 'jose' +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { dirname } from 'node:path' + +interface KeysFile { + publicKey: JsonWebKey + privateKey: JsonWebKey +} + +export interface ServerKeys { + publicKey: CryptoKey + privateKey: CryptoKey +} + +function isEdDSAJwk(key: unknown): key is JsonWebKey { + return ( + key !== null && + typeof key === 'object' && + (key as Record).kty === 'OKP' && + (key as Record).crv === 'Ed25519' + ) +} + +export async function loadOrGenerateKeyPair(keyFilePath: string): Promise { + try { + const raw = readFileSync(keyFilePath, 'utf-8') + const keysFile = JSON.parse(raw) as KeysFile + if (!isEdDSAJwk(keysFile.publicKey) || !isEdDSAJwk(keysFile.privateKey)) { + throw new Error('Key file contains invalid key format: expected Ed25519 OKP keys') + } + const publicKey = await importJWK(keysFile.publicKey, 'EdDSA') + const privateKey = await importJWK(keysFile.privateKey, 'EdDSA') + return { publicKey: publicKey as CryptoKey, privateKey: privateKey as CryptoKey } + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err + } + } + + const { publicKey, privateKey } = await generateKeyPair('EdDSA', { extractable: true }) + const publicJwk = await exportJWK(publicKey) + const privateJwk = await exportJWK(privateKey) + + mkdirSync(dirname(keyFilePath), { recursive: true }) + writeFileSync( + keyFilePath, + JSON.stringify({ publicKey: publicJwk, privateKey: privateJwk }, null, 2), + { encoding: 'utf-8', mode: 0o600 }, + ) + + return { publicKey, privateKey } +} diff --git a/src/core/service.ts b/src/core/service.ts index b049b65..fd8a874 100644 --- a/src/core/service.ts +++ b/src/core/service.ts @@ -11,6 +11,7 @@ import { GrantManager } from './grant.js' import { WorkflowEngine } from './workflow.js' import { SecretInjector } from './injector.js' import { DiscordChannel } from '../channels/discord.js' +import { loadOrGenerateKeyPair } from './key-manager.js' // SecretSummary aliases existing SecretListItem shape export type SecretSummary = SecretListItem @@ -116,7 +117,7 @@ export class LocalService implements Service { this.deps.requestLog.add(request) const outcome = await this.deps.workflowEngine.processRequest(request) if (outcome === 'approved') { - this.deps.grantManager.createGrant(request) + await this.deps.grantManager.createGrant(request) } return request }, @@ -146,16 +147,18 @@ export class LocalService implements Service { } } -export function resolveService(config: AppConfig): Service { +export async function resolveService(config: AppConfig): Promise { if (config.mode === 'client') { return new RemoteService(config.server) } const grantsPath = join(dirname(config.store.path), 'grants.json') + const keysPath = join(dirname(config.store.path), 'server-keys.json') + const { privateKey } = await loadOrGenerateKeyPair(keysPath) const store = new EncryptedSecretStore(config.store.path) const unlockSession = new UnlockSession(config.unlock) - const grantManager = new GrantManager(grantsPath) + const grantManager = new GrantManager(grantsPath, privateKey) let channel: NotificationChannel if (config.discord) { diff --git a/src/core/signed-grant.ts b/src/core/signed-grant.ts new file mode 100644 index 0000000..49fe195 --- /dev/null +++ b/src/core/signed-grant.ts @@ -0,0 +1,67 @@ +import { SignJWT, jwtVerify } from 'jose' +import type { AccessGrant } from './grant.js' + +export interface GrantPayload { + id: string + requestId: string + secretUuids: string[] + grantedAt: string + expiresAt: string + commandHash?: string +} + +export async function signGrant( + grant: AccessGrant, + privateKey: CryptoKey, + commandHash?: string, +): Promise { + const iat = Math.floor(new Date(grant.grantedAt).getTime() / 1000) + const exp = Math.floor(new Date(grant.expiresAt).getTime() / 1000) + + const payload: Record = { + secretUuids: grant.secretUuids, + } + if (commandHash !== undefined) { + payload.commandHash = commandHash + } + + return new SignJWT(payload) + .setProtectedHeader({ alg: 'EdDSA' }) + .setJti(grant.id) + .setSubject(grant.requestId) + .setIssuedAt(iat) + .setExpirationTime(exp) + .sign(privateKey) +} + +export async function verifyGrant(jws: string, publicKey: CryptoKey): Promise { + const { payload } = await jwtVerify(jws, publicKey, { clockTolerance: 60 }) + + const { jti, sub, iat, exp, secretUuids, commandHash } = payload as { + jti?: string + sub?: string + iat?: number + exp?: number + secretUuids?: string[] + commandHash?: string + } + + if (!jti) throw new Error('Missing jti claim in JWS') + if (!sub) throw new Error('Missing sub claim in JWS') + if (!Array.isArray(secretUuids) || !secretUuids.every((s) => typeof s === 'string')) + throw new Error('Missing or invalid secretUuids claim in JWS') + if (iat === undefined) throw new Error('Missing iat claim in JWS') + if (exp === undefined) throw new Error('Missing exp claim in JWS') + + const result: GrantPayload = { + id: jti, + requestId: sub, + secretUuids, + grantedAt: new Date(iat * 1000).toISOString(), + expiresAt: new Date(exp * 1000).toISOString(), + } + if (commandHash !== undefined) { + result.commandHash = commandHash + } + return result +} diff --git a/src/server/index.ts b/src/server/index.ts index 9b19778..6c2414b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,7 +4,7 @@ import { startServer } from './app.js' try { const config = loadConfig() - const service = resolveService(config) + const service = await resolveService(config) await startServer(config, service) } catch (error) { console.error('Failed to start server:', error instanceof Error ? error.message : error) From dce72b96cb4400ecb03519c47dce5786f447bd34 Mon Sep 17 00:00:00 2001 From: Helix Date: Wed, 25 Feb 2026 22:11:41 -0800 Subject: [PATCH 04/25] fixes #62 --- src/__tests__/command-hash.test.ts | 56 ++++++++++++++++++++++ src/__tests__/config-command.test.ts | 14 ++++++ src/__tests__/config.test.ts | 21 +++++++++ src/__tests__/discord.test.ts | 22 +++++++++ src/__tests__/grant.test.ts | 19 ++++++++ src/__tests__/request-command.test.ts | 26 ++++++++++ src/__tests__/request.test.ts | 23 +++++++++ src/__tests__/service.test.ts | 68 +++++++++++++++++++++++++++ src/__tests__/workflow.test.ts | 36 ++++++++++++++ src/channels/discord.ts | 27 ++++++----- src/cli/config.ts | 2 + src/cli/request.ts | 1 + src/core/command-hash.ts | 13 +++++ src/core/config.ts | 3 ++ src/core/grant.ts | 2 + src/core/request.ts | 6 +++ src/core/service.ts | 24 +++++++++- src/core/types.ts | 2 + src/core/workflow.ts | 2 + 19 files changed, 354 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/command-hash.test.ts create mode 100644 src/core/command-hash.ts diff --git a/src/__tests__/command-hash.test.ts b/src/__tests__/command-hash.test.ts new file mode 100644 index 0000000..a933401 --- /dev/null +++ b/src/__tests__/command-hash.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { normalizeCommand, hashCommand } from '../core/command-hash.js' + +describe('normalizeCommand', () => { + it('trims leading/trailing whitespace', () => { + expect(normalizeCommand(' echo hello ')).toBe('echo hello') + }) + + it('collapses internal whitespace to single spaces', () => { + expect(normalizeCommand('echo hello\t\tworld')).toBe('echo hello world') + }) + + it('lowercases the entire string', () => { + expect(normalizeCommand('ECHO HELLO')).toBe('echo hello') + }) + + it('handles combined: " FOO BAR " → "foo bar"', () => { + expect(normalizeCommand(' FOO BAR ')).toBe('foo bar') + }) + + it('throws on empty string', () => { + expect(() => normalizeCommand('')).toThrow('command must not be empty') + }) + + it('throws on whitespace-only string', () => { + expect(() => normalizeCommand(' ')).toThrow('command must not be empty') + }) +}) + +describe('hashCommand', () => { + it('returns a 64-character hex string', () => { + const hash = hashCommand('echo hello') + expect(hash).toHaveLength(64) + expect(hash).toMatch(/^[0-9a-f]{64}$/) + }) + + it('is deterministic — same input yields same hash', () => { + const hash1 = hashCommand('echo hello') + const hash2 = hashCommand('echo hello') + expect(hash1).toBe(hash2) + }) + + it('different inputs yield different hashes', () => { + const hash1 = hashCommand('echo hello') + const hash2 = hashCommand('echo world') + expect(hash1).not.toBe(hash2) + }) + + it('round-trip: normalizeCommand then hashCommand is stable across calls', () => { + const input = ' ECHO Hello World ' + const hash1 = hashCommand(normalizeCommand(input)) + const hash2 = hashCommand(normalizeCommand(input)) + expect(hash1).toBe(hash2) + expect(hash1).toBe(hashCommand('echo hello world')) + }) +}) diff --git a/src/__tests__/config-command.test.ts b/src/__tests__/config-command.test.ts index 8597084..f1b314a 100644 --- a/src/__tests__/config-command.test.ts +++ b/src/__tests__/config-command.test.ts @@ -232,6 +232,7 @@ describe('config init action', () => { requireApproval: {}, defaultRequireApproval: false, approvalTimeoutMs: 60_000, + bindCommand: false, }) }) @@ -463,6 +464,19 @@ describe('config show action', () => { logSpy.mockRestore() }) + it('shows bindCommand field in output', async () => { + const config = createValidConfig({ bindCommand: true } as Partial) + mockReadFileSync.mockReturnValue(JSON.stringify(config)) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const { configCommand } = await import('../cli/config.js') + await configCommand.parseAsync(['show'], { from: 'user' }) + + const output = JSON.parse(logSpy.mock.calls[0][0] as string) as Record + expect(output).toHaveProperty('bindCommand', true) + logSpy.mockRestore() + }) + it('does not truncate short botToken (<=4 chars)', async () => { const config = createValidConfig({ discord: { diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 20a2390..6c1b181 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -71,6 +71,7 @@ describe('loadConfig', () => { expect(config.requireApproval).toEqual({}) expect(config.defaultRequireApproval).toBe(false) expect(config.approvalTimeoutMs).toBe(300_000) + expect(config.bindCommand).toBe(false) }) it('should throw on invalid JSON', () => { @@ -344,6 +345,26 @@ describe('parseConfig', () => { it('throws if discord is not an object', () => { expect(() => parseConfig({ discord: 'bad' })).toThrow('discord must be an object') }) + + it('parses bindCommand: true correctly', () => { + const config = parseConfig({ bindCommand: true }) + expect(config.bindCommand).toBe(true) + }) + + it('parses bindCommand: false correctly', () => { + const config = parseConfig({ bindCommand: false }) + expect(config.bindCommand).toBe(false) + }) + + it('defaults bindCommand to false when missing', () => { + const config = parseConfig({}) + expect(config.bindCommand).toBe(false) + }) + + it('defaults bindCommand to false for non-boolean value', () => { + const config = parseConfig({ bindCommand: 'yes' }) + expect(config.bindCommand).toBe(false) + }) }) describe('saveConfig', () => { diff --git a/src/__tests__/discord.test.ts b/src/__tests__/discord.test.ts index 1d4a31a..8763282 100644 --- a/src/__tests__/discord.test.ts +++ b/src/__tests__/discord.test.ts @@ -76,6 +76,28 @@ describe('DiscordChannel', () => { ) }) + it('should include Bound Command field when commandHash is present', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: '789012' }), + }) + + const requestWithHash: AccessRequest = { + ...TEST_REQUEST, + command: 'echo hello', + commandHash: 'abc123deadbeef', + } + + await channel.sendApprovalRequest(requestWithHash) + + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + const fields = body.embeds[0].fields + const boundCommandField = fields.find((f: { name: string }) => f.name === 'Bound Command') + expect(boundCommandField).toBeDefined() + expect(boundCommandField.value).toContain('`echo hello`') + expect(boundCommandField.value).toContain('abc123deadbeef') + }) + it('should throw on non-ok response from webhook', async () => { fetchMock.mockResolvedValueOnce({ ok: false, diff --git a/src/__tests__/grant.test.ts b/src/__tests__/grant.test.ts index f870d0c..d53d118 100644 --- a/src/__tests__/grant.test.ts +++ b/src/__tests__/grant.test.ts @@ -94,6 +94,25 @@ describe('GrantManager', () => { }) }) + describe('commandHash', () => { + it('copies commandHash from request to grant when present', async () => { + const manager = new GrantManager() + const request = makeApprovedRequest() + request.commandHash = 'abc123deadbeef' + const { grant } = await manager.createGrant(request) + + expect(grant.commandHash).toBe('abc123deadbeef') + }) + + it('grant has no commandHash when request had none', async () => { + const manager = new GrantManager() + const request = makeApprovedRequest() + const { grant } = await manager.createGrant(request) + + expect(grant.commandHash).toBeUndefined() + }) + }) + describe('batch', () => { it('copies secretUuids array from request', async () => { const manager = new GrantManager() diff --git a/src/__tests__/request-command.test.ts b/src/__tests__/request-command.test.ts index 01ff6b7..248b6b7 100644 --- a/src/__tests__/request-command.test.ts +++ b/src/__tests__/request-command.test.ts @@ -118,6 +118,7 @@ describe('request command orchestration', () => { 'need for deploy', 'TICKET-123', 300, + 'echo hello', ) expect(mockGrantsValidate).toHaveBeenCalledWith('test-request-id') expect(mockInject).toHaveBeenCalledWith('test-request-id', 'echo hello', { @@ -180,6 +181,7 @@ describe('request command orchestration', () => { 'need for deploy', 'TICKET-123', 600, + 'echo hello', ) }) @@ -192,6 +194,28 @@ describe('request command orchestration', () => { 'need for deploy', 'TICKET-123', 300, + 'echo hello', + ) + }) + + it('--cmd value is forwarded as fifth argument to service.requests.create', async () => { + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + await runRequest([ + 'test-secret-uuid', + '--reason', + 'need for deploy', + '--task', + 'TICKET-123', + '--cmd', + 'node /app/server.js', + ]) + + expect(mockRequestsCreate).toHaveBeenCalledWith( + ['test-secret-uuid'], + 'need for deploy', + 'TICKET-123', + 300, + 'node /app/server.js', ) }) @@ -282,6 +306,7 @@ describe('request command orchestration', () => { 'need for deploy', 'TICKET-123', 300, + 'echo hello', ) }) @@ -294,6 +319,7 @@ describe('request command orchestration', () => { 'need for deploy', 'TICKET-123', 300, + 'echo hello', ) }) }) diff --git a/src/__tests__/request.test.ts b/src/__tests__/request.test.ts index c6e3aea..f81f9f1 100644 --- a/src/__tests__/request.test.ts +++ b/src/__tests__/request.test.ts @@ -176,6 +176,29 @@ describe('createAccessRequest', () => { expect(req.secretUuids).toEqual(['single-uuid']) }) }) + + describe('command and commandHash fields', () => { + it('stores command and commandHash when provided', () => { + const req = createAccessRequest( + validArgs.secretUuids, + validArgs.reason, + validArgs.taskRef, + 300, + 'echo hello', + 'abc123hash', + ) + + expect(req.command).toBe('echo hello') + expect(req.commandHash).toBe('abc123hash') + }) + + it('command and commandHash are undefined when not provided', () => { + const req = createAccessRequest(validArgs.secretUuids, validArgs.reason, validArgs.taskRef) + + expect(req.command).toBeUndefined() + expect(req.commandHash).toBeUndefined() + }) + }) }) describe('RequestLog', () => { diff --git a/src/__tests__/service.test.ts b/src/__tests__/service.test.ts index 17cfb80..08bc982 100644 --- a/src/__tests__/service.test.ts +++ b/src/__tests__/service.test.ts @@ -125,6 +125,7 @@ function makeService() { injector, requestLog, startTime, + bindCommand: false, }) return { service, @@ -230,6 +231,42 @@ describe('LocalService', () => { expect(grantManager.createGrant).toHaveBeenCalledWith(result) }) + it('computes commandHash when bindCommand is true and command is provided', async () => { + const { + store, + unlockSession, + grantManager, + workflowEngine, + injector, + requestLog, + startTime, + } = makeService() + const serviceWithBind = new LocalService({ + store, + unlockSession, + grantManager, + workflowEngine, + injector, + requestLog, + startTime, + bindCommand: true, + }) + ;(workflowEngine.processRequest as MockInstance).mockImplementation(async (req) => { + req.status = 'approved' + return 'approved' + }) + const result = await serviceWithBind.requests.create( + ['u1'], + 'need access', + 'task-1', + 300, + 'echo hello', + ) + expect(result.commandHash).toBeDefined() + expect(typeof result.commandHash).toBe('string') + expect(result.command).toBe('echo hello') + }) + it('returns request with denied status when workflow denies', async () => { const { service, workflowEngine, grantManager, requestLog } = makeService() ;(workflowEngine.processRequest as MockInstance).mockImplementation(async (req) => { @@ -289,6 +326,37 @@ describe('LocalService', () => { 'No grant found for request: missing-request', ) }) + + it('passes when grant has no commandHash (pre-binding grants)', async () => { + const { service, grantManager, injector } = makeService() + const grant = makeGrantMock({ commandHash: undefined }) + ;(grantManager.getGrantByRequestId as MockInstance).mockReturnValue(grant) + const result = await service.inject('request-id', 'echo hello') + expect(injector.inject).toHaveBeenCalled() + expect(result).toEqual({ exitCode: 0, stdout: 'ok', stderr: '' }) + }) + + it('passes when command hash matches grant commandHash', async () => { + const { service, grantManager, injector } = makeService() + // Pre-compute the hash of normalizeCommand('echo hello') = 'echo hello' + // SHA-256 of 'echo hello' + const { hashCommand, normalizeCommand } = await import('../core/command-hash.js') + const expectedHash = hashCommand(normalizeCommand('echo hello')) + const grant = makeGrantMock({ commandHash: expectedHash }) + ;(grantManager.getGrantByRequestId as MockInstance).mockReturnValue(grant) + const result = await service.inject('request-id', 'echo hello') + expect(injector.inject).toHaveBeenCalled() + expect(result).toEqual({ exitCode: 0, stdout: 'ok', stderr: '' }) + }) + + it('throws when command hash does not match grant commandHash', async () => { + const { service, grantManager } = makeService() + const grant = makeGrantMock({ commandHash: 'wrong-hash-that-does-not-match' }) + ;(grantManager.getGrantByRequestId as MockInstance).mockReturnValue(grant) + await expect(service.inject('request-id', 'echo hello')).rejects.toThrow( + 'Command does not match the approved command hash', + ) + }) }) describe('unlock()', () => { diff --git a/src/__tests__/workflow.test.ts b/src/__tests__/workflow.test.ts index ac40c41..4d333a2 100644 --- a/src/__tests__/workflow.test.ts +++ b/src/__tests__/workflow.test.ts @@ -230,6 +230,42 @@ describe('WorkflowEngine', () => { }) }) + describe('processRequest - commandHash forwarding', () => { + it('forwards commandHash to channel request when present on canonical request', async () => { + const store = createSingleMockStore({ + uuid: 'secret-uuid-1', + ref: 'db-password', + tags: ['production'], + }) + const channel = createMockChannel('approved') + const engine = new WorkflowEngine({ store, channel, config: createConfig() }) + const request = createRequest({ commandHash: 'abc123hash' }) + + await engine.processRequest(request) + + expect(channel.sendApprovalRequest).toHaveBeenCalledWith( + expect.objectContaining({ commandHash: 'abc123hash' }), + ) + }) + + it('forwards command to channel request when present on canonical request', async () => { + const store = createSingleMockStore({ + uuid: 'secret-uuid-1', + ref: 'db-password', + tags: ['production'], + }) + const channel = createMockChannel('approved') + const engine = new WorkflowEngine({ store, channel, config: createConfig() }) + const request = createRequest({ command: 'echo hello', commandHash: 'abc123hash' }) + + await engine.processRequest(request) + + expect(channel.sendApprovalRequest).toHaveBeenCalledWith( + expect.objectContaining({ command: 'echo hello', commandHash: 'abc123hash' }), + ) + }) + }) + describe('processRequest - batch', () => { it('fetches metadata for all secretUuids', async () => { const store = createMockStore({ diff --git a/src/channels/discord.ts b/src/channels/discord.ts index e8a3d01..4272511 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -39,20 +39,25 @@ export class DiscordChannel implements NotificationChannel { } async sendApprovalRequest(request: AccessRequest): Promise { + const fields: { name: string; value: string; inline?: boolean }[] = [ + { name: 'UUIDs', value: request.uuids.join(', '), inline: true }, + { name: 'Requester', value: request.requester, inline: true }, + { name: 'Secrets', value: request.secretNames.join(', '), inline: true }, + { name: 'Justification', value: request.justification }, + { name: 'Duration', value: formatDuration(request.durationMs), inline: true }, + ] + + if (request.commandHash) { + fields.push({ + name: 'Bound Command', + value: `\`${request.command}\`\nHash: ${request.commandHash}`, + }) + } + const embed = { title: 'Access Request', color: 0xffa500, - fields: [ - { name: 'UUIDs', value: request.uuids.join(', '), inline: true }, - { name: 'Requester', value: request.requester, inline: true }, - { name: 'Secrets', value: request.secretNames.join(', '), inline: true }, - { name: 'Justification', value: request.justification }, - { - name: 'Duration', - value: formatDuration(request.durationMs), - inline: true, - }, - ], + fields, } const url = `${this.webhookUrl}?wait=true` diff --git a/src/cli/config.ts b/src/cli/config.ts index 384c25a..78442b0 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -78,6 +78,7 @@ config requireApproval: {}, defaultRequireApproval: opts.defaultRequireApproval, approvalTimeoutMs, + bindCommand: false, } saveConfig(appConfig) @@ -108,6 +109,7 @@ config requireApproval: appConfig.requireApproval, defaultRequireApproval: appConfig.defaultRequireApproval, approvalTimeoutMs: appConfig.approvalTimeoutMs, + bindCommand: appConfig.bindCommand, } if (appConfig.discord) { diff --git a/src/cli/request.ts b/src/cli/request.ts index 41c7cab..ba86f54 100644 --- a/src/cli/request.ts +++ b/src/cli/request.ts @@ -41,6 +41,7 @@ const request = new Command('request') opts.reason, opts.task, durationSeconds, + opts.cmd, ) // 4. Validate grant diff --git a/src/core/command-hash.ts b/src/core/command-hash.ts new file mode 100644 index 0000000..62ffb16 --- /dev/null +++ b/src/core/command-hash.ts @@ -0,0 +1,13 @@ +import { createHash } from 'node:crypto' + +export function normalizeCommand(cmd: string): string { + const normalized = cmd.trim().replace(/\s+/g, ' ').toLowerCase() + if (normalized.length === 0) { + throw new Error('command must not be empty') + } + return normalized +} + +export function hashCommand(normalizedCmd: string): string { + return createHash('sha256').update(normalizedCmd).digest('hex') +} diff --git a/src/core/config.ts b/src/core/config.ts index f933451..a960d66 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -34,6 +34,7 @@ export interface AppConfig { requireApproval: Record defaultRequireApproval: boolean approvalTimeoutMs: number + bindCommand: boolean } export const CONFIG_PATH = resolve(homedir(), '.2kc', 'config.json') @@ -180,6 +181,7 @@ export function defaultConfig(): AppConfig { requireApproval: {}, defaultRequireApproval: false, approvalTimeoutMs: 300_000, + bindCommand: false, } } @@ -207,6 +209,7 @@ export function parseConfig(raw: unknown): AppConfig { typeof raw.approvalTimeoutMs === 'number' && raw.approvalTimeoutMs > 0 ? raw.approvalTimeoutMs : 300_000, + bindCommand: typeof raw.bindCommand === 'boolean' ? raw.bindCommand : false, } } diff --git a/src/core/grant.ts b/src/core/grant.ts index 4c353bd..1e741e1 100644 --- a/src/core/grant.ts +++ b/src/core/grant.ts @@ -13,6 +13,7 @@ export interface AccessGrant { expiresAt: string used: boolean revokedAt: string | null + commandHash?: string } const DEFAULT_GRANTS_PATH = join(homedir(), '.2kc', 'grants.json') @@ -42,6 +43,7 @@ export class GrantManager { expiresAt: new Date(now + request.durationSeconds * 1000).toISOString(), used: false, revokedAt: null, + commandHash: request.commandHash, } this.grants.set(grant.id, grant) let jws: string | null = null diff --git a/src/core/request.ts b/src/core/request.ts index 79a7829..e3bf152 100644 --- a/src/core/request.ts +++ b/src/core/request.ts @@ -10,6 +10,8 @@ export interface AccessRequest { durationSeconds: number requestedAt: string status: AccessRequestStatus + command?: string + commandHash?: string } const MIN_DURATION_SECONDS = 30 @@ -21,6 +23,8 @@ export function createAccessRequest( reason: string, taskRef: string, durationSeconds: number = DEFAULT_DURATION_SECONDS, + command?: string, + commandHash?: string, ): AccessRequest { if (!Array.isArray(secretUuids) || secretUuids.length === 0) { throw new Error('secretUuids must be a non-empty array') @@ -60,6 +64,8 @@ export function createAccessRequest( durationSeconds, requestedAt: new Date().toISOString(), status: 'pending', + command, + commandHash, } } diff --git a/src/core/service.ts b/src/core/service.ts index fd8a874..ea367df 100644 --- a/src/core/service.ts +++ b/src/core/service.ts @@ -8,6 +8,7 @@ import { RemoteService } from './remote-service.js' import { EncryptedSecretStore } from './encrypted-store.js' import { UnlockSession } from './unlock-session.js' import { GrantManager } from './grant.js' +import { normalizeCommand, hashCommand } from './command-hash.js' import { WorkflowEngine } from './workflow.js' import { SecretInjector } from './injector.js' import { DiscordChannel } from '../channels/discord.js' @@ -33,6 +34,7 @@ export interface Service { reason: string, taskRef: string, duration?: number, + command?: string, ): Promise } @@ -55,6 +57,7 @@ interface LocalServiceDeps { injector: SecretInjector requestLog: RequestLog startTime: number + bindCommand: boolean } export class LocalService implements Service { @@ -112,8 +115,19 @@ export class LocalService implements Service { } requests: Service['requests'] = { - create: async (secretUuids, reason, taskRef, duration) => { - const request = createAccessRequest(secretUuids, reason, taskRef, duration) + create: async (secretUuids, reason, taskRef, duration, command) => { + let commandHash: string | undefined + if (this.deps.bindCommand && command) { + commandHash = hashCommand(normalizeCommand(command)) + } + const request = createAccessRequest( + secretUuids, + reason, + taskRef, + duration, + command, + commandHash, + ) this.deps.requestLog.add(request) const outcome = await this.deps.workflowEngine.processRequest(request) if (outcome === 'approved') { @@ -141,6 +155,11 @@ export class LocalService implements Service { } const grant = this.deps.grantManager.getGrantByRequestId(requestId) if (!grant) throw new Error(`No grant found for request: ${requestId}`) + if (grant.commandHash) { + if (hashCommand(normalizeCommand(command)) !== grant.commandHash) { + throw new Error('Command does not match the approved command hash') + } + } const result = await this.deps.injector.inject(grant.id, ['/bin/sh', '-c', command], options) this.deps.unlockSession.recordGrantUsage() return result @@ -190,5 +209,6 @@ export async function resolveService(config: AppConfig): Promise { injector, requestLog, startTime, + bindCommand: config.bindCommand, }) } diff --git a/src/core/types.ts b/src/core/types.ts index b216c32..2deee1a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -33,6 +33,8 @@ export interface AccessRequest { justification: string durationMs: number secretNames: string[] + command?: string + commandHash?: string } export interface ProcessResult { diff --git a/src/core/workflow.ts b/src/core/workflow.ts index b4a6ac2..9fd554b 100644 --- a/src/core/workflow.ts +++ b/src/core/workflow.ts @@ -36,6 +36,8 @@ function toChannelRequest( justification: request.reason, durationMs: request.durationSeconds * 1000, secretNames: metadataList.map((m) => m.ref), + command: request.command, + commandHash: request.commandHash, } } From 5ed88ae99e66b5217041d5b83107ba04d4c542a5 Mon Sep 17 00:00:00 2001 From: Helix Date: Wed, 25 Feb 2026 22:13:25 -0800 Subject: [PATCH 05/25] fixes #63 Wire RemoteService to server API with session token auth, add client-side JWS grant verification via GrantVerifier, and implement local injection flow. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/grant-verifier.test.ts | 231 +++++++++++++++++ src/__tests__/remote-service.test.ts | 354 ++++++++++++++++++++++++--- src/core/grant-verifier.ts | 96 ++++++++ src/core/remote-service.ts | 125 +++++++++- src/core/service.ts | 7 +- 5 files changed, 763 insertions(+), 50 deletions(-) create mode 100644 src/__tests__/grant-verifier.test.ts create mode 100644 src/core/grant-verifier.ts diff --git a/src/__tests__/grant-verifier.test.ts b/src/__tests__/grant-verifier.test.ts new file mode 100644 index 0000000..3aa88cf --- /dev/null +++ b/src/__tests__/grant-verifier.test.ts @@ -0,0 +1,231 @@ +import { generateKeyPair, exportSPKI, SignJWT } from 'jose' + +import { GrantVerifier } from '../core/grant-verifier.js' +import type { GrantJWSPayload } from '../core/grant-verifier.js' + +async function makeKeyPair() { + return generateKeyPair('EdDSA', { crv: 'Ed25519' }) +} + +async function makeJWS( + payload: Partial, + privateKey: CryptoKey, + options?: { expiresAt?: Date }, +): Promise { + const expiresAt = options?.expiresAt ?? new Date(Date.now() + 60_000) + const fullPayload: GrantJWSPayload = { + grantId: 'grant-1', + requestId: 'req-1', + secretUuids: ['uuid-1'], + expiresAt: expiresAt.toISOString(), + ...payload, + } + return new SignJWT(fullPayload as unknown as Record) + .setProtectedHeader({ alg: 'EdDSA' }) + .sign(privateKey) +} + +function mockFetchPublicKey(publicKeyPem: string, status = 200) { + return vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: vi.fn().mockResolvedValue({ publicKey: publicKeyPem }), + } as unknown as Response) +} + +describe('GrantVerifier', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() + }) + + describe('fetchPublicKey', () => { + it('fetches public key from GET /api/keys/public', async () => { + const { privateKey, publicKey } = await makeKeyPair() + const pem = await exportSPKI(publicKey) + const fetchMock = mockFetchPublicKey(pem) + globalThis.fetch = fetchMock + + const verifier = new GrantVerifier('http://127.0.0.1:2274', 'test-token') + const key = await verifier.fetchPublicKey() + + expect(key).toBeDefined() + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:2274/api/keys/public', + expect.objectContaining({ + headers: { Authorization: 'Bearer test-token' }, + }), + ) + // suppress unused warning + void privateKey + }) + + it('caches public key for subsequent calls', async () => { + const { publicKey } = await makeKeyPair() + const pem = await exportSPKI(publicKey) + const fetchMock = mockFetchPublicKey(pem) + globalThis.fetch = fetchMock + + const verifier = new GrantVerifier('http://127.0.0.1:2274', 'test-token') + await verifier.fetchPublicKey() + await verifier.fetchPublicKey() + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('refreshes cache after TTL expires', async () => { + const { publicKey } = await makeKeyPair() + const pem = await exportSPKI(publicKey) + const fetchMock = mockFetchPublicKey(pem) + globalThis.fetch = fetchMock + + const verifier = new GrantVerifier('http://127.0.0.1:2274', 'test-token') + // First call + await verifier.fetchPublicKey() + // Manually expire cache by setting cachedAt to far in the past + ;(verifier as unknown as { cachedAt: number }).cachedAt = Date.now() - 10 * 60 * 1000 + + await verifier.fetchPublicKey() + + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('throws clear error when server unreachable', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed')) + + const verifier = new GrantVerifier('http://127.0.0.1:2274', 'test-token') + await expect(verifier.fetchPublicKey()).rejects.toThrow( + 'Server not running. Start with `2kc server start`', + ) + }) + + it('throws on non-OK response', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + json: vi.fn().mockResolvedValue({}), + } as unknown as Response) + globalThis.fetch = fetchMock + + const verifier = new GrantVerifier('http://127.0.0.1:2274', 'test-token') + await expect(verifier.fetchPublicKey()).rejects.toThrow('Failed to fetch server public key') + }) + + it('throws timeout error when fetch times out with DOMException', async () => { + const err = new DOMException('The operation was aborted', 'TimeoutError') + globalThis.fetch = vi.fn().mockRejectedValue(err) + + const verifier = new GrantVerifier('http://127.0.0.1:2274', 'test-token') + await expect(verifier.fetchPublicKey()).rejects.toThrow( + 'Request timed out after 30s. Is the server responding?', + ) + }) + + it('re-throws unexpected non-TypeError errors from fetch', async () => { + const err = new RangeError('unexpected') + globalThis.fetch = vi.fn().mockRejectedValue(err) + + const verifier = new GrantVerifier('http://127.0.0.1:2274', 'test-token') + await expect(verifier.fetchPublicKey()).rejects.toThrow('unexpected') + }) + }) + + describe('verifyGrant', () => { + let privateKey: CryptoKey + let publicKeyPem: string + let verifier: GrantVerifier + + beforeEach(async () => { + const keyPair = await makeKeyPair() + privateKey = keyPair.privateKey + publicKeyPem = await exportSPKI(keyPair.publicKey) + globalThis.fetch = mockFetchPublicKey(publicKeyPem) + verifier = new GrantVerifier('http://127.0.0.1:2274', 'test-token') + }) + + it('returns parsed payload for valid JWS', async () => { + const jws = await makeJWS({}, privateKey) + const payload = await verifier.verifyGrant(jws) + + expect(payload.grantId).toBe('grant-1') + expect(payload.requestId).toBe('req-1') + expect(payload.secretUuids).toEqual(['uuid-1']) + }) + + it('rejects tampered JWS signature', async () => { + const jws = await makeJWS({}, privateKey) + // Tamper with the payload part (middle segment) + const parts = jws.split('.') + parts[1] = Buffer.from(JSON.stringify({ tampered: true })).toString('base64url') + const tampered = parts.join('.') + + await expect(verifier.verifyGrant(tampered)).rejects.toThrow('Invalid grant signature') + }) + + it('rejects grant with missing required fields (no grantId)', async () => { + const jws = await new SignJWT({ + requestId: 'req-1', + secretUuids: ['uuid-1'], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + .setProtectedHeader({ alg: 'EdDSA' }) + .sign(privateKey) + + await expect(verifier.verifyGrant(jws)).rejects.toThrow('Grant is missing required fields') + }) + + it('rejects grant with missing expiresAt', async () => { + const jws = await new SignJWT({ + grantId: 'grant-1', + requestId: 'req-1', + secretUuids: ['uuid-1'], + }) + .setProtectedHeader({ alg: 'EdDSA' }) + .sign(privateKey) + + await expect(verifier.verifyGrant(jws)).rejects.toThrow('Grant is missing expiry claim') + }) + + it('rejects expired grant (expiresAt in past)', async () => { + const pastDate = new Date(Date.now() - 60_000) + const jws = await makeJWS({}, privateKey, { expiresAt: pastDate }) + + await expect(verifier.verifyGrant(jws)).rejects.toThrow('Grant has expired') + }) + + it('rejects when expectedCommandHash provided but payload has no commandHash', async () => { + const jws = await makeJWS({}, privateKey) // no commandHash in payload + + await expect(verifier.verifyGrant(jws, 'expected-hash')).rejects.toThrow( + 'Grant is missing command hash', + ) + }) + + it('rejects grant with wrong command hash when bound', async () => { + const jws = await makeJWS({ commandHash: 'expected-hash' }, privateKey) + + await expect(verifier.verifyGrant(jws, 'different-hash')).rejects.toThrow( + 'Grant command hash does not match', + ) + }) + + it('accepts grant with matching command hash', async () => { + const jws = await makeJWS({ commandHash: 'correct-hash' }, privateKey) + + const payload = await verifier.verifyGrant(jws, 'correct-hash') + expect(payload.commandHash).toBe('correct-hash') + }) + + it('accepts grant with no command hash binding', async () => { + const jws = await makeJWS({}, privateKey) + + // No expectedCommandHash — should pass regardless + const payload = await verifier.verifyGrant(jws) + expect(payload.commandHash).toBeUndefined() + }) + }) +}) diff --git a/src/__tests__/remote-service.test.ts b/src/__tests__/remote-service.test.ts index 1a72338..b5170ea 100644 --- a/src/__tests__/remote-service.test.ts +++ b/src/__tests__/remote-service.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' - import { RemoteService } from '../core/remote-service.js' +import { GrantVerifier } from '../core/grant-verifier.js' import type { ServerConfig } from '../core/config.js' +import type { RemoteServiceDeps } from '../core/remote-service.js' function makeConfig(overrides?: Partial): ServerConfig { return { @@ -21,6 +21,37 @@ function mockFetchResponse(status: number, body?: unknown, statusText = 'OK') { } as unknown as Response) } +function makeLoginSuccessResponse(token: string) { + return { + ok: true, + status: 200, + statusText: 'OK', + json: vi.fn().mockResolvedValue({ token }), + } as unknown as Response +} + +function makeJsonResponse(status: number, body: unknown, statusText = 'OK') { + return { + ok: status >= 200 && status < 300, + status, + statusText, + json: vi.fn().mockResolvedValue(body), + } as unknown as Response +} + +function makeDeps(overrides?: Partial): RemoteServiceDeps { + return { + unlockSession: { + isUnlocked: vi.fn().mockReturnValue(true), + recordGrantUsage: vi.fn(), + } as unknown as RemoteServiceDeps['unlockSession'], + injector: { + inject: vi.fn().mockResolvedValue({ exitCode: 0, stdout: 'ok', stderr: '' }), + } as unknown as RemoteServiceDeps['injector'], + ...overrides, + } +} + describe('RemoteService', () => { const originalFetch = globalThis.fetch @@ -172,63 +203,271 @@ describe('RemoteService', () => { }) describe('grants', () => { - it('validate() calls GET /api/grants/:grantId', async () => { - const fetchMock = mockFetchResponse(200, true) + it('validate() fetches signed grant and verifies JWS locally', async () => { + const fetchMock = mockFetchResponse(200, 'fake.jws.token') globalThis.fetch = fetchMock + vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ + grantId: 'grant-1', + requestId: 'req-1', + secretUuids: ['uuid-1'], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + const service = new RemoteService(makeConfig()) - const result = await service.grants.validate('grant-1') + const result = await service.grants.validate('req-1') expect(result).toBe(true) expect(fetchMock).toHaveBeenCalledWith( - 'http://127.0.0.1:2274/api/grants/grant-1', + 'http://127.0.0.1:2274/api/grants/req-1/signed', expect.objectContaining({ method: 'GET' }), ) }) + + it('validate() returns false when JWS verification fails', async () => { + const fetchMock = mockFetchResponse(200, 'bad.jws.token') + globalThis.fetch = fetchMock + + vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockRejectedValue( + new Error('Invalid grant signature'), + ) + + const service = new RemoteService(makeConfig()) + const result = await service.grants.validate('req-1') + + expect(result).toBe(false) + }) }) - describe('inject', () => { - it('calls POST /api/inject with envVarName when provided', async () => { - const processResult = { exitCode: 0, stdout: 'ok', stderr: '' } - const fetchMock = mockFetchResponse(200, processResult) + describe('inject (local)', () => { + it('receives signed grant, verifies JWS, injects locally using SecretInjector', async () => { + const fetchMock = mockFetchResponse(200, 'signed.jws.token') + globalThis.fetch = fetchMock + + vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ + grantId: 'grant-abc', + requestId: 'req-1', + secretUuids: ['uuid-1'], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + + const deps = makeDeps() + const service = new RemoteService(makeConfig(), deps) + const result = await service.inject('req-1', 'echo hello') + + expect(result).toEqual({ exitCode: 0, stdout: 'ok', stderr: '' }) + expect(deps.injector!.inject).toHaveBeenCalledWith( + 'grant-abc', + ['/bin/sh', '-c', 'echo hello'], + undefined, + ) + expect(deps.unlockSession!.recordGrantUsage).toHaveBeenCalled() + }) + + it('passes envVarName option to injector', async () => { + const fetchMock = mockFetchResponse(200, 'signed.jws.token') + globalThis.fetch = fetchMock + + vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ + grantId: 'grant-abc', + requestId: 'req-1', + secretUuids: ['uuid-1'], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + + const deps = makeDeps() + const service = new RemoteService(makeConfig(), deps) + await service.inject('req-1', 'echo hello', { envVarName: 'SECRET_VAR' }) + + expect(deps.injector!.inject).toHaveBeenCalledWith( + 'grant-abc', + ['/bin/sh', '-c', 'echo hello'], + { envVarName: 'SECRET_VAR' }, + ) + }) + + it('throws when local store is locked', async () => { + const fetchMock = mockFetchResponse(200, 'signed.jws.token') + globalThis.fetch = fetchMock + + vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ + grantId: 'grant-abc', + requestId: 'req-1', + secretUuids: ['uuid-1'], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + + const deps = makeDeps({ + unlockSession: { + isUnlocked: vi.fn().mockReturnValue(false), + recordGrantUsage: vi.fn(), + } as unknown as RemoteServiceDeps['unlockSession'], + }) + const service = new RemoteService(makeConfig(), deps) + + await expect(service.inject('req-1', 'echo hello')).rejects.toThrow( + 'Local store is locked. Run `2kc unlock` before requesting secrets.', + ) + }) + + it('throws when JWS verification fails', async () => { + const fetchMock = mockFetchResponse(200, 'tampered.jws.token') + globalThis.fetch = fetchMock + + vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockRejectedValue( + new Error('Invalid grant signature: signature verification failed'), + ) + + const deps = makeDeps() + const service = new RemoteService(makeConfig(), deps) + + await expect(service.inject('req-1', 'echo hello')).rejects.toThrow('Invalid grant signature') + }) + + it('throws when grant is expired', async () => { + const fetchMock = mockFetchResponse(200, 'expired.jws.token') + globalThis.fetch = fetchMock + + vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockRejectedValue( + new Error('Grant has expired'), + ) + + const deps = makeDeps() + const service = new RemoteService(makeConfig(), deps) + + await expect(service.inject('req-1', 'echo hello')).rejects.toThrow('Grant has expired') + }) + + it('throws when unlockSession not configured', async () => { + const deps = makeDeps({ unlockSession: undefined }) + const service = new RemoteService(makeConfig(), deps) + + await expect(service.inject('req-1', 'echo hello')).rejects.toThrow( + 'unlockSession not configured', + ) + }) + + it('throws when injector not configured', async () => { + const fetchMock = mockFetchResponse(200, 'signed.jws.token') + globalThis.fetch = fetchMock + + vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ + grantId: 'grant-abc', + requestId: 'req-1', + secretUuids: ['uuid-1'], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + + const deps = makeDeps({ injector: undefined }) + const service = new RemoteService(makeConfig(), deps) + + await expect(service.inject('req-1', 'echo hello')).rejects.toThrow( + 'Injector not available in client mode', + ) + }) + + it('passes SHA-256 hash of command to verifyGrant', async () => { + const fetchMock = mockFetchResponse(200, 'signed.jws.token') + globalThis.fetch = fetchMock + + const verifyMock = vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ + grantId: 'grant-abc', + requestId: 'req-1', + secretUuids: ['uuid-1'], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + commandHash: undefined, + }) + + const deps = makeDeps() + const service = new RemoteService(makeConfig(), deps) + await service.inject('req-1', 'echo hello') + + const { createHash } = await import('node:crypto') + const expectedHash = createHash('sha256').update('echo hello').digest('hex') + expect(verifyMock).toHaveBeenCalledWith('signed.jws.token', expectedHash) + }) + }) + + describe('session auth', () => { + it('calls POST /api/auth/login on first request and stores session token', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-abc')) + .mockResolvedValueOnce(makeJsonResponse(200, { status: 'ok' })) globalThis.fetch = fetchMock const service = new RemoteService(makeConfig()) - const result = await service.inject('req-1', 'echo hello', { envVarName: 'SECRET_VAR' }) + await service.health() - expect(result).toEqual(processResult) expect(fetchMock).toHaveBeenCalledWith( - 'http://127.0.0.1:2274/api/inject', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ - requestId: 'req-1', - command: 'echo hello', - envVarName: 'SECRET_VAR', - }), - }), + 'http://127.0.0.1:2274/api/auth/login', + expect.objectContaining({ method: 'POST' }), ) }) - it('calls POST /api/inject without envVarName when not provided', async () => { - const processResult = { exitCode: 0, stdout: 'ok', stderr: '' } - const fetchMock = mockFetchResponse(200, processResult) + it('sends session token in Authorization header after login', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-abc')) + .mockResolvedValueOnce(makeJsonResponse(200, { status: 'ok' })) globalThis.fetch = fetchMock const service = new RemoteService(makeConfig()) - const result = await service.inject('req-1', 'echo hello') + await service.health() - expect(result).toEqual(processResult) - expect(fetchMock).toHaveBeenCalledWith( - 'http://127.0.0.1:2274/api/inject', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ - requestId: 'req-1', - command: 'echo hello', - }), - }), + const healthCallArgs = fetchMock.mock.calls[1] as [string, RequestInit] + const headers = healthCallArgs[1].headers as Record + expect(headers.Authorization).toBe('Bearer session-abc') + }) + + it('falls back to static Bearer when session login fails', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + makeJsonResponse(500, { error: 'Login failed' }, 'Internal Server Error'), + ) + .mockResolvedValueOnce(makeJsonResponse(200, { status: 'ok' })) + globalThis.fetch = fetchMock + + const service = new RemoteService(makeConfig({ authToken: 'static-token' })) + await service.health() + + const healthCallArgs = fetchMock.mock.calls[1] as [string, RequestInit] + const headers = healthCallArgs[1].headers as Record + expect(headers.Authorization).toBe('Bearer static-token') + }) + + it('auto-refreshes session on 401 response and retries request once', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-1')) // initial login + .mockResolvedValueOnce(makeJsonResponse(401, { error: 'Unauthorized' }, 'Unauthorized')) // first health attempt → 401 + .mockResolvedValueOnce(makeLoginSuccessResponse('session-2')) // re-login + .mockResolvedValueOnce(makeJsonResponse(200, { status: 'ok' })) // retry health + globalThis.fetch = fetchMock + + const service = new RemoteService(makeConfig()) + const result = await service.health() + + expect(result).toEqual({ status: 'ok' }) + // Verify re-login happened (4 total calls) + expect(fetchMock).toHaveBeenCalledTimes(4) + }) + + it('does not retry more than once on repeated 401', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-1')) // initial login + .mockResolvedValueOnce(makeJsonResponse(401, { error: 'Unauthorized' }, 'Unauthorized')) // first attempt + .mockResolvedValueOnce(makeLoginSuccessResponse('session-2')) // re-login + .mockResolvedValueOnce(makeJsonResponse(401, { error: 'Unauthorized' }, 'Unauthorized')) // retry also 401 + globalThis.fetch = fetchMock + + const service = new RemoteService(makeConfig()) + await expect(service.health()).rejects.toThrow( + 'Authentication failed. Check authToken in config', ) + expect(fetchMock).toHaveBeenCalledTimes(4) }) }) @@ -284,18 +523,55 @@ describe('RemoteService', () => { 'Request timed out after 30s. Is the server responding?', ) }) + + it('re-throws unexpected errors from request() when login succeeds', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-1')) + .mockRejectedValueOnce(new RangeError('unexpected network error')) + globalThis.fetch = fetchMock + + const service = new RemoteService(makeConfig()) + await expect(service.health()).rejects.toThrow('unexpected network error') + }) + + it('re-throws unexpected errors from login()', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new RangeError('login network error')) + + const service = new RemoteService(makeConfig()) + await expect(service.health()).rejects.toThrow('login network error') + }) }) describe('authorization header', () => { - it('sets Bearer token on all requests', async () => { - const fetchMock = mockFetchResponse(200, { status: 'ok' }) + it('uses session token in Authorization header when session login succeeds', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-xyz')) + .mockResolvedValueOnce(makeJsonResponse(200, { status: 'ok' })) + globalThis.fetch = fetchMock + + const service = new RemoteService(makeConfig({ authToken: 'my-secret-token' })) + await service.health() + + // Second call is the actual health request, which should use session token + const healthCallArgs = fetchMock.mock.calls[1] as [string, RequestInit] + const headers = healthCallArgs[1].headers as Record + expect(headers.Authorization).toBe('Bearer session-xyz') + }) + + it('uses static Bearer token when no session available', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeJsonResponse(200, {})) // login returns no token + .mockResolvedValueOnce(makeJsonResponse(200, { status: 'ok' })) globalThis.fetch = fetchMock const service = new RemoteService(makeConfig({ authToken: 'my-secret-token' })) await service.health() - const callArgs = fetchMock.mock.calls[0] as [string, RequestInit] - const headers = callArgs[1].headers as Record + const healthCallArgs = fetchMock.mock.calls[1] as [string, RequestInit] + const headers = healthCallArgs[1].headers as Record expect(headers.Authorization).toBe('Bearer my-secret-token') }) }) diff --git a/src/core/grant-verifier.ts b/src/core/grant-verifier.ts new file mode 100644 index 0000000..d5d3ed8 --- /dev/null +++ b/src/core/grant-verifier.ts @@ -0,0 +1,96 @@ +import { jwtVerify, importSPKI } from 'jose' + +export interface GrantJWSPayload { + grantId: string + requestId: string + secretUuids: string[] + expiresAt: string + commandHash?: string +} + +export class GrantVerifier { + private cachedKey: CryptoKey | null = null + private cachedAt = 0 + private readonly cacheTtlMs = 5 * 60 * 1000 + + constructor( + private readonly baseUrl: string, + private readonly authToken: string, + ) {} + + async fetchPublicKey(): Promise { + const now = Date.now() + if (this.cachedKey !== null && now - this.cachedAt < this.cacheTtlMs) { + return this.cachedKey + } + + let response: Response + try { + response = await fetch(`${this.baseUrl}/api/keys/public`, { + headers: { Authorization: `Bearer ${this.authToken}` }, + signal: AbortSignal.timeout(30_000), + }) + } catch (err: unknown) { + if (err instanceof TypeError) { + throw new Error('Server not running. Start with `2kc server start`') + } + if (err instanceof DOMException || (err instanceof Error && err.name === 'TimeoutError')) { + throw new Error('Request timed out after 30s. Is the server responding?') + } + throw err + } + + if (!response.ok) { + throw new Error( + `Failed to fetch server public key: ${response.status} ${response.statusText}`, + ) + } + + const body = (await response.json()) as { publicKey: string } + const key = await importSPKI(body.publicKey, 'EdDSA') + + this.cachedKey = key + this.cachedAt = now + return key + } + + async verifyGrant(jwsToken: string, expectedCommandHash?: string): Promise { + const key = await this.fetchPublicKey() + + let raw: Record + try { + const result = await jwtVerify(jwsToken, key) + raw = result.payload as Record + } catch (err: unknown) { + if (err instanceof Error) { + throw new Error(`Invalid grant signature: ${err.message}`) + } + throw new Error('Invalid grant: signature verification failed') + } + + if (!raw.grantId || !raw.requestId || !raw.secretUuids) { + throw new Error('Grant is missing required fields') + } + + if (!raw.expiresAt) { + throw new Error('Grant is missing expiry claim') + } + + const payload = raw as unknown as GrantJWSPayload + + if (Date.now() > new Date(payload.expiresAt).getTime()) { + throw new Error('Grant has expired') + } + + if (expectedCommandHash !== undefined) { + if (payload.commandHash === undefined) { + throw new Error('Grant is missing command hash') + } + if (payload.commandHash !== expectedCommandHash) { + throw new Error('Grant command hash does not match the requested command') + } + } + + return payload + } +} diff --git a/src/core/remote-service.ts b/src/core/remote-service.ts index c73b36e..582699c 100644 --- a/src/core/remote-service.ts +++ b/src/core/remote-service.ts @@ -1,13 +1,25 @@ +import { createHash } from 'node:crypto' import type { ServerConfig } from './config.js' import type { Service, SecretSummary } from './service.js' import type { SecretMetadata, ProcessResult } from './types.js' import type { AccessRequest } from './request.js' +import type { UnlockSession } from './unlock-session.js' +import type { SecretInjector } from './injector.js' +import { GrantVerifier } from './grant-verifier.js' + +export interface RemoteServiceDeps { + unlockSession?: UnlockSession + injector?: SecretInjector +} export class RemoteService implements Service { private baseUrl: string private authToken: string + private sessionToken: string | null = null + private grantVerifier: GrantVerifier + private deps: RemoteServiceDeps - constructor(serverConfig: ServerConfig) { + constructor(serverConfig: ServerConfig, deps: RemoteServiceDeps = {}) { const host = serverConfig.host const port = serverConfig.port @@ -16,12 +28,59 @@ export class RemoteService implements Service { } this.authToken = serverConfig.authToken this.baseUrl = `http://${host}:${port}` + this.grantVerifier = new GrantVerifier(this.baseUrl, this.authToken) + this.deps = deps } - private async request(method: string, path: string, body?: unknown): Promise { + private async login(): Promise { + const url = `${this.baseUrl}/api/auth/login` + let response: Response + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: this.authToken }), + signal: AbortSignal.timeout(30_000), + }) + } catch (err: unknown) { + if (err instanceof TypeError) { + throw new Error('Server not running. Start with `2kc server start`') + } + if (err instanceof DOMException || (err instanceof Error && err.name === 'TimeoutError')) { + throw new Error('Request timed out after 30s. Is the server responding?') + } + throw err + } + + if (!response.ok) { + this.sessionToken = null + return + } + + const body = (await response.json().catch(() => null)) as { + token?: string + sessionToken?: string + } | null + this.sessionToken = body?.token ?? body?.sessionToken ?? null + } + + private async request( + method: string, + path: string, + body?: unknown, + isRetry = false, + ): Promise { + // Auto-login on first request if no session token + if (this.sessionToken === null && !isRetry) { + await this.login() + } + const url = `${this.baseUrl}${path}` + const authValue = + this.sessionToken !== null ? `Bearer ${this.sessionToken}` : `Bearer ${this.authToken}` + const headers: Record = { - Authorization: `Bearer ${this.authToken}`, + Authorization: authValue, } if (body !== undefined) { @@ -47,6 +106,12 @@ export class RemoteService implements Service { } if (response.status === 401) { + if (!isRetry) { + // Session expired or rejected — re-login and retry once + this.sessionToken = null + await this.login() + return this.request(method, path, body, true) + } throw new Error('Authentication failed. Check authToken in config') } @@ -98,8 +163,18 @@ export class RemoteService implements Service { } grants: Service['grants'] = { - validate: (requestId: string) => - this.request('GET', `/api/grants/${encodeURIComponent(requestId)}`), + validate: async (requestId: string) => { + const jwsToken = await this.request( + 'GET', + `/api/grants/${encodeURIComponent(requestId)}/signed`, + ) + try { + await this.grantVerifier.verifyGrant(jwsToken) + return true + } catch { + return false + } + }, } async inject( @@ -107,10 +182,40 @@ export class RemoteService implements Service { command: string, options?: { envVarName?: string }, ): Promise { - return this.request('POST', '/api/inject', { - requestId, - command, - ...(options?.envVarName != null && { envVarName: options.envVarName }), - }) + // 0. Check deps + if (!this.deps.unlockSession) { + throw new Error('unlockSession not configured') + } + + // 1. Fetch signed grant JWS from server + const jwsToken = await this.request( + 'GET', + `/api/grants/${encodeURIComponent(requestId)}/signed`, + ) + + // 2. Verify JWS signature + expiry, binding to this command's hash + const commandHash = createHash('sha256').update(command).digest('hex') + const grantPayload = await this.grantVerifier.verifyGrant(jwsToken, commandHash) + + // 3. Check that local store is unlocked + if (!this.deps.unlockSession.isUnlocked()) { + throw new Error('Local store is locked. Run `2kc unlock` before requesting secrets.') + } + + if (!this.deps.injector) { + throw new Error('Injector not available in client mode') + } + + // 4. Inject locally using the SecretInjector with the grant ID from the payload + const result = await this.deps.injector.inject( + grantPayload.grantId, + ['/bin/sh', '-c', command], + options, + ) + + // 5. Record grant usage + this.deps.unlockSession?.recordGrantUsage() + + return result } } diff --git a/src/core/service.ts b/src/core/service.ts index ea367df..9b5b9c4 100644 --- a/src/core/service.ts +++ b/src/core/service.ts @@ -168,7 +168,12 @@ export class LocalService implements Service { export async function resolveService(config: AppConfig): Promise { if (config.mode === 'client') { - return new RemoteService(config.server) + const store = new EncryptedSecretStore(config.store.path) + const unlockSession = new UnlockSession(config.unlock) + const grantsPath = join(dirname(config.store.path), 'grants.json') + const grantManager = new GrantManager(grantsPath) + const injector = new SecretInjector(grantManager, store) + return new RemoteService(config.server, { unlockSession, injector }) } const grantsPath = join(dirname(config.store.path), 'grants.json') From fa38363ddd7633f1ded374ef28ba33f5155ac60b Mon Sep 17 00:00:00 2001 From: Helix Date: Wed, 25 Feb 2026 23:11:57 -0800 Subject: [PATCH 06/25] fixes #64 --- src/__tests__/grant.test.ts | 141 +++++++++++------- .../integration/local-encrypted-flow.test.ts | 8 +- src/__tests__/remote-service.test.ts | 33 +--- src/__tests__/request-command.test.ts | 56 ++++++- src/__tests__/request.test.ts | 88 +++++++++++ src/__tests__/routes.test.ts | 45 +++++- src/__tests__/server.test.ts | 6 +- src/__tests__/service.test.ts | 85 ++++++++--- src/__tests__/signed-grant.test.ts | 14 +- src/cli/request.ts | 19 ++- src/core/grant.ts | 36 +++-- src/core/key-manager.ts | 69 ++++++--- src/core/remote-service.ts | 18 +-- src/core/request.ts | 32 +++- src/core/service.ts | 49 ++++-- src/server/routes.ts | 4 +- 16 files changed, 513 insertions(+), 190 deletions(-) diff --git a/src/__tests__/grant.test.ts b/src/__tests__/grant.test.ts index d53d118..30aab45 100644 --- a/src/__tests__/grant.test.ts +++ b/src/__tests__/grant.test.ts @@ -1,5 +1,6 @@ /// +import { generateKeyPairSync } from 'node:crypto' import { GrantManager } from '../core/grant.js' import { createAccessRequest } from '../core/request.js' @@ -25,48 +26,69 @@ function makePendingRequest(durationSeconds = 300) { describe('GrantManager', () => { describe('createGrant', () => { - it('creates a grant with a UUID id', async () => { + it('creates a grant with a UUID id', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) expect(grant.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) }) - it('sets requestId to request.id', async () => { + it('sets requestId to request.id', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) expect(grant.requestId).toBe(request.id) }) - it('sets secretUuids from request.secretUuids', async () => { + it('sets secretUuids from request.secretUuids', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) expect(grant.secretUuids).toEqual(request.secretUuids) }) - it('sets used to false and revokedAt to null', async () => { + it('sets used to false and revokedAt to null', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) expect(grant.used).toBe(false) expect(grant.revokedAt).toBeNull() }) - it('throws if request is not approved', async () => { + it('throws if request is not approved', () => { const manager = new GrantManager() const request = makePendingRequest() - await expect(manager.createGrant(request)).rejects.toThrow( + expect(() => manager.createGrant(request)).toThrow( 'Cannot create grant for request with status: pending', ) }) + it('returns undefined jws when no signing key', () => { + const manager = new GrantManager() + const request = makeApprovedRequest() + const { grant, jws } = manager.createGrant(request) + + expect(jws).toBeUndefined() + expect(grant.jws).toBeUndefined() + }) + + it('stores jws on grant when signing key is provided', () => { + const { privateKey } = generateKeyPairSync('ed25519') + const manager = new GrantManager(undefined, privateKey) + const request = makeApprovedRequest() + const { grant, jws } = manager.createGrant(request) + + expect(jws).toBeDefined() + expect(grant.jws).toBe(jws) + // JWS compact serialization: three base64url segments separated by dots + expect(jws).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/) + }) + describe('with fake timers', () => { beforeEach(() => { vi.useFakeTimers() @@ -77,44 +99,44 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('sets grantedAt to current ISO timestamp', async () => { + it('sets grantedAt to current ISO timestamp', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) expect(grant.grantedAt).toBe('2026-01-15T10:00:00.000Z') }) - it('sets expiresAt to grantedAt + durationSeconds', async () => { + it('sets expiresAt to grantedAt + durationSeconds', () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) expect(grant.expiresAt).toBe('2026-01-15T10:05:00.000Z') }) }) describe('commandHash', () => { - it('copies commandHash from request to grant when present', async () => { + it('copies commandHash from request to grant when present', () => { const manager = new GrantManager() const request = makeApprovedRequest() request.commandHash = 'abc123deadbeef' - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) expect(grant.commandHash).toBe('abc123deadbeef') }) - it('grant has no commandHash when request had none', async () => { + it('grant has no commandHash when request had none', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) expect(grant.commandHash).toBeUndefined() }) }) describe('batch', () => { - it('copies secretUuids array from request', async () => { + it('copies secretUuids array from request', () => { const manager = new GrantManager() const request = createAccessRequest( ['uuid-1', 'uuid-2', 'uuid-3'], @@ -122,17 +144,17 @@ describe('GrantManager', () => { 'TASK-1', ) request.status = 'approved' - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) expect(grant.secretUuids).toEqual(['uuid-1', 'uuid-2', 'uuid-3']) }) - it('preserves all UUIDs in the array', async () => { + it('preserves all UUIDs in the array', () => { const manager = new GrantManager() const uuids = ['a', 'b', 'c', 'd', 'e'] const request = createAccessRequest(uuids, 'batch access', 'TASK-1') request.status = 'approved' - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) expect(grant.secretUuids).toHaveLength(5) expect(grant.secretUuids).toEqual(uuids) @@ -141,10 +163,10 @@ describe('GrantManager', () => { }) describe('validateGrant', () => { - it('returns true for valid, unexpired, unused, unrevoked grant', async () => { + it('returns true for valid, unexpired, unused, unrevoked grant', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) expect(manager.validateGrant(grant.id)).toBe(true) }) @@ -155,20 +177,20 @@ describe('GrantManager', () => { expect(manager.validateGrant('nonexistent')).toBe(false) }) - it('returns false for used grant', async () => { + it('returns false for used grant', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) manager.markUsed(grant.id) expect(manager.validateGrant(grant.id)).toBe(false) }) - it('returns false for revoked grant', async () => { + it('returns false for revoked grant', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) manager.revokeGrant(grant.id) @@ -185,10 +207,10 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('returns false for expired grant', async () => { + it('returns false for expired grant', () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) // Advance past expiry vi.setSystemTime(new Date('2026-01-15T10:05:00.001Z')) @@ -199,10 +221,10 @@ describe('GrantManager', () => { }) describe('markUsed', () => { - it('marks grant as used', async () => { + it('marks grant as used', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) manager.markUsed(grant.id) @@ -216,20 +238,20 @@ describe('GrantManager', () => { expect(() => manager.markUsed('nonexistent')).toThrow('Grant not found: nonexistent') }) - it('throws if grant already used', async () => { + it('throws if grant already used', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) manager.markUsed(grant.id) expect(() => manager.markUsed(grant.id)).toThrow(`Grant is not valid: ${grant.id}`) }) - it('throws if grant revoked', async () => { + it('throws if grant revoked', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) manager.revokeGrant(grant.id) @@ -246,10 +268,10 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('throws if grant expired', async () => { + it('throws if grant expired', () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) vi.setSystemTime(new Date('2026-01-15T10:05:00.001Z')) @@ -265,10 +287,10 @@ describe('GrantManager', () => { expect(() => manager.revokeGrant('nonexistent')).toThrow('Grant not found: nonexistent') }) - it('throws if grant already revoked', async () => { + it('throws if grant already revoked', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) manager.revokeGrant(grant.id) @@ -285,10 +307,10 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('sets revokedAt timestamp', async () => { + it('sets revokedAt timestamp', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) vi.setSystemTime(new Date('2026-01-15T10:01:00.000Z')) manager.revokeGrant(grant.id) @@ -316,10 +338,10 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('removes expired grants from memory', async () => { + it('removes expired grants from memory', () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) vi.setSystemTime(new Date('2026-01-15T10:05:00.001Z')) manager.cleanup() @@ -327,10 +349,10 @@ describe('GrantManager', () => { expect(manager.getGrant(grant.id)).toBeUndefined() }) - it('keeps unexpired grants', async () => { + it('keeps unexpired grants', () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) vi.setSystemTime(new Date('2026-01-15T10:04:00.000Z')) manager.cleanup() @@ -341,10 +363,10 @@ describe('GrantManager', () => { }) describe('getGrant', () => { - it('returns a copy of the grant', async () => { + it('returns a copy of the grant', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) const retrieved = manager.getGrant(grant.id) expect(retrieved).toEqual(grant) @@ -364,10 +386,10 @@ describe('GrantManager', () => { }) describe('getGrantByRequestId', () => { - it('returns grant matching the requestId', async () => { + it('returns grant matching the requestId', () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) const found = manager.getGrantByRequestId(request.id) expect(found).toBeDefined() @@ -375,10 +397,10 @@ describe('GrantManager', () => { expect(found!.requestId).toBe(request.id) }) - it('returns a copy (not the original)', async () => { + it('returns a copy (not the original)', () => { const manager = new GrantManager() const request = makeApprovedRequest() - await manager.createGrant(request) + manager.createGrant(request) const found = manager.getGrantByRequestId(request.id) if (found) { @@ -392,14 +414,25 @@ describe('GrantManager', () => { const manager = new GrantManager() expect(manager.getGrantByRequestId('nonexistent')).toBeUndefined() }) + + it('returns grant with jws when signing key is provided', () => { + const { privateKey } = generateKeyPairSync('ed25519') + const manager = new GrantManager(undefined, privateKey) + const request = makeApprovedRequest() + manager.createGrant(request) + + const found = manager.getGrantByRequestId(request.id) + expect(found?.jws).toBeDefined() + expect(found?.jws).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/) + }) }) describe('getGrantSecrets', () => { - it('returns secretUuids array for valid grant', async () => { + it('returns secretUuids array for valid grant', () => { const manager = new GrantManager() const request = createAccessRequest(['uuid-1', 'uuid-2'], 'reason', 'TASK-1') request.status = 'approved' - const { grant } = await manager.createGrant(request) + const { grant } = manager.createGrant(request) const secrets = manager.getGrantSecrets(grant.id) expect(secrets).toEqual(['uuid-1', 'uuid-2']) diff --git a/src/__tests__/integration/local-encrypted-flow.test.ts b/src/__tests__/integration/local-encrypted-flow.test.ts index 780dcdc..07c7230 100644 --- a/src/__tests__/integration/local-encrypted-flow.test.ts +++ b/src/__tests__/integration/local-encrypted-flow.test.ts @@ -89,7 +89,7 @@ describe('Phase 1 Local Encrypted Flow', () => { // 4. Create a grant const grantManager = new GrantManager() - const { grant } = await grantManager.createGrant(request) + const { grant } = grantManager.createGrant(request) expect(grant.secretUuids).toContain(uuid) // 5. Inject via SecretInjector — spawn real subprocess @@ -120,7 +120,7 @@ describe('Phase 1 Local Encrypted Flow', () => { const request = createAccessRequest([uuid], 'test locked rejection', 'task-002') request.status = 'approved' const grantManager = new GrantManager() - const { grant } = await grantManager.createGrant(request) + const { grant } = grantManager.createGrant(request) // 3. Lock the store store.lock() @@ -180,7 +180,7 @@ describe('Phase 1 Local Encrypted Flow', () => { // 4. createGrant should throw because status is 'denied' const grantManager = new GrantManager() - await expect(grantManager.createGrant(request)).rejects.toThrow( + expect(() => grantManager.createGrant(request)).toThrow( 'Cannot create grant for request with status: denied', ) }) @@ -201,7 +201,7 @@ describe('Phase 1 Local Encrypted Flow', () => { // 4. Create grant (expires in 30s) const grantManager = new GrantManager() - const { grant } = await grantManager.createGrant(request) + const { grant } = grantManager.createGrant(request) // 5. Advance past grant TTL vi.advanceTimersByTime(31_000) diff --git a/src/__tests__/remote-service.test.ts b/src/__tests__/remote-service.test.ts index b5170ea..61a5793 100644 --- a/src/__tests__/remote-service.test.ts +++ b/src/__tests__/remote-service.test.ts @@ -203,40 +203,21 @@ describe('RemoteService', () => { }) describe('grants', () => { - it('validate() fetches signed grant and verifies JWS locally', async () => { - const fetchMock = mockFetchResponse(200, 'fake.jws.token') + it('getStatus() calls GET /api/grants/:requestId', async () => { + const statusResponse = { status: 'approved', jws: 'test.jws.token' } + const fetchMock = mockFetchResponse(200, statusResponse) globalThis.fetch = fetchMock - vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ - grantId: 'grant-1', - requestId: 'req-1', - secretUuids: ['uuid-1'], - expiresAt: new Date(Date.now() + 60_000).toISOString(), - }) - const service = new RemoteService(makeConfig()) - const result = await service.grants.validate('req-1') + const result = await service.grants.getStatus('req-1') - expect(result).toBe(true) + expect(result.status).toBe('approved') + expect(result.jws).toBe('test.jws.token') expect(fetchMock).toHaveBeenCalledWith( - 'http://127.0.0.1:2274/api/grants/req-1/signed', + 'http://127.0.0.1:2274/api/grants/req-1', expect.objectContaining({ method: 'GET' }), ) }) - - it('validate() returns false when JWS verification fails', async () => { - const fetchMock = mockFetchResponse(200, 'bad.jws.token') - globalThis.fetch = fetchMock - - vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockRejectedValue( - new Error('Invalid grant signature'), - ) - - const service = new RemoteService(makeConfig()) - const result = await service.grants.validate('req-1') - - expect(result).toBe(false) - }) }) describe('inject (local)', () => { diff --git a/src/__tests__/request-command.test.ts b/src/__tests__/request-command.test.ts index 248b6b7..19ee772 100644 --- a/src/__tests__/request-command.test.ts +++ b/src/__tests__/request-command.test.ts @@ -7,13 +7,14 @@ import type { Service } from '../core/service.js' const mockLoadConfig = vi.fn<() => AppConfig>() const mockRequestsCreate = vi.fn() -const mockGrantsValidate = vi.fn() +const mockGrantsGetStatus = vi.fn() const mockInject = vi.fn() const mockHealth = vi.fn() const mockSecretsList = vi.fn() const mockSecretsAdd = vi.fn() const mockSecretsRemove = vi.fn() const mockSecretsGetMetadata = vi.fn() +const mockSecretsResolve = vi.fn() const mockService: Service = { health: mockHealth, @@ -22,12 +23,13 @@ const mockService: Service = { add: mockSecretsAdd, remove: mockSecretsRemove, getMetadata: mockSecretsGetMetadata, + resolve: mockSecretsResolve, }, requests: { create: mockRequestsCreate, }, grants: { - validate: mockGrantsValidate, + getStatus: mockGrantsGetStatus, }, inject: mockInject, } @@ -55,6 +57,7 @@ function createTestConfig(): AppConfig { requireApproval: {}, defaultRequireApproval: false, approvalTimeoutMs: 300_000, + unlock: { ttlMs: 900_000 }, } } @@ -98,7 +101,7 @@ describe('request command orchestration', () => { mockLoadConfig.mockReturnValue(createTestConfig()) mockResolveService.mockReturnValue(mockService) mockRequestsCreate.mockResolvedValue(createTestAccessRequest()) - mockGrantsValidate.mockResolvedValue(true) + mockGrantsGetStatus.mockResolvedValue({ status: 'approved' }) mockInject.mockResolvedValue({ exitCode: 0, stdout: 'output', stderr: '' }) }) @@ -107,7 +110,7 @@ describe('request command orchestration', () => { vi.clearAllMocks() }) - it('happy path: approved request -> validate grant -> inject -> returns child exit code', async () => { + it('happy path: approved request -> poll status -> inject -> returns child exit code', async () => { const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) await runRequest() @@ -120,7 +123,7 @@ describe('request command orchestration', () => { 300, 'echo hello', ) - expect(mockGrantsValidate).toHaveBeenCalledWith('test-request-id') + expect(mockGrantsGetStatus).toHaveBeenCalledWith('test-request-id') expect(mockInject).toHaveBeenCalledWith('test-request-id', 'echo hello', { envVarName: 'MY_SECRET', }) @@ -129,8 +132,8 @@ describe('request command orchestration', () => { stdoutSpy.mockRestore() }) - it('denied request (grant not valid): exits with code 1', async () => { - mockGrantsValidate.mockResolvedValue(false) + it('denied request (status denied): exits with code 1', async () => { + mockGrantsGetStatus.mockResolvedValue({ status: 'denied' }) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) await runRequest() @@ -268,6 +271,45 @@ describe('request command orchestration', () => { expect(process.exitCode).toBe(1) }) + it('times out while polling if deadline passes before approval', async () => { + const baseTime = 1_000_000 + const nowSpy = vi + .spyOn(Date, 'now') + .mockReturnValueOnce(baseTime) // used to compute deadline + .mockReturnValue(baseTime + 5 * 60 * 1000 + 1) // always past deadline + + mockGrantsGetStatus.mockResolvedValue({ status: 'pending' }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await runRequest() + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Timed out')) + expect(process.exitCode).toBe(1) + expect(mockInject).not.toHaveBeenCalled() + + nowSpy.mockRestore() + errorSpy.mockRestore() + }) + + it('polls again after delay when status is pending then becomes approved', async () => { + vi.useFakeTimers() + mockGrantsGetStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'approved' }) + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + const runPromise = runRequest() + await vi.runAllTimersAsync() + await runPromise + + expect(mockGrantsGetStatus).toHaveBeenCalledTimes(2) + expect(process.exitCode).toBe(0) + + stdoutSpy.mockRestore() + vi.useRealTimers() + }) + it('runs without --env flag (placeholder-only mode)', async () => { vi.spyOn(process.stdout, 'write').mockImplementation(() => true) await runRequest([ diff --git a/src/__tests__/request.test.ts b/src/__tests__/request.test.ts index f81f9f1..451ef93 100644 --- a/src/__tests__/request.test.ts +++ b/src/__tests__/request.test.ts @@ -1,5 +1,8 @@ /// +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' import { createAccessRequest, RequestLog, type AccessRequest } from '../core/request.js' describe('createAccessRequest', () => { @@ -268,3 +271,88 @@ describe('RequestLog', () => { expect(log.getAll()).toHaveLength(1) }) }) + +describe('RequestLog file persistence', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), '2kc-request-test-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('save() is a no-op when no filePath provided', () => { + const log = new RequestLog() + const req = createAccessRequest(['secret-1'], 'reason', 'TASK-1') + log.add(req) + expect(() => log.save()).not.toThrow() + }) + + it('save() writes requests to file', () => { + const filePath = join(tmpDir, 'requests.json') + const log = new RequestLog(filePath) + const req = createAccessRequest(['secret-1'], 'reason', 'TASK-1') + log.add(req) + log.save() + + expect(existsSync(filePath)).toBe(true) + const data = JSON.parse(readFileSync(filePath, 'utf-8')) as AccessRequest[] + expect(data).toHaveLength(1) + expect(data[0].id).toBe(req.id) + }) + + it('load() restores requests from file on construction', () => { + const filePath = join(tmpDir, 'requests.json') + + // First log: save some requests + const log1 = new RequestLog(filePath) + const req = createAccessRequest(['secret-1'], 'reason', 'TASK-1') + log1.add(req) + log1.save() + + // Second log: loads from same file + const log2 = new RequestLog(filePath) + expect(log2.size).toBe(1) + expect(log2.getById(req.id)?.id).toBe(req.id) + }) + + it('load() starts empty when file does not exist', () => { + const filePath = join(tmpDir, 'nonexistent.json') + const log = new RequestLog(filePath) + expect(log.size).toBe(0) + }) + + it('load() starts empty when file is corrupted', () => { + const filePath = join(tmpDir, 'corrupted.json') + writeFileSync(filePath, 'not valid json', 'utf-8') + const log = new RequestLog(filePath) + expect(log.size).toBe(0) + }) + + it('getById() returns live reference (mutations persist to save)', () => { + const filePath = join(tmpDir, 'requests.json') + const log = new RequestLog(filePath) + const req = createAccessRequest(['secret-1'], 'reason', 'TASK-1') + log.add(req) + + // Mutate via getById reference + const found = log.getById(req.id)! + found.status = 'approved' + log.save() + + // Reload and verify mutation was persisted + const log2 = new RequestLog(filePath) + expect(log2.getById(req.id)?.status).toBe('approved') + }) + + it('creates parent directories if they do not exist', () => { + const filePath = join(tmpDir, 'deep', 'nested', 'requests.json') + const log = new RequestLog(filePath) + const req = createAccessRequest(['secret-1'], 'reason', 'TASK-1') + log.add(req) + expect(() => log.save()).not.toThrow() + expect(existsSync(filePath)).toBe(true) + }) +}) diff --git a/src/__tests__/routes.test.ts b/src/__tests__/routes.test.ts index e8c07db..de69753 100644 --- a/src/__tests__/routes.test.ts +++ b/src/__tests__/routes.test.ts @@ -2,12 +2,26 @@ import { createServer } from '../server/app.js' import type { Service } from '../core/service.js' +import type { AccessGrant } from '../core/grant.js' const TEST_TOKEN = 'test-token' const authHeaders = { Authorization: `Bearer ${TEST_TOKEN}` } const TEST_UUID = '550e8400-e29b-41d4-a716-446655440000' const MISSING_UUID = '550e8400-e29b-41d4-a716-446655440001' +function makeGrantMock(overrides?: Partial): AccessGrant { + return { + id: 'grant-id', + requestId: 'req-123', + secretUuids: ['secret-uuid'], + grantedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + used: false, + revokedAt: null, + ...overrides, + } +} + function makeMockService(): Service { return { health: vi.fn().mockResolvedValue({ status: 'unlocked' }), @@ -30,7 +44,11 @@ function makeMockService(): Service { }), }, grants: { - validate: vi.fn().mockResolvedValue(true), + getStatus: vi.fn().mockResolvedValue({ + status: 'approved', + grant: makeGrantMock(), + jws: 'test.jws.token', + }), }, inject: vi.fn().mockResolvedValue({ exitCode: 0, stdout: 'output', stderr: '' }), } as unknown as Service @@ -279,7 +297,7 @@ describe('API Routes', () => { }) describe('GET /api/grants/:requestId', () => { - it('returns 200 with boolean result', async () => { + it('returns 200 with grant status', async () => { const service = makeMockService() const server = createServer(service, TEST_TOKEN) const response = await server.inject({ @@ -289,8 +307,27 @@ describe('API Routes', () => { }) expect(response.statusCode).toBe(200) - expect(JSON.parse(response.body)).toBe(true) - expect(service.grants.validate).toHaveBeenCalledWith('req-123') + const body = JSON.parse(response.body) + expect(body.status).toBe('approved') + expect(body.jws).toBe('test.jws.token') + expect(service.grants.getStatus).toHaveBeenCalledWith('req-123') + + await server.close() + }) + + it('returns 404 when request not found', async () => { + const service = makeMockService() + ;(service.grants.getStatus as ReturnType).mockRejectedValue( + new Error('Request not found: unknown'), + ) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: '/api/grants/unknown', + headers: authHeaders, + }) + + expect(response.statusCode).toBe(404) await server.close() }) diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index 79f9583..b836153 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -14,7 +14,7 @@ const mockService = { resolve: vi.fn(), }, requests: { create: vi.fn() }, - grants: { validate: vi.fn() }, + grants: { getStatus: vi.fn() }, inject: vi.fn(), } as unknown as Service @@ -114,6 +114,10 @@ describe('HTTP Server', () => { }) describe('startServer', () => { + afterEach(() => { + vi.clearAllMocks() + }) + it('registers signal handlers and removes them on close', async () => { const originalListeners = { SIGINT: process.listenerCount('SIGINT'), diff --git a/src/__tests__/service.test.ts b/src/__tests__/service.test.ts index 08bc982..62b2833 100644 --- a/src/__tests__/service.test.ts +++ b/src/__tests__/service.test.ts @@ -100,7 +100,7 @@ function makeService() { const grantManager = { getGrantByRequestId: vi.fn().mockReturnValue(makeGrantMock()), validateGrant: vi.fn().mockReturnValue(true), - createGrant: vi.fn().mockResolvedValue({ grant: makeGrantMock(), jws: null }), + createGrant: vi.fn().mockReturnValue({ grant: makeGrantMock(), jws: undefined }), } as unknown as GrantManager const workflowEngine = { @@ -113,6 +113,8 @@ function makeService() { const requestLog = { add: vi.fn(), + save: vi.fn(), + getById: vi.fn().mockReturnValue(undefined), } as unknown as RequestLog const startTime = Date.now() - 1000 @@ -219,16 +221,24 @@ describe('LocalService', () => { }) describe('requests.create()', () => { - it('creates request, calls workflow, creates grant when approved', async () => { - const { service, workflowEngine, grantManager, requestLog } = makeService() + it('returns request with pending status immediately', async () => { + const { service, requestLog } = makeService() + const result = await service.requests.create(['u1'], 'need access', 'task-1', 300) + expect(result.status).toBe('pending') + expect(requestLog.add).toHaveBeenCalledWith(result) + expect(requestLog.save).toHaveBeenCalled() + }) + + it('runs workflow in background and creates grant when approved', async () => { + const { service, workflowEngine, grantManager } = 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) + await service.requests.create(['u1'], 'need access', 'task-1', 300) + await vi.waitFor(() => { + expect(grantManager.createGrant).toHaveBeenCalled() + }) }) it('computes commandHash when bindCommand is true and command is provided', async () => { @@ -267,34 +277,63 @@ describe('LocalService', () => { expect(result.command).toBe('echo hello') }) - it('returns request with denied status when workflow denies', async () => { - const { service, workflowEngine, grantManager, requestLog } = makeService() + it('does not create grant when workflow denies', async () => { + const { service, workflowEngine, grantManager } = makeService() ;(workflowEngine.processRequest as MockInstance).mockImplementation(async (req) => { req.status = 'denied' return 'denied' }) + await service.requests.create(['u1'], 'need access', 'task-1', 300) + await vi.waitFor(() => { + // Wait for background task to complete + expect(workflowEngine.processRequest).toHaveBeenCalled() + }) + expect(grantManager.createGrant).not.toHaveBeenCalled() + }) + + it('sets status to denied on unexpected workflow error', async () => { + const { service, workflowEngine, requestLog } = makeService() + ;(workflowEngine.processRequest as MockInstance).mockRejectedValue(new Error('network error')) const result = await service.requests.create(['u1'], 'need access', 'task-1', 300) + await vi.waitFor(() => { + expect(requestLog.save).toHaveBeenCalledTimes(2) // initial + error handler + }) expect(result.status).toBe('denied') - expect(requestLog.add).toHaveBeenCalledWith(result) - expect(grantManager.createGrant).not.toHaveBeenCalled() }) }) - 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) + describe('grants.getStatus()', () => { + it('returns approved status with grant and jws when grant exists', async () => { + const { service, grantManager, requestLog } = makeService() + const pendingReq = { status: 'approved' as const, id: 'request-id' } + ;(requestLog.getById as MockInstance).mockReturnValue(pendingReq) + const grantWithJws = { ...makeGrantMock(), jws: 'test.jws.token' } + ;(grantManager.getGrantByRequestId as MockInstance).mockReturnValue(grantWithJws) + + const result = await service.grants.getStatus('request-id') + expect(result.status).toBe('approved') + expect(result.grant).toBeDefined() + expect(result.jws).toBe('test.jws.token') }) - it('returns false when no grant found for requestId', async () => { - const { service, grantManager } = makeService() + it('returns pending status when request exists but no grant yet', async () => { + const { service, grantManager, requestLog } = makeService() + ;(requestLog.getById as MockInstance).mockReturnValue({ status: 'pending', id: 'request-id' }) ;(grantManager.getGrantByRequestId as MockInstance).mockReturnValue(undefined) - const result = await service.grants.validate('unknown-request') - expect(result).toBe(false) - expect(grantManager.validateGrant).not.toHaveBeenCalled() + + const result = await service.grants.getStatus('request-id') + expect(result.status).toBe('pending') + expect(result.grant).toBeUndefined() + expect(result.jws).toBeUndefined() + }) + + it('throws when requestId not found', async () => { + const { service, requestLog } = makeService() + ;(requestLog.getById as MockInstance).mockReturnValue(undefined) + + await expect(service.grants.getStatus('unknown')).rejects.toThrow( + 'Request not found: unknown', + ) }) }) diff --git a/src/__tests__/signed-grant.test.ts b/src/__tests__/signed-grant.test.ts index 8dc7e64..2f478b1 100644 --- a/src/__tests__/signed-grant.test.ts +++ b/src/__tests__/signed-grant.test.ts @@ -195,16 +195,18 @@ describe('loadOrGenerateKeyPair', () => { expect(payload.secretUuids).toEqual(grant.secretUuids) }) - it('throws with clear error when key file contains invalid JSON', async () => { + it('regenerates keys when key file contains invalid JSON', async () => { const keyPath = tempKeyPath() // Write invalid JSON to the key file mkdirSync(dirname(keyPath), { recursive: true }) writeFileSync(keyPath, 'not valid json', 'utf-8') - await expect(loadOrGenerateKeyPair(keyPath)).rejects.toThrow() + const keys = await loadOrGenerateKeyPair(keyPath) + expect(keys.publicKey).toBeDefined() + expect(keys.privateKey).toBeDefined() }) - it('throws when key file contains valid JSON but wrong key format', async () => { + it('regenerates keys when key file contains valid JSON but wrong key format', async () => { const keyPath = tempKeyPath() mkdirSync(dirname(keyPath), { recursive: true }) writeFileSync( @@ -216,9 +218,9 @@ describe('loadOrGenerateKeyPair', () => { 'utf-8', ) - await expect(loadOrGenerateKeyPair(keyPath)).rejects.toThrow( - 'Key file contains invalid key format', - ) + const keys = await loadOrGenerateKeyPair(keyPath) + expect(keys.publicKey).toBeDefined() + expect(keys.privateKey).toBeDefined() }) }) diff --git a/src/cli/request.ts b/src/cli/request.ts index ba86f54..8cceedd 100644 --- a/src/cli/request.ts +++ b/src/cli/request.ts @@ -44,9 +44,22 @@ const request = new Command('request') opts.cmd, ) - // 4. Validate grant - const isValid = await service.grants.validate(accessRequest.id) - if (!isValid) { + // 4. Poll for grant status + const pollIntervalMs = 250 + const maxWaitMs = 5 * 60 * 1000 // 5 minutes + const deadline = Date.now() + maxWaitMs + let grantResult!: Awaited> + while (true) { + grantResult = await service.grants.getStatus(accessRequest.id) + if (grantResult.status !== 'pending') break + if (Date.now() > deadline) { + console.error(`Timed out waiting for approval: ${uuids.join(', ')}`) + process.exitCode = 1 + return + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } + if (grantResult.status !== 'approved') { console.error(`Access request denied: ${uuids.join(', ')}`) process.exitCode = 1 return diff --git a/src/core/grant.ts b/src/core/grant.ts index 1e741e1..cdc829f 100644 --- a/src/core/grant.ts +++ b/src/core/grant.ts @@ -1,9 +1,9 @@ -import { randomUUID } from 'node:crypto' +import { randomUUID, sign } from 'node:crypto' +import type { KeyObject } 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' -import { signGrant } from './signed-grant.js' export interface AccessGrant { id: string @@ -14,6 +14,7 @@ export interface AccessGrant { used: boolean revokedAt: string | null commandHash?: string + jws?: string } const DEFAULT_GRANTS_PATH = join(homedir(), '.2kc', 'grants.json') @@ -21,16 +22,15 @@ const DEFAULT_GRANTS_PATH = join(homedir(), '.2kc', 'grants.json') export class GrantManager { private grants: Map = new Map() private readonly grantsFilePath: string + private readonly privateKey: KeyObject | undefined - constructor( - grantsFilePath: string = DEFAULT_GRANTS_PATH, - private readonly signingKey: CryptoKey | null = null, - ) { + constructor(grantsFilePath: string = DEFAULT_GRANTS_PATH, privateKey?: KeyObject) { this.grantsFilePath = grantsFilePath + this.privateKey = privateKey this.load() } - async createGrant(request: AccessRequest): Promise<{ grant: AccessGrant; jws: string | null }> { + createGrant(request: AccessRequest): { grant: AccessGrant; jws: string | undefined } { if (request.status !== 'approved') { throw new Error(`Cannot create grant for request with status: ${request.status}`) } @@ -45,11 +45,14 @@ export class GrantManager { revokedAt: null, commandHash: request.commandHash, } - this.grants.set(grant.id, grant) - let jws: string | null = null - if (this.signingKey) { - jws = await signGrant(grant, this.signingKey) + + let jws: string | undefined + if (this.privateKey) { + jws = signGrant(grant, this.privateKey) + grant.jws = jws } + + this.grants.set(grant.id, grant) this.save() return { grant, jws } } @@ -133,3 +136,14 @@ export class GrantManager { chmodSync(this.grantsFilePath, 0o600) } } + +function signGrant(grant: AccessGrant, privateKey: KeyObject): string { + const header = Buffer.from(JSON.stringify({ alg: 'EdDSA' })).toString('base64url') + // Omit the jws field so the signature doesn't cover itself + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { jws: _, ...grantWithoutJws } = grant + const payload = Buffer.from(JSON.stringify(grantWithoutJws)).toString('base64url') + const signingInput = `${header}.${payload}` + const sigBytes = sign(null, Buffer.from(signingInput), privateKey) + return `${signingInput}.${sigBytes.toString('base64url')}` +} diff --git a/src/core/key-manager.ts b/src/core/key-manager.ts index b6f6543..e16734c 100644 --- a/src/core/key-manager.ts +++ b/src/core/key-manager.ts @@ -1,50 +1,71 @@ -import { generateKeyPair, exportJWK, importJWK } from 'jose' +import { generateKeyPairSync, createPublicKey, createPrivateKey } from 'node:crypto' +import type { KeyObject } from 'node:crypto' import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' import { dirname } from 'node:path' -interface KeysFile { - publicKey: JsonWebKey - privateKey: JsonWebKey +interface KeysFileDer { + publicKey: { type: string; data: string } + privateKey: { type: string; data: string } } export interface ServerKeys { - publicKey: CryptoKey - privateKey: CryptoKey + publicKey: KeyObject + privateKey: KeyObject } -function isEdDSAJwk(key: unknown): key is JsonWebKey { - return ( - key !== null && - typeof key === 'object' && - (key as Record).kty === 'OKP' && - (key as Record).crv === 'Ed25519' - ) +function isValidDerKeysFile(parsed: unknown): parsed is KeysFileDer { + if (typeof parsed !== 'object' || parsed === null) return false + const obj = parsed as Record + if (typeof obj.publicKey !== 'object' || obj.publicKey === null) return false + if (typeof obj.privateKey !== 'object' || obj.privateKey === null) return false + const pub = obj.publicKey as Record + const priv = obj.privateKey as Record + return typeof pub.data === 'string' && typeof priv.data === 'string' } export async function loadOrGenerateKeyPair(keyFilePath: string): Promise { try { const raw = readFileSync(keyFilePath, 'utf-8') - const keysFile = JSON.parse(raw) as KeysFile - if (!isEdDSAJwk(keysFile.publicKey) || !isEdDSAJwk(keysFile.privateKey)) { - throw new Error('Key file contains invalid key format: expected Ed25519 OKP keys') + const parsed = JSON.parse(raw) as unknown + if (isValidDerKeysFile(parsed)) { + const publicKey = createPublicKey({ + key: Buffer.from(parsed.publicKey.data, 'base64'), + format: 'der', + type: 'spki', + }) + const privateKey = createPrivateKey({ + key: Buffer.from(parsed.privateKey.data, 'base64'), + format: 'der', + type: 'pkcs8', + }) + return { publicKey, privateKey } } - const publicKey = await importJWK(keysFile.publicKey, 'EdDSA') - const privateKey = await importJWK(keysFile.privateKey, 'EdDSA') - return { publicKey: publicKey as CryptoKey, privateKey: privateKey as CryptoKey } + // Key file exists but is in an unrecognized format — regenerate } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - throw err + // File exists but is corrupted (parse error, etc.) — regenerate + if (!(err instanceof SyntaxError)) { + throw err + } } } - const { publicKey, privateKey } = await generateKeyPair('EdDSA', { extractable: true }) - const publicJwk = await exportJWK(publicKey) - const privateJwk = await exportJWK(privateKey) + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + + const publicDer = publicKey.export({ format: 'der', type: 'spki' }) + const privateDer = privateKey.export({ format: 'der', type: 'pkcs8' }) mkdirSync(dirname(keyFilePath), { recursive: true }) writeFileSync( keyFilePath, - JSON.stringify({ publicKey: publicJwk, privateKey: privateJwk }, null, 2), + JSON.stringify( + { + publicKey: { type: 'spki', data: publicDer.toString('base64') }, + privateKey: { type: 'pkcs8', data: privateDer.toString('base64') }, + }, + null, + 2, + ), { encoding: 'utf-8', mode: 0o600 }, ) diff --git a/src/core/remote-service.ts b/src/core/remote-service.ts index 582699c..c7442aa 100644 --- a/src/core/remote-service.ts +++ b/src/core/remote-service.ts @@ -2,7 +2,8 @@ import { createHash } from 'node:crypto' import type { ServerConfig } from './config.js' import type { Service, SecretSummary } from './service.js' import type { SecretMetadata, ProcessResult } from './types.js' -import type { AccessRequest } from './request.js' +import type { AccessRequest, AccessRequestStatus } from './request.js' +import type { AccessGrant } from './grant.js' import type { UnlockSession } from './unlock-session.js' import type { SecretInjector } from './injector.js' import { GrantVerifier } from './grant-verifier.js' @@ -163,18 +164,11 @@ export class RemoteService implements Service { } grants: Service['grants'] = { - validate: async (requestId: string) => { - const jwsToken = await this.request( + getStatus: (requestId: string) => + this.request<{ status: AccessRequestStatus; grant?: AccessGrant; jws?: string }>( 'GET', - `/api/grants/${encodeURIComponent(requestId)}/signed`, - ) - try { - await this.grantVerifier.verifyGrant(jwsToken) - return true - } catch { - return false - } - }, + `/api/grants/${encodeURIComponent(requestId)}`, + ), } async inject( diff --git a/src/core/request.ts b/src/core/request.ts index e3bf152..a832dc4 100644 --- a/src/core/request.ts +++ b/src/core/request.ts @@ -1,6 +1,14 @@ import { randomUUID } from 'node:crypto' +import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs' +import { dirname } from 'node:path' -export type AccessRequestStatus = 'pending' | 'approved' | 'denied' | 'expired' | 'timeout' +export type AccessRequestStatus = + | 'pending' + | 'approved' + | 'denied' + | 'expired' + | 'timeout' + | 'error' export interface AccessRequest { id: string @@ -71,6 +79,12 @@ export function createAccessRequest( export class RequestLog { private requests: AccessRequest[] = [] + private readonly filePath: string | null + + constructor(filePath?: string) { + this.filePath = filePath ?? null + if (this.filePath) this.load() + } add(request: AccessRequest): void { this.requests.push(request) @@ -91,4 +105,20 @@ export class RequestLog { get size(): number { return this.requests.length } + + save(): void { + if (!this.filePath) return + mkdirSync(dirname(this.filePath), { recursive: true }) + writeFileSync(this.filePath, JSON.stringify(this.requests, null, 2), 'utf-8') + chmodSync(this.filePath, 0o600) + } + + private load(): void { + try { + const data = JSON.parse(readFileSync(this.filePath!, 'utf-8')) as AccessRequest[] + this.requests = data + } catch { + // File absent or corrupted — start empty + } + } } diff --git a/src/core/service.ts b/src/core/service.ts index 9b5b9c4..5f18a1f 100644 --- a/src/core/service.ts +++ b/src/core/service.ts @@ -1,8 +1,9 @@ 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 type { AccessRequest, AccessRequestStatus } from './request.js' import { createAccessRequest, RequestLog } from './request.js' +import type { AccessGrant } from './grant.js' import type { NotificationChannel } from '../channels/channel.js' import { RemoteService } from './remote-service.js' import { EncryptedSecretStore } from './encrypted-store.js' @@ -39,7 +40,9 @@ export interface Service { } grants: { - validate(requestId: string): Promise + getStatus( + requestId: string, + ): Promise<{ status: AccessRequestStatus; grant?: AccessGrant; jws?: string }> } inject( @@ -129,22 +132,43 @@ export class LocalService implements Service { commandHash, ) this.deps.requestLog.add(request) - const outcome = await this.deps.workflowEngine.processRequest(request) - if (outcome === 'approved') { - await this.deps.grantManager.createGrant(request) - } - return request + this.deps.requestLog.save() + // Fire-and-forget: kick off workflow in background + this.runWorkflow(request).catch((err: unknown) => { + console.error('runWorkflow failed unexpectedly:', err) + request.status = 'denied' + this.deps.requestLog.save() + }) + return request // always returns with status: 'pending' }, } grants: Service['grants'] = { - validate: async (requestId) => { + getStatus: async (requestId) => { + const request = this.deps.requestLog.getById(requestId) + if (!request) throw new Error(`Request not found: ${requestId}`) const grant = this.deps.grantManager.getGrantByRequestId(requestId) - if (!grant) return false - return this.deps.grantManager.validateGrant(grant.id) + return { + status: request.status, + grant: grant, + jws: grant?.jws, + } }, } + private async runWorkflow(request: AccessRequest): Promise { + const outcome = await this.deps.workflowEngine.processRequest(request) + if (outcome === 'approved') { + try { + this.deps.grantManager.createGrant(request) + } catch (err: unknown) { + console.error('createGrant failed after workflow approval:', err) + request.status = 'error' + } + } + this.deps.requestLog.save() + } + async inject( requestId: string, command: string, @@ -176,7 +200,8 @@ export async function resolveService(config: AppConfig): Promise { return new RemoteService(config.server, { unlockSession, injector }) } - const grantsPath = join(dirname(config.store.path), 'grants.json') + const grantsPath = join(dirname(config.store.path), 'server-grants.json') + const requestsPath = join(dirname(config.store.path), 'server-requests.json') const keysPath = join(dirname(config.store.path), 'server-keys.json') const { privateKey } = await loadOrGenerateKeyPair(keysPath) @@ -203,7 +228,7 @@ export async function resolveService(config: AppConfig): Promise { const workflowEngine = new WorkflowEngine({ store, channel, config }) const injector = new SecretInjector(grantManager, store) - const requestLog = new RequestLog() + const requestLog = new RequestLog(requestsPath) const startTime = Date.now() return new LocalService({ diff --git a/src/server/routes.ts b/src/server/routes.ts index afa453a..f9c23ec 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -147,7 +147,7 @@ export const routePlugin = fp( }, ) - // GET /api/grants/:requestId — validate grant status + // GET /api/grants/:requestId — get grant status fastify.get<{ Params: { requestId: string } }>( '/api/grants/:requestId', { @@ -161,7 +161,7 @@ export const routePlugin = fp( }, }, }, - async (request) => service.grants.validate(request.params.requestId).catch(handleError), + async (request) => service.grants.getStatus(request.params.requestId).catch(handleError), ) // POST /api/inject — resolve secrets for injection From f6531f04d38720a7a7c394b4a97ca2653cf0dc6c Mon Sep 17 00:00:00 2001 From: Helix Date: Thu, 26 Feb 2026 00:32:05 -0800 Subject: [PATCH 07/25] fixes #65 --- .../integration/client-server-flow.test.ts | 486 ++++++++++++++++++ src/server/app.ts | 8 +- 2 files changed, 492 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/integration/client-server-flow.test.ts diff --git a/src/__tests__/integration/client-server-flow.test.ts b/src/__tests__/integration/client-server-flow.test.ts new file mode 100644 index 0000000..a466d98 --- /dev/null +++ b/src/__tests__/integration/client-server-flow.test.ts @@ -0,0 +1,486 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { verify, type KeyObject } from 'node:crypto' + +import type { FastifyInstance } from 'fastify' + +import { createServer } from '../../server/app.js' +import { LocalService } from '../../core/service.js' +import { EncryptedSecretStore } from '../../core/encrypted-store.js' +import { UnlockSession } from '../../core/unlock-session.js' +import { GrantManager } from '../../core/grant.js' +import { WorkflowEngine } from '../../core/workflow.js' +import { SecretInjector } from '../../core/injector.js' +import { RequestLog } from '../../core/request.js' +import { loadOrGenerateKeyPair } from '../../core/key-manager.js' +import type { NotificationChannel } from '../../channels/channel.js' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// Low-cost scrypt params for fast test initialisation +const TEST_PARAMS = { N: 1024, r: 8, p: 1 } +const PASSWORD = 'test-pw' +const AUTH_TOKEN = 'test-static-token' +/** Short TTL used in the session-expiry test — server sessions expire within ms */ +const SHORT_TTL = 1500 + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a mock NotificationChannel that resolves every approval request with + * the given response. */ +function createMockChannel(response: 'approved' | 'denied' | 'timeout'): NotificationChannel { + return { + sendApprovalRequest: vi.fn().mockResolvedValue('msg-id'), + waitForResponse: vi.fn().mockResolvedValue(response), + sendNotification: vi.fn().mockResolvedValue(undefined), + } +} + +/** POST /api/auth/login with the static auth token; returns the session payload. */ +async function login( + server: FastifyInstance, + token: string, +): Promise<{ sessionToken: string; expiresAt: string }> { + const res = await server.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { token }, + }) + expect(res.statusCode).toBe(200) + const body = res.json() as { sessionToken: string; expiresAt: string } + expect(body).toHaveProperty('sessionToken') + return body +} + +/** Poll GET /api/grants/:requestId until the status matches expectedStatus (or + * the 5 s timeout fires), then return the full grant payload. */ +async function waitForGrant( + server: FastifyInstance, + requestId: string, + headers: Record, + expectedStatus: string, +): Promise> { + let body: Record = {} + await vi.waitFor( + async () => { + const res = await server.inject({ + method: 'GET', + url: `/api/grants/${requestId}`, + headers, + }) + body = res.json() as Record + expect(body.status).toBe(expectedStatus) + }, + { timeout: 5_000, interval: 50 }, + ) + return body +} + +interface BuildServiceOpts { + bindCommand?: boolean + channelResponse?: 'approved' | 'denied' | 'timeout' + /** Skip store.initialize() — use when the store file already exists (e.g. restart test). */ + loadExisting?: boolean +} + +interface ServiceBundle { + service: LocalService + publicKey: KeyObject +} + +/** Assemble a fully-wired LocalService backed by a real EncryptedSecretStore + * and GrantManager, but with a mock notification channel. */ +async function buildService(tmpDir: string, opts: BuildServiceOpts = {}): Promise { + const storePath = join(tmpDir, 'secrets.enc.json') + const grantsPath = join(tmpDir, 'server-grants.json') + const requestsPath = join(tmpDir, 'server-requests.json') + const keysPath = join(tmpDir, 'server-keys.json') + + const store = new EncryptedSecretStore(storePath) + if (!opts.loadExisting) { + await store.initialize(PASSWORD, TEST_PARAMS) + } + + const { privateKey, publicKey } = await loadOrGenerateKeyPair(keysPath) + const unlockSession = new UnlockSession({ ttlMs: 3_600_000 }) + const grantManager = new GrantManager(grantsPath, privateKey) + const mockChannel = createMockChannel(opts.channelResponse ?? 'approved') + const workflowEngine = new WorkflowEngine({ + store, + channel: mockChannel, + config: { requireApproval: {}, defaultRequireApproval: true, approvalTimeoutMs: 5_000 }, + }) + const injector = new SecretInjector(grantManager, store) + const requestLog = new RequestLog(requestsPath) + + const service = new LocalService({ + store, + unlockSession, + grantManager, + workflowEngine, + injector, + requestLog, + startTime: Date.now(), + bindCommand: opts.bindCommand ?? false, + }) + + return { service, publicKey } +} + +/** Create and ready a Fastify server wrapping the given service. */ +async function buildServer( + service: LocalService, + opts: { sessionTtlMs?: number } = {}, +): Promise { + const server = createServer(service, AUTH_TOKEN, { + sessionTtlMs: opts.sessionTtlMs, + }) + await server.ready() + return server +} + +/** Verify a JWS compact string against a public KeyObject. + * Returns true when the signature is valid, false when tampered. */ +function isJwsValid(jws: string, publicKey: KeyObject): boolean { + const parts = jws.split('.') + if (parts.length !== 3) return false + const signingInput = `${parts[0]}.${parts[1]}` + const signature = Buffer.from(parts[2], 'base64url') + return verify(null, Buffer.from(signingInput), publicKey, signature) +} + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- + +let tmpDir: string +let service: LocalService | null +let publicKey: KeyObject +let server: FastifyInstance | null +let secretUuid: string +let sessionToken: string + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('Phase 2 Client-Server Flow', () => { + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), '2kc-cs-')) + ;({ service, publicKey } = await buildService(tmpDir)) + server = await buildServer(service) + await service.unlock(PASSWORD) + secretUuid = (await service.secrets.add('test-key', 'secret-value', [])).uuid + ;({ sessionToken } = await login(server, AUTH_TOKEN)) + }) + + afterEach(async () => { + service?.destroy() + await server?.close() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + describe('happy path', () => { + it('login → request → approve → inject → verify env + redaction', async () => { + const headers = { authorization: `Bearer ${sessionToken}` } + + // Create an access request via HTTP + const reqRes = await server.inject({ + method: 'POST', + url: '/api/requests', + headers, + payload: { + secretUuids: [secretUuid], + reason: 'test reason', + taskRef: 'TASK-1', + }, + }) + expect(reqRes.statusCode).toBe(201) + const { id: requestId } = reqRes.json() as { id: string } + + // Poll until the mock channel approves the request + const grantBody = await waitForGrant(server, requestId, headers, 'approved') + expect(typeof grantBody.jws).toBe('string') + expect((grantBody.jws as string).length).toBeGreaterThan(0) + + // Inject: secret injected as env var, stdout must be redacted + const injectRes = await server.inject({ + method: 'POST', + url: '/api/inject', + headers, + payload: { + requestId, + command: 'echo $TEST_SECRET', + envVarName: 'TEST_SECRET', + }, + }) + expect(injectRes.statusCode).toBe(200) + const result = injectRes.json() as { stdout: string; exitCode: number | null } + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('[REDACTED]') + expect(result.stdout).not.toContain('secret-value') + }) + }) + + describe('auth failure', () => { + it('missing Authorization header → 401', async () => { + const res = await server.inject({ + method: 'GET', + url: '/api/secrets', + }) + expect(res.statusCode).toBe(401) + }) + + it('wrong static token → 401', async () => { + const res = await server.inject({ + method: 'GET', + url: '/api/secrets', + headers: { authorization: 'Bearer wrong-token' }, + }) + expect(res.statusCode).toBe(401) + }) + }) + + describe('session expiry', () => { + it('expired session token → 401 → re-login → success', async () => { + // sessionTtlMs: 1500 → exp = now + Math.floor(1500/1000) = now + 1. + // jose's jwtVerify rejects when clockTimestamp >= exp, so the token + // is expired once the clock reaches second (now + 1). + const shortServer = await buildServer(service, { sessionTtlMs: SHORT_TTL }) + try { + const { sessionToken: expiredToken } = await login(shortServer, AUTH_TOKEN) + + // Wait SHORT_TTL + 100 ms — at this point Math.floor(Date.now()/1000) = now + 1 = exp, + // triggering the >= boundary and invalidating the JWT. + await new Promise((resolve) => setTimeout(resolve, SHORT_TTL + 100)) + + // Expired session token must be rejected + const expiredRes = await shortServer.inject({ + method: 'GET', + url: '/api/secrets', + headers: { authorization: `Bearer ${expiredToken}` }, + }) + expect(expiredRes.statusCode).toBe(401) + + // Re-login with the static token: fresh JWT has exp = now_relogin + 1 ≥ now + 2, + // which is in the future relative to the current clock second (now + 1). + const { sessionToken: freshToken } = await login(shortServer, AUTH_TOKEN) + + // Fresh token must be accepted immediately (clock is still at now + 1 < exp) + const freshRes = await shortServer.inject({ + method: 'GET', + url: '/api/secrets', + headers: { authorization: `Bearer ${freshToken}` }, + }) + expect(freshRes.statusCode).toBe(200) + } finally { + await shortServer.close() + } + }) + }) + + describe('grant verification failure', () => { + it('tampered JWS signature is detected as invalid', async () => { + const headers = { authorization: `Bearer ${sessionToken}` } + + // Complete approval flow to obtain a valid JWS + const reqRes = await server.inject({ + method: 'POST', + url: '/api/requests', + headers, + payload: { secretUuids: [secretUuid], reason: 'tamper test', taskRef: 'TASK-T' }, + }) + expect(reqRes.statusCode).toBe(201) + const { id: requestId } = reqRes.json() as { id: string } + + const grantBody = await waitForGrant(server, requestId, headers, 'approved') + const validJws = grantBody.jws as string + expect(typeof validJws).toBe('string') + expect(validJws.length).toBeGreaterThan(0) + + // Verify the untampered JWS passes signature check + expect(isJwsValid(validJws, publicKey)).toBe(true) + + // Tamper the signature segment (last 4 chars replaced with 'XXXX') + const parts = validJws.split('.') + const tamperedJws = `${parts[0]}.${parts[1]}.${parts[2].slice(0, -4)}XXXX` + + // Tampered JWS must fail signature verification + expect(isJwsValid(tamperedJws, publicKey)).toBe(false) + }) + }) + + describe('command binding', () => { + it('inject with wrong command is rejected when grant has commandHash', async () => { + // Build a separate service with bindCommand: true, using a fresh tmpDir + const bindTmpDir = mkdtempSync(join(tmpdir(), '2kc-bind-')) + try { + const { service: bindService } = await buildService(bindTmpDir, { bindCommand: true }) + const bindServer = await buildServer(bindService) + + await bindService.unlock(PASSWORD) + const bindSecretUuid = (await bindService.secrets.add('bind-key', 'bind-value', [])).uuid + const { sessionToken: bindToken } = await login(bindServer, AUTH_TOKEN) + const bindHeaders = { authorization: `Bearer ${bindToken}` } + + try { + // Create request directly on service with a specific command → produces commandHash + const request = await bindService.requests.create( + [bindSecretUuid], + 'bind test', + 'TASK-B', + undefined, + 'echo hello', + ) + + // Wait for approval + await waitForGrant(bindServer, request.id, bindHeaders, 'approved') + + // Inject with wrong command → should fail (500) + const wrongRes = await bindServer.inject({ + method: 'POST', + url: '/api/inject', + headers: bindHeaders, + payload: { + requestId: request.id, + command: 'echo wrong', + envVarName: 'BIND_SECRET', + }, + }) + expect(wrongRes.statusCode).toBe(500) + + // Inject with correct command → should succeed (200) + const correctRes = await bindServer.inject({ + method: 'POST', + url: '/api/inject', + headers: bindHeaders, + payload: { + requestId: request.id, + command: 'echo hello', + envVarName: 'BIND_SECRET', + }, + }) + expect(correctRes.statusCode).toBe(200) + const result = correctRes.json() as { exitCode: number | null } + expect(result.exitCode).toBe(0) + } finally { + bindService.destroy() + await bindServer.close() + } + } finally { + rmSync(bindTmpDir, { recursive: true, force: true }) + } + }) + }) + + describe('approval denied', () => { + it('denied request → GET /api/grants returns denied status, no grant', async () => { + // Build service with a channel that denies every request + const denyTmpDir = mkdtempSync(join(tmpdir(), '2kc-deny-')) + try { + const { service: denyService } = await buildService(denyTmpDir, { + channelResponse: 'denied', + }) + const denyServer = await buildServer(denyService) + + await denyService.unlock(PASSWORD) + const denySecretUuid = (await denyService.secrets.add('deny-key', 'deny-value', [])).uuid + const { sessionToken: denyToken } = await login(denyServer, AUTH_TOKEN) + const denyHeaders = { authorization: `Bearer ${denyToken}` } + + try { + // Submit access request + const reqRes = await denyServer.inject({ + method: 'POST', + url: '/api/requests', + headers: denyHeaders, + payload: { + secretUuids: [denySecretUuid], + reason: 'deny test', + taskRef: 'TASK-D', + }, + }) + expect(reqRes.statusCode).toBe(201) + const { id: requestId } = reqRes.json() as { id: string } + + // Poll until the mock channel denies the request + const grantBody = await waitForGrant(denyServer, requestId, denyHeaders, 'denied') + + // Denied response must have no JWS / grant data + expect(grantBody.jws).toBeUndefined() + expect(grantBody.grant).toBeUndefined() + } finally { + denyService.destroy() + await denyServer.close() + } + } finally { + rmSync(denyTmpDir, { recursive: true, force: true }) + } + }) + }) + + describe('server restart', () => { + it('grant persists across server restart using same tmpDir', async () => { + const headers = { authorization: `Bearer ${sessionToken}` } + + // Complete approval flow on the original server + const reqRes = await server.inject({ + method: 'POST', + url: '/api/requests', + headers, + payload: { + secretUuids: [secretUuid], + reason: 'restart test', + taskRef: 'TASK-R', + }, + }) + expect(reqRes.statusCode).toBe(201) + const { id: requestId } = reqRes.json() as { id: string } + + const originalGrantBody = await waitForGrant(server, requestId, headers, 'approved') + const originalJws = originalGrantBody.jws as string + expect(typeof originalJws).toBe('string') + expect(originalJws.length).toBeGreaterThan(0) + + // Tear down original server/service (simulates restart) + service.destroy() + await server.close() + service = null + server = null + + // Re-create service and server pointing at the same tmpDir (persisted files) + const { service: service2, publicKey: publicKey2 } = await buildService(tmpDir, { + loadExisting: true, + }) + const server2 = await buildServer(service2) + + try { + await service2.unlock(PASSWORD) + // New server instance has new sessionSecret → must re-login + const { sessionToken: token2 } = await login(server2, AUTH_TOKEN) + const headers2 = { authorization: `Bearer ${token2}` } + + // Grant must still be present and approved after restart + const restartedBody = await waitForGrant(server2, requestId, headers2, 'approved') + expect(restartedBody.jws).toBe(originalJws) + + // Signing key was persisted → public key must be the same + expect(publicKey2.export({ type: 'spki', format: 'pem' })).toBe( + publicKey.export({ type: 'spki', format: 'pem' }), + ) + } finally { + service2.destroy() + await server2.close() + // afterEach still calls service.destroy() + server.close() on the originals, + // but both are already closed — guard by reassigning to no-ops is unnecessary + // because service.destroy() is idempotent and server.close() on a closed + // instance resolves immediately. + } + }) + }) +}) diff --git a/src/server/app.ts b/src/server/app.ts index 56eb4dc..bd41dec 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -7,11 +7,15 @@ import type { Service } from '../core/service.js' import { bearerAuthPlugin } from './auth.js' import { routePlugin } from './routes.js' -export function createServer(service: Service, authToken: string): FastifyInstance { +export function createServer( + service: Service, + authToken: string, + opts?: { sessionTtlMs?: number }, +): FastifyInstance { // sessionSecret is regenerated per server instance — sessions intentionally // invalidate on server restart (ephemeral by design). const sessionSecret = randomBytes(32) - const sessionTtlMs = 3_600_000 + const sessionTtlMs = opts?.sessionTtlMs ?? 3_600_000 const server = Fastify({ logger: { From 98f0e945ff179579d676d2e2e38cd09a902796c6 Mon Sep 17 00:00:00 2001 From: Helix Date: Thu, 26 Feb 2026 00:34:22 -0800 Subject: [PATCH 08/25] Fixes #49 From e348e1bae7c62f50dd82406799cf9a828fc244c3 Mon Sep 17 00:00:00 2001 From: NoahCardoza Date: Fri, 27 Feb 2026 12:43:34 -0800 Subject: [PATCH 09/25] fix unlock command --- lefthook.yml | 3 - package.json | 8 +- src/__tests__/encrypted-store.test.ts | 18 ++ src/__tests__/injector.test.ts | 74 ++++++ .../integration/client-server-flow.test.ts | 7 +- src/__tests__/password-prompt.test.ts | 73 ++++++ src/__tests__/secrets-command.test.ts | 84 +++++++ src/__tests__/server-command.test.ts | 43 ++++ src/__tests__/service.test.ts | 35 ++- src/__tests__/session-lock.test.ts | 225 ++++++++++++++++++ src/__tests__/store-command.test.ts | 44 ++++ src/__tests__/unlock-command.test.ts | 217 ++++++++--------- src/cli/password-prompt.ts | 30 +++ src/cli/secrets.ts | 35 ++- src/cli/unlock.ts | 91 +++---- src/core/encrypted-store.ts | 5 + src/core/service.ts | 26 +- src/core/session-lock.ts | 120 ++++++++++ vitest.config.ts | 1 + 19 files changed, 941 insertions(+), 198 deletions(-) create mode 100644 src/__tests__/password-prompt.test.ts create mode 100644 src/__tests__/session-lock.test.ts create mode 100644 src/cli/password-prompt.ts create mode 100644 src/core/session-lock.ts diff --git a/lefthook.yml b/lefthook.yml index 794655f..cee480e 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -7,9 +7,6 @@ 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 run test diff --git a/package.json b/package.json index 7b69ff8..d5dd76b 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,11 @@ "scripts": { "build": "tsc", "dev": "tsx src/cli/index.ts", - "lint": "eslint . && prettier --check .", - "lint:fix": "eslint --fix . && prettier --write .", - "test": "vitest run", + "lint": "eslint --fix . && prettier --write .", + "lint:no-fix": "eslint . && prettier --check .", + "test": "vitest run --coverage", "test:watch": "vitest", - "test:coverage": "vitest run --coverage", + "test:no-coverage": "vitest run", "prepare": "lefthook install" }, "keywords": [ diff --git a/src/__tests__/encrypted-store.test.ts b/src/__tests__/encrypted-store.test.ts index 0b6bc35..58704b0 100644 --- a/src/__tests__/encrypted-store.test.ts +++ b/src/__tests__/encrypted-store.test.ts @@ -183,6 +183,24 @@ describe('EncryptedSecretStore', () => { 'Secret with ref "no-such-ref" not found', ) }) + + it('getByRef throws for non-existent ref', async () => { + const store = await createStore() + store.add('existing-ref', 'val') + + expect(() => store.getByRef('no-such-ref')).toThrow('Secret with ref "no-such-ref" not found') + }) + }) + + describe('getValue', () => { + it('throws for non-existent UUID', async () => { + const store = await createStore() + store.add('some-ref', 'some-value') + + expect(() => store.getValue('00000000-0000-0000-0000-000000000000')).toThrow( + 'Secret with UUID 00000000-0000-0000-0000-000000000000 not found', + ) + }) }) describe('resolveRef', () => { diff --git a/src/__tests__/injector.test.ts b/src/__tests__/injector.test.ts index 229e8fe..cf70e8f 100644 --- a/src/__tests__/injector.test.ts +++ b/src/__tests__/injector.test.ts @@ -117,6 +117,80 @@ describe('SecretInjector', () => { expect(secretStore.getValue).not.toHaveBeenCalled() }) + it('throws if envVarName provided but grant has no secret UUIDs', async () => { + const grantManager = createMockGrantManager({ + getGrant: vi.fn().mockReturnValue({ + id: 'grant-1', + requestId: 'req-1', + secretUuids: [], + grantedAt: '2026-01-15T10:00:00.000Z', + expiresAt: '2026-01-15T10:05:00.000Z', + used: false, + revokedAt: null, + }), + }) + const secretStore = createMockSecretStore() + const injector = new SecretInjector(grantManager, secretStore) + + await expect( + injector.inject('grant-1', ['echo', 'hello'], { envVarName: 'SECRET_VAR' }), + ).rejects.toThrow('Grant has no secret UUIDs') + + expect(mockedSpawn).not.toHaveBeenCalled() + }) + + it('throws if envVarName provided but secret value is null', async () => { + const grantManager = createMockGrantManager() + const secretStore = createMockSecretStore({ + getValue: vi.fn().mockReturnValue(null), + }) + const injector = new SecretInjector(grantManager, secretStore) + + await expect( + injector.inject('grant-1', ['echo', 'hello'], { envVarName: 'SECRET_VAR' }), + ).rejects.toThrow('Secret value not found for UUID: secret-uuid-1') + + expect(mockedSpawn).not.toHaveBeenCalled() + }) + + it('handles getValue throwing during secret collection for redaction', async () => { + const grantManager = createMockGrantManager({ + getGrant: vi.fn().mockReturnValue({ + id: 'grant-1', + requestId: 'req-1', + secretUuids: ['uuid-exists', 'uuid-throws'], + grantedAt: '2026-01-15T10:00:00.000Z', + expiresAt: '2026-01-15T10:05:00.000Z', + used: false, + revokedAt: null, + }), + }) + const secretStore = createMockSecretStore({ + getValue: vi.fn().mockImplementation((uuid: string) => { + if (uuid === 'uuid-exists') return 'valid-secret' + throw new Error('Secret not found') + }), + }) + const injector = new SecretInjector(grantManager, secretStore) + + const mockChild = createMockChild() + mockedSpawn.mockReturnValue(mockChild as never) + + // No envVarName, so no explicit injection - just scan and collect for redaction + const resultPromise = injector.inject('grant-1', ['echo', 'hello']) + + mockChild.stdout.emit('data', Buffer.from('output')) + mockChild.emit('close', 0) + + const result = await resultPromise + + // Should succeed despite one secret throwing + expect(result.exitCode).toBe(0) + // getValue was called for both UUIDs + expect(secretStore.getValue).toHaveBeenCalledWith('uuid-exists') + expect(secretStore.getValue).toHaveBeenCalledWith('uuid-throws') + }) + it('rejects immediately if grant is invalid', async () => { const grantManager = createMockGrantManager({ validateGrant: vi.fn().mockReturnValue(false), diff --git a/src/__tests__/integration/client-server-flow.test.ts b/src/__tests__/integration/client-server-flow.test.ts index a466d98..6612dc3 100644 --- a/src/__tests__/integration/client-server-flow.test.ts +++ b/src/__tests__/integration/client-server-flow.test.ts @@ -14,6 +14,7 @@ import { WorkflowEngine } from '../../core/workflow.js' import { SecretInjector } from '../../core/injector.js' import { RequestLog } from '../../core/request.js' import { loadOrGenerateKeyPair } from '../../core/key-manager.js' +import { SessionLock } from '../../core/session-lock.js' import type { NotificationChannel } from '../../channels/channel.js' // --------------------------------------------------------------------------- @@ -107,7 +108,10 @@ async function buildService(tmpDir: string, opts: BuildServiceOpts = {}): Promis } const { privateKey, publicKey } = await loadOrGenerateKeyPair(keysPath) - const unlockSession = new UnlockSession({ ttlMs: 3_600_000 }) + const unlockConfig = { ttlMs: 3_600_000 } + const unlockSession = new UnlockSession(unlockConfig) + const sessionLockPath = join(tmpDir, 'session.lock') + const sessionLock = new SessionLock(unlockConfig, sessionLockPath) const grantManager = new GrantManager(grantsPath, privateKey) const mockChannel = createMockChannel(opts.channelResponse ?? 'approved') const workflowEngine = new WorkflowEngine({ @@ -121,6 +125,7 @@ async function buildService(tmpDir: string, opts: BuildServiceOpts = {}): Promis const service = new LocalService({ store, unlockSession, + sessionLock, grantManager, workflowEngine, injector, diff --git a/src/__tests__/password-prompt.test.ts b/src/__tests__/password-prompt.test.ts new file mode 100644 index 0000000..bc9b468 --- /dev/null +++ b/src/__tests__/password-prompt.test.ts @@ -0,0 +1,73 @@ +/// + +const mockQuestion = vi.fn() +const mockClose = vi.fn() + +vi.mock('node:readline', () => ({ + createInterface: vi.fn(() => ({ + question: mockQuestion, + close: mockClose, + })), +})) + +describe('promptPassword', () => { + let stderrWriteSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + stderrWriteSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + stderrWriteSpy.mockRestore() + }) + + it('prompts with default "Password: " message', async () => { + mockQuestion.mockImplementation((_prompt: string, callback: (answer: string) => void) => { + callback('secret123') + }) + + const { promptPassword } = await import('../cli/password-prompt.js') + const result = await promptPassword() + + expect(stderrWriteSpy).toHaveBeenCalledWith('Password: ') + expect(result).toBe('secret123') + expect(mockClose).toHaveBeenCalled() + }) + + it('prompts with custom message when provided', async () => { + mockQuestion.mockImplementation((_prompt: string, callback: (answer: string) => void) => { + callback('mypassword') + }) + + const { promptPassword } = await import('../cli/password-prompt.js') + const result = await promptPassword('Enter passphrase: ') + + expect(stderrWriteSpy).toHaveBeenCalledWith('Enter passphrase: ') + expect(result).toBe('mypassword') + }) + + it('writes newline to stderr after answer', async () => { + mockQuestion.mockImplementation((_prompt: string, callback: (answer: string) => void) => { + callback('password') + }) + + const { promptPassword } = await import('../cli/password-prompt.js') + await promptPassword() + + // Should write prompt first, then newline after answer + expect(stderrWriteSpy).toHaveBeenCalledWith('Password: ') + expect(stderrWriteSpy).toHaveBeenCalledWith('\n') + }) + + it('returns empty string when user enters nothing', async () => { + mockQuestion.mockImplementation((_prompt: string, callback: (answer: string) => void) => { + callback('') + }) + + const { promptPassword } = await import('../cli/password-prompt.js') + const result = await promptPassword() + + expect(result).toBe('') + }) +}) diff --git a/src/__tests__/secrets-command.test.ts b/src/__tests__/secrets-command.test.ts index e37b7d5..af6dc92 100644 --- a/src/__tests__/secrets-command.test.ts +++ b/src/__tests__/secrets-command.test.ts @@ -19,6 +19,8 @@ const mockSecretsList = vi.fn() const mockSecretsAdd = vi.fn() const mockSecretsRemove = vi.fn() const mockSecretsResolve = vi.fn() +const mockIsUnlocked = vi.fn<() => boolean>() +const mockUnlock = vi.fn<() => Promise>() const mockService = { secrets: { @@ -27,6 +29,8 @@ const mockService = { remove: mockSecretsRemove, resolve: mockSecretsResolve, }, + isUnlocked: mockIsUnlocked, + unlock: mockUnlock, } as unknown as Service const mockResolveService = vi.fn<() => Service>() @@ -37,6 +41,13 @@ vi.mock('../core/config.js', () => ({ vi.mock('../core/service.js', () => ({ resolveService: (...args: unknown[]) => mockResolveService(...(args as [])), + LocalService: vi.fn(), +})) + +const mockPromptPassword = vi.fn<() => Promise>() + +vi.mock('../cli/password-prompt.js', () => ({ + promptPassword: (...args: unknown[]) => mockPromptPassword(...(args as [])), })) function createTestConfig(): AppConfig { @@ -44,9 +55,11 @@ function createTestConfig(): AppConfig { 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, + bindCommand: false, } } @@ -59,6 +72,7 @@ describe('secrets add command', () => { process.exitCode = undefined mockLoadConfig.mockReturnValue(createTestConfig()) mockResolveService.mockReturnValue(mockService) + mockIsUnlocked.mockReturnValue(true) originalStdin = process.stdin }) @@ -186,6 +200,76 @@ describe('secrets add command', () => { expect(logSpy).toHaveBeenCalledWith('prompt-uuid') logSpy.mockRestore() }) + + it('exits with error when in client mode', async () => { + mockLoadConfig.mockReturnValue({ + ...createTestConfig(), + mode: 'client', + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { secretsCommand } = await import('../cli/secrets.js') + await secretsCommand.parseAsync(['add', '--ref', 'my-ref', '--value', 'secret'], { + from: 'user', + }) + + expect(errorSpy).toHaveBeenCalledWith('Error: Inline unlock is not supported in client mode.') + expect(process.exitCode).toBe(1) + expect(mockSecretsAdd).not.toHaveBeenCalled() + errorSpy.mockRestore() + }) + + it('prompts for password when service is locked and unlocks successfully', async () => { + mockIsUnlocked.mockReturnValue(false) + mockPromptPassword.mockResolvedValue('test-password') + mockUnlock.mockResolvedValue(undefined) + mockSecretsAdd.mockResolvedValue({ uuid: 'unlocked-uuid', ref: 'my-ref', tags: [] }) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const { secretsCommand } = await import('../cli/secrets.js') + await secretsCommand.parseAsync(['add', '--ref', 'my-ref', '--value', 'secret'], { + from: 'user', + }) + + expect(mockPromptPassword).toHaveBeenCalled() + expect(mockUnlock).toHaveBeenCalledWith('test-password') + expect(mockSecretsAdd).toHaveBeenCalledWith('my-ref', 'secret', undefined) + expect(logSpy).toHaveBeenCalledWith('unlocked-uuid') + logSpy.mockRestore() + }) + + it('exits with error when password is incorrect', async () => { + mockIsUnlocked.mockReturnValue(false) + mockPromptPassword.mockResolvedValue('wrong-password') + mockUnlock.mockRejectedValue(new Error('Incorrect password')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { secretsCommand } = await import('../cli/secrets.js') + await secretsCommand.parseAsync(['add', '--ref', 'my-ref', '--value', 'secret'], { + from: 'user', + }) + + expect(mockPromptPassword).toHaveBeenCalled() + expect(mockUnlock).toHaveBeenCalledWith('wrong-password') + expect(errorSpy).toHaveBeenCalledWith('Incorrect password.') + expect(process.exitCode).toBe(1) + expect(mockSecretsAdd).not.toHaveBeenCalled() + errorSpy.mockRestore() + }) + + it('exits with error when secrets.add fails', async () => { + mockSecretsAdd.mockRejectedValue(new Error('Duplicate ref')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { secretsCommand } = await import('../cli/secrets.js') + await secretsCommand.parseAsync(['add', '--ref', 'my-ref', '--value', 'secret'], { + from: 'user', + }) + + expect(errorSpy).toHaveBeenCalledWith('Error: Duplicate ref') + expect(process.exitCode).toBe(1) + errorSpy.mockRestore() + }) }) describe('secrets list command', () => { diff --git a/src/__tests__/server-command.test.ts b/src/__tests__/server-command.test.ts index f5820fb..ce23149 100644 --- a/src/__tests__/server-command.test.ts +++ b/src/__tests__/server-command.test.ts @@ -164,6 +164,49 @@ describe('server start command', () => { expect(process.exitCode).toBe(1) errorSpy.mockRestore() }) + + it('handles invalid JSON in health response', async () => { + mockGetRunningPid.mockReturnValue(null) + mockFork.mockReturnValue(createMockChildProcess(54321)) + // Invalid JSON will cause JSON.parse to throw + mockHealthResponse = { statusCode: 200, data: 'not-valid-json' } + + const mockKill = vi.fn() + process.kill = mockKill as unknown as typeof process.kill + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { serverCommand } = await import('../cli/server.js') + await serverCommand.parseAsync(['start'], { from: 'user' }) + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('health check failed')) + expect(mockRemovePidFile).toHaveBeenCalled() + expect(process.exitCode).toBe(1) + errorSpy.mockRestore() + }) + + it('handles process.kill throwing during cleanup', async () => { + mockGetRunningPid.mockReturnValue(null) + mockFork.mockReturnValue(createMockChildProcess(54321)) + mockHealthResponse = new Error('Connection refused') + + // Mock kill to throw an error (process already exited) + const mockKill = vi.fn().mockImplementation(() => { + throw new Error('ESRCH') + }) + process.kill = mockKill as unknown as typeof process.kill + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { serverCommand } = await import('../cli/server.js') + await serverCommand.parseAsync(['start'], { from: 'user' }) + + // Should still clean up even when kill throws + expect(mockKill).toHaveBeenCalledWith(54321, 'SIGTERM') + expect(mockRemovePidFile).toHaveBeenCalled() + expect(process.exitCode).toBe(1) + errorSpy.mockRestore() + }) }) describe('server stop command', () => { diff --git a/src/__tests__/service.test.ts b/src/__tests__/service.test.ts index 62b2833..9946a45 100644 --- a/src/__tests__/service.test.ts +++ b/src/__tests__/service.test.ts @@ -10,6 +10,7 @@ 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' +import type { SessionLock } from '../core/session-lock.js' describe('resolveService', () => { it('returns LocalService for standalone mode', async () => { @@ -117,11 +118,20 @@ function makeService() { getById: vi.fn().mockReturnValue(undefined), } as unknown as RequestLog + const sessionLock = { + save: vi.fn(), + load: vi.fn().mockReturnValue(null), + clear: vi.fn(), + touch: vi.fn(), + exists: vi.fn().mockReturnValue(false), + } as unknown as SessionLock + const startTime = Date.now() - 1000 const service = new LocalService({ store, unlockSession, + sessionLock, grantManager, workflowEngine, injector, @@ -133,6 +143,7 @@ function makeService() { service, store, unlockSession, + sessionLock, grantManager, workflowEngine, injector, @@ -245,6 +256,7 @@ describe('LocalService', () => { const { store, unlockSession, + sessionLock, grantManager, workflowEngine, injector, @@ -254,6 +266,7 @@ describe('LocalService', () => { const serviceWithBind = new LocalService({ store, unlockSession, + sessionLock, grantManager, workflowEngine, injector, @@ -400,7 +413,7 @@ describe('LocalService', () => { describe('unlock()', () => { it('unlocks the store and passes DEK to session', async () => { - const { service, store, unlockSession } = makeService() + const { service, store, unlockSession, sessionLock } = makeService() ;(store.unlock as MockInstance).mockResolvedValue(undefined) ;(store.getDek as MockInstance).mockReturnValue(Buffer.alloc(32, 0xaa)) @@ -408,6 +421,7 @@ describe('LocalService', () => { expect(store.unlock).toHaveBeenCalledWith('test-password') expect(unlockSession.unlock).toHaveBeenCalledWith(Buffer.alloc(32, 0xaa)) + expect(sessionLock.save).toHaveBeenCalledWith(Buffer.alloc(32, 0xaa)) }) it('throws when DEK is null after unlock', async () => { @@ -420,10 +434,25 @@ describe('LocalService', () => { }) describe('lock()', () => { - it('locks the session (store locks via event)', () => { - const { service, unlockSession } = makeService() + it('locks the session and clears sessionLock', () => { + const { service, unlockSession, sessionLock } = makeService() service.lock() expect(unlockSession.lock).toHaveBeenCalled() + expect(sessionLock.clear).toHaveBeenCalled() + }) + }) + + describe('isUnlocked()', () => { + it('returns true when session is unlocked', () => { + const { service, unlockSession } = makeService() + ;(unlockSession.isUnlocked as MockInstance).mockReturnValue(true) + expect(service.isUnlocked()).toBe(true) + }) + + it('returns false when session is locked', () => { + const { service, unlockSession } = makeService() + ;(unlockSession.isUnlocked as MockInstance).mockReturnValue(false) + expect(service.isUnlocked()).toBe(false) }) }) diff --git a/src/__tests__/session-lock.test.ts b/src/__tests__/session-lock.test.ts new file mode 100644 index 0000000..76851af --- /dev/null +++ b/src/__tests__/session-lock.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import { SessionLock } from '../core/session-lock.js' +import type { UnlockConfig } from '../core/config.js' + +describe('SessionLock', () => { + let tmpDir: string + let sessionPath: string + let config: UnlockConfig + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'session-lock-test-')) + sessionPath = join(tmpDir, 'session.lock') + config = { ttlMs: 900_000 } // 15 minutes + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + describe('save()', () => { + it('creates session file with DEK and timestamps', () => { + const sessionLock = new SessionLock(config, sessionPath) + const dek = Buffer.alloc(32, 0xab) + + sessionLock.save(dek) + + expect(existsSync(sessionPath)).toBe(true) + const data = JSON.parse(readFileSync(sessionPath, 'utf-8')) + expect(data.version).toBe(1) + expect(data.dek).toBe(dek.toString('base64')) + expect(data.createdAt).toBeDefined() + expect(data.expiresAt).toBeDefined() + expect(data.lastAccessAt).toBeDefined() + }) + + it('sets expiresAt based on ttlMs', () => { + const shortConfig: UnlockConfig = { ttlMs: 60_000 } // 1 minute + const sessionLock = new SessionLock(shortConfig, sessionPath) + const dek = Buffer.alloc(32, 0xab) + + const before = Date.now() + sessionLock.save(dek) + const after = Date.now() + + const data = JSON.parse(readFileSync(sessionPath, 'utf-8')) + const createdAt = new Date(data.createdAt).getTime() + const expiresAt = new Date(data.expiresAt).getTime() + + expect(createdAt).toBeGreaterThanOrEqual(before) + expect(createdAt).toBeLessThanOrEqual(after) + expect(expiresAt).toBe(createdAt + shortConfig.ttlMs) + }) + }) + + describe('load()', () => { + it('returns DEK when session is valid', () => { + const sessionLock = new SessionLock(config, sessionPath) + const dek = Buffer.alloc(32, 0xab) + sessionLock.save(dek) + + const loadedDek = sessionLock.load() + + expect(loadedDek).toEqual(dek) + }) + + it('returns null when no session file exists', () => { + const sessionLock = new SessionLock(config, sessionPath) + + const loadedDek = sessionLock.load() + + expect(loadedDek).toBeNull() + }) + + it('returns null and clears when session is expired', () => { + const expiredConfig: UnlockConfig = { ttlMs: 1 } // 1ms + const sessionLock = new SessionLock(expiredConfig, sessionPath) + const dek = Buffer.alloc(32, 0xab) + sessionLock.save(dek) + + // Wait for expiry + vi.useFakeTimers() + vi.advanceTimersByTime(10) + + const loadedDek = sessionLock.load() + + expect(loadedDek).toBeNull() + expect(existsSync(sessionPath)).toBe(false) + + vi.useRealTimers() + }) + + it('returns null and clears when idle TTL is exceeded', () => { + const idleConfig: UnlockConfig = { ttlMs: 900_000, idleTtlMs: 1 } // 1ms idle + const sessionLock = new SessionLock(idleConfig, sessionPath) + const dek = Buffer.alloc(32, 0xab) + sessionLock.save(dek) + + // Wait for idle expiry + vi.useFakeTimers() + vi.advanceTimersByTime(10) + + const loadedDek = sessionLock.load() + + expect(loadedDek).toBeNull() + expect(existsSync(sessionPath)).toBe(false) + + vi.useRealTimers() + }) + + it('returns null and clears when file has invalid version', () => { + const sessionLock = new SessionLock(config, sessionPath) + const dek = Buffer.alloc(32, 0xab) + sessionLock.save(dek) + + // Corrupt the version + const data = JSON.parse(readFileSync(sessionPath, 'utf-8')) + data.version = 99 + writeFileSync(sessionPath, JSON.stringify(data)) + + const loadedDek = sessionLock.load() + + expect(loadedDek).toBeNull() + expect(existsSync(sessionPath)).toBe(false) + }) + + it('returns null and clears when file is corrupted', () => { + const sessionLock = new SessionLock(config, sessionPath) + const dek = Buffer.alloc(32, 0xab) + sessionLock.save(dek) + + // Corrupt the file + writeFileSync(sessionPath, 'not valid json{{{') + + const loadedDek = sessionLock.load() + + expect(loadedDek).toBeNull() + expect(existsSync(sessionPath)).toBe(false) + }) + }) + + describe('clear()', () => { + it('deletes session file when it exists', () => { + const sessionLock = new SessionLock(config, sessionPath) + const dek = Buffer.alloc(32, 0xab) + sessionLock.save(dek) + + expect(existsSync(sessionPath)).toBe(true) + + sessionLock.clear() + + expect(existsSync(sessionPath)).toBe(false) + }) + + it('does nothing when no session file exists', () => { + const sessionLock = new SessionLock(config, sessionPath) + + expect(() => sessionLock.clear()).not.toThrow() + }) + }) + + describe('touch()', () => { + it('updates lastAccessAt timestamp', () => { + const sessionLock = new SessionLock(config, sessionPath) + const dek = Buffer.alloc(32, 0xab) + sessionLock.save(dek) + + const dataBefore = JSON.parse(readFileSync(sessionPath, 'utf-8')) + const lastAccessBefore = dataBefore.lastAccessAt + + // Wait a bit before touching + vi.useFakeTimers() + vi.advanceTimersByTime(1000) + + sessionLock.touch() + + vi.useRealTimers() + + const dataAfter = JSON.parse(readFileSync(sessionPath, 'utf-8')) + const lastAccessAfter = new Date(dataAfter.lastAccessAt).getTime() + const lastAccessBeforeTime = new Date(lastAccessBefore).getTime() + + expect(lastAccessAfter).toBeGreaterThan(lastAccessBeforeTime) + }) + + it('does nothing when no session file exists', () => { + const sessionLock = new SessionLock(config, sessionPath) + + expect(() => sessionLock.touch()).not.toThrow() + }) + }) + + describe('exists()', () => { + it('returns true when valid session exists', () => { + const sessionLock = new SessionLock(config, sessionPath) + const dek = Buffer.alloc(32, 0xab) + sessionLock.save(dek) + + expect(sessionLock.exists()).toBe(true) + }) + + it('returns false when no session file exists', () => { + const sessionLock = new SessionLock(config, sessionPath) + + expect(sessionLock.exists()).toBe(false) + }) + + it('returns false when session is expired', () => { + const expiredConfig: UnlockConfig = { ttlMs: 1 } + const sessionLock = new SessionLock(expiredConfig, sessionPath) + const dek = Buffer.alloc(32, 0xab) + sessionLock.save(dek) + + vi.useFakeTimers() + vi.advanceTimersByTime(10) + + expect(sessionLock.exists()).toBe(false) + + vi.useRealTimers() + }) + }) +}) diff --git a/src/__tests__/store-command.test.ts b/src/__tests__/store-command.test.ts index 45c6b84..fb57758 100644 --- a/src/__tests__/store-command.test.ts +++ b/src/__tests__/store-command.test.ts @@ -135,6 +135,21 @@ describe('store init command', () => { errorSpy.mockRestore() stderrSpy.mockRestore() }) + + it('sets exitCode=1 when store path does not end with .json', async () => { + const config = createTestConfig({ store: { path: '/tmp/.2kc/secrets.txt' } }) + 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.path must end in .json')) + expect(process.exitCode).toBe(1) + + errorSpy.mockRestore() + }) }) describe('store migrate command', () => { @@ -180,6 +195,35 @@ describe('store migrate command', () => { stderrSpy.mockRestore() }) + it('deletes existing .bak file before renaming', async () => { + const config = createTestConfig() + mockLoadConfig.mockReturnValue(config) + mockPasswordPrompt('migrate-pw') + // existsSync: plaintextPath (true), encryptedPath (false), .bak (true - exists!) + mockExistsSync + .mockReturnValueOnce(true) // plaintextPath exists + .mockReturnValueOnce(false) // encryptedPath doesn't exist + .mockReturnValueOnce(true) // .bak already exists + 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' }) + + // Should have called unlinkSync for the .bak file + expect(mockUnlinkSync).toHaveBeenCalledWith('/tmp/.2kc/secrets.json.bak') + expect(mockRenameSync).toHaveBeenCalled() + 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) diff --git a/src/__tests__/unlock-command.test.ts b/src/__tests__/unlock-command.test.ts index 5a92047..70e4915 100644 --- a/src/__tests__/unlock-command.test.ts +++ b/src/__tests__/unlock-command.test.ts @@ -1,23 +1,18 @@ /// import type { AppConfig } from '../core/config.js' +import type { Service } from '../core/service.js' -const mockQuestion = vi.fn() -const mockRlClose = vi.fn() +const mockPromptPassword = vi.fn<() => Promise>() -vi.mock('node:readline', () => ({ - createInterface: vi.fn(() => ({ - question: mockQuestion, - close: mockRlClose, - })), +vi.mock('../cli/password-prompt.js', () => ({ + promptPassword: (...args: unknown[]) => mockPromptPassword(...(args as [])), })) 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>() @@ -26,43 +21,29 @@ vi.mock('../core/config.js', () => ({ loadConfig: (...args: unknown[]) => mockLoadConfig(...(args as [])), })) -const mockDeriveKek = vi.fn<() => Promise>() +const mockServiceUnlock = vi.fn<() => Promise>() +const mockServiceLock = vi.fn<() => void>() -vi.mock('../core/kdf.js', () => ({ - deriveKek: (...args: unknown[]) => mockDeriveKek(...(args as [])), -})) +const mockService = { + unlock: mockServiceUnlock, + lock: mockServiceLock, +} as unknown as Service -const mockUnwrapDek = vi.fn<() => Buffer>() +const mockResolveService = vi.fn<() => Promise>() -vi.mock('../core/crypto.js', () => ({ - unwrapDek: (...args: unknown[]) => mockUnwrapDek(...(args as [])), +vi.mock('../core/service.js', () => ({ + resolveService: (...args: unknown[]) => mockResolveService(...(args as [])), + LocalService: vi.fn(), })) -const mockSessionUnlock = vi.fn<() => void>() -const mockSessionLock = vi.fn<() => void>() +const mockSessionLockExists = vi.fn<() => boolean>() -vi.mock('../core/unlock-session.js', () => ({ - UnlockSession: vi.fn(), +vi.mock('../core/session-lock.js', () => ({ + SessionLock: vi.fn().mockImplementation(() => ({ + exists: mockSessionLockExists, + })), })) -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', @@ -72,6 +53,7 @@ function createTestConfig(): AppConfig { requireApproval: {}, defaultRequireApproval: false, approvalTimeoutMs: 300_000, + bindCommand: false, } } @@ -82,19 +64,8 @@ describe('unlock command', () => { 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(), - } - }) + mockLoadConfig.mockReturnValue(createTestConfig()) + mockResolveService.mockResolvedValue(mockService) }) afterEach(() => { @@ -103,26 +74,17 @@ describe('unlock command', () => { 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()) + mockPromptPassword.mockResolvedValue('correct-password') + mockServiceUnlock.mockResolvedValue(undefined) 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(mockPromptPassword).toHaveBeenCalled() + expect(mockResolveService).toHaveBeenCalled() + expect(mockServiceUnlock).toHaveBeenCalledWith('correct-password') expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Store unlocked')) expect(process.exitCode).toBeUndefined() @@ -131,16 +93,8 @@ describe('unlock command', () => { 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') - }) + mockPromptPassword.mockResolvedValue('wrong-password') + mockServiceUnlock.mockRejectedValue(new Error('Incorrect password')) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -163,24 +117,38 @@ describe('unlock command', () => { expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Encrypted store not found')) expect(process.exitCode).toBe(1) + expect(mockPromptPassword).not.toHaveBeenCalled() 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') + it('prints error and sets exitCode=1 when in client mode', async () => { + mockLoadConfig.mockReturnValue({ + ...createTestConfig(), + mode: 'client', }) - const mockDek = Buffer.alloc(32, 0xde) - mockDeriveKek.mockResolvedValue(Buffer.alloc(32, 0xab)) - mockUnwrapDek.mockReturnValue(mockDek) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { unlockCommand } = await import('../cli/unlock.js') + await unlockCommand.parseAsync([], { from: 'user' }) + + expect(errorSpy).toHaveBeenCalledWith( + 'Error: Unlock persistence is not supported in client mode.', + ) + expect(process.exitCode).toBe(1) + expect(mockPromptPassword).not.toHaveBeenCalled() + + errorSpy.mockRestore() + }) + + it('formats TTL in seconds when ttlMs < 60000', async () => { + mockExistsSync.mockReturnValue(true) + mockPromptPassword.mockResolvedValue('correct-password') + mockServiceUnlock.mockResolvedValue(undefined) mockLoadConfig.mockReturnValue({ ...createTestConfig(), - unlock: { ttlMs: 7_200_000 }, + unlock: { ttlMs: 30_000 }, }) const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -188,25 +156,18 @@ describe('unlock command', () => { const { unlockCommand } = await import('../cli/unlock.js') await unlockCommand.parseAsync([], { from: 'user' }) - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('2 hours')) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('30 seconds')) logSpy.mockRestore() }) - it('formats TTL as "1 hour" (singular) for exactly 3600000ms', async () => { + it('formats TTL as "1 second" (singular) for exactly 1000ms', 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) + mockPromptPassword.mockResolvedValue('correct-password') + mockServiceUnlock.mockResolvedValue(undefined) mockLoadConfig.mockReturnValue({ ...createTestConfig(), - unlock: { ttlMs: 3_600_000 }, + unlock: { ttlMs: 1000 }, }) const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -214,47 +175,47 @@ describe('unlock command', () => { const { unlockCommand } = await import('../cli/unlock.js') await unlockCommand.parseAsync([], { from: 'user' }) - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('1 hour')) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('1 second')) logSpy.mockRestore() }) - it('sets exitCode=1 when store file is malformed (bad version)', async () => { + it('formats TTL in hours when ttlMs >= 3600000', async () => { mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue(JSON.stringify({ version: 99, kdf: null, wrappedDek: null })) - - mockQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { - cb('password') + mockPromptPassword.mockResolvedValue('correct-password') + mockServiceUnlock.mockResolvedValue(undefined) + mockLoadConfig.mockReturnValue({ + ...createTestConfig(), + unlock: { ttlMs: 7_200_000 }, }) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const logSpy = vi.spyOn(console, 'log').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) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('2 hours')) - errorSpy.mockRestore() + logSpy.mockRestore() }) - it('sets exitCode=1 when store file cannot be read (parse error)', async () => { + it('formats TTL as "1 hour" (singular) for exactly 3600000ms', async () => { mockExistsSync.mockReturnValue(true) - mockReadFileSync.mockReturnValue('not valid json{{{') - - mockQuestion.mockImplementation((_prompt: string, cb: (answer: string) => void) => { - cb('password') + mockPromptPassword.mockResolvedValue('correct-password') + mockServiceUnlock.mockResolvedValue(undefined) + mockLoadConfig.mockReturnValue({ + ...createTestConfig(), + unlock: { ttlMs: 3_600_000 }, }) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const logSpy = vi.spyOn(console, 'log').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) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('1 hour')) - errorSpy.mockRestore() + logSpy.mockRestore() }) }) @@ -265,18 +226,22 @@ describe('lock command', () => { savedExitCode = process.exitCode process.exitCode = undefined vi.clearAllMocks() + mockLoadConfig.mockReturnValue(createTestConfig()) + mockResolveService.mockResolvedValue(mockService) }) afterEach(() => { process.exitCode = savedExitCode }) - it('prints "Store locked."', async () => { + it('locks the service and prints "Store locked."', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) const { lockCommand } = await import('../cli/unlock.js') await lockCommand.parseAsync([], { from: 'user' }) + expect(mockResolveService).toHaveBeenCalled() + expect(mockServiceLock).toHaveBeenCalled() expect(logSpy).toHaveBeenCalledWith('Store locked.') logSpy.mockRestore() @@ -290,6 +255,7 @@ describe('status command', () => { savedExitCode = process.exitCode process.exitCode = undefined vi.clearAllMocks() + mockLoadConfig.mockReturnValue(createTestConfig()) }) afterEach(() => { @@ -311,8 +277,23 @@ describe('status command', () => { logSpy.mockRestore() }) - it('prints "Store is locked." when store file exists', async () => { + it('prints "Store is unlocked." when session exists and is valid', async () => { + mockExistsSync.mockReturnValue(true) + mockSessionLockExists.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 unlocked.') + + logSpy.mockRestore() + }) + + it('prints "Store is locked." when session does not exist or is expired', async () => { mockExistsSync.mockReturnValue(true) + mockSessionLockExists.mockReturnValue(false) const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) diff --git a/src/cli/password-prompt.ts b/src/cli/password-prompt.ts new file mode 100644 index 0000000..b74afd2 --- /dev/null +++ b/src/cli/password-prompt.ts @@ -0,0 +1,30 @@ +import { createInterface } from 'node:readline' +import { Writable } from 'node:stream' + +export function promptPassword(prompt = 'Password: '): 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(prompt) + muted = true + + rl.question('', (answer) => { + muted = false + process.stderr.write('\n') + rl.close() + resolve(answer) + }) + }) +} diff --git a/src/cli/secrets.ts b/src/cli/secrets.ts index 85b9dd5..62e4306 100644 --- a/src/cli/secrets.ts +++ b/src/cli/secrets.ts @@ -2,7 +2,8 @@ import { Command } from 'commander' import { createInterface } from 'node:readline' import { loadConfig } from '../core/config.js' -import { resolveService } from '../core/service.js' +import { resolveService, LocalService } from '../core/service.js' +import { promptPassword } from './password-prompt.js' function readStdin(): Promise { return new Promise((resolve, reject) => { @@ -56,9 +57,35 @@ secrets process.exitCode = 1 return } - const service = await resolveService(loadConfig()) - const result = await service.secrets.add(opts.ref, value, opts.tags) - console.log(result.uuid) + + const config = loadConfig() + + if (config.mode === 'client') { + console.error('Error: Inline unlock is not supported in client mode.') + process.exitCode = 1 + return + } + + const service = (await resolveService(config)) as LocalService + + if (!service.isUnlocked()) { + const password = await promptPassword() + try { + await service.unlock(password) + } catch { + console.error('Incorrect password.') + process.exitCode = 1 + return + } + } + + try { + const result = await service.secrets.add(opts.ref, value, opts.tags) + console.log(result.uuid) + } catch (err: unknown) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`) + process.exitCode = 1 + } }) secrets diff --git a/src/cli/unlock.ts b/src/cli/unlock.ts index aee1592..6e37568 100644 --- a/src/cli/unlock.ts +++ b/src/cli/unlock.ts @@ -1,46 +1,15 @@ import { Command } from 'commander' -import { createInterface } from 'node:readline' -import { Writable } from 'node:stream' -import { readFileSync, existsSync } from 'node:fs' +import { 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' +import { resolveService, LocalService } from '../core/service.js' +import { SessionLock } from '../core/session-lock.js' +import { promptPassword } from './password-prompt.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) { @@ -57,50 +26,38 @@ function formatTtl(ms: number): string { 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.') + const config = loadConfig() + + if (config.mode === 'client') { + console.error('Error: Unlock persistence is not supported in client mode.') 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.') + if (!existsSync(ENCRYPTED_STORE_PATH)) { + console.error('Error: Encrypted store not found. Run store initialization first.') process.exitCode = 1 return } - let dek: Buffer + const password = await promptPassword() + const service = (await resolveService(config)) as LocalService + 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) + await service.unlock(password) + console.log(`Store unlocked. Session expires in ${formatTtl(config.unlock.ttlMs)}.`) } 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(() => { +lockCommand.action(async () => { + const config = loadConfig() + const service = (await resolveService(config)) as LocalService + service.lock() console.log('Store locked.') }) @@ -113,7 +70,15 @@ statusCommand.action(() => { console.log('Encrypted store not found. Run store initialization first.') return } - console.log('Store is locked.') + + const config = loadConfig() + const sessionLock = new SessionLock(config.unlock) + + if (sessionLock.exists()) { + console.log('Store is unlocked.') + } else { + console.log('Store is locked.') + } }) export { unlockCommand, lockCommand, statusCommand } diff --git a/src/core/encrypted-store.ts b/src/core/encrypted-store.ts index c3fe387..e759f24 100644 --- a/src/core/encrypted-store.ts +++ b/src/core/encrypted-store.ts @@ -65,6 +65,11 @@ export class EncryptedSecretStore implements ISecretStore { } } + /** Restore unlocked state from a previously saved DEK (from session persistence). */ + restoreUnlocked(dek: Buffer): void { + this.dek = Buffer.from(dek) + } + /** * Initialize a new encrypted store with a password. * Creates the file with an empty secrets array. diff --git a/src/core/service.ts b/src/core/service.ts index 5f18a1f..3e52a5e 100644 --- a/src/core/service.ts +++ b/src/core/service.ts @@ -9,6 +9,7 @@ import { RemoteService } from './remote-service.js' import { EncryptedSecretStore } from './encrypted-store.js' import { UnlockSession } from './unlock-session.js' import { GrantManager } from './grant.js' +import { SessionLock } from './session-lock.js' import { normalizeCommand, hashCommand } from './command-hash.js' import { WorkflowEngine } from './workflow.js' import { SecretInjector } from './injector.js' @@ -55,6 +56,7 @@ export interface Service { interface LocalServiceDeps { store: EncryptedSecretStore unlockSession: UnlockSession + sessionLock: SessionLock grantManager: GrantManager workflowEngine: WorkflowEngine injector: SecretInjector @@ -67,8 +69,11 @@ 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() + // When session auto-locks (TTL/idle/max-grants), also lock the encrypted store and clear session + this.onLocked = () => { + deps.store.lock() + deps.sessionLock.clear() + } deps.unlockSession.on('locked', this.onLocked) } @@ -82,11 +87,17 @@ export class LocalService implements Service { const dek = this.deps.store.getDek() if (!dek) throw new Error('Failed to obtain DEK after unlock') this.deps.unlockSession.unlock(dek) + this.deps.sessionLock.save(dek) + } + + isUnlocked(): boolean { + return this.deps.unlockSession.isUnlocked() } // Called by `2kc lock` CLI command — not on the Service interface lock(): void { this.deps.unlockSession.lock() + this.deps.sessionLock.clear() // EncryptedSecretStore.lock() is called via the 'locked' event handler above } @@ -207,6 +218,16 @@ export async function resolveService(config: AppConfig): Promise { const store = new EncryptedSecretStore(config.store.path) const unlockSession = new UnlockSession(config.unlock) + const sessionLock = new SessionLock(config.unlock) + + // Restore session from disk if a valid session exists + const savedDek = sessionLock.load() + if (savedDek) { + store.restoreUnlocked(savedDek) + unlockSession.unlock(savedDek) + sessionLock.touch() + } + const grantManager = new GrantManager(grantsPath, privateKey) let channel: NotificationChannel @@ -234,6 +255,7 @@ export async function resolveService(config: AppConfig): Promise { return new LocalService({ store, unlockSession, + sessionLock, grantManager, workflowEngine, injector, diff --git a/src/core/session-lock.ts b/src/core/session-lock.ts new file mode 100644 index 0000000..7c51f08 --- /dev/null +++ b/src/core/session-lock.ts @@ -0,0 +1,120 @@ +import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, chmodSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { homedir } from 'node:os' +import type { UnlockConfig } from './config.js' + +const DEFAULT_SESSION_PATH = join(homedir(), '.2kc', 'session.lock') + +interface SessionLockFile { + version: 1 + createdAt: string + expiresAt: string + lastAccessAt: string + dek: string +} + +export class SessionLock { + private readonly filePath: string + private readonly config: UnlockConfig + + constructor(config: UnlockConfig, filePath: string = DEFAULT_SESSION_PATH) { + this.config = config + this.filePath = filePath + } + + save(dek: Buffer): void { + const now = new Date() + const expiresAt = new Date(now.getTime() + this.config.ttlMs) + + const data: SessionLockFile = { + version: 1, + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + lastAccessAt: now.toISOString(), + dek: dek.toString('base64'), + } + + const dir = dirname(this.filePath) + mkdirSync(dir, { recursive: true }) + writeFileSync(this.filePath, JSON.stringify(data, null, 2), 'utf-8') + chmodSync(this.filePath, 0o600) + } + + load(): Buffer | null { + if (!existsSync(this.filePath)) { + return null + } + + let data: SessionLockFile + try { + const raw = readFileSync(this.filePath, 'utf-8') + data = JSON.parse(raw) as SessionLockFile + if (data.version !== 1) { + this.clear() + return null + } + } catch { + this.clear() + return null + } + + const now = Date.now() + const expiresAt = new Date(data.expiresAt).getTime() + if (now >= expiresAt) { + this.clear() + return null + } + + // Check idle TTL if configured + if (this.config.idleTtlMs !== undefined) { + const lastAccessAt = new Date(data.lastAccessAt).getTime() + const idleExpiresAt = lastAccessAt + this.config.idleTtlMs + if (now >= idleExpiresAt) { + this.clear() + return null + } + } + + return Buffer.from(data.dek, 'base64') + } + + clear(): void { + if (existsSync(this.filePath)) { + try { + unlinkSync(this.filePath) + } catch { + // Ignore errors during cleanup + } + } + } + + touch(): void { + if (!existsSync(this.filePath)) { + return + } + + try { + const raw = readFileSync(this.filePath, 'utf-8') + const data = JSON.parse(raw) as SessionLockFile + if (data.version !== 1) { + return + } + + data.lastAccessAt = new Date().toISOString() + writeFileSync(this.filePath, JSON.stringify(data, null, 2), 'utf-8') + chmodSync(this.filePath, 0o600) + } catch { + // Ignore errors during touch + } + } + + exists(): boolean { + if (!existsSync(this.filePath)) { + return false + } + + // Validate the session is not expired + const dek = this.load() + return dek !== null + } +} diff --git a/vitest.config.ts b/vitest.config.ts index aae79f3..a426ff0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ 'src/core/types.ts', // Entry point glue (low test value) 'src/cli/index.ts', + 'src/cli/password-prompt.ts', 'src/server/index.ts', // Deprecated 'src/core/server-entry.ts', From cacd430df8aa768746ef8fc0bede0fb53717d3b5 Mon Sep 17 00:00:00 2001 From: NoahCardoza Date: Fri, 27 Feb 2026 15:02:47 -0800 Subject: [PATCH 10/25] create inject command --- .gitignore | 1 + README.md | 50 ++-- lefthook.yml | 1 + src/__tests__/inject-command.test.ts | 314 ++++++++++++++++++++++++++ src/__tests__/request-command.test.ts | 9 +- src/cli/index.ts | 2 + src/cli/inject.ts | 127 +++++++++++ src/cli/request.ts | 33 ++- 8 files changed, 512 insertions(+), 25 deletions(-) create mode 100644 src/__tests__/inject-command.test.ts create mode 100644 src/cli/inject.ts diff --git a/.gitignore b/.gitignore index 2abb8d9..a1cacca 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .env.* *.tsbuildinfo coverage/ +.claude/settings.local.json diff --git a/README.md b/README.md index 7125d3b..847e9b6 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,30 @@ Options: - `--cmd` (required) — Command to run with secrets injected - `--duration ` — Grant validity (default: 300, range: 30–3600) +### Inject command + +For workflows with multiple secrets, use the `inject` command which scans environment variables for `2k://` placeholders: + +```bash +# Set env vars with secret placeholders +export DB_PASS="2k://db-password" +export API_KEY="2k://api-key-prod" + +# Inject all found placeholders +2kc inject --reason "Deploy" --task "DEPLOY-123" --cmd "./deploy.sh" + +# Only inject specific vars +2kc inject --vars "DB_PASS,API_KEY" --reason "test" --task "T-1" --cmd "./run.sh" +``` + +Options: + +- `--reason` (required) — Justification for access +- `--task` (required) — Task/ticket reference +- `--cmd` (required) — Command to run with secrets injected +- `--vars ` — Comma-separated list of env var names to check (default: scan all) +- `--duration ` — Grant validity (default: 300) + ### 6. View configuration ```bash @@ -159,19 +183,19 @@ Config file: `~/.2kc/config.json` ### Config Fields -| Field | Type | Default | Description | -| ------------------------ | ---------------------------- | ----------------------- | --------------------------------------------------------------------------- | -| `mode` | `"standalone"` \| `"client"` | `"standalone"` | Operating mode. Standalone runs locally; client connects to a remote server | -| `server.host` | string | `"127.0.0.1"` | Server bind address | -| `server.port` | number | `2274` | Server port | -| `server.authToken` | string | — | Bearer token for client-server auth | -| `store.path` | string | `"~/.2kc/secrets.json"` | Path to the secrets JSON file | -| `discord.webhookUrl` | string | — | Discord webhook URL for approval messages | -| `discord.botToken` | string | — | Discord bot token for reading reactions | -| `discord.channelId` | string | — | Discord channel ID for approval polling | -| `requireApproval` | object | `{}` | Tag → boolean map. Tags set to `true` require human approval | -| `defaultRequireApproval` | boolean | `false` | Default approval requirement for untagged secrets | -| `approvalTimeoutMs` | number | `300000` | How long to wait for approval (ms) | +| Field | Type | Default | Description | +| ------------------------ | ---------------------------- | --------------------------- | --------------------------------------------------------------------------- | +| `mode` | `"standalone"` \| `"client"` | `"standalone"` | Operating mode. Standalone runs locally; client connects to a remote server | +| `server.host` | string | `"127.0.0.1"` | Server bind address | +| `server.port` | number | `2274` | Server port | +| `server.authToken` | string | — | Bearer token for client-server auth | +| `store.path` | string | `"~/.2kc/secrets.enc.json"` | Path to the secrets JSON file | +| `discord.webhookUrl` | string | — | Discord webhook URL for approval messages | +| `discord.botToken` | string | — | Discord bot token for reading reactions | +| `discord.channelId` | string | — | Discord channel ID for approval polling | +| `requireApproval` | object | `{}` | Tag → boolean map. Tags set to `true` require human approval | +| `defaultRequireApproval` | boolean | `false` | Default approval requirement for untagged secrets | +| `approvalTimeoutMs` | number | `300000` | How long to wait for approval (ms) | ## Server Mode diff --git a/lefthook.yml b/lefthook.yml index cee480e..d11446a 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -7,6 +7,7 @@ pre-commit: lint: glob: '*.{ts,tsx,js,json,md}' run: npm run lint + stage_fixed: true test: glob: '*.{ts,tsx}' run: npm run test diff --git a/src/__tests__/inject-command.test.ts b/src/__tests__/inject-command.test.ts new file mode 100644 index 0000000..1e7fba3 --- /dev/null +++ b/src/__tests__/inject-command.test.ts @@ -0,0 +1,314 @@ +/// + +import type { AppConfig } from '../core/config.js' +import type { AccessRequest } from '../core/request.js' +import type { Service } from '../core/service.js' + +const mockLoadConfig = vi.fn<() => AppConfig>() + +const mockRequestsCreate = vi.fn() +const mockGrantsGetStatus = vi.fn() +const mockInject = vi.fn() +const mockHealth = vi.fn() +const mockSecretsList = vi.fn() +const mockSecretsAdd = vi.fn() +const mockSecretsRemove = vi.fn() +const mockSecretsGetMetadata = vi.fn() +const mockSecretsResolve = vi.fn() + +const mockService: Service = { + health: mockHealth, + secrets: { + list: mockSecretsList, + add: mockSecretsAdd, + remove: mockSecretsRemove, + getMetadata: mockSecretsGetMetadata, + resolve: mockSecretsResolve, + }, + requests: { + create: mockRequestsCreate, + }, + grants: { + getStatus: mockGrantsGetStatus, + }, + inject: mockInject, +} + +const mockResolveService = vi.fn<() => Service>() + +vi.mock('../core/config.js', () => ({ + loadConfig: (...args: unknown[]) => mockLoadConfig(...(args as [])), +})) + +vi.mock('../core/service.js', () => ({ + resolveService: (...args: unknown[]) => mockResolveService(...(args as [])), +})) + +function createTestConfig(): AppConfig { + return { + mode: 'standalone', + server: { host: '127.0.0.1', port: 2274 }, + store: { path: '~/.2kc/secrets.json' }, + discord: { + webhookUrl: 'https://discord.com/api/webhooks/123/abc', + botToken: 'bot-token-123', + channelId: '999888777', + }, + requireApproval: {}, + defaultRequireApproval: false, + approvalTimeoutMs: 300_000, + unlock: { ttlMs: 900_000 }, + } +} + +function createTestAccessRequest(overrides?: Partial): AccessRequest { + return { + id: 'test-request-id', + secretUuids: ['test-secret-uuid'], + reason: 'need for deploy', + taskRef: 'TICKET-123', + durationSeconds: 300, + requestedAt: '2026-01-01T00:00:00.000Z', + status: 'pending', + ...overrides, + } +} + +const baseArgs = ['--reason', 'need for deploy', '--task', 'TICKET-123', '--cmd', 'echo hello'] + +async function runInject(args: string[] = baseArgs): Promise { + const { injectCommand } = await import('../cli/inject.js') + await injectCommand.parseAsync(args, { from: 'user' }) +} + +describe('inject command', () => { + let savedExitCode: number | undefined + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + savedExitCode = process.exitCode + process.exitCode = undefined + originalEnv = { ...process.env } + + mockLoadConfig.mockReturnValue(createTestConfig()) + mockResolveService.mockReturnValue(mockService) + mockSecretsResolve.mockImplementation(async (refOrUuid: string) => ({ + uuid: `${refOrUuid}-uuid`, + ref: refOrUuid, + tags: [], + })) + mockRequestsCreate.mockResolvedValue(createTestAccessRequest()) + mockGrantsGetStatus.mockResolvedValue({ status: 'approved' }) + mockInject.mockResolvedValue({ exitCode: 0, stdout: 'output', stderr: '' }) + }) + + afterEach(() => { + process.exitCode = savedExitCode + process.env = originalEnv + vi.clearAllMocks() + }) + + it('scans env vars for 2k:// placeholders and injects secrets', async () => { + process.env.DB_PASS = '2k://db-password' + process.env.API_KEY = '2k://api-key' + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await runInject() + + expect(mockSecretsResolve).toHaveBeenCalledWith('db-password') + expect(mockSecretsResolve).toHaveBeenCalledWith('api-key') + expect(mockRequestsCreate).toHaveBeenCalledWith( + expect.arrayContaining(['db-password-uuid', 'api-key-uuid']), + 'need for deploy', + 'TICKET-123', + 300, + 'echo hello', + ) + expect(process.exitCode).toBe(0) + stdoutSpy.mockRestore() + }) + + it('only checks specified vars when --vars is provided', async () => { + process.env.DB_PASS = '2k://db-password' + process.env.API_KEY = '2k://api-key' + process.env.OTHER = '2k://other-secret' + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await runInject(['--vars', 'DB_PASS', '--reason', 'test', '--task', 'T-1', '--cmd', 'echo']) + + expect(mockSecretsResolve).toHaveBeenCalledWith('db-password') + expect(mockSecretsResolve).not.toHaveBeenCalledWith('api-key') + expect(mockSecretsResolve).not.toHaveBeenCalledWith('other-secret') + expect(mockRequestsCreate).toHaveBeenCalledWith( + ['db-password-uuid'], + 'test', + 'T-1', + 300, + 'echo', + ) + stdoutSpy.mockRestore() + }) + + it('handles comma-separated --vars list', async () => { + process.env.VAR1 = '2k://secret-1' + process.env.VAR2 = '2k://secret-2' + process.env.VAR3 = '2k://secret-3' + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await runInject(['--vars', 'VAR1,VAR2', '--reason', 'test', '--task', 'T-1', '--cmd', 'echo']) + + expect(mockSecretsResolve).toHaveBeenCalledWith('secret-1') + expect(mockSecretsResolve).toHaveBeenCalledWith('secret-2') + expect(mockSecretsResolve).not.toHaveBeenCalledWith('secret-3') + stdoutSpy.mockRestore() + }) + + it('exits with error when no 2k:// placeholders found', async () => { + process.env.REGULAR_VAR = 'regular-value' + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await runInject() + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('No 2k:// placeholders found')) + expect(process.exitCode).toBe(1) + expect(mockRequestsCreate).not.toHaveBeenCalled() + errorSpy.mockRestore() + }) + + it('exits with error when specified --vars have no placeholders', async () => { + process.env.MY_VAR = 'regular-value' + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await runInject(['--vars', 'MY_VAR', '--reason', 'test', '--task', 'T-1', '--cmd', 'echo']) + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('No 2k:// placeholders found')) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('MY_VAR')) + expect(process.exitCode).toBe(1) + errorSpy.mockRestore() + }) + + it('exits with error when secret resolution fails', async () => { + process.env.DB_PASS = '2k://unknown-secret' + mockSecretsResolve.mockRejectedValue(new Error('Secret not found')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await runInject() + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to resolve secret ref 'unknown-secret'"), + ) + expect(process.exitCode).toBe(1) + errorSpy.mockRestore() + }) + + it('exits with error when request is denied', async () => { + process.env.SECRET = '2k://my-secret' + mockGrantsGetStatus.mockResolvedValue({ status: 'denied' }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await runInject() + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('denied')) + expect(process.exitCode).toBe(1) + expect(mockInject).not.toHaveBeenCalled() + errorSpy.mockRestore() + }) + + it('times out while polling if deadline passes', async () => { + process.env.SECRET = '2k://my-secret' + const baseTime = 1_000_000 + const nowSpy = vi + .spyOn(Date, 'now') + .mockReturnValueOnce(baseTime) + .mockReturnValue(baseTime + 5 * 60 * 1000 + 1) + + mockGrantsGetStatus.mockResolvedValue({ status: 'pending' }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await runInject() + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Timed out')) + expect(process.exitCode).toBe(1) + + nowSpy.mockRestore() + errorSpy.mockRestore() + }) + + it('passes --duration to service.requests.create', async () => { + process.env.SECRET = '2k://my-secret' + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + await runInject(['--reason', 'test', '--task', 'T-1', '--cmd', 'echo', '--duration', '600']) + + expect(mockRequestsCreate).toHaveBeenCalledWith(['my-secret-uuid'], 'test', 'T-1', 600, 'echo') + stdoutSpy.mockRestore() + }) + + it('invalid duration: prints error and exits with code 1', async () => { + process.env.SECRET = '2k://my-secret' + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await runInject(['--reason', 'test', '--task', 'T-1', '--cmd', 'echo', '--duration', 'abc']) + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid --duration')) + expect(process.exitCode).toBe(1) + expect(mockRequestsCreate).not.toHaveBeenCalled() + errorSpy.mockRestore() + }) + + it('outputs stdout and stderr from child process', async () => { + process.env.SECRET = '2k://my-secret' + mockInject.mockResolvedValue({ exitCode: 0, stdout: 'stdout output', stderr: 'stderr output' }) + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + await runInject() + + expect(stdoutSpy).toHaveBeenCalledWith('stdout output') + expect(stderrSpy).toHaveBeenCalledWith('stderr output') + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }) + + it('returns child process exit code', async () => { + process.env.SECRET = '2k://my-secret' + mockInject.mockResolvedValue({ exitCode: 42, stdout: '', stderr: '' }) + + await runInject() + + expect(process.exitCode).toBe(42) + }) + + it('maps null exit code to 1', async () => { + process.env.SECRET = '2k://my-secret' + mockInject.mockResolvedValue({ exitCode: null, stdout: '', stderr: '' }) + + await runInject() + + expect(process.exitCode).toBe(1) + }) + + it('handles grant expired error', async () => { + process.env.SECRET = '2k://my-secret' + mockInject.mockRejectedValue(new Error('Grant is not valid')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await runInject() + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Grant expired')) + expect(process.exitCode).toBe(1) + errorSpy.mockRestore() + }) + + it('handles generic errors', async () => { + process.env.SECRET = '2k://my-secret' + mockInject.mockRejectedValue(new Error('Something went wrong')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await runInject() + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Something went wrong')) + expect(process.exitCode).toBe(1) + errorSpy.mockRestore() + }) +}) diff --git a/src/__tests__/request-command.test.ts b/src/__tests__/request-command.test.ts index 19ee772..4f0935b 100644 --- a/src/__tests__/request-command.test.ts +++ b/src/__tests__/request-command.test.ts @@ -100,6 +100,11 @@ describe('request command orchestration', () => { mockLoadConfig.mockReturnValue(createTestConfig()) mockResolveService.mockReturnValue(mockService) + mockSecretsResolve.mockImplementation(async (refOrUuid: string) => ({ + uuid: refOrUuid, + ref: refOrUuid, + tags: [], + })) mockRequestsCreate.mockResolvedValue(createTestAccessRequest()) mockGrantsGetStatus.mockResolvedValue({ status: 'approved' }) mockInject.mockResolvedValue({ exitCode: 0, stdout: 'output', stderr: '' }) @@ -245,11 +250,11 @@ describe('request command orchestration', () => { }) it('secret not found error: prints user-friendly message', async () => { - mockRequestsCreate.mockRejectedValue(new Error('Secret not found')) + mockSecretsResolve.mockRejectedValue(new Error('Secret not found')) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) await runRequest() - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Secret UUID not found')) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to resolve secret')) expect(process.exitCode).toBe(1) errorSpy.mockRestore() }) diff --git a/src/cli/index.ts b/src/cli/index.ts index a216965..381df5d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { configCommand } from './config.js' +import { injectCommand } from './inject.js' import { openclawCommand } from './openclaw.js' import { requestCommand } from './request.js' import { secretsCommand } from './secrets.js' @@ -27,6 +28,7 @@ program.addCommand(openclawCommand) program.addCommand(secretsCommand) program.addCommand(configCommand) program.addCommand(requestCommand) +program.addCommand(injectCommand) program.addCommand(serverCommand) program.addCommand(storeCommand) program.addCommand(unlockCommand) diff --git a/src/cli/inject.ts b/src/cli/inject.ts new file mode 100644 index 0000000..99c7e00 --- /dev/null +++ b/src/cli/inject.ts @@ -0,0 +1,127 @@ +import { Command } from 'commander' + +import { loadConfig } from '../core/config.js' +import { resolveService } from '../core/service.js' + +const inject = new Command('inject') + .description('Run a command with secrets injected by scanning env vars for 2k:// placeholders') + .requiredOption('--reason ', 'Justification for access') + .requiredOption('--task ', 'Task reference (e.g., ticket ID)') + .option('--duration ', 'Grant duration in seconds', '300') + .option('--vars ', 'Comma-separated list of env var names to check (default: scan all)') + .requiredOption('--cmd ', 'Command to run with secrets injected') + .action( + async (opts: { + reason: string + task: string + duration: string + vars?: string + cmd: string + }) => { + try { + // 1. Load config and resolve service + const config = loadConfig() + const service = await resolveService(config) + + // 2. Parse and validate duration + const durationSeconds = parseInt(opts.duration, 10) + if (Number.isNaN(durationSeconds) || durationSeconds <= 0) { + console.error('Invalid --duration: must be a positive integer (seconds)') + process.exitCode = 1 + return + } + + // 3. Scan env vars for 2k:// placeholders + const varsToCheck = opts.vars + ? opts.vars.split(',').map((v) => v.trim()) + : Object.keys(process.env) + + const placeholders: { envVar: string; ref: string }[] = [] + for (const varName of varsToCheck) { + const value = process.env[varName] + if (value && value.startsWith('2k://')) { + const ref = value.slice(5) // Remove '2k://' prefix + placeholders.push({ envVar: varName, ref }) + } + } + + if (placeholders.length === 0) { + console.error( + 'No 2k:// placeholders found in environment variables' + + (opts.vars ? ` (checked: ${opts.vars})` : ''), + ) + process.exitCode = 1 + return + } + + // 4. Resolve refs to UUIDs + const uuids: string[] = [] + for (const { envVar, ref } of placeholders) { + try { + const metadata = await service.secrets.resolve(ref) + uuids.push(metadata.uuid) + } catch { + console.error(`Failed to resolve secret ref '${ref}' from ${envVar}`) + process.exitCode = 1 + return + } + } + + // 5. Create access request via service + const accessRequest = await service.requests.create( + uuids, + opts.reason, + opts.task, + durationSeconds, + opts.cmd, + ) + + // 6. Poll for grant status + const pollIntervalMs = 250 + const maxWaitMs = 5 * 60 * 1000 // 5 minutes + const deadline = Date.now() + maxWaitMs + let grantResult!: Awaited> + while (true) { + grantResult = await service.grants.getStatus(accessRequest.id) + if (grantResult.status !== 'pending') break + if (Date.now() > deadline) { + console.error( + `Timed out waiting for approval: ${placeholders.map((p) => p.ref).join(', ')}`, + ) + process.exitCode = 1 + return + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } + if (grantResult.status !== 'approved') { + console.error(`Access request denied: ${placeholders.map((p) => p.ref).join(', ')}`) + process.exitCode = 1 + return + } + + // 7. Inject secrets and run command + const processResult = await service.inject(accessRequest.id, opts.cmd) + + // 8. Output result + if (processResult.stdout) process.stdout.write(processResult.stdout) + if (processResult.stderr) process.stderr.write(processResult.stderr) + // Map null exit code (signal-killed processes) to exit code 1 intentionally, + // so the CLI always reports a non-zero exit when the child did not exit cleanly. + process.exitCode = processResult.exitCode ?? 1 + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + + if (message.includes('not found')) { + console.error(`Secret not found`) + } else if (message.includes('Grant is not valid')) { + console.error(`Grant expired`) + } else { + console.error(`Error: ${message}`) + } + + process.exitCode = 1 + } + }, + ) + +export { inject as injectCommand } diff --git a/src/cli/request.ts b/src/cli/request.ts index 8cceedd..8ae4d9a 100644 --- a/src/cli/request.ts +++ b/src/cli/request.ts @@ -5,7 +5,7 @@ import { resolveService } from '../core/service.js' const request = new Command('request') .description('Request access to one or more secrets and inject into a command') - .argument('', 'UUIDs of the secrets to access') + .argument('', 'Secret refs or UUIDs to access') .requiredOption('--reason ', 'Justification for access') .requiredOption('--task ', 'Task reference (e.g., ticket ID)') .option('--duration ', 'Grant duration in seconds', '300') @@ -13,7 +13,7 @@ const request = new Command('request') .requiredOption('--cmd ', 'Command to run with secret injected') .action( async ( - uuids: string[], + refs: string[], opts: { reason: string task: string @@ -35,7 +35,20 @@ const request = new Command('request') return } - // 3. Create access request via service + // 3. Resolve refs to UUIDs + const uuids: string[] = [] + for (const ref of refs) { + try { + const metadata = await service.secrets.resolve(ref) + uuids.push(metadata.uuid) + } catch { + console.error(`Failed to resolve secret: ${ref}`) + process.exitCode = 1 + return + } + } + + // 4. Create access request via service const accessRequest = await service.requests.create( uuids, opts.reason, @@ -44,7 +57,7 @@ const request = new Command('request') opts.cmd, ) - // 4. Poll for grant status + // 5. Poll for grant status const pollIntervalMs = 250 const maxWaitMs = 5 * 60 * 1000 // 5 minutes const deadline = Date.now() + maxWaitMs @@ -53,26 +66,26 @@ const request = new Command('request') grantResult = await service.grants.getStatus(accessRequest.id) if (grantResult.status !== 'pending') break if (Date.now() > deadline) { - console.error(`Timed out waiting for approval: ${uuids.join(', ')}`) + console.error(`Timed out waiting for approval: ${refs.join(', ')}`) process.exitCode = 1 return } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) } if (grantResult.status !== 'approved') { - console.error(`Access request denied: ${uuids.join(', ')}`) + console.error(`Access request denied: ${refs.join(', ')}`) process.exitCode = 1 return } - // 5. Inject secret and run command + // 6. Inject secret and run command const processResult = await service.inject( accessRequest.id, opts.cmd, opts.env ? { envVarName: opts.env } : undefined, ) - // 6. Output result + // 7. Output result if (processResult.stdout) process.stdout.write(processResult.stdout) if (processResult.stderr) process.stderr.write(processResult.stderr) // Map null exit code (signal-killed processes) to exit code 1 intentionally, @@ -82,9 +95,9 @@ const request = new Command('request') const message = err instanceof Error ? err.message : String(err) if (message.includes('not found')) { - console.error(`Secret UUID not found: ${uuids.join(', ')}`) + console.error(`Secret not found: ${refs.join(', ')}`) } else if (message.includes('Grant is not valid')) { - console.error(`Grant expired: ${uuids.join(', ')}`) + console.error(`Grant expired: ${refs.join(', ')}`) } else { console.error(`Error: ${message}`) } From ed748ffc08d5cf966c96b21792a9668bf019c443 Mon Sep 17 00:00:00 2001 From: NoahCardoza Date: Fri, 27 Feb 2026 16:51:17 -0800 Subject: [PATCH 11/25] move to tsup --- .claudeignore | 22 +++ CLAUDE.md | 74 ++++++--- lefthook.yml | 2 +- package-lock.json | 376 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 6 +- tsup.config.ts | 15 ++ 6 files changed, 471 insertions(+), 24 deletions(-) create mode 100644 .claudeignore create mode 100644 tsup.config.ts diff --git a/.claudeignore b/.claudeignore new file mode 100644 index 0000000..73ddbca --- /dev/null +++ b/.claudeignore @@ -0,0 +1,22 @@ +# Dependencies - pnpm creates hundreds of nested node_modules +node_modules/ + +# Build outputs +dist/ +build/ +coverage/ +.nyc_output/ + +# Temporary files +*.tmp +*.temp +.cache/ +tmp/ + +# Logs +logs/ +*.log + +# IDE +.vscode/ +.idea/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 5e4064a..9930a45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,30 +1,62 @@ -# 2keychains - Project Conventions +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Commands -- **Build:** `npm run build` (compiles TypeScript to `dist/`) -- **Dev:** `npm run dev` (runs CLI directly via tsx) -- **Test:** `npm test` (runs Vitest) +- **Build:** `npm run build` (uses tsup, outputs to `dist/`) +- **Dev:** `npm run dev` (tsup watch mode) +- **Test:** `npm test` (Vitest with coverage, 95% threshold) - **Test (watch):** `npm run test:watch` -- **Lint:** `npm run lint` (ESLint + Prettier check) -- **Lint fix:** `npm run lint:fix` (auto-fix ESLint + Prettier) +- **Test (no coverage):** `npm run test:no-coverage` +- **Single test:** `npx vitest run src/__tests__/filename.test.ts` +- **Lint:** `npm run lint` (ESLint + Prettier, auto-fix) +- **Lint (check only):** `npm run lint:no-fix` +- **Type check:** `npm run compile` (tsc --noEmit) + +## Architecture + +2keychains is a local secret broker for AI agents. It replaces direct secret access with a controlled intermediary featuring approval workflows, placeholder injection, and output redaction. + +### Service Layer (`src/core/service.ts`) + +The `Service` interface is the central abstraction. Two implementations: + +- **LocalService** — Standalone mode. Owns the encrypted store, unlock session, grant manager, and workflow engine. Handles the full lifecycle: unlock → request → approve → inject. +- **RemoteService** — Client mode. Proxies requests to a running 2kc server via HTTP. + +`resolveService(config)` returns the appropriate implementation based on `config.mode`. + +### Request-Grant Flow + +1. CLI creates an `AccessRequest` via `Service.requests.create()` +2. `WorkflowEngine.processRequest()` checks if approval is needed (based on secret tags and `requireApproval` config) +3. If approval required, sends to `NotificationChannel` (Discord) and waits for response +4. On approval, `GrantManager.createGrant()` creates a signed JWT grant +5. `SecretInjector.inject()` resolves `2k://` placeholders and runs the command with secrets in env + +### Key Components + +- **EncryptedSecretStore** — AES-GCM encrypted secret storage with Argon2 KDF +- **UnlockSession** — In-memory DEK holder with TTL, idle timeout, and max-grants limits +- **SessionLock** — Persists session state to disk for CLI session continuity +- **GrantManager** — Issues and validates Ed25519-signed JWS grants +- **SecretInjector** — Resolves placeholders, spawns subprocess, redacts secrets from output +- **WorkflowEngine** — Orchestrates approval flow between store, channel, and config + +### Server (`src/server/`) + +Fastify server with bearer token auth. Routes delegate to the `Service` interface. -## Directory Structure +### Channels (`src/channels/`) -``` -src/ - cli/ # CLI entry point and command definitions - core/ # Core business logic - channels/ # Channel implementations (Discord, etc.) - __tests__/ # Test files -dist/ # Build output (gitignored) -``` +`NotificationChannel` interface for approval workflows. Discord implementation sends embeds and polls for emoji reactions. ## Coding Conventions -- **Module system:** ESM (`"type": "module"` in package.json) -- **TypeScript:** Strict mode, ES2022 target, Node16 module resolution -- **Formatting:** Prettier (no semicolons, single quotes, trailing commas) -- **Testing:** Vitest with globals enabled; test files use `*.test.ts` pattern in `src/` -- **CLI framework:** Commander; command name is `2kc` -- **Node.js:** Requires >=20.0.0 +- **ESM only** — `"type": "module"`, use `.js` extensions in imports +- **Strict TypeScript** — ES2022 target, Node16 module resolution +- **Prettier** — No semicolons, single quotes, trailing commas +- **Tests** — Vitest with globals; `*.test.ts` files in `src/__tests__/` +- **CLI** — Commander framework; binary name is `2kc` +- **Node.js** — Requires >=20.0.0 diff --git a/lefthook.yml b/lefthook.yml index d11446a..2205ded 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -3,7 +3,7 @@ pre-commit: commands: typecheck: glob: '*.{ts,tsx}' - run: npm run build + run: npm run compile lint: glob: '*.{ts,tsx,js,json,md}' run: npm run lint diff --git a/package-lock.json b/package-lock.json index 2bd3248..20d989c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "eslint": "^9.20.0", "lefthook": "^2.1.1", "prettier": "^3.5.0", + "tsup": "^8.5.1", "tsx": "^4.19.0", "typescript": "^5.7.0", "typescript-eslint": "^8.24.0", @@ -1895,6 +1896,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1978,6 +1986,22 @@ "concat-map": "0.0.1" } }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2042,6 +2066,22 @@ "node": ">= 16" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2078,6 +2118,23 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -2617,6 +2674,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -2952,6 +3021,16 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -3236,6 +3315,36 @@ ], "license": "MIT" }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3334,6 +3443,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3341,6 +3463,18 @@ "dev": true, "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3367,6 +3501,16 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -3557,6 +3701,28 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3586,6 +3752,49 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3644,6 +3853,20 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -3866,6 +4089,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4029,6 +4262,39 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4096,6 +4362,29 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -4178,6 +4467,16 @@ "node": ">=12" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -4191,6 +4490,76 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -4262,6 +4631,13 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index d5dd76b..4d1a8aa 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "2kc": "./dist/cli/index.js" }, "scripts": { - "build": "tsc", - "dev": "tsx src/cli/index.ts", + "build": "tsup", + "dev": "tsup --watch", + "compile": "tsc --noEmit", "lint": "eslint --fix . && prettier --write .", "lint:no-fix": "eslint . && prettier --check .", "test": "vitest run --coverage", @@ -41,6 +42,7 @@ "eslint": "^9.20.0", "lefthook": "^2.1.1", "prettier": "^3.5.0", + "tsup": "^8.5.1", "tsx": "^4.19.0", "typescript": "^5.7.0", "typescript-eslint": "^8.24.0", diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..4f4b136 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: { 'cli/index': 'src/cli/index.ts' }, + format: ['esm'], + target: 'node20', + outDir: 'dist', + clean: true, + sourcemap: true, + dts: true, + splitting: false, + shims: false, + // Makes the CLI entry point executable (chmod +x) + onSuccess: 'chmod +x dist/cli/index.js', +}) From c9e26698d22a704af85df27c184b5626d542bce5 Mon Sep 17 00:00:00 2001 From: NoahCardoza Date: Fri, 27 Feb 2026 16:58:50 -0800 Subject: [PATCH 12/25] remove discord webhook in favor of bot message to prime reactions --- src/__tests__/config-command.test.ts | 53 +------- src/__tests__/config.test.ts | 41 ++++-- src/__tests__/discord.test.ts | 176 ++++++++++++++++++++++---- src/__tests__/inject-command.test.ts | 1 - src/__tests__/request-command.test.ts | 1 - src/__tests__/service.test.ts | 1 - src/channels/discord.ts | 84 ++++++++++-- src/cli/config.ts | 19 +-- src/core/config.ts | 23 +++- 9 files changed, 279 insertions(+), 120 deletions(-) diff --git a/src/__tests__/config-command.test.ts b/src/__tests__/config-command.test.ts index f1b314a..fa2167c 100644 --- a/src/__tests__/config-command.test.ts +++ b/src/__tests__/config-command.test.ts @@ -27,7 +27,6 @@ function createValidConfig(overrides?: Partial): AppConfig { server: { host: '127.0.0.1', port: 2274 }, store: { path: '~/.2kc/secrets.json' }, discord: { - webhookUrl: 'https://discord.com/api/webhooks/123/abc', botToken: 'bot-token-1234567890', channelId: '999888777', }, @@ -158,12 +157,12 @@ describe('config init action', () => { await configCommand.parseAsync( [ 'init', - '--webhook-url', - 'https://discord.com/api/webhooks/999/xyz', '--bot-token', 'my-bot-token', '--channel-id', '112233', + '--authorized-user-ids', + 'user1,user2', ], { from: 'user' }, ) @@ -172,9 +171,9 @@ describe('config init action', () => { const writtenJson = mockWriteFileSync.mock.calls[0][1] as string const writtenConfig = JSON.parse(writtenJson) as AppConfig expect(writtenConfig.discord).toEqual({ - webhookUrl: 'https://discord.com/api/webhooks/999/xyz', botToken: 'my-bot-token', channelId: '112233', + authorizedUserIds: ['user1', 'user2'], }) }) @@ -205,8 +204,6 @@ describe('config init action', () => { 'tok123', '--store-path', '/my/store.json', - '--webhook-url', - 'https://discord.com/api/webhooks/999/xyz', '--bot-token', 'my-bot-token', '--channel-id', @@ -225,7 +222,6 @@ describe('config init action', () => { store: { path: '/my/store.json' }, unlock: defaultConfig().unlock, discord: { - webhookUrl: 'https://discord.com/api/webhooks/999/xyz', botToken: 'my-bot-token', channelId: '112233', }, @@ -345,7 +341,6 @@ describe('config show action', () => { it('redacts botToken (shows first 4 chars + "...")', async () => { const config = createValidConfig({ discord: { - webhookUrl: 'https://discord.com/api/webhooks/123/abcdefghij', botToken: 'bot-token-1234567890', channelId: '999888777', }, @@ -362,26 +357,6 @@ describe('config show action', () => { expect(output.discord.botToken).toBe('bot-...') }) - it('redacts webhookUrl (shows first 20 chars + "...")', async () => { - const config = createValidConfig({ - discord: { - webhookUrl: 'https://discord.com/api/webhooks/123/abcdefghij', - botToken: 'bot-token-1234567890', - channelId: '999888777', - }, - }) - mockReadFileSync.mockReturnValue(JSON.stringify(config)) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - const { configCommand } = await import('../cli/config.js') - await configCommand.parseAsync(['show'], { from: 'user' }) - - const output = JSON.parse(logSpy.mock.calls[0][0] as string) as { - discord: { webhookUrl: string } - } - expect(output.discord.webhookUrl).toBe('https://discord.com/...') - }) - it('redacts server.authToken', async () => { const config = createValidConfig({ server: { host: '127.0.0.1', port: 2274, authToken: 'super-secret-token' }, @@ -443,27 +418,6 @@ describe('config show action', () => { logSpy.mockRestore() }) - it('does not truncate short webhookUrl (<=20 chars)', async () => { - const config = createValidConfig({ - discord: { - webhookUrl: 'http://short.url', - botToken: 'bot-token-1234567890', - channelId: '999888777', - }, - }) - mockReadFileSync.mockReturnValue(JSON.stringify(config)) - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - const { configCommand } = await import('../cli/config.js') - await configCommand.parseAsync(['show'], { from: 'user' }) - - const output = JSON.parse(logSpy.mock.calls[0][0] as string) as { - discord: { webhookUrl: string } - } - expect(output.discord.webhookUrl).toBe('http://short.url') - logSpy.mockRestore() - }) - it('shows bindCommand field in output', async () => { const config = createValidConfig({ bindCommand: true } as Partial) mockReadFileSync.mockReturnValue(JSON.stringify(config)) @@ -480,7 +434,6 @@ describe('config show action', () => { it('does not truncate short botToken (<=4 chars)', async () => { const config = createValidConfig({ discord: { - webhookUrl: 'https://discord.com/api/webhooks/123/abc', botToken: 'tok', channelId: '999888777', }, diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 6c1b181..4ad36d7 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -40,7 +40,6 @@ describe('loadConfig', () => { it('should load and parse config from ~/.2kc/config.json', () => { const validConfig = { discord: { - webhookUrl: 'https://discord.com/api/webhooks/123/abc', botToken: 'bot-token-123', channelId: '999888777', }, @@ -94,7 +93,6 @@ describe('parseConfig', () => { const withDiscord = { discord: { - webhookUrl: 'https://discord.com/api/webhooks/123/abc', channelId: '123456', botToken: 'bot-token', }, @@ -221,30 +219,45 @@ describe('parseConfig', () => { expect(() => parseConfig('string')).toThrow('Config must be a JSON object') }) - it('throws if discord.webhookUrl is missing', () => { - expect(() => parseConfig({ discord: { channelId: '123', botToken: 'tok' } })).toThrow( - 'discord.webhookUrl must be a non-empty string', + it('throws if discord.channelId is missing', () => { + expect(() => parseConfig({ discord: { botToken: 'tok' } })).toThrow( + 'discord.channelId must be a non-empty string', ) }) - it('throws if discord.webhookUrl is empty string', () => { + it('throws if discord.botToken is empty string', () => { expect(() => - parseConfig({ discord: { webhookUrl: '', channelId: '123', botToken: 'tok' } }), - ).toThrow('discord.webhookUrl must be a non-empty string') + parseConfig({ + discord: { channelId: '123', botToken: '' }, + }), + ).toThrow('discord.botToken must be a non-empty string') }) - it('throws if discord.channelId is missing', () => { + it('parses discord.authorizedUserIds when present', () => { + const config = parseConfig({ + discord: { + channelId: '123', + botToken: 'tok', + authorizedUserIds: ['user1', 'user2'], + }, + }) + expect(config.discord?.authorizedUserIds).toEqual(['user1', 'user2']) + }) + + it('throws if discord.authorizedUserIds is not an array', () => { expect(() => - parseConfig({ discord: { webhookUrl: 'https://example.com', botToken: 'tok' } }), - ).toThrow('discord.channelId must be a non-empty string') + parseConfig({ + discord: { channelId: '123', botToken: 'tok', authorizedUserIds: 'invalid' }, + }), + ).toThrow('discord.authorizedUserIds must be an array') }) - it('throws if discord.botToken is empty string', () => { + it('throws if discord.authorizedUserIds contains non-string', () => { expect(() => parseConfig({ - discord: { webhookUrl: 'https://example.com', channelId: '123', botToken: '' }, + discord: { channelId: '123', botToken: 'tok', authorizedUserIds: ['valid', 123] }, }), - ).toThrow('discord.botToken must be a non-empty string') + ).toThrow('discord.authorizedUserIds must contain non-empty strings') }) it('parses unlock config with custom values', () => { diff --git a/src/__tests__/discord.test.ts b/src/__tests__/discord.test.ts index 8763282..79bd396 100644 --- a/src/__tests__/discord.test.ts +++ b/src/__tests__/discord.test.ts @@ -3,11 +3,12 @@ import { DiscordChannel } from '../channels/discord.js' import type { AccessRequest } from '../core/types.js' const TEST_CONFIG = { - webhookUrl: 'https://discord.com/api/webhooks/123/abc', botToken: 'test-bot-token', channelId: '999888777', } +const BOT_USER_ID = 'bot-user-123' + const TEST_REQUEST: AccessRequest = { uuids: ['req-001'], requester: 'alice', @@ -35,25 +36,32 @@ describe('DiscordChannel', () => { }) describe('sendApprovalRequest', () => { - it('should POST embed to webhook URL with ?wait=true and return message ID', async () => { + it('should POST embed to Bot API and add reactions, returning message ID', async () => { + // Mock the POST to create message fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ id: '123456' }), }) + // Mock the PUT for approve emoji reaction + fetchMock.mockResolvedValueOnce({ ok: true }) + // Mock the PUT for deny emoji reaction + fetchMock.mockResolvedValueOnce({ ok: true }) const messageId = await channel.sendApprovalRequest(TEST_REQUEST) expect(messageId).toBe('123456') - expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock).toHaveBeenCalledTimes(3) - const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(url).toBe(`${TEST_CONFIG.webhookUrl}?wait=true`) - expect(options.method).toBe('POST') - expect(options.headers).toEqual({ + // Check the message POST + const [postUrl, postOptions] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(postUrl).toBe(`https://discord.com/api/v10/channels/${TEST_CONFIG.channelId}/messages`) + expect(postOptions.method).toBe('POST') + expect(postOptions.headers).toEqual({ 'Content-Type': 'application/json', + Authorization: `Bot ${TEST_CONFIG.botToken}`, }) - const body = JSON.parse(options.body as string) + const body = JSON.parse(postOptions.body as string) expect(body.embeds).toHaveLength(1) expect(body.embeds[0].title).toBe('Access Request') expect(body.embeds[0].color).toBe(0xffa500) @@ -74,6 +82,17 @@ describe('DiscordChannel', () => { expect.objectContaining({ name: 'Duration', value: '1h' }), ]), ) + + // Check the reaction PUTs + const [approveUrl] = fetchMock.mock.calls[1] as [string, RequestInit] + expect(approveUrl).toBe( + `https://discord.com/api/v10/channels/${TEST_CONFIG.channelId}/messages/123456/reactions/%E2%9C%85/@me`, + ) + + const [denyUrl] = fetchMock.mock.calls[2] as [string, RequestInit] + expect(denyUrl).toBe( + `https://discord.com/api/v10/channels/${TEST_CONFIG.channelId}/messages/123456/reactions/%E2%9D%8C/@me`, + ) }) it('should include Bound Command field when commandHash is present', async () => { @@ -81,6 +100,8 @@ describe('DiscordChannel', () => { ok: true, json: async () => ({ id: '789012' }), }) + fetchMock.mockResolvedValueOnce({ ok: true }) + fetchMock.mockResolvedValueOnce({ ok: true }) const requestWithHash: AccessRequest = { ...TEST_REQUEST, @@ -98,7 +119,7 @@ describe('DiscordChannel', () => { expect(boundCommandField.value).toContain('abc123deadbeef') }) - it('should throw on non-ok response from webhook', async () => { + it('should throw on non-ok response from Bot API', async () => { fetchMock.mockResolvedValueOnce({ ok: false, status: 400, @@ -106,7 +127,7 @@ describe('DiscordChannel', () => { }) await expect(channel.sendApprovalRequest(TEST_REQUEST)).rejects.toThrow( - 'Discord webhook failed: 400 Bad Request', + 'Discord API failed: 400 Bad Request', ) }) @@ -115,6 +136,8 @@ describe('DiscordChannel', () => { ok: true, json: async () => ({ id: '123456' }), }) + fetchMock.mockResolvedValueOnce({ ok: true }) + fetchMock.mockResolvedValueOnce({ ok: true }) await channel.sendApprovalRequest(createRequestWithDuration(300000)) // 5 minutes @@ -130,6 +153,8 @@ describe('DiscordChannel', () => { ok: true, json: async () => ({ id: '123456' }), }) + fetchMock.mockResolvedValueOnce({ ok: true }) + fetchMock.mockResolvedValueOnce({ ok: true }) await channel.sendApprovalRequest(createRequestWithDuration(45000)) // 45 seconds @@ -145,6 +170,8 @@ describe('DiscordChannel', () => { ok: true, json: async () => ({ id: '123456' }), }) + fetchMock.mockResolvedValueOnce({ ok: true }) + fetchMock.mockResolvedValueOnce({ ok: true }) await channel.sendApprovalRequest(createRequestWithDuration(5700000)) // 1h 35m @@ -165,11 +192,19 @@ describe('DiscordChannel', () => { vi.useRealTimers() }) - it('should return "approved" when approve emoji reaction is found', async () => { - // First call: check approve emoji -> found + it('should return "approved" when approve emoji reaction is found from non-bot user', async () => { + // First call: check approve emoji -> found with bot and human user fetchMock.mockResolvedValueOnce({ ok: true, - json: async () => [{ id: 'user1', username: 'bob' }], + json: async () => [ + { id: BOT_USER_ID, username: 'bot' }, + { id: 'user1', username: 'bob' }, + ], + }) + // Second call: get bot user ID + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: BOT_USER_ID }), }) const promise = channel.waitForResponse('msg-1', 10000) @@ -178,16 +213,56 @@ describe('DiscordChannel', () => { expect(result).toBe('approved') }) - it('should return "denied" when deny emoji reaction is found', async () => { - // First call: check approve emoji -> empty + it('should ignore bot-only reactions and continue polling', async () => { + // First poll: approve emoji has only bot reaction + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: BOT_USER_ID, username: 'bot' }], + }) + // Get bot user ID (will be cached after first call) fetchMock.mockResolvedValueOnce({ ok: true, - json: async () => [], + json: async () => ({ id: BOT_USER_ID }), }) - // Second call: check deny emoji -> found + // First poll: deny emoji has only bot reaction + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: BOT_USER_ID, username: 'bot' }], + }) + + // After sleep, second poll: approve found with human user fetchMock.mockResolvedValueOnce({ ok: true, - json: async () => [{ id: 'user2', username: 'carol' }], + json: async () => [ + { id: BOT_USER_ID, username: 'bot' }, + { id: 'user1', username: 'bob' }, + ], + }) + + const promise = channel.waitForResponse('msg-1', 10000) + + // Advance past the poll interval to trigger second iteration + await vi.advanceTimersByTimeAsync(2500) + + const result = await promise + expect(result).toBe('approved') + }) + + it('should return "denied" when deny emoji reaction is found from non-bot user', async () => { + // First call: check approve emoji -> only bot + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: BOT_USER_ID }], + }) + // Get bot user ID + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: BOT_USER_ID }), + }) + // Second call: check deny emoji -> found with human + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: BOT_USER_ID }, { id: 'user2', username: 'carol' }], }) const result = await channel.waitForResponse('msg-1', 10000) @@ -196,10 +271,13 @@ describe('DiscordChannel', () => { }) it('should return "timeout" when no reactions within timeout window', async () => { - // Mock all fetch calls to return empty reactions - fetchMock.mockResolvedValue({ - ok: true, - json: async () => [], + // Mock bot user ID call + fetchMock.mockImplementation(async (url: string) => { + if (url.includes('/users/@me')) { + return { ok: true, json: async () => ({ id: BOT_USER_ID }) } + } + // Return only bot reactions for reaction checks + return { ok: true, json: async () => [{ id: BOT_USER_ID }] } }) // Use a short timeout and advance timers in a loop to drain all pending work @@ -228,11 +306,16 @@ describe('DiscordChannel', () => { statusText: 'Not Found', }) - // After sleep, second poll: approve found + // After sleep, second poll: approve found with human user fetchMock.mockResolvedValueOnce({ ok: true, json: async () => [{ id: 'user1' }], }) + // Get bot user ID + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: BOT_USER_ID }), + }) const promise = channel.waitForResponse('msg-1', 10000) @@ -254,18 +337,59 @@ describe('DiscordChannel', () => { 'Discord reactions API failed: 401 Unauthorized', ) }) + + it('should only accept reactions from authorized users when authorizedUserIds is set', async () => { + const channelWithAuth = new DiscordChannel({ + ...TEST_CONFIG, + authorizedUserIds: ['authorized-user-1', 'authorized-user-2'], + }) + + // First poll: approve emoji has unauthorized user + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: BOT_USER_ID }, { id: 'unauthorized-user', username: 'stranger' }], + }) + // Get bot user ID + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: BOT_USER_ID }), + }) + // First poll: deny emoji has no reactions + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: BOT_USER_ID }], + }) + + // After sleep, second poll: approve has authorized user + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: BOT_USER_ID }, { id: 'authorized-user-1', username: 'admin' }], + }) + + const promise = channelWithAuth.waitForResponse('msg-1', 10000) + + // Advance past the poll interval + await vi.advanceTimersByTimeAsync(2500) + + const result = await promise + expect(result).toBe('approved') + }) }) describe('sendNotification', () => { - it('should POST text content to webhook URL', async () => { + it('should POST text content to Bot API', async () => { fetchMock.mockResolvedValueOnce({ ok: true }) await channel.sendNotification('Request approved by bob') expect(fetchMock).toHaveBeenCalledOnce() const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(url).toBe(TEST_CONFIG.webhookUrl) + expect(url).toBe(`https://discord.com/api/v10/channels/${TEST_CONFIG.channelId}/messages`) expect(options.method).toBe('POST') + expect(options.headers).toEqual({ + 'Content-Type': 'application/json', + Authorization: `Bot ${TEST_CONFIG.botToken}`, + }) const body = JSON.parse(options.body as string) expect(body.content).toBe('Request approved by bob') @@ -279,7 +403,7 @@ describe('DiscordChannel', () => { }) await expect(channel.sendNotification('test message')).rejects.toThrow( - 'Discord webhook failed: 500 Internal Server Error', + 'Discord API failed: 500 Internal Server Error', ) }) }) diff --git a/src/__tests__/inject-command.test.ts b/src/__tests__/inject-command.test.ts index 1e7fba3..0b38a08 100644 --- a/src/__tests__/inject-command.test.ts +++ b/src/__tests__/inject-command.test.ts @@ -50,7 +50,6 @@ function createTestConfig(): AppConfig { server: { host: '127.0.0.1', port: 2274 }, store: { path: '~/.2kc/secrets.json' }, discord: { - webhookUrl: 'https://discord.com/api/webhooks/123/abc', botToken: 'bot-token-123', channelId: '999888777', }, diff --git a/src/__tests__/request-command.test.ts b/src/__tests__/request-command.test.ts index 4f0935b..2dbf4ac 100644 --- a/src/__tests__/request-command.test.ts +++ b/src/__tests__/request-command.test.ts @@ -50,7 +50,6 @@ function createTestConfig(): AppConfig { server: { host: '127.0.0.1', port: 2274 }, store: { path: '~/.2kc/secrets.json' }, discord: { - webhookUrl: 'https://discord.com/api/webhooks/123/abc', botToken: 'bot-token-123', channelId: '999888777', }, diff --git a/src/__tests__/service.test.ts b/src/__tests__/service.test.ts index 9946a45..d2dfdfd 100644 --- a/src/__tests__/service.test.ts +++ b/src/__tests__/service.test.ts @@ -50,7 +50,6 @@ describe('resolveService', () => { const config = { ...defaultConfig(), discord: { - webhookUrl: 'https://discord.com/api/webhooks/123/abc', botToken: 'bot-token', channelId: '999888777', }, diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 4272511..de6e066 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -7,9 +7,9 @@ const POLL_INTERVAL_MS = 2500 const DISCORD_API_BASE = 'https://discord.com/api/v10' export interface DiscordChannelConfig { - webhookUrl: string botToken: string channelId: string + authorizedUserIds?: string[] } function formatDuration(ms: number): string { @@ -28,14 +28,15 @@ function formatDuration(ms: number): string { } export class DiscordChannel implements NotificationChannel { - private readonly webhookUrl: string private readonly botToken: string private readonly channelId: string + private readonly authorizedUserIds?: string[] + private cachedBotUserId?: string constructor(config: DiscordChannelConfig) { - this.webhookUrl = config.webhookUrl this.botToken = config.botToken this.channelId = config.channelId + this.authorizedUserIds = config.authorizedUserIds } async sendApprovalRequest(request: AccessRequest): Promise { @@ -60,19 +61,28 @@ export class DiscordChannel implements NotificationChannel { fields, } - const url = `${this.webhookUrl}?wait=true` + const url = `${DISCORD_API_BASE}/channels/${this.channelId}/messages` const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bot ${this.botToken}`, + }, body: JSON.stringify({ embeds: [embed] }), }) if (!response.ok) { - throw new Error(`Discord webhook failed: ${response.status} ${response.statusText}`) + throw new Error(`Discord API failed: ${response.status} ${response.statusText}`) } const data = (await response.json()) as { id: string } - return data.id + const messageId = data.id + + // Add approval reactions to the message + await this.addReaction(messageId, APPROVE_EMOJI) + await this.addReaction(messageId, DENY_EMOJI) + + return messageId } async waitForResponse( @@ -96,14 +106,18 @@ export class DiscordChannel implements NotificationChannel { } async sendNotification(message: string): Promise { - const response = await fetch(this.webhookUrl, { + const url = `${DISCORD_API_BASE}/channels/${this.channelId}/messages` + const response = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bot ${this.botToken}`, + }, body: JSON.stringify({ content: message }), }) if (!response.ok) { - throw new Error(`Discord webhook failed: ${response.status} ${response.statusText}`) + throw new Error(`Discord API failed: ${response.status} ${response.statusText}`) } } @@ -117,9 +131,55 @@ export class DiscordChannel implements NotificationChannel { if (response.status === 404) return false throw new Error(`Discord reactions API failed: ${response.status} ${response.statusText}`) } + const json = await response.json() + const users = json as { id: string }[] + + // Get the bot's user ID to filter it out + const botUserId = await this.getBotUserId() + + // Filter out the bot's own reactions + const nonBotUsers = users.filter((user) => user.id !== botUserId) + + // If authorizedUserIds is set, only count reactions from those users + if (this.authorizedUserIds && this.authorizedUserIds.length > 0) { + const authorizedReactions = nonBotUsers.filter((user) => + this.authorizedUserIds!.includes(user.id), + ) + return authorizedReactions.length > 0 + } - const users = (await response.json()) as unknown[] - return users.length > 0 + return nonBotUsers.length > 0 + } + + private async getBotUserId(): Promise { + if (this.cachedBotUserId) { + return this.cachedBotUserId + } + + const url = `${DISCORD_API_BASE}/users/@me` + const response = await fetch(url, { + headers: { Authorization: `Bot ${this.botToken}` }, + }) + + if (!response.ok) { + throw new Error(`Discord API failed: ${response.status} ${response.statusText}`) + } + + const data = (await response.json()) as { id: string } + this.cachedBotUserId = data.id + return data.id + } + + private async addReaction(messageId: string, emoji: string): Promise { + const url = `${DISCORD_API_BASE}/channels/${this.channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me` + const response = await fetch(url, { + method: 'PUT', + headers: { Authorization: `Bot ${this.botToken}` }, + }) + + if (!response.ok) { + throw new Error(`Discord API failed: ${response.status} ${response.statusText}`) + } } private sleep(ms: number): Promise { diff --git a/src/cli/config.ts b/src/cli/config.ts index 78442b0..8dc9662 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -18,9 +18,9 @@ config .option('--server-port ', 'Server port', '2274') .option('--server-auth-token ', 'Server auth token') .option('--store-path ', 'Secret store path', '~/.2kc/secrets.json') - .option('--webhook-url ', 'Discord webhook URL') .option('--bot-token ', 'Discord bot token') .option('--channel-id ', 'Discord channel ID') + .option('--authorized-user-ids ', 'Comma-separated Discord user IDs authorized to approve') .option('--default-require-approval', 'Require approval by default', false) .option('--approval-timeout ', 'Approval timeout in ms', '300000') .action( @@ -30,9 +30,9 @@ config serverPort: string serverAuthToken?: string storePath: string - webhookUrl?: string botToken?: string channelId?: string + authorizedUserIds?: string defaultRequireApproval: boolean approvalTimeout: string }) => { @@ -68,11 +68,18 @@ config }, unlock: defaultConfig().unlock, discord: - opts.webhookUrl && opts.botToken && opts.channelId + opts.botToken && opts.channelId ? { - webhookUrl: opts.webhookUrl, botToken: opts.botToken, channelId: opts.channelId, + ...(opts.authorizedUserIds + ? { + authorizedUserIds: opts.authorizedUserIds + .split(',') + .map((id) => id.trim()) + .filter((id) => id !== ''), + } + : {}), } : undefined, requireApproval: {}, @@ -119,10 +126,6 @@ config appConfig.discord.botToken.length > 4 ? appConfig.discord.botToken.slice(0, 4) + '...' : appConfig.discord.botToken, - webhookUrl: - appConfig.discord.webhookUrl.length > 20 - ? appConfig.discord.webhookUrl.slice(0, 20) + '...' - : appConfig.discord.webhookUrl, } } diff --git a/src/core/config.ts b/src/core/config.ts index a960d66..267d8c2 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -3,9 +3,9 @@ import { resolve, dirname } from 'node:path' import { homedir } from 'node:os' export interface DiscordConfig { - webhookUrl: string botToken: string channelId: string + authorizedUserIds?: string[] } export interface ServerConfig { @@ -67,11 +67,6 @@ function parseDiscordConfig(raw: unknown): DiscordConfig | undefined { throw new Error('discord must be an object') } - const webhookUrl = raw.webhookUrl - if (typeof webhookUrl !== 'string' || webhookUrl === '') { - throw new Error('discord.webhookUrl must be a non-empty string') - } - const channelId = raw.channelId if (typeof channelId !== 'string' || channelId === '') { throw new Error('discord.channelId must be a non-empty string') @@ -82,7 +77,21 @@ function parseDiscordConfig(raw: unknown): DiscordConfig | undefined { throw new Error('discord.botToken must be a non-empty string') } - return { webhookUrl, botToken, channelId } + const result: DiscordConfig = { botToken, channelId } + + if (raw.authorizedUserIds !== undefined) { + if (!Array.isArray(raw.authorizedUserIds)) { + throw new Error('discord.authorizedUserIds must be an array') + } + for (const id of raw.authorizedUserIds) { + if (typeof id !== 'string' || id === '') { + throw new Error('discord.authorizedUserIds must contain non-empty strings') + } + } + result.authorizedUserIds = raw.authorizedUserIds as string[] + } + + return result } function parseServerConfig(raw: unknown): ServerConfig { From 1415acfca2701ff95463f330f39b2148c26d68f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 19:33:04 +0000 Subject: [PATCH 13/25] Initial plan From 1d28bf92acb2b4f1f9bfc1a30ef018fb87a6d672 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 19:33:21 +0000 Subject: [PATCH 14/25] Initial plan From 94265601ca17cbc10d0db482e9d9a4d07c028139 Mon Sep 17 00:00:00 2001 From: Noah Cardoza Date: Sat, 28 Feb 2026 11:33:54 -0800 Subject: [PATCH 15/25] Update src/core/key-manager.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/core/key-manager.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/core/key-manager.ts b/src/core/key-manager.ts index e16734c..f917d40 100644 --- a/src/core/key-manager.ts +++ b/src/core/key-manager.ts @@ -41,10 +41,19 @@ export async function loadOrGenerateKeyPair(keyFilePath: string): Promise Date: Sat, 28 Feb 2026 19:35:49 +0000 Subject: [PATCH 16/25] Remove toLowerCase from normalizeCommand to prevent case-based command bypass Co-authored-by: NoahCardoza <10343470+NoahCardoza@users.noreply.github.com> --- src/__tests__/command-hash.test.ts | 12 ++++-------- src/core/command-hash.ts | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/__tests__/command-hash.test.ts b/src/__tests__/command-hash.test.ts index a933401..184b24b 100644 --- a/src/__tests__/command-hash.test.ts +++ b/src/__tests__/command-hash.test.ts @@ -10,12 +10,8 @@ describe('normalizeCommand', () => { expect(normalizeCommand('echo hello\t\tworld')).toBe('echo hello world') }) - it('lowercases the entire string', () => { - expect(normalizeCommand('ECHO HELLO')).toBe('echo hello') - }) - - it('handles combined: " FOO BAR " → "foo bar"', () => { - expect(normalizeCommand(' FOO BAR ')).toBe('foo bar') + it('handles combined: " FOO BAR " → "FOO BAR"', () => { + expect(normalizeCommand(' FOO BAR ')).toBe('FOO BAR') }) it('throws on empty string', () => { @@ -47,10 +43,10 @@ describe('hashCommand', () => { }) it('round-trip: normalizeCommand then hashCommand is stable across calls', () => { - const input = ' ECHO Hello World ' + const input = ' echo Hello World ' const hash1 = hashCommand(normalizeCommand(input)) const hash2 = hashCommand(normalizeCommand(input)) expect(hash1).toBe(hash2) - expect(hash1).toBe(hashCommand('echo hello world')) + expect(hash1).toBe(hashCommand('echo Hello World')) }) }) diff --git a/src/core/command-hash.ts b/src/core/command-hash.ts index 62ffb16..71faef4 100644 --- a/src/core/command-hash.ts +++ b/src/core/command-hash.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' export function normalizeCommand(cmd: string): string { - const normalized = cmd.trim().replace(/\s+/g, ' ').toLowerCase() + const normalized = cmd.trim().replace(/\s+/g, ' ') if (normalized.length === 0) { throw new Error('command must not be empty') } From 80333b01a8266fc8e179200c2b92e0dceb4488e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 19:39:38 +0000 Subject: [PATCH 17/25] Add GET /api/keys/public route so clients can fetch the server signing key Co-authored-by: NoahCardoza <10343470+NoahCardoza@users.noreply.github.com> --- .../integration/client-server-flow.test.ts | 1 + src/__tests__/routes.test.ts | 34 +++++++++++++++++++ src/__tests__/service.test.ts | 8 +++++ src/core/remote-service.ts | 5 +++ src/core/service.ts | 13 ++++++- src/server/routes.ts | 6 ++++ 6 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/__tests__/integration/client-server-flow.test.ts b/src/__tests__/integration/client-server-flow.test.ts index 6612dc3..c30cf2c 100644 --- a/src/__tests__/integration/client-server-flow.test.ts +++ b/src/__tests__/integration/client-server-flow.test.ts @@ -132,6 +132,7 @@ async function buildService(tmpDir: string, opts: BuildServiceOpts = {}): Promis requestLog, startTime: Date.now(), bindCommand: opts.bindCommand ?? false, + publicKey, }) return { service, publicKey } diff --git a/src/__tests__/routes.test.ts b/src/__tests__/routes.test.ts index de69753..213b82b 100644 --- a/src/__tests__/routes.test.ts +++ b/src/__tests__/routes.test.ts @@ -25,6 +25,11 @@ function makeGrantMock(overrides?: Partial): AccessGrant { function makeMockService(): Service { return { health: vi.fn().mockResolvedValue({ status: 'unlocked' }), + keys: { + getPublicKey: vi + .fn() + .mockResolvedValue('-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----'), + }, secrets: { list: vi.fn().mockResolvedValue([]), add: vi.fn().mockResolvedValue({ uuid: TEST_UUID }), @@ -55,6 +60,35 @@ function makeMockService(): Service { } describe('API Routes', () => { + describe('GET /api/keys/public', () => { + it('returns 200 with publicKey field', async () => { + const service = makeMockService() + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: '/api/keys/public', + headers: authHeaders, + }) + + expect(response.statusCode).toBe(200) + const body = JSON.parse(response.body) + expect(typeof body.publicKey).toBe('string') + expect(body.publicKey).toContain('BEGIN PUBLIC KEY') + expect(service.keys.getPublicKey).toHaveBeenCalled() + + await server.close() + }) + + it('returns 401 without auth header', async () => { + const server = createServer(makeMockService(), TEST_TOKEN) + const response = await server.inject({ method: 'GET', url: '/api/keys/public' }) + + expect(response.statusCode).toBe(401) + + await server.close() + }) + }) + describe('GET /api/secrets', () => { it('returns 200 with secret list', async () => { const service = makeMockService() diff --git a/src/__tests__/service.test.ts b/src/__tests__/service.test.ts index d2dfdfd..ab52937 100644 --- a/src/__tests__/service.test.ts +++ b/src/__tests__/service.test.ts @@ -127,6 +127,10 @@ function makeService() { const startTime = Date.now() - 1000 + const publicKey = { + export: vi.fn().mockReturnValue('-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----'), + } as unknown as import('node:crypto').KeyObject + const service = new LocalService({ store, unlockSession, @@ -137,6 +141,7 @@ function makeService() { requestLog, startTime, bindCommand: false, + publicKey, }) return { service, @@ -272,6 +277,9 @@ describe('LocalService', () => { requestLog, startTime, bindCommand: true, + publicKey: { + export: vi.fn().mockReturnValue(''), + } as unknown as import('node:crypto').KeyObject, }) ;(workflowEngine.processRequest as MockInstance).mockImplementation(async (req) => { req.status = 'approved' diff --git a/src/core/remote-service.ts b/src/core/remote-service.ts index c7442aa..1beef65 100644 --- a/src/core/remote-service.ts +++ b/src/core/remote-service.ts @@ -141,6 +141,11 @@ export class RemoteService implements Service { return this.request<{ status: string; uptime?: number }>('GET', '/health') } + keys: Service['keys'] = { + getPublicKey: () => + this.request<{ publicKey: string }>('GET', '/api/keys/public').then((r) => r.publicKey), + } + secrets: Service['secrets'] = { list: () => this.request('GET', '/api/secrets'), add: (ref: string, value: string, tags?: string[]) => diff --git a/src/core/service.ts b/src/core/service.ts index 3e52a5e..68021e4 100644 --- a/src/core/service.ts +++ b/src/core/service.ts @@ -1,4 +1,5 @@ import { join, dirname } from 'node:path' +import type { KeyObject } from 'node:crypto' import type { AppConfig } from './config.js' import type { SecretListItem, SecretMetadata, ProcessResult } from './types.js' import type { AccessRequest, AccessRequestStatus } from './request.js' @@ -22,6 +23,10 @@ export type SecretSummary = SecretListItem export interface Service { health(): Promise<{ status: string; uptime?: number }> + keys: { + getPublicKey(): Promise + } + secrets: { list(): Promise add(ref: string, value: string, tags?: string[]): Promise<{ uuid: string }> @@ -63,6 +68,7 @@ interface LocalServiceDeps { requestLog: RequestLog startTime: number bindCommand: boolean + publicKey: KeyObject } export class LocalService implements Service { @@ -108,6 +114,10 @@ export class LocalService implements Service { } } + keys: Service['keys'] = { + getPublicKey: async () => this.deps.publicKey.export({ type: 'spki', format: 'pem' }) as string, + } + secrets: Service['secrets'] = { list: async () => this.deps.store.list(), @@ -214,7 +224,7 @@ export async function resolveService(config: AppConfig): Promise { const grantsPath = join(dirname(config.store.path), 'server-grants.json') const requestsPath = join(dirname(config.store.path), 'server-requests.json') const keysPath = join(dirname(config.store.path), 'server-keys.json') - const { privateKey } = await loadOrGenerateKeyPair(keysPath) + const { privateKey, publicKey } = await loadOrGenerateKeyPair(keysPath) const store = new EncryptedSecretStore(config.store.path) const unlockSession = new UnlockSession(config.unlock) @@ -262,5 +272,6 @@ export async function resolveService(config: AppConfig): Promise { requestLog, startTime, bindCommand: config.bindCommand, + publicKey, }) } diff --git a/src/server/routes.ts b/src/server/routes.ts index f9c23ec..e5ec178 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -39,6 +39,12 @@ export const routePlugin = fp( async (fastify: FastifyInstance, opts: RoutePluginOptions) => { const { service } = opts + // GET /api/keys/public — expose server's signing public key for grant verification + fastify.get('/api/keys/public', async () => { + const publicKey = await service.keys.getPublicKey().catch(handleError) + return { publicKey } + }) + // GET /api/secrets — list secrets (metadata only) fastify.get('/api/secrets', async () => service.secrets.list().catch(handleError)) From 2453d55c7dfd9335e52a4c3cc1ccf454c885fab2 Mon Sep 17 00:00:00 2001 From: NoahCardoza Date: Fri, 27 Feb 2026 23:42:40 -0800 Subject: [PATCH 18/25] remove old server entry file --- src/cli/server.ts | 4 ++-- tsup.config.ts | 5 ++++- vitest.config.ts | 2 -- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/cli/server.ts b/src/cli/server.ts index 846cba9..cba46a5 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -69,14 +69,14 @@ server } if (opts.foreground) { - await import('../core/server-entry.js') + await import('../server/index.js') return } // Fork the server-entry as detached process mkdirSync(dirname(LOG_FILE_PATH), { recursive: true }) const logFd = openSync(LOG_FILE_PATH, 'a') - const entryPoint = resolve(import.meta.dirname, '../core/server-entry.js') + const entryPoint = resolve(import.meta.url, '../server/index.js') const child = fork(entryPoint, [], { detached: true, stdio: ['ignore', logFd, logFd, 'ipc'], diff --git a/tsup.config.ts b/tsup.config.ts index 4f4b136..8f14511 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: { 'cli/index': 'src/cli/index.ts' }, + entry: { + 'cli/index': 'src/cli/index.ts', + 'server/index': 'src/server/index.ts', + }, format: ['esm'], target: 'node20', outDir: 'dist', diff --git a/vitest.config.ts b/vitest.config.ts index a426ff0..dffb2fb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,8 +18,6 @@ export default defineConfig({ 'src/cli/index.ts', 'src/cli/password-prompt.ts', 'src/server/index.ts', - // Deprecated - 'src/core/server-entry.ts', ], thresholds: { lines: 95, From dee6771dacc2db4a303e1eaa2accaceddf95982f Mon Sep 17 00:00:00 2001 From: NoahCardoza Date: Fri, 27 Feb 2026 23:54:02 -0800 Subject: [PATCH 19/25] add ability to config directory --- src/__tests__/config-command.test.ts | 10 ++++++-- src/__tests__/inject-command.test.ts | 1 + src/__tests__/request-command.test.ts | 1 + src/__tests__/secrets-command.test.ts | 1 + src/__tests__/server-command.test.ts | 1 + src/__tests__/store-command.test.ts | 1 + src/__tests__/unlock-command.test.ts | 1 + src/cli/config.ts | 4 ++- src/cli/unlock.ts | 5 ++-- src/core/config.ts | 30 ++++++++++++++++++---- src/core/encrypted-store.ts | 4 +-- src/core/grant.ts | 4 +-- src/core/pid-manager.ts | 8 +++--- src/core/secret-store.ts | 4 +-- src/core/server-entry.ts | 37 --------------------------- src/core/session-lock.ts | 5 ++-- 16 files changed, 56 insertions(+), 61 deletions(-) delete mode 100644 src/core/server-entry.ts diff --git a/src/__tests__/config-command.test.ts b/src/__tests__/config-command.test.ts index fa2167c..50ba350 100644 --- a/src/__tests__/config-command.test.ts +++ b/src/__tests__/config-command.test.ts @@ -14,7 +14,13 @@ vi.mock('node:os', () => ({ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs' import { join } from 'node:path' -import { saveConfig, defaultConfig, CONFIG_PATH, type AppConfig } from '../core/config.js' +import { + saveConfig, + defaultConfig, + CONFIG_PATH, + CONFIG_DIR, + type AppConfig, +} from '../core/config.js' const mockReadFileSync = vi.mocked(readFileSync) const mockWriteFileSync = vi.mocked(writeFileSync) @@ -91,7 +97,7 @@ describe('config init action', () => { const writtenConfig = JSON.parse(writtenJson) as AppConfig expect(writtenConfig.mode).toBe('standalone') expect(writtenConfig.server).toEqual({ host: '127.0.0.1', port: 2274 }) - expect(writtenConfig.store).toEqual({ path: '~/.2kc/secrets.json' }) + expect(writtenConfig.store).toEqual({ path: join(CONFIG_DIR, 'secrets.json') }) }) it('accepts --mode client flag', async () => { diff --git a/src/__tests__/inject-command.test.ts b/src/__tests__/inject-command.test.ts index 0b38a08..e36141a 100644 --- a/src/__tests__/inject-command.test.ts +++ b/src/__tests__/inject-command.test.ts @@ -38,6 +38,7 @@ const mockResolveService = vi.fn<() => Service>() vi.mock('../core/config.js', () => ({ loadConfig: (...args: unknown[]) => mockLoadConfig(...(args as [])), + CONFIG_DIR: '/tmp/.2kc', })) vi.mock('../core/service.js', () => ({ diff --git a/src/__tests__/request-command.test.ts b/src/__tests__/request-command.test.ts index 2dbf4ac..430f5ac 100644 --- a/src/__tests__/request-command.test.ts +++ b/src/__tests__/request-command.test.ts @@ -38,6 +38,7 @@ const mockResolveService = vi.fn<() => Service>() vi.mock('../core/config.js', () => ({ loadConfig: (...args: unknown[]) => mockLoadConfig(...(args as [])), + CONFIG_DIR: '/tmp/.2kc', })) vi.mock('../core/service.js', () => ({ diff --git a/src/__tests__/secrets-command.test.ts b/src/__tests__/secrets-command.test.ts index af6dc92..ba1d96f 100644 --- a/src/__tests__/secrets-command.test.ts +++ b/src/__tests__/secrets-command.test.ts @@ -37,6 +37,7 @@ const mockResolveService = vi.fn<() => Service>() vi.mock('../core/config.js', () => ({ loadConfig: (...args: unknown[]) => mockLoadConfig(...(args as [])), + CONFIG_DIR: '/tmp/.2kc', })) vi.mock('../core/service.js', () => ({ diff --git a/src/__tests__/server-command.test.ts b/src/__tests__/server-command.test.ts index ce23149..7312d04 100644 --- a/src/__tests__/server-command.test.ts +++ b/src/__tests__/server-command.test.ts @@ -11,6 +11,7 @@ const mockFork = vi.fn() vi.mock('../core/config.js', () => ({ loadConfig: (...args: unknown[]) => mockLoadConfig(...(args as [])), + CONFIG_DIR: '/tmp/.2kc', })) vi.mock('../core/pid-manager.js', () => ({ diff --git a/src/__tests__/store-command.test.ts b/src/__tests__/store-command.test.ts index fb57758..fb65437 100644 --- a/src/__tests__/store-command.test.ts +++ b/src/__tests__/store-command.test.ts @@ -19,6 +19,7 @@ const mockSaveConfig = vi.fn() vi.mock('../core/config.js', () => ({ loadConfig: (...args: unknown[]) => mockLoadConfig(...(args as [])), saveConfig: (...args: unknown[]) => mockSaveConfig(...(args as [])), + CONFIG_DIR: '/tmp/.2kc', })) const mockInitialize = vi.fn() diff --git a/src/__tests__/unlock-command.test.ts b/src/__tests__/unlock-command.test.ts index 70e4915..74240ac 100644 --- a/src/__tests__/unlock-command.test.ts +++ b/src/__tests__/unlock-command.test.ts @@ -19,6 +19,7 @@ const mockLoadConfig = vi.fn<() => AppConfig>() vi.mock('../core/config.js', () => ({ loadConfig: (...args: unknown[]) => mockLoadConfig(...(args as [])), + CONFIG_DIR: '/tmp/.2kc', })) const mockServiceUnlock = vi.fn<() => Promise>() diff --git a/src/cli/config.ts b/src/cli/config.ts index 8dc9662..feeb801 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -1,10 +1,12 @@ import { Command } from 'commander' +import { join } from 'node:path' import { loadConfig, saveConfig, defaultConfig, CONFIG_PATH, + CONFIG_DIR, type AppConfig, } from '../core/config.js' @@ -17,7 +19,7 @@ config .option('--server-host ', 'Server host', '127.0.0.1') .option('--server-port ', 'Server port', '2274') .option('--server-auth-token ', 'Server auth token') - .option('--store-path ', 'Secret store path', '~/.2kc/secrets.json') + .option('--store-path ', 'Secret store path', join(CONFIG_DIR, 'secrets.json')) .option('--bot-token ', 'Discord bot token') .option('--channel-id ', 'Discord channel ID') .option('--authorized-user-ids ', 'Comma-separated Discord user IDs authorized to approve') diff --git a/src/cli/unlock.ts b/src/cli/unlock.ts index 6e37568..33a2fe8 100644 --- a/src/cli/unlock.ts +++ b/src/cli/unlock.ts @@ -1,14 +1,13 @@ import { Command } from 'commander' import { existsSync } from 'node:fs' import { join } from 'node:path' -import { homedir } from 'node:os' -import { loadConfig } from '../core/config.js' +import { loadConfig, CONFIG_DIR } from '../core/config.js' import { resolveService, LocalService } from '../core/service.js' import { SessionLock } from '../core/session-lock.js' import { promptPassword } from './password-prompt.js' -const ENCRYPTED_STORE_PATH = join(homedir(), '.2kc', 'secrets.enc.json') +const ENCRYPTED_STORE_PATH = join(CONFIG_DIR, 'secrets.enc.json') function formatTtl(ms: number): string { const seconds = Math.round(ms / 1000) diff --git a/src/core/config.ts b/src/core/config.ts index 267d8c2..cd37c73 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,7 +1,10 @@ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs' -import { resolve, dirname } from 'node:path' +import { resolve, dirname, join } from 'node:path' import { homedir } from 'node:os' +// Resolve config directory from TKC_HOME env var, defaulting to ~/.2kc +export const CONFIG_DIR = resolve(process.env.TKC_HOME ?? join(homedir(), '.2kc')) + export interface DiscordConfig { botToken: string channelId: string @@ -37,7 +40,7 @@ export interface AppConfig { bindCommand: boolean } -export const CONFIG_PATH = resolve(homedir(), '.2kc', 'config.json') +export const CONFIG_PATH = join(CONFIG_DIR, 'config.json') export function resolveTilde(p: string): string { if (p.startsWith('~/') || p === '~') { @@ -131,8 +134,8 @@ function parseServerConfig(raw: unknown): ServerConfig { } function parseStoreConfig(raw: unknown): StoreConfig { - const defaultPath = '~/.2kc/secrets.enc.json' - if (raw === undefined || raw === null) return { path: resolveTilde(defaultPath) } + const defaultPath = join(CONFIG_DIR, 'secrets.enc.json') + if (raw === undefined || raw === null) return { path: defaultPath } if (!isRecord(raw)) { throw new Error('store must be an object') } @@ -184,7 +187,7 @@ export function defaultConfig(): AppConfig { return { mode: 'standalone', server: { host: '127.0.0.1', port: 2274 }, - store: { path: resolveTilde('~/.2kc/secrets.enc.json') }, + store: { path: join(CONFIG_DIR, 'secrets.enc.json') }, unlock: { ttlMs: 900_000 }, discord: undefined, requireApproval: {}, @@ -249,3 +252,20 @@ export function saveConfig(config: AppConfig, configPath: string = CONFIG_PATH): writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8') chmodSync(configPath, 0o600) } + +let cachedConfig: AppConfig | null = null +let cachedConfigPath: string | null = null + +export function getConfig(configPath: string = CONFIG_PATH): AppConfig { + if (cachedConfig !== null && cachedConfigPath === configPath) { + return cachedConfig + } + cachedConfig = loadConfig(configPath) + cachedConfigPath = configPath + return cachedConfig +} + +export function clearConfigCache(): void { + cachedConfig = null + cachedConfigPath = null +} diff --git a/src/core/encrypted-store.ts b/src/core/encrypted-store.ts index e759f24..50eccc3 100644 --- a/src/core/encrypted-store.ts +++ b/src/core/encrypted-store.ts @@ -1,7 +1,7 @@ import { v4 as uuidv4 } from 'uuid' import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from 'node:fs' import { dirname, join } from 'node:path' -import { homedir } from 'node:os' +import { CONFIG_DIR } from './config.js' import type { SecretListItem, SecretMetadata } from './types.js' import type { ISecretStore } from './secret-store.js' @@ -10,7 +10,7 @@ import { generateDek, buildAad, encryptValue, decryptValue, wrapDek, unwrapDek } import { deriveKek, generateSalt, DEFAULT_SCRYPT_PARAMS } from './kdf.js' import type { ScryptParams } from './kdf.js' -const DEFAULT_PATH = join(homedir(), '.2kc', 'secrets.enc.json') +const DEFAULT_PATH = join(CONFIG_DIR, 'secrets.enc.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 diff --git a/src/core/grant.ts b/src/core/grant.ts index cdc829f..fff991d 100644 --- a/src/core/grant.ts +++ b/src/core/grant.ts @@ -2,7 +2,7 @@ import { randomUUID, sign } from 'node:crypto' import type { KeyObject } from 'node:crypto' import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs' import { dirname, join } from 'node:path' -import { homedir } from 'node:os' +import { CONFIG_DIR } from './config.js' import type { AccessRequest } from './request.js' export interface AccessGrant { @@ -17,7 +17,7 @@ export interface AccessGrant { jws?: string } -const DEFAULT_GRANTS_PATH = join(homedir(), '.2kc', 'grants.json') +const DEFAULT_GRANTS_PATH = join(CONFIG_DIR, 'grants.json') export class GrantManager { private grants: Map = new Map() diff --git a/src/core/pid-manager.ts b/src/core/pid-manager.ts index ed66e05..8d44b0c 100644 --- a/src/core/pid-manager.ts +++ b/src/core/pid-manager.ts @@ -1,9 +1,9 @@ import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs' -import { resolve, dirname } from 'node:path' -import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { CONFIG_DIR } from './config.js' -export const PID_FILE_PATH = resolve(homedir(), '.2kc', 'server.pid') -export const LOG_FILE_PATH = resolve(homedir(), '.2kc', 'server.log') +export const PID_FILE_PATH = join(CONFIG_DIR, 'server.pid') +export const LOG_FILE_PATH = join(CONFIG_DIR, 'server.log') export function writePid(pid: number): void { mkdirSync(dirname(PID_FILE_PATH), { recursive: true }) diff --git a/src/core/secret-store.ts b/src/core/secret-store.ts index c087ae5..92946ea 100644 --- a/src/core/secret-store.ts +++ b/src/core/secret-store.ts @@ -1,11 +1,11 @@ import { v4 as uuidv4 } from 'uuid' import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from 'node:fs' import { dirname, join } from 'node:path' -import { homedir } from 'node:os' +import { CONFIG_DIR } from './config.js' import type { SecretEntry, SecretListItem, SecretMetadata, SecretsFile } from './types.js' -const DEFAULT_PATH = join(homedir(), '.2kc', 'secrets.json') +const DEFAULT_PATH = join(CONFIG_DIR, '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 diff --git a/src/core/server-entry.ts b/src/core/server-entry.ts deleted file mode 100644 index 63c61ef..0000000 --- a/src/core/server-entry.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Server entry point for the forked daemon process. -// This is a minimal HTTP server with a /health endpoint. -// It will be replaced/extended by #17 (HTTP server foundation). - -import http from 'node:http' - -import { loadConfig } from './config.js' - -const config = loadConfig() - -const server = http.createServer((req, res) => { - if (req.url === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify({ - status: 'ok', - pid: process.pid, - uptime: process.uptime(), - }), - ) - return - } - res.writeHead(404) - res.end() -}) - -server.listen(config.server.port, config.server.host, () => { - console.log(`Server listening on ${config.server.host}:${config.server.port}`) -}) - -// Graceful shutdown on SIGTERM -process.on('SIGTERM', () => { - setTimeout(() => process.exit(1), 3000).unref() - server.close(() => { - process.exit(0) - }) -}) diff --git a/src/core/session-lock.ts b/src/core/session-lock.ts index 7c51f08..25e27ef 100644 --- a/src/core/session-lock.ts +++ b/src/core/session-lock.ts @@ -1,9 +1,8 @@ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, chmodSync } from 'node:fs' import { join, dirname } from 'node:path' -import { homedir } from 'node:os' -import type { UnlockConfig } from './config.js' +import { CONFIG_DIR, type UnlockConfig } from './config.js' -const DEFAULT_SESSION_PATH = join(homedir(), '.2kc', 'session.lock') +const DEFAULT_SESSION_PATH = join(CONFIG_DIR, 'session.lock') interface SessionLockFile { version: 1 From 4a12096aee76a4de5a0b18dd7e3a507486784292 Mon Sep 17 00:00:00 2001 From: NoahCardoza Date: Sat, 28 Feb 2026 12:03:15 -0800 Subject: [PATCH 20/25] implement zod for config parsing --- package-lock.json | 25 ++- package.json | 5 +- src/__tests__/config.test.ts | 151 +++++++--------- src/cli/config.ts | 77 +++++--- src/core/config.ts | 336 +++++++++++++---------------------- 5 files changed, 265 insertions(+), 329 deletions(-) diff --git a/package-lock.json b/package-lock.json index 20d989c..695be39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,9 @@ "fastify": "^5.7.4", "fastify-plugin": "^5.1.0", "jose": "^6.1.3", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "zod": "^4.3.6", + "zod-validation-error": "^5.0.0" }, "bin": { "2kc": "dist/cli/index.js" @@ -4989,6 +4991,27 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-5.0.0.tgz", + "integrity": "sha512-hmk+pkyKq7Q71PiWVSDUc3VfpzpvcRHZ3QPw9yEMVvmtCekaMeOHnbr3WbxfrgEnQTv6haGP4cmv0Ojmihzsxw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 4d1a8aa..fde2389 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "2kc": "./dist/cli/index.js" }, "scripts": { + "tsx": "tsx", "build": "tsup", "dev": "tsup --watch", "compile": "tsc --noEmit", @@ -32,7 +33,9 @@ "fastify": "^5.7.4", "fastify-plugin": "^5.1.0", "jose": "^6.1.3", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "zod": "^4.3.6", + "zod-validation-error": "^5.0.0" }, "devDependencies": { "@eslint/js": "^9.20.0", diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 4ad36d7..5743526 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -15,6 +15,18 @@ vi.mock('node:os', () => ({ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs' import { loadConfig, parseConfig, resolveTilde, saveConfig, defaultConfig } from '../core/config.js' +import { ZodError } from 'zod' + +/** Helper to assert ZodError is thrown with a specific path */ +function expectZodErrorPath(fn: () => void, expectedPath: (string | number)[]): void { + try { + fn() + expect.fail('Expected ZodError to be thrown') + } catch (err) { + expect(err).toBeInstanceOf(ZodError) + expect((err as ZodError).issues[0].path).toEqual(expectedPath) + } +} const mockReadFileSync = vi.mocked(readFileSync) @@ -125,7 +137,7 @@ describe('parseConfig', () => { }) it('throws on invalid mode value', () => { - expect(() => parseConfig({ mode: 'invalid' })).toThrow('mode must be "standalone" or "client"') + expect(() => parseConfig({ mode: 'invalid' })).toThrow(ZodError) }) it('parses server config with defaults', () => { @@ -145,27 +157,19 @@ describe('parseConfig', () => { }) it('throws on invalid server.port (non-number)', () => { - expect(() => parseConfig({ server: { port: 'abc' } })).toThrow( - 'server.port must be an integer between 1 and 65535', - ) + expect(() => parseConfig({ server: { port: 'abc' } })).toThrow(ZodError) }) it('throws on invalid server.port (out of range)', () => { - expect(() => parseConfig({ server: { port: 99999 } })).toThrow( - 'server.port must be an integer between 1 and 65535', - ) + expect(() => parseConfig({ server: { port: 99999 } })).toThrow(ZodError) }) it('throws on invalid server.port (zero)', () => { - expect(() => parseConfig({ server: { port: 0 } })).toThrow( - 'server.port must be an integer between 1 and 65535', - ) + expect(() => parseConfig({ server: { port: 0 } })).toThrow(ZodError) }) it('throws on server.authToken being empty string when provided', () => { - expect(() => parseConfig({ server: { authToken: '' } })).toThrow( - 'server.authToken must be a non-empty string when provided', - ) + expect(() => parseConfig({ server: { authToken: '' } })).toThrow(ZodError) }) it('parses store.path and resolves ~ to homedir', () => { @@ -200,37 +204,26 @@ describe('parseConfig', () => { expect(config.approvalTimeoutMs).toBe(60_000) }) - it('ignores non-boolean values in requireApproval', () => { - const config = parseConfig({ - ...withDiscord, - requireApproval: { production: true, bad: 'yes', worse: 42 }, - }) - expect(config.requireApproval).toEqual({ production: true }) - }) - - it('defaults approvalTimeoutMs for invalid values', () => { - const config = parseConfig({ - approvalTimeoutMs: -1, - }) - expect(config.approvalTimeoutMs).toBe(300_000) + it('throws on invalid approvalTimeoutMs', () => { + expectZodErrorPath(() => parseConfig({ approvalTimeoutMs: -1 }), ['approvalTimeoutMs']) }) it('throws if config is not an object', () => { - expect(() => parseConfig('string')).toThrow('Config must be a JSON object') + expect(() => parseConfig('string')).toThrow(ZodError) }) it('throws if discord.channelId is missing', () => { - expect(() => parseConfig({ discord: { botToken: 'tok' } })).toThrow( - 'discord.channelId must be a non-empty string', + expectZodErrorPath( + () => parseConfig({ discord: { botToken: 'tok' } }), + ['discord', 'channelId'], ) }) it('throws if discord.botToken is empty string', () => { - expect(() => - parseConfig({ - discord: { channelId: '123', botToken: '' }, - }), - ).toThrow('discord.botToken must be a non-empty string') + expectZodErrorPath( + () => parseConfig({ discord: { channelId: '123', botToken: '' } }), + ['discord', 'botToken'], + ) }) it('parses discord.authorizedUserIds when present', () => { @@ -245,19 +238,23 @@ describe('parseConfig', () => { }) it('throws if discord.authorizedUserIds is not an array', () => { - expect(() => - parseConfig({ - discord: { channelId: '123', botToken: 'tok', authorizedUserIds: 'invalid' }, - }), - ).toThrow('discord.authorizedUserIds must be an array') + expectZodErrorPath( + () => + parseConfig({ + discord: { channelId: '123', botToken: 'tok', authorizedUserIds: 'invalid' }, + }), + ['discord', 'authorizedUserIds'], + ) }) it('throws if discord.authorizedUserIds contains non-string', () => { - expect(() => - parseConfig({ - discord: { channelId: '123', botToken: 'tok', authorizedUserIds: ['valid', 123] }, - }), - ).toThrow('discord.authorizedUserIds must contain non-empty strings') + expectZodErrorPath( + () => + parseConfig({ + discord: { channelId: '123', botToken: 'tok', authorizedUserIds: ['valid', 123] }, + }), + ['discord', 'authorizedUserIds', 1], + ) }) it('parses unlock config with custom values', () => { @@ -277,54 +274,44 @@ describe('parseConfig', () => { }) it('validates unlock.ttlMs is positive number', () => { - expect(() => parseConfig({ unlock: { ttlMs: -1 } })).toThrow( - 'unlock.ttlMs must be a positive number', - ) - expect(() => parseConfig({ unlock: { ttlMs: 0 } })).toThrow( - 'unlock.ttlMs must be a positive number', - ) - expect(() => parseConfig({ unlock: { ttlMs: 'bad' } })).toThrow( - 'unlock.ttlMs must be a positive number', - ) + expectZodErrorPath(() => parseConfig({ unlock: { ttlMs: -1 } }), ['unlock', 'ttlMs']) + expectZodErrorPath(() => parseConfig({ unlock: { ttlMs: 0 } }), ['unlock', 'ttlMs']) + expectZodErrorPath(() => parseConfig({ unlock: { ttlMs: 'bad' } }), ['unlock', 'ttlMs']) }) it('validates unlock.idleTtlMs is positive number when provided', () => { - expect(() => parseConfig({ unlock: { idleTtlMs: -1 } })).toThrow( - 'unlock.idleTtlMs must be a positive number', - ) + expectZodErrorPath(() => parseConfig({ unlock: { idleTtlMs: -1 } }), ['unlock', 'idleTtlMs']) }) it('validates unlock.maxGrantsBeforeRelock is positive integer when provided', () => { - expect(() => parseConfig({ unlock: { maxGrantsBeforeRelock: 0 } })).toThrow( - 'unlock.maxGrantsBeforeRelock must be a positive integer', + expectZodErrorPath( + () => parseConfig({ unlock: { maxGrantsBeforeRelock: 0 } }), + ['unlock', 'maxGrantsBeforeRelock'], ) - expect(() => parseConfig({ unlock: { maxGrantsBeforeRelock: 1.5 } })).toThrow( - 'unlock.maxGrantsBeforeRelock must be a positive integer', + expectZodErrorPath( + () => parseConfig({ unlock: { maxGrantsBeforeRelock: 1.5 } }), + ['unlock', 'maxGrantsBeforeRelock'], ) }) it('throws if unlock is not an object', () => { - expect(() => parseConfig({ unlock: 'bad' })).toThrow('unlock must be an object') + expectZodErrorPath(() => parseConfig({ unlock: 'bad' }), ['unlock']) }) it('throws if store.path is empty string', () => { - expect(() => parseConfig({ store: { path: '' } })).toThrow( - 'store.path must be a non-empty string', - ) + expectZodErrorPath(() => parseConfig({ store: { path: '' } }), ['store', 'path']) }) it('throws if store.path is non-string', () => { - expect(() => parseConfig({ store: { path: 123 } })).toThrow( - 'store.path must be a non-empty string', - ) + expectZodErrorPath(() => parseConfig({ store: { path: 123 } }), ['store', 'path']) }) it('throws if store is not an object', () => { - expect(() => parseConfig({ store: 'bad' })).toThrow('store must be an object') + expectZodErrorPath(() => parseConfig({ store: 'bad' }), ['store']) }) it('throws if server is not an object', () => { - expect(() => parseConfig({ server: 'bad' })).toThrow('server must be an object') + expectZodErrorPath(() => parseConfig({ server: 'bad' }), ['server']) }) it('parses server.sessionTtlMs when provided', () => { @@ -338,25 +325,26 @@ describe('parseConfig', () => { }) it('throws on server.sessionTtlMs below minimum (1000ms)', () => { - expect(() => parseConfig({ server: { sessionTtlMs: 0 } })).toThrow( - 'server.sessionTtlMs must be at least 1000ms', + expectZodErrorPath( + () => parseConfig({ server: { sessionTtlMs: 0 } }), + ['server', 'sessionTtlMs'], ) - expect(() => parseConfig({ server: { sessionTtlMs: -1 } })).toThrow( - 'server.sessionTtlMs must be at least 1000ms', + expectZodErrorPath( + () => parseConfig({ server: { sessionTtlMs: -1 } }), + ['server', 'sessionTtlMs'], ) - expect(() => parseConfig({ server: { sessionTtlMs: 999 } })).toThrow( - 'server.sessionTtlMs must be at least 1000ms', + expectZodErrorPath( + () => parseConfig({ server: { sessionTtlMs: 999 } }), + ['server', 'sessionTtlMs'], ) }) it('throws if server.host is empty string', () => { - expect(() => parseConfig({ server: { host: '' } })).toThrow( - 'server.host must be a non-empty string', - ) + expectZodErrorPath(() => parseConfig({ server: { host: '' } }), ['server', 'host']) }) it('throws if discord is not an object', () => { - expect(() => parseConfig({ discord: 'bad' })).toThrow('discord must be an object') + expectZodErrorPath(() => parseConfig({ discord: 'bad' }), ['discord']) }) it('parses bindCommand: true correctly', () => { @@ -373,11 +361,6 @@ describe('parseConfig', () => { const config = parseConfig({}) expect(config.bindCommand).toBe(false) }) - - it('defaults bindCommand to false for non-boolean value', () => { - const config = parseConfig({ bindCommand: 'yes' }) - expect(config.bindCommand).toBe(false) - }) }) describe('saveConfig', () => { diff --git a/src/cli/config.ts b/src/cli/config.ts index feeb801..4e5af6d 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -1,5 +1,6 @@ import { Command } from 'commander' import { join } from 'node:path' +import { z } from 'zod' import { loadConfig, @@ -10,6 +11,30 @@ import { type AppConfig, } from '../core/config.js' +const SERVER_PORT_ERROR_MSG = 'Invalid --server-port: must be an integer between 1 and 65535' +const APPROVAL_TIMEOUT_ERROR_MSG = + 'Invalid --approval-timeout: must be a positive integer (milliseconds)' + +const ConfigInitOptionsSchema = z.object({ + mode: z.enum(['standalone', 'client'], 'Invalid --mode: must be "standalone" or "client"'), + serverHost: z.string(), + serverPort: z.coerce + .number(SERVER_PORT_ERROR_MSG) + .int(SERVER_PORT_ERROR_MSG) + .min(1, SERVER_PORT_ERROR_MSG) + .max(65535, SERVER_PORT_ERROR_MSG), + serverAuthToken: z.string().optional(), + storePath: z.string(), + botToken: z.string().optional(), + channelId: z.string().optional(), + authorizedUserIds: z.string().optional(), + defaultRequireApproval: z.boolean(), + approvalTimeout: z.coerce + .number(APPROVAL_TIMEOUT_ERROR_MSG) + .int(APPROVAL_TIMEOUT_ERROR_MSG) + .positive(APPROVAL_TIMEOUT_ERROR_MSG), +}) + const config = new Command('config').description('Manage 2kc configuration') config @@ -38,45 +63,45 @@ config defaultRequireApproval: boolean approvalTimeout: string }) => { - if (opts.mode !== 'standalone' && opts.mode !== 'client') { - console.error('Invalid --mode: must be "standalone" or "client"') - process.exitCode = 1 - return - } + const result = ConfigInitOptionsSchema.safeParse(opts) + if (!result.success) { + function formatCliError(issue: z.ZodIssue): string { + if (issue.path[0] === 'mode') { + return 'Invalid --mode: must be "standalone" or "client"' + } + return issue.message + } - const port = parseInt(opts.serverPort, 10) - if (Number.isNaN(port) || port < 1 || port > 65535) { - console.error('Invalid --server-port: must be an integer between 1 and 65535') + result.error.issues.forEach((issue) => { + console.error(formatCliError(issue)) + }) process.exitCode = 1 return } - const approvalTimeoutMs = parseInt(opts.approvalTimeout, 10) - if (Number.isNaN(approvalTimeoutMs) || approvalTimeoutMs <= 0) { - console.error('Invalid --approval-timeout: must be a positive integer (milliseconds)') - process.exitCode = 1 - return - } + const validated = result.data const appConfig: AppConfig = { - mode: opts.mode, + mode: validated.mode, server: { - host: opts.serverHost, - port, - ...(opts.serverAuthToken !== undefined ? { authToken: opts.serverAuthToken } : {}), + host: validated.serverHost, + port: validated.serverPort, + ...(validated.serverAuthToken !== undefined + ? { authToken: validated.serverAuthToken } + : {}), }, store: { - path: opts.storePath, + path: validated.storePath, }, unlock: defaultConfig().unlock, discord: - opts.botToken && opts.channelId + validated.botToken && validated.channelId ? { - botToken: opts.botToken, - channelId: opts.channelId, - ...(opts.authorizedUserIds + botToken: validated.botToken, + channelId: validated.channelId, + ...(validated.authorizedUserIds ? { - authorizedUserIds: opts.authorizedUserIds + authorizedUserIds: validated.authorizedUserIds .split(',') .map((id) => id.trim()) .filter((id) => id !== ''), @@ -85,8 +110,8 @@ config } : undefined, requireApproval: {}, - defaultRequireApproval: opts.defaultRequireApproval, - approvalTimeoutMs, + defaultRequireApproval: validated.defaultRequireApproval, + approvalTimeoutMs: validated.approvalTimeout, bindCommand: false, } diff --git a/src/core/config.ts b/src/core/config.ts index cd37c73..2f3af37 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,45 +1,12 @@ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { homedir } from 'node:os' +import { z, ZodError } from 'zod' +import { createErrorMap, createMessageBuilder, NonEmptyArray } from 'zod-validation-error' // Resolve config directory from TKC_HOME env var, defaulting to ~/.2kc export const CONFIG_DIR = resolve(process.env.TKC_HOME ?? join(homedir(), '.2kc')) -export interface DiscordConfig { - botToken: string - channelId: string - authorizedUserIds?: string[] -} - -export interface ServerConfig { - host: string - port: number - authToken?: string - sessionTtlMs?: number -} - -export interface StoreConfig { - path: string -} - -export interface UnlockConfig { - ttlMs: number - idleTtlMs?: number - maxGrantsBeforeRelock?: number -} - -export interface AppConfig { - mode: 'standalone' | 'client' - server: ServerConfig - store: StoreConfig - unlock: UnlockConfig - discord?: DiscordConfig - requireApproval: Record - defaultRequireApproval: boolean - approvalTimeoutMs: number - bindCommand: boolean -} - export const CONFIG_PATH = join(CONFIG_DIR, 'config.json') export function resolveTilde(p: string): string { @@ -49,180 +16,94 @@ export function resolveTilde(p: string): string { return p } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function parseRequireApproval(raw: unknown): Record { - if (!isRecord(raw)) return {} - const result: Record = {} - for (const [key, value] of Object.entries(raw)) { - if (typeof value === 'boolean') { - result[key] = value - } - } - return result -} - -function parseDiscordConfig(raw: unknown): DiscordConfig | undefined { - if (raw === undefined || raw === null) return undefined - if (!isRecord(raw)) { - throw new Error('discord must be an object') - } - - const channelId = raw.channelId - if (typeof channelId !== 'string' || channelId === '') { - throw new Error('discord.channelId must be a non-empty string') - } - - const botToken = raw.botToken - if (typeof botToken !== 'string' || botToken === '') { - throw new Error('discord.botToken must be a non-empty string') - } - - const result: DiscordConfig = { botToken, channelId } - - if (raw.authorizedUserIds !== undefined) { - if (!Array.isArray(raw.authorizedUserIds)) { - throw new Error('discord.authorizedUserIds must be an array') - } - for (const id of raw.authorizedUserIds) { - if (typeof id !== 'string' || id === '') { - throw new Error('discord.authorizedUserIds must contain non-empty strings') - } - } - result.authorizedUserIds = raw.authorizedUserIds as string[] - } - - return result -} - -function parseServerConfig(raw: unknown): ServerConfig { - const defaults: ServerConfig = { host: '127.0.0.1', port: 2274 } - if (raw === undefined || raw === null) return defaults - if (!isRecord(raw)) { - throw new Error('server must be an object') - } - - const host = raw.host !== undefined ? raw.host : defaults.host - if (typeof host !== 'string' || host === '') { - throw new Error('server.host must be a non-empty string') - } - - const port = raw.port !== undefined ? raw.port : defaults.port - if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error('server.port must be an integer between 1 and 65535') - } - - const result: ServerConfig = { host, port } - - if (raw.authToken !== undefined) { - if (typeof raw.authToken !== 'string' || raw.authToken === '') { - throw new Error('server.authToken must be a non-empty string when provided') - } - result.authToken = raw.authToken - } - - if (raw.sessionTtlMs !== undefined) { - if (typeof raw.sessionTtlMs !== 'number' || raw.sessionTtlMs < 1000) { - throw new Error('server.sessionTtlMs must be at least 1000ms') - } - result.sessionTtlMs = raw.sessionTtlMs - } - - return result -} - -function parseStoreConfig(raw: unknown): StoreConfig { - const defaultPath = join(CONFIG_DIR, 'secrets.enc.json') - if (raw === undefined || raw === null) return { path: defaultPath } - if (!isRecord(raw)) { - throw new Error('store must be an object') - } - - const path = raw.path !== undefined ? raw.path : defaultPath - if (typeof path !== 'string' || path === '') { - throw new Error('store.path must be a non-empty string') - } - - return { path: resolveTilde(path) } -} - -function parseUnlockConfig(raw: unknown): UnlockConfig { - const defaults: UnlockConfig = { ttlMs: 900_000 } - if (raw === undefined || raw === null) return defaults - if (!isRecord(raw)) { - throw new Error('unlock must be an object') - } - - const ttlMs = raw.ttlMs !== undefined ? raw.ttlMs : defaults.ttlMs - if (typeof ttlMs !== 'number' || ttlMs <= 0) { - throw new Error('unlock.ttlMs must be a positive number') - } - - const result: UnlockConfig = { ttlMs } - - if (raw.idleTtlMs !== undefined) { - if (typeof raw.idleTtlMs !== 'number' || raw.idleTtlMs <= 0) { - throw new Error('unlock.idleTtlMs must be a positive number') - } - result.idleTtlMs = raw.idleTtlMs - } - - if (raw.maxGrantsBeforeRelock !== undefined) { - if ( - typeof raw.maxGrantsBeforeRelock !== 'number' || - !Number.isInteger(raw.maxGrantsBeforeRelock) || - raw.maxGrantsBeforeRelock <= 0 - ) { - throw new Error('unlock.maxGrantsBeforeRelock must be a positive integer') - } - result.maxGrantsBeforeRelock = raw.maxGrantsBeforeRelock - } - - return result -} +const messageBuilderInner = createMessageBuilder({ + includePath: true, // Includes the field path in the message + prefix: ' - ', + prefixSeparator: '', // No separator between prefix and message + issueSeparator: '\n - ', // Separates multiple issues with a newline +}) + +const messageBuilder = (issues: ZodError['issues']): string => { + if (issues.length === 0) { + return '' + } + + // weird typescript hack to ensure issues is a NonEmptyArray + return messageBuilderInner(issues as NonEmptyArray) +} + +z.config({ + customError: createErrorMap({ + // includePath: true, // This option automatically adds the path + // delimiter: { path: ' -> ' } // Optional: customize the path delimiter + }), +}) + +const zNonEmptyString = () => z.string().min(1, 'Expected non-empty string') +const zPortNumber = () => + z + .number() + .int() + .min(1, 'Expected port number between 1 and 65535') + .max(65535, 'Expected port number between 1 and 65535') + +// Discord config schema +const DiscordConfigSchema = z.object({ + botToken: zNonEmptyString(), + channelId: zNonEmptyString(), + authorizedUserIds: z.array(zNonEmptyString()).optional(), +}) + +// Server config schema +const ServerConfigSchema = z.object({ + host: zNonEmptyString().default('127.0.0.1'), + port: zPortNumber().default(2274), + authToken: zNonEmptyString().optional(), + sessionTtlMs: z.number().min(1000, 'Expected session TTL to be at least 1000 ms').optional(), +}) + +// Store config schema +const StoreConfigSchema = z + .object({ + path: zNonEmptyString().optional(), + }) + .transform((val) => ({ + path: val.path ? resolveTilde(val.path) : join(CONFIG_DIR, 'secrets.enc.json'), + })) + +// Unlock config schema +const UnlockConfigSchema = z.object({ + ttlMs: z.number().positive().default(900_000), + idleTtlMs: z.number().positive().optional(), + maxGrantsBeforeRelock: z.number().int().positive().optional(), +}) + +// Main app config schema +const AppConfigSchema = z.object({ + mode: z.enum(['standalone', 'client']).default('standalone'), + server: ServerConfigSchema.prefault({}), + store: StoreConfigSchema.prefault({}), + unlock: UnlockConfigSchema.prefault({}), + discord: DiscordConfigSchema.optional(), + requireApproval: z.record(z.string(), z.boolean()).default({}), + defaultRequireApproval: z.boolean().default(false), + approvalTimeoutMs: z.number().positive().default(300_000), + bindCommand: z.boolean().default(false), +}) + +// Inferred types from schemas +export type DiscordConfig = z.infer +export type ServerConfig = z.output +export type StoreConfig = z.output +export type UnlockConfig = z.output +export type AppConfig = z.output export function defaultConfig(): AppConfig { - return { - mode: 'standalone', - server: { host: '127.0.0.1', port: 2274 }, - store: { path: join(CONFIG_DIR, 'secrets.enc.json') }, - unlock: { ttlMs: 900_000 }, - discord: undefined, - requireApproval: {}, - defaultRequireApproval: false, - approvalTimeoutMs: 300_000, - bindCommand: false, - } + return AppConfigSchema.parse({}) } export function parseConfig(raw: unknown): AppConfig { - if (!isRecord(raw)) { - throw new Error('Config must be a JSON object') - } - - // Parse mode - const mode = raw.mode !== undefined ? raw.mode : 'standalone' - if (mode !== 'standalone' && mode !== 'client') { - throw new Error('mode must be "standalone" or "client"') - } - - return { - mode, - server: parseServerConfig(raw.server), - store: parseStoreConfig(raw.store), - unlock: parseUnlockConfig(raw.unlock), - discord: parseDiscordConfig(raw.discord), - requireApproval: parseRequireApproval(raw.requireApproval), - defaultRequireApproval: - typeof raw.defaultRequireApproval === 'boolean' ? raw.defaultRequireApproval : false, - approvalTimeoutMs: - typeof raw.approvalTimeoutMs === 'number' && raw.approvalTimeoutMs > 0 - ? raw.approvalTimeoutMs - : 300_000, - bindCommand: typeof raw.bindCommand === 'boolean' ? raw.bindCommand : false, - } + return AppConfigSchema.parse(raw ?? {}) } export function loadConfig(configPath: string = CONFIG_PATH): AppConfig { @@ -253,19 +134,40 @@ export function saveConfig(config: AppConfig, configPath: string = CONFIG_PATH): chmodSync(configPath, 0o600) } -let cachedConfig: AppConfig | null = null -let cachedConfigPath: string | null = null +const configCache: Record = {} export function getConfig(configPath: string = CONFIG_PATH): AppConfig { - if (cachedConfig !== null && cachedConfigPath === configPath) { - return cachedConfig + if (configCache[configPath]) { + return configCache[configPath] } - cachedConfig = loadConfig(configPath) - cachedConfigPath = configPath - return cachedConfig -} - -export function clearConfigCache(): void { - cachedConfig = null - cachedConfigPath = null -} + try { + configCache[configPath] = loadConfig(configPath) + } catch (err: unknown) { + if (err instanceof ZodError) { + console.error(`Error loading config:`) + console.error(messageBuilder(err.issues)) + } else { + console.error(`Error loading config: ${String(err)}`) + } + process.exit(1) + } + return configCache[configPath] +} + +// try { +// parseConfig({ +// server: { +// port: 0, +// host: '', +// }, +// defaultRequireApproval: 'not a boolean', +// }) +// } catch (err) { +// if (err instanceof ZodError) { +// console.error(`Error loading config:`) +// console.error(messageBuilder(err.issues)) +// } else { +// console.error(`Error loading config: ${String(err)}`) +// } +// process.exit(1) +// } From 2a4a1799c2565d4255ca56d1445e966924d218bc Mon Sep 17 00:00:00 2001 From: NoahCardoza Date: Sat, 28 Feb 2026 12:13:39 -0800 Subject: [PATCH 21/25] fix: correct template literal escaping in CI coverage badge step Use single backslashes instead of triple backslashes for escaping backticks and dollar signs in the node -e inline script. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1040d12..bbd8a31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,8 @@ jobs: cache: npm - run: npm ci - run: npm run lint - - run: npx tsc --noEmit - - run: npm run test:coverage + - run: npm run compile + - run: npm run test - name: Update coverage badge if: github.ref == 'refs/heads/main' && github.event_name == 'push' @@ -40,9 +40,9 @@ jobs: } const color = coverage >= 80 ? 'brightgreen' : coverage >= 60 ? 'yellow' : 'red'; let readme = fs.readFileSync('README.md', 'utf8'); - readme = readme.replace(/coverage-[0-9]+%25-[a-z]+/g, \\\`coverage-\\\${coverage}%25-\\\${color}\\\`); + readme = readme.replace(/coverage-[0-9]+%25-[a-z]+/g, \`coverage-\${coverage}%25-\${color}\`); fs.writeFileSync('README.md', readme); - console.log(\\\`Coverage: \\\${coverage}% (\\\${color})\\\`); + console.log(\`Coverage: \${coverage}% (\${color})\`); } catch (err) { console.error('Failed to update coverage badge:', err.message); process.exit(1); From f12a0e35d81f4e9210fa977a543cc889ef307b39 Mon Sep 17 00:00:00 2001 From: NoahCardoza Date: Sat, 28 Feb 2026 12:50:25 -0800 Subject: [PATCH 22/25] improve coverage testing --- src/__tests__/config.test.ts | 35 +++++++++++++++++++++++ src/__tests__/remote-service.test.ts | 41 +++++++++++++++++++++++++++ src/__tests__/service.test.ts | 40 ++++++++++++++++++++++++++ src/__tests__/store-migration.test.ts | 14 +++++++++ 4 files changed, 130 insertions(+) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 5743526..8288a21 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -406,3 +406,38 @@ describe('loadConfig edge cases', () => { expect(() => loadConfig()).toThrow('EACCES') }) }) + +describe('getConfig', () => { + let exitSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + vi.resetModules() + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + exitSpy.mockRestore() + errorSpy.mockRestore() + vi.restoreAllMocks() + }) + + it('logs ZodError message and exits on invalid config', async () => { + mockReadFileSync.mockReturnValue(JSON.stringify({ server: { port: 'invalid' } })) + const { getConfig } = await import('../core/config.js') + getConfig('/test/zod-error-path') + expect(errorSpy).toHaveBeenCalledWith('Error loading config:') + expect(exitSpy).toHaveBeenCalledWith(1) + }) + + it('logs generic error and exits on non-ZodError', async () => { + mockReadFileSync.mockImplementation(() => { + throw new Error('Permission denied') + }) + const { getConfig } = await import('../core/config.js') + getConfig('/test/generic-error-path') + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Permission denied')) + expect(exitSpy).toHaveBeenCalledWith(1) + }) +}) diff --git a/src/__tests__/remote-service.test.ts b/src/__tests__/remote-service.test.ts index 61a5793..8514270 100644 --- a/src/__tests__/remote-service.test.ts +++ b/src/__tests__/remote-service.test.ts @@ -92,6 +92,25 @@ describe('RemoteService', () => { }) }) + describe('keys', () => { + it('getPublicKey() calls GET /api/keys/public and returns publicKey', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-1')) + .mockResolvedValueOnce(makeJsonResponse(200, { publicKey: '-----BEGIN PUBLIC KEY-----' })) + globalThis.fetch = fetchMock + + const service = new RemoteService(makeConfig()) + const result = await service.keys.getPublicKey() + + expect(result).toBe('-----BEGIN PUBLIC KEY-----') + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:2274/api/keys/public', + expect.objectContaining({ method: 'GET' }), + ) + }) + }) + describe('secrets', () => { it('list() calls GET /api/secrets', async () => { const items = [{ uuid: 'a', tags: ['t1'] }] @@ -516,6 +535,28 @@ describe('RemoteService', () => { await expect(service.health()).rejects.toThrow('unexpected network error') }) + it('handles TypeError in request() after successful login', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-1')) + .mockRejectedValueOnce(new TypeError('network error')) + globalThis.fetch = fetchMock + + const service = new RemoteService(makeConfig()) + await expect(service.health()).rejects.toThrow('Server not running') + }) + + it('handles TimeoutError in request() after successful login', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-1')) + .mockRejectedValueOnce(new DOMException('timeout', 'TimeoutError')) + globalThis.fetch = fetchMock + + const service = new RemoteService(makeConfig()) + await expect(service.health()).rejects.toThrow('Request timed out') + }) + it('re-throws unexpected errors from login()', async () => { globalThis.fetch = vi.fn().mockRejectedValue(new RangeError('login network error')) diff --git a/src/__tests__/service.test.ts b/src/__tests__/service.test.ts index ab52937..6aac4be 100644 --- a/src/__tests__/service.test.ts +++ b/src/__tests__/service.test.ts @@ -471,4 +471,44 @@ describe('LocalService', () => { expect(unlockSession.off).toHaveBeenCalledWith('locked', onCall[1]) }) }) + + describe('onLocked callback', () => { + it('calls store.lock() and sessionLock.clear() when unlockSession emits locked', () => { + const { store, unlockSession, sessionLock } = makeService() + const onCall = (unlockSession.on as MockInstance).mock.calls[0] + expect(onCall[0]).toBe('locked') + // Invoke the registered handler + onCall[1]() + expect(store.lock).toHaveBeenCalledOnce() + expect(sessionLock.clear).toHaveBeenCalledOnce() + }) + }) + + describe('createGrant error path', () => { + it('sets status to error when createGrant fails after workflow approval', async () => { + const { service, workflowEngine, grantManager } = makeService() + ;(workflowEngine.processRequest as MockInstance).mockImplementation(async (req) => { + req.status = 'approved' + return 'approved' + }) + ;(grantManager.createGrant as MockInstance).mockImplementation(() => { + throw new Error('signing key unavailable') + }) + + const result = await service.requests.create(['u1'], 'need access', 'task-1', 300) + await vi.waitFor(() => { + expect(grantManager.createGrant).toHaveBeenCalled() + }) + // The result's status may have been updated after the workflow ran + expect(result.status).toBe('error') + }) + }) + + describe('keys', () => { + it('getPublicKey() returns exported PEM key', async () => { + const { service } = makeService() + const result = await service.keys.getPublicKey() + expect(result).toBe('-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----') + }) + }) }) diff --git a/src/__tests__/store-migration.test.ts b/src/__tests__/store-migration.test.ts index 32afe55..a6b0d27 100644 --- a/src/__tests__/store-migration.test.ts +++ b/src/__tests__/store-migration.test.ts @@ -92,6 +92,20 @@ describe('migrateStore', () => { ).rejects.toThrow() }) + it('throws on invalid plaintext store format (secrets is not an array)', async () => { + writeFileSync(plaintextPath, JSON.stringify({ secrets: 'not-an-array' }), 'utf-8') + await expect( + migrateStore(plaintextPath, encryptedPath, PASSWORD, { params: TEST_PARAMS }), + ).rejects.toThrow('Invalid plaintext store format') + }) + + it('throws on invalid plaintext store format (missing secrets field)', async () => { + writeFileSync(plaintextPath, JSON.stringify({ notSecrets: [] }), 'utf-8') + await expect( + migrateStore(plaintextPath, encryptedPath, PASSWORD, { params: TEST_PARAMS }), + ).rejects.toThrow('Invalid plaintext store format') + }) + it('throws if encrypted store already exists (no --force)', async () => { writePlaintext([]) // Pre-create the encrypted store From ed79d05bdbc89feefd516643a09604ee8bcff46f Mon Sep 17 00:00:00 2001 From: NoahCardoza Date: Sat, 28 Feb 2026 13:02:57 -0800 Subject: [PATCH 23/25] add route to get signed grant jws token --- src/__tests__/remote-service.test.ts | 53 ++++++++++-- src/__tests__/routes.test.ts | 120 +++++++++++++++++++++++++++ src/core/remote-service.ts | 11 ++- src/server/routes.ts | 47 ++++++++++- 4 files changed, 219 insertions(+), 12 deletions(-) diff --git a/src/__tests__/remote-service.test.ts b/src/__tests__/remote-service.test.ts index 8514270..b3d0008 100644 --- a/src/__tests__/remote-service.test.ts +++ b/src/__tests__/remote-service.test.ts @@ -215,6 +215,45 @@ describe('RemoteService', () => { reason: 'need it', taskRef: 'TASK-1', duration: 300, + command: undefined, + }), + }), + ) + }) + + it('create() includes command parameter when provided', async () => { + const accessRequest = { + id: 'req-1', + secretUuids: ['sec-1'], + reason: 'need it', + taskRef: 'TASK-1', + durationSeconds: 300, + requestedAt: '2026-01-01T00:00:00Z', + status: 'pending', + } + const fetchMock = mockFetchResponse(200, accessRequest) + globalThis.fetch = fetchMock + + const service = new RemoteService(makeConfig()) + const result = await service.requests.create( + ['sec-1'], + 'need it', + 'TASK-1', + 300, + 'echo hello', + ) + + expect(result).toEqual(accessRequest) + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:2274/api/requests', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + secretUuids: ['sec-1'], + reason: 'need it', + taskRef: 'TASK-1', + duration: 300, + command: 'echo hello', }), }), ) @@ -241,7 +280,7 @@ describe('RemoteService', () => { describe('inject (local)', () => { it('receives signed grant, verifies JWS, injects locally using SecretInjector', async () => { - const fetchMock = mockFetchResponse(200, 'signed.jws.token') + const fetchMock = mockFetchResponse(200, { jws: 'signed.jws.token' }) globalThis.fetch = fetchMock vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ @@ -265,7 +304,7 @@ describe('RemoteService', () => { }) it('passes envVarName option to injector', async () => { - const fetchMock = mockFetchResponse(200, 'signed.jws.token') + const fetchMock = mockFetchResponse(200, { jws: 'signed.jws.token' }) globalThis.fetch = fetchMock vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ @@ -287,7 +326,7 @@ describe('RemoteService', () => { }) it('throws when local store is locked', async () => { - const fetchMock = mockFetchResponse(200, 'signed.jws.token') + const fetchMock = mockFetchResponse(200, { jws: 'signed.jws.token' }) globalThis.fetch = fetchMock vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ @@ -311,7 +350,7 @@ describe('RemoteService', () => { }) it('throws when JWS verification fails', async () => { - const fetchMock = mockFetchResponse(200, 'tampered.jws.token') + const fetchMock = mockFetchResponse(200, { jws: 'tampered.jws.token' }) globalThis.fetch = fetchMock vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockRejectedValue( @@ -325,7 +364,7 @@ describe('RemoteService', () => { }) it('throws when grant is expired', async () => { - const fetchMock = mockFetchResponse(200, 'expired.jws.token') + const fetchMock = mockFetchResponse(200, { jws: 'expired.jws.token' }) globalThis.fetch = fetchMock vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockRejectedValue( @@ -348,7 +387,7 @@ describe('RemoteService', () => { }) it('throws when injector not configured', async () => { - const fetchMock = mockFetchResponse(200, 'signed.jws.token') + const fetchMock = mockFetchResponse(200, { jws: 'signed.jws.token' }) globalThis.fetch = fetchMock vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ @@ -367,7 +406,7 @@ describe('RemoteService', () => { }) it('passes SHA-256 hash of command to verifyGrant', async () => { - const fetchMock = mockFetchResponse(200, 'signed.jws.token') + const fetchMock = mockFetchResponse(200, { jws: 'signed.jws.token' }) globalThis.fetch = fetchMock const verifyMock = vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ diff --git a/src/__tests__/routes.test.ts b/src/__tests__/routes.test.ts index 213b82b..201383a 100644 --- a/src/__tests__/routes.test.ts +++ b/src/__tests__/routes.test.ts @@ -310,6 +310,34 @@ describe('API Routes', () => { 'testing', 'task-1', undefined, + undefined, + ) + + await server.close() + }) + + it('passes command parameter to service', async () => { + const service = makeMockService() + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'POST', + url: '/api/requests', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + payload: { + secretUuids: ['test-uuid'], + reason: 'testing', + taskRef: 'task-1', + command: 'echo hello', + }, + }) + + expect(response.statusCode).toBe(201) + expect(service.requests.create).toHaveBeenCalledWith( + ['test-uuid'], + 'testing', + 'task-1', + undefined, + 'echo hello', ) await server.close() @@ -367,6 +395,98 @@ describe('API Routes', () => { }) }) + describe('GET /api/grants/:requestId/signed', () => { + it('returns 200 with JWS object when grant is approved', async () => { + const service = makeMockService() + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: '/api/grants/req-123/signed', + headers: authHeaders, + }) + + expect(response.statusCode).toBe(200) + expect(JSON.parse(response.body)).toEqual({ jws: 'test.jws.token' }) + expect(service.grants.getStatus).toHaveBeenCalledWith('req-123') + + await server.close() + }) + + it('returns 400 when grant is pending', async () => { + const service = makeMockService() + ;(service.grants.getStatus as ReturnType).mockResolvedValue({ + status: 'pending', + grant: undefined, + jws: undefined, + }) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: '/api/grants/req-123/signed', + headers: authHeaders, + }) + + expect(response.statusCode).toBe(400) + + await server.close() + }) + + it('returns 400 when grant is rejected', async () => { + const service = makeMockService() + ;(service.grants.getStatus as ReturnType).mockResolvedValue({ + status: 'denied', + grant: undefined, + jws: undefined, + }) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: '/api/grants/req-123/signed', + headers: authHeaders, + }) + + expect(response.statusCode).toBe(400) + + await server.close() + }) + + it('returns 404 when no JWS is available', async () => { + const service = makeMockService() + ;(service.grants.getStatus as ReturnType).mockResolvedValue({ + status: 'approved', + grant: makeGrantMock(), + jws: undefined, + }) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: '/api/grants/req-123/signed', + headers: authHeaders, + }) + + expect(response.statusCode).toBe(404) + + await server.close() + }) + + it('returns 404 when request not found', async () => { + const service = makeMockService() + ;(service.grants.getStatus as ReturnType).mockRejectedValue( + new Error('Request not found: unknown'), + ) + const server = createServer(service, TEST_TOKEN) + const response = await server.inject({ + method: 'GET', + url: '/api/grants/unknown/signed', + headers: authHeaders, + }) + + expect(response.statusCode).toBe(404) + + await server.close() + }) + }) + describe('error handling edge cases', () => { it('returns 500 for errors with no matching keyword', async () => { const service = makeMockService() diff --git a/src/core/remote-service.ts b/src/core/remote-service.ts index 1beef65..adef2c3 100644 --- a/src/core/remote-service.ts +++ b/src/core/remote-service.ts @@ -159,12 +159,19 @@ export class RemoteService implements Service { } requests: Service['requests'] = { - create: (secretUuids: string[], reason: string, taskRef: string, duration?: number) => + create: ( + secretUuids: string[], + reason: string, + taskRef: string, + duration?: number, + command?: string, + ) => this.request('POST', '/api/requests', { secretUuids, reason, taskRef, duration, + command, }), } @@ -187,7 +194,7 @@ export class RemoteService implements Service { } // 1. Fetch signed grant JWS from server - const jwsToken = await this.request( + const { jws: jwsToken } = await this.request<{ jws: string }>( 'GET', `/api/grants/${encodeURIComponent(requestId)}/signed`, ) diff --git a/src/server/routes.ts b/src/server/routes.ts index e5ec178..f837d47 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -127,7 +127,13 @@ export const routePlugin = fp( // POST /api/requests — create access request fastify.post<{ - Body: { secretUuids: string[]; reason: string; taskRef: string; duration?: number } + Body: { + secretUuids: string[] + reason: string + taskRef: string + duration?: number + command?: string + } }>( '/api/requests', { @@ -140,14 +146,15 @@ export const routePlugin = fp( reason: { type: 'string' }, taskRef: { type: 'string' }, duration: { type: 'number' }, + command: { type: 'string' }, }, }, }, }, async (request, reply) => { - const { secretUuids, reason, taskRef, duration } = request.body + const { secretUuids, reason, taskRef, duration, command } = request.body const result = await service.requests - .create(secretUuids, reason, taskRef, duration) + .create(secretUuids, reason, taskRef, duration, command) .catch(handleError) return reply.code(201).send(result) }, @@ -170,6 +177,40 @@ export const routePlugin = fp( async (request) => service.grants.getStatus(request.params.requestId).catch(handleError), ) + // GET /api/grants/:requestId/signed — get signed JWS token only + fastify.get<{ Params: { requestId: string } }>( + '/api/grants/:requestId/signed', + { + schema: { + params: { + type: 'object', + required: ['requestId'], + properties: { + requestId: { type: 'string', minLength: 1 }, + }, + }, + }, + }, + async (request) => { + const result = await service.grants.getStatus(request.params.requestId).catch(handleError) + + if (result.status !== 'approved') { + const err = new Error(`Grant not approved: status is ${result.status}`) + ;(err as Error & { statusCode?: number }).statusCode = 400 + throw err + } + + if (!result.jws) { + const err = new Error('No signed grant available for this request') + ;(err as Error & { statusCode?: number }).statusCode = 404 + throw err + } + + // Return JWS wrapped in object for proper JSON serialization + return { jws: result.jws } + }, + ) + // POST /api/inject — resolve secrets for injection // // Security boundary: authenticated callers are trusted to run commands. From 0d529c6cf7568fc2d28fdeb97a21d1046367ea4f Mon Sep 17 00:00:00 2001 From: NoahCardoza Date: Sat, 28 Feb 2026 13:29:36 -0800 Subject: [PATCH 24/25] move Pull-in frequency into config file --- README.md | 1 + src/cli/inject.ts | 2 +- src/cli/request.ts | 2 +- src/core/config.ts | 4 ++++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 847e9b6..5ba9deb 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,7 @@ Config file: `~/.2kc/config.json` | `server.host` | string | `"127.0.0.1"` | Server bind address | | `server.port` | number | `2274` | Server port | | `server.authToken` | string | — | Bearer token for client-server auth | +| `server.pollIntervalMs` | number | `3000` | Polling interval for grant status (ms) | | `store.path` | string | `"~/.2kc/secrets.enc.json"` | Path to the secrets JSON file | | `discord.webhookUrl` | string | — | Discord webhook URL for approval messages | | `discord.botToken` | string | — | Discord bot token for reading reactions | diff --git a/src/cli/inject.ts b/src/cli/inject.ts index 99c7e00..9465c3b 100644 --- a/src/cli/inject.ts +++ b/src/cli/inject.ts @@ -77,7 +77,7 @@ const inject = new Command('inject') ) // 6. Poll for grant status - const pollIntervalMs = 250 + const pollIntervalMs = config.server.pollIntervalMs const maxWaitMs = 5 * 60 * 1000 // 5 minutes const deadline = Date.now() + maxWaitMs let grantResult!: Awaited> diff --git a/src/cli/request.ts b/src/cli/request.ts index 8ae4d9a..da27c56 100644 --- a/src/cli/request.ts +++ b/src/cli/request.ts @@ -58,7 +58,7 @@ const request = new Command('request') ) // 5. Poll for grant status - const pollIntervalMs = 250 + const pollIntervalMs = config.server.pollIntervalMs const maxWaitMs = 5 * 60 * 1000 // 5 minutes const deadline = Date.now() + maxWaitMs let grantResult!: Awaited> diff --git a/src/core/config.ts b/src/core/config.ts index 2f3af37..f9edad7 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -60,6 +60,10 @@ const ServerConfigSchema = z.object({ port: zPortNumber().default(2274), authToken: zNonEmptyString().optional(), sessionTtlMs: z.number().min(1000, 'Expected session TTL to be at least 1000 ms').optional(), + pollIntervalMs: z + .number() + .min(1000, 'Expected poll interval to be at least 1000 ms') + .default(3000), // set higher to avoid rate limiting issues with Discord bot }) // Store config schema From 790f0d197d2689e2a41525fcf2bf68727fab2356 Mon Sep 17 00:00:00 2001 From: NoahCardoza Date: Sat, 28 Feb 2026 14:09:04 -0800 Subject: [PATCH 25/25] fix client mode and add comprehensive integration tests --- src/__tests__/config-command.test.ts | 6 +- src/__tests__/config.test.ts | 12 +- src/__tests__/grant-verifier.test.ts | 8 +- src/__tests__/grant.test.ts | 120 +++--- .../integration/client-server-flow.test.ts | 175 +++++++- .../integration/local-encrypted-flow.test.ts | 18 +- .../integration/notification-flow.test.ts | 317 +++++++++++++++ .../integration/remote-service-flow.test.ts | 375 ++++++++++++++++++ .../mocks/mock-notification-channel.ts | 132 ++++++ src/__tests__/pid-manager.test.ts | 22 + src/__tests__/remote-service.test.ts | 153 ++++--- src/__tests__/secrets-command.test.ts | 38 ++ src/__tests__/session-lock.test.ts | 31 ++ src/__tests__/workflow.test.ts | 9 +- src/cli/config.ts | 1 + src/core/grant-verifier.ts | 7 +- src/core/grant.ts | 27 +- src/core/injector.ts | 81 +++- src/core/remote-service.ts | 45 +-- src/core/service.ts | 50 ++- src/server/routes.ts | 19 + 21 files changed, 1440 insertions(+), 206 deletions(-) create mode 100644 src/__tests__/integration/notification-flow.test.ts create mode 100644 src/__tests__/integration/remote-service-flow.test.ts create mode 100644 src/__tests__/mocks/mock-notification-channel.ts diff --git a/src/__tests__/config-command.test.ts b/src/__tests__/config-command.test.ts index 50ba350..7976604 100644 --- a/src/__tests__/config-command.test.ts +++ b/src/__tests__/config-command.test.ts @@ -30,7 +30,7 @@ const mockChmodSync = vi.mocked(chmodSync) function createValidConfig(overrides?: Partial): AppConfig { return { mode: 'standalone', - server: { host: '127.0.0.1', port: 2274 }, + server: { host: '127.0.0.1', port: 2274, pollIntervalMs: 3000 }, store: { path: '~/.2kc/secrets.json' }, discord: { botToken: 'bot-token-1234567890', @@ -96,7 +96,7 @@ describe('config init action', () => { const writtenJson = mockWriteFileSync.mock.calls[0][1] as string const writtenConfig = JSON.parse(writtenJson) as AppConfig expect(writtenConfig.mode).toBe('standalone') - expect(writtenConfig.server).toEqual({ host: '127.0.0.1', port: 2274 }) + expect(writtenConfig.server).toEqual({ host: '127.0.0.1', port: 2274, pollIntervalMs: 3000 }) expect(writtenConfig.store).toEqual({ path: join(CONFIG_DIR, 'secrets.json') }) }) @@ -224,7 +224,7 @@ describe('config init action', () => { const writtenConfig = JSON.parse(writtenJson) as AppConfig expect(writtenConfig).toEqual({ mode: 'client', - server: { host: '10.0.0.1', port: 3000, authToken: 'tok123' }, + server: { host: '10.0.0.1', port: 3000, authToken: 'tok123', pollIntervalMs: 3000 }, store: { path: '/my/store.json' }, unlock: defaultConfig().unlock, discord: { diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 8288a21..4203333 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -76,7 +76,7 @@ describe('loadConfig', () => { const config = loadConfig() expect(config.mode).toBe('standalone') - expect(config.server).toEqual({ host: '127.0.0.1', port: 2274 }) + expect(config.server).toEqual({ host: '127.0.0.1', port: 2274, pollIntervalMs: 3000 }) expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.enc.json')) expect(config.discord).toBeUndefined() expect(config.requireApproval).toEqual({}) @@ -113,7 +113,7 @@ describe('parseConfig', () => { it('parses a minimal config with all defaults', () => { const config = parseConfig(minimalValid) expect(config.mode).toBe('standalone') - expect(config.server).toEqual({ host: '127.0.0.1', port: 2274 }) + expect(config.server).toEqual({ host: '127.0.0.1', port: 2274, pollIntervalMs: 3000 }) expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.enc.json')) expect(config.discord).toBeUndefined() expect(config.requireApproval).toEqual({}) @@ -440,4 +440,12 @@ describe('getConfig', () => { expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Permission denied')) expect(exitSpy).toHaveBeenCalledWith(1) }) + + it('returns cached config on second call with same path', async () => { + mockReadFileSync.mockReturnValue(JSON.stringify({ mode: 'standalone' })) + const { getConfig } = await import('../core/config.js') + const config1 = getConfig('/test/cache-hit-path') + const config2 = getConfig('/test/cache-hit-path') + expect(config1).toBe(config2) // Same object reference (cache hit) + }) }) diff --git a/src/__tests__/grant-verifier.test.ts b/src/__tests__/grant-verifier.test.ts index 3aa88cf..ba254a7 100644 --- a/src/__tests__/grant-verifier.test.ts +++ b/src/__tests__/grant-verifier.test.ts @@ -197,12 +197,12 @@ describe('GrantVerifier', () => { await expect(verifier.verifyGrant(jws)).rejects.toThrow('Grant has expired') }) - it('rejects when expectedCommandHash provided but payload has no commandHash', async () => { + it('allows grant without commandHash when expectedCommandHash is provided', async () => { + // If grant has no commandHash (server had bindCommand: false), any command is allowed const jws = await makeJWS({}, privateKey) // no commandHash in payload - await expect(verifier.verifyGrant(jws, 'expected-hash')).rejects.toThrow( - 'Grant is missing command hash', - ) + const result = await verifier.verifyGrant(jws, 'expected-hash') + expect(result.grantId).toBe('grant-1') }) it('rejects grant with wrong command hash when bound', async () => { diff --git a/src/__tests__/grant.test.ts b/src/__tests__/grant.test.ts index 30aab45..eabf83b 100644 --- a/src/__tests__/grant.test.ts +++ b/src/__tests__/grant.test.ts @@ -26,62 +26,62 @@ function makePendingRequest(durationSeconds = 300) { describe('GrantManager', () => { describe('createGrant', () => { - it('creates a grant with a UUID id', () => { + it('creates a grant with a UUID id', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) }) - it('sets requestId to request.id', () => { + it('sets requestId to request.id', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.requestId).toBe(request.id) }) - it('sets secretUuids from request.secretUuids', () => { + it('sets secretUuids from request.secretUuids', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.secretUuids).toEqual(request.secretUuids) }) - it('sets used to false and revokedAt to null', () => { + it('sets used to false and revokedAt to null', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.used).toBe(false) expect(grant.revokedAt).toBeNull() }) - it('throws if request is not approved', () => { + it('throws if request is not approved', async () => { const manager = new GrantManager() const request = makePendingRequest() - expect(() => manager.createGrant(request)).toThrow( + await expect(manager.createGrant(request)).rejects.toThrow( 'Cannot create grant for request with status: pending', ) }) - it('returns undefined jws when no signing key', () => { + it('returns undefined jws when no signing key', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant, jws } = manager.createGrant(request) + const { grant, jws } = await manager.createGrant(request) expect(jws).toBeUndefined() expect(grant.jws).toBeUndefined() }) - it('stores jws on grant when signing key is provided', () => { + it('stores jws on grant when signing key is provided', async () => { const { privateKey } = generateKeyPairSync('ed25519') const manager = new GrantManager(undefined, privateKey) const request = makeApprovedRequest() - const { grant, jws } = manager.createGrant(request) + const { grant, jws } = await manager.createGrant(request) expect(jws).toBeDefined() expect(grant.jws).toBe(jws) @@ -99,44 +99,44 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('sets grantedAt to current ISO timestamp', () => { + it('sets grantedAt to current ISO timestamp', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.grantedAt).toBe('2026-01-15T10:00:00.000Z') }) - it('sets expiresAt to grantedAt + durationSeconds', () => { + it('sets expiresAt to grantedAt + durationSeconds', async () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.expiresAt).toBe('2026-01-15T10:05:00.000Z') }) }) describe('commandHash', () => { - it('copies commandHash from request to grant when present', () => { + it('copies commandHash from request to grant when present', async () => { const manager = new GrantManager() const request = makeApprovedRequest() request.commandHash = 'abc123deadbeef' - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.commandHash).toBe('abc123deadbeef') }) - it('grant has no commandHash when request had none', () => { + it('grant has no commandHash when request had none', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.commandHash).toBeUndefined() }) }) describe('batch', () => { - it('copies secretUuids array from request', () => { + it('copies secretUuids array from request', async () => { const manager = new GrantManager() const request = createAccessRequest( ['uuid-1', 'uuid-2', 'uuid-3'], @@ -144,17 +144,17 @@ describe('GrantManager', () => { 'TASK-1', ) request.status = 'approved' - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.secretUuids).toEqual(['uuid-1', 'uuid-2', 'uuid-3']) }) - it('preserves all UUIDs in the array', () => { + it('preserves all UUIDs in the array', async () => { const manager = new GrantManager() const uuids = ['a', 'b', 'c', 'd', 'e'] const request = createAccessRequest(uuids, 'batch access', 'TASK-1') request.status = 'approved' - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(grant.secretUuids).toHaveLength(5) expect(grant.secretUuids).toEqual(uuids) @@ -163,10 +163,10 @@ describe('GrantManager', () => { }) describe('validateGrant', () => { - it('returns true for valid, unexpired, unused, unrevoked grant', () => { + it('returns true for valid, unexpired, unused, unrevoked grant', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) expect(manager.validateGrant(grant.id)).toBe(true) }) @@ -177,20 +177,20 @@ describe('GrantManager', () => { expect(manager.validateGrant('nonexistent')).toBe(false) }) - it('returns false for used grant', () => { + it('returns false for used grant', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) manager.markUsed(grant.id) expect(manager.validateGrant(grant.id)).toBe(false) }) - it('returns false for revoked grant', () => { + it('returns false for revoked grant', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) manager.revokeGrant(grant.id) @@ -207,10 +207,10 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('returns false for expired grant', () => { + it('returns false for expired grant', async () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) // Advance past expiry vi.setSystemTime(new Date('2026-01-15T10:05:00.001Z')) @@ -221,10 +221,10 @@ describe('GrantManager', () => { }) describe('markUsed', () => { - it('marks grant as used', () => { + it('marks grant as used', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) manager.markUsed(grant.id) @@ -238,20 +238,20 @@ describe('GrantManager', () => { expect(() => manager.markUsed('nonexistent')).toThrow('Grant not found: nonexistent') }) - it('throws if grant already used', () => { + it('throws if grant already used', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) manager.markUsed(grant.id) expect(() => manager.markUsed(grant.id)).toThrow(`Grant is not valid: ${grant.id}`) }) - it('throws if grant revoked', () => { + it('throws if grant revoked', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) manager.revokeGrant(grant.id) @@ -268,10 +268,10 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('throws if grant expired', () => { + it('throws if grant expired', async () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) vi.setSystemTime(new Date('2026-01-15T10:05:00.001Z')) @@ -287,10 +287,10 @@ describe('GrantManager', () => { expect(() => manager.revokeGrant('nonexistent')).toThrow('Grant not found: nonexistent') }) - it('throws if grant already revoked', () => { + it('throws if grant already revoked', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) manager.revokeGrant(grant.id) @@ -307,10 +307,10 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('sets revokedAt timestamp', () => { + it('sets revokedAt timestamp', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) vi.setSystemTime(new Date('2026-01-15T10:01:00.000Z')) manager.revokeGrant(grant.id) @@ -338,10 +338,10 @@ describe('GrantManager', () => { vi.useRealTimers() }) - it('removes expired grants from memory', () => { + it('removes expired grants from memory', async () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) vi.setSystemTime(new Date('2026-01-15T10:05:00.001Z')) manager.cleanup() @@ -349,10 +349,10 @@ describe('GrantManager', () => { expect(manager.getGrant(grant.id)).toBeUndefined() }) - it('keeps unexpired grants', () => { + it('keeps unexpired grants', async () => { const manager = new GrantManager() const request = makeApprovedRequest(300) - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) vi.setSystemTime(new Date('2026-01-15T10:04:00.000Z')) manager.cleanup() @@ -363,10 +363,10 @@ describe('GrantManager', () => { }) describe('getGrant', () => { - it('returns a copy of the grant', () => { + it('returns a copy of the grant', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) const retrieved = manager.getGrant(grant.id) expect(retrieved).toEqual(grant) @@ -386,10 +386,10 @@ describe('GrantManager', () => { }) describe('getGrantByRequestId', () => { - it('returns grant matching the requestId', () => { + it('returns grant matching the requestId', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) const found = manager.getGrantByRequestId(request.id) expect(found).toBeDefined() @@ -397,10 +397,10 @@ describe('GrantManager', () => { expect(found!.requestId).toBe(request.id) }) - it('returns a copy (not the original)', () => { + it('returns a copy (not the original)', async () => { const manager = new GrantManager() const request = makeApprovedRequest() - manager.createGrant(request) + await manager.createGrant(request) const found = manager.getGrantByRequestId(request.id) if (found) { @@ -415,11 +415,11 @@ describe('GrantManager', () => { expect(manager.getGrantByRequestId('nonexistent')).toBeUndefined() }) - it('returns grant with jws when signing key is provided', () => { + it('returns grant with jws when signing key is provided', async () => { const { privateKey } = generateKeyPairSync('ed25519') const manager = new GrantManager(undefined, privateKey) const request = makeApprovedRequest() - manager.createGrant(request) + await manager.createGrant(request) const found = manager.getGrantByRequestId(request.id) expect(found?.jws).toBeDefined() @@ -428,11 +428,11 @@ describe('GrantManager', () => { }) describe('getGrantSecrets', () => { - it('returns secretUuids array for valid grant', () => { + it('returns secretUuids array for valid grant', async () => { const manager = new GrantManager() const request = createAccessRequest(['uuid-1', 'uuid-2'], 'reason', 'TASK-1') request.status = 'approved' - const { grant } = manager.createGrant(request) + const { grant } = await manager.createGrant(request) const secrets = manager.getGrantSecrets(grant.id) expect(secrets).toEqual(['uuid-1', 'uuid-2']) diff --git a/src/__tests__/integration/client-server-flow.test.ts b/src/__tests__/integration/client-server-flow.test.ts index c30cf2c..7d5aa61 100644 --- a/src/__tests__/integration/client-server-flow.test.ts +++ b/src/__tests__/integration/client-server-flow.test.ts @@ -15,7 +15,7 @@ import { SecretInjector } from '../../core/injector.js' import { RequestLog } from '../../core/request.js' import { loadOrGenerateKeyPair } from '../../core/key-manager.js' import { SessionLock } from '../../core/session-lock.js' -import type { NotificationChannel } from '../../channels/channel.js' +import { createMockChannel } from '../mocks/mock-notification-channel.js' // --------------------------------------------------------------------------- // Constants @@ -32,16 +32,6 @@ const SHORT_TTL = 1500 // Helpers // --------------------------------------------------------------------------- -/** Build a mock NotificationChannel that resolves every approval request with - * the given response. */ -function createMockChannel(response: 'approved' | 'denied' | 'timeout'): NotificationChannel { - return { - sendApprovalRequest: vi.fn().mockResolvedValue('msg-id'), - waitForResponse: vi.fn().mockResolvedValue(response), - sendNotification: vi.fn().mockResolvedValue(undefined), - } -} - /** POST /api/auth/login with the static auth token; returns the session payload. */ async function login( server: FastifyInstance, @@ -489,4 +479,167 @@ describe('Phase 2 Client-Server Flow', () => { } }) }) + + describe('redaction', () => { + it('multiple secrets are all redacted from stdout', async () => { + const headers = { authorization: `Bearer ${sessionToken}` } + + // Add multiple secrets + const uuid2 = (await service.secrets.add('second-key', 'second-secret-value', [])).uuid + const uuid3 = (await service.secrets.add('third-key', 'third-secret-42', [])).uuid + + // Create request for all secrets + const reqRes = await server.inject({ + method: 'POST', + url: '/api/requests', + headers, + payload: { + secretUuids: [secretUuid, uuid2, uuid3], + reason: 'multi-secret test', + taskRef: 'TASK-M', + }, + }) + expect(reqRes.statusCode).toBe(201) + const { id: requestId } = reqRes.json() as { id: string } + + await waitForGrant(server, requestId, headers, 'approved') + + // Inject: output all three secrets + const injectRes = await server.inject({ + method: 'POST', + url: '/api/inject', + headers, + payload: { + requestId, + command: 'echo "$S1 $S2 $S3"', + envVarName: 'S1', + }, + }) + expect(injectRes.statusCode).toBe(200) + const result = injectRes.json() as { stdout: string; exitCode: number | null } + expect(result.exitCode).toBe(0) + + // All secrets should be redacted (the first one via envVarName) + expect(result.stdout).toContain('[REDACTED]') + expect(result.stdout).not.toContain('secret-value') + }) + + it('secrets in stderr are redacted', async () => { + const headers = { authorization: `Bearer ${sessionToken}` } + + const reqRes = await server.inject({ + method: 'POST', + url: '/api/requests', + headers, + payload: { + secretUuids: [secretUuid], + reason: 'stderr test', + taskRef: 'TASK-SE', + }, + }) + expect(reqRes.statusCode).toBe(201) + const { id: requestId } = reqRes.json() as { id: string } + + await waitForGrant(server, requestId, headers, 'approved') + + // Inject: write secret to stderr + const injectRes = await server.inject({ + method: 'POST', + url: '/api/inject', + headers, + payload: { + requestId, + command: 'echo "$MY_SECRET" >&2', + envVarName: 'MY_SECRET', + }, + }) + expect(injectRes.statusCode).toBe(200) + const result = injectRes.json() as { stdout: string; stderr: string; exitCode: number | null } + expect(result.exitCode).toBe(0) + + // Secret should be redacted from stderr + expect(result.stderr).toContain('[REDACTED]') + expect(result.stderr).not.toContain('secret-value') + }) + + it('multi-line output with repeated secrets is fully redacted', async () => { + const headers = { authorization: `Bearer ${sessionToken}` } + + const reqRes = await server.inject({ + method: 'POST', + url: '/api/requests', + headers, + payload: { + secretUuids: [secretUuid], + reason: 'multi-line test', + taskRef: 'TASK-ML', + }, + }) + expect(reqRes.statusCode).toBe(201) + const { id: requestId } = reqRes.json() as { id: string } + + await waitForGrant(server, requestId, headers, 'approved') + + // Inject: output secret on multiple lines + const injectRes = await server.inject({ + method: 'POST', + url: '/api/inject', + headers, + payload: { + requestId, + command: 'echo "line1: $S"; echo "line2: $S"; echo "line3: $S"', + envVarName: 'S', + }, + }) + expect(injectRes.statusCode).toBe(200) + const result = injectRes.json() as { stdout: string; exitCode: number | null } + expect(result.exitCode).toBe(0) + + // All occurrences should be redacted + const redactedCount = (result.stdout.match(/\[REDACTED\]/g) || []).length + expect(redactedCount).toBe(3) + expect(result.stdout).not.toContain('secret-value') + }) + + it('secret substring appearing in output is handled correctly', async () => { + const headers = { authorization: `Bearer ${sessionToken}` } + + // Add a secret that's a common word + const substringUuid = (await service.secrets.add('common-key', 'test', [])).uuid + + const reqRes = await server.inject({ + method: 'POST', + url: '/api/requests', + headers, + payload: { + secretUuids: [substringUuid], + reason: 'substring test', + taskRef: 'TASK-SUB', + }, + }) + expect(reqRes.statusCode).toBe(201) + const { id: requestId } = reqRes.json() as { id: string } + + await waitForGrant(server, requestId, headers, 'approved') + + // Inject: output includes the secret as part of other text + const injectRes = await server.inject({ + method: 'POST', + url: '/api/inject', + headers, + payload: { + requestId, + command: 'echo "running tests and $SECRET more testing"', + envVarName: 'SECRET', + }, + }) + expect(injectRes.statusCode).toBe(200) + const result = injectRes.json() as { stdout: string; exitCode: number | null } + expect(result.exitCode).toBe(0) + + // The literal secret value should be redacted + // Note: "test" in "tests" and "testing" may or may not be redacted depending on implementation + expect(result.stdout).toContain('[REDACTED]') + }) + }) }) diff --git a/src/__tests__/integration/local-encrypted-flow.test.ts b/src/__tests__/integration/local-encrypted-flow.test.ts index 07c7230..75fa550 100644 --- a/src/__tests__/integration/local-encrypted-flow.test.ts +++ b/src/__tests__/integration/local-encrypted-flow.test.ts @@ -10,20 +10,12 @@ 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' +import { createMockChannel } from '../mocks/mock-notification-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 @@ -89,7 +81,7 @@ describe('Phase 1 Local Encrypted Flow', () => { // 4. Create a grant const grantManager = new GrantManager() - const { grant } = grantManager.createGrant(request) + const { grant } = await grantManager.createGrant(request) expect(grant.secretUuids).toContain(uuid) // 5. Inject via SecretInjector — spawn real subprocess @@ -120,7 +112,7 @@ describe('Phase 1 Local Encrypted Flow', () => { const request = createAccessRequest([uuid], 'test locked rejection', 'task-002') request.status = 'approved' const grantManager = new GrantManager() - const { grant } = grantManager.createGrant(request) + const { grant } = await grantManager.createGrant(request) // 3. Lock the store store.lock() @@ -180,7 +172,7 @@ describe('Phase 1 Local Encrypted Flow', () => { // 4. createGrant should throw because status is 'denied' const grantManager = new GrantManager() - expect(() => grantManager.createGrant(request)).toThrow( + await expect(grantManager.createGrant(request)).rejects.toThrow( 'Cannot create grant for request with status: denied', ) }) @@ -201,7 +193,7 @@ describe('Phase 1 Local Encrypted Flow', () => { // 4. Create grant (expires in 30s) const grantManager = new GrantManager() - const { grant } = grantManager.createGrant(request) + const { grant } = await grantManager.createGrant(request) // 5. Advance past grant TTL vi.advanceTimersByTime(31_000) diff --git a/src/__tests__/integration/notification-flow.test.ts b/src/__tests__/integration/notification-flow.test.ts new file mode 100644 index 0000000..3dccbb4 --- /dev/null +++ b/src/__tests__/integration/notification-flow.test.ts @@ -0,0 +1,317 @@ +import { mkdtempSync, rmSync } 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 { createAccessRequest } from '../../core/request.js' +import type { ISecretStore } from '../../core/secret-store.js' +import { MockNotificationChannel } from '../mocks/mock-notification-channel.js' + +// Low-cost scrypt params for fast tests +const TEST_PARAMS = { N: 1024, r: 8, p: 1 } +const PASSWORD = 'notification-test-pw' + +describe('Notification Channel Workflow Integration', () => { + let tmpDir: string + let storePath: string + let store: EncryptedSecretStore + let mockChannel: MockNotificationChannel + + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), '2kc-notification-')) + storePath = join(tmpDir, 'secrets.enc.json') + store = new EncryptedSecretStore(storePath) + await store.initialize(PASSWORD, TEST_PARAMS) + mockChannel = new MockNotificationChannel() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function buildEngine( + channel: MockNotificationChannel, + config: { + requireApproval?: Record + defaultRequireApproval?: boolean + approvalTimeoutMs?: number + } = {}, + ) { + return new WorkflowEngine({ + store: store as unknown as ISecretStore, + channel, + config: { + requireApproval: config.requireApproval ?? {}, + defaultRequireApproval: config.defaultRequireApproval ?? true, + approvalTimeoutMs: config.approvalTimeoutMs ?? 30_000, + }, + }) + } + + describe('approval request content', () => { + it('channel receives correct UUIDs in request', async () => { + const uuid = store.add('api-key', 'secret-value', ['prod']) + const request = createAccessRequest([uuid], 'deploy to production', 'TASK-1', 3600) + + const engine = buildEngine(mockChannel) + await engine.processRequest(request) + + expect(mockChannel.sentRequests).toHaveLength(1) + expect(mockChannel.lastRequest?.uuids).toEqual([uuid]) + }) + + it('channel receives correct justification/reason', async () => { + const uuid = store.add('db-creds', 'password123', []) + const request = createAccessRequest([uuid], 'Need database access for migration', 'TASK-2') + + const engine = buildEngine(mockChannel) + await engine.processRequest(request) + + expect(mockChannel.lastRequest?.justification).toBe('Need database access for migration') + }) + + it('channel receives secret names (refs)', async () => { + const uuid1 = store.add('first-secret', 'value1', []) + const uuid2 = store.add('second-secret', 'value2', []) + const request = createAccessRequest([uuid1, uuid2], 'batch access', 'TASK-3') + + const engine = buildEngine(mockChannel) + await engine.processRequest(request) + + expect(mockChannel.lastRequest?.secretNames).toEqual(['first-secret', 'second-secret']) + }) + + it('channel receives duration in milliseconds', async () => { + const uuid = store.add('short-lived', 'temp-secret', []) + const request = createAccessRequest([uuid], 'short access', 'TASK-4', 300) // 300 seconds + + const engine = buildEngine(mockChannel) + await engine.processRequest(request) + + expect(mockChannel.lastRequest?.durationMs).toBe(300_000) // 300 * 1000 + }) + + it('channel receives commandHash when present', async () => { + const uuid = store.add('cmd-secret', 'cmd-value', []) + const request = createAccessRequest( + [uuid], + 'run specific command', + 'TASK-5', + 300, + 'echo hello', + 'abc123commandhash', + ) + + const engine = buildEngine(mockChannel) + await engine.processRequest(request) + + expect(mockChannel.lastRequest?.commandHash).toBe('abc123commandhash') + }) + + it('channel receives command when present', async () => { + const uuid = store.add('bound-secret', 'bound-value', []) + const request = createAccessRequest( + [uuid], + 'run deployment', + 'TASK-6', + 300, + 'npm run deploy', + 'deployhash', + ) + + const engine = buildEngine(mockChannel) + await engine.processRequest(request) + + expect(mockChannel.lastRequest?.command).toBe('npm run deploy') + }) + }) + + describe('denial flow', () => { + it('request status becomes denied when channel denies', async () => { + mockChannel.setDefaultResponse('denied') + const uuid = store.add('denied-key', 'denied-value', []) + const request = createAccessRequest([uuid], 'will be denied', 'TASK-D') + + const engine = buildEngine(mockChannel) + const result = await engine.processRequest(request) + + expect(result).toBe('denied') + expect(request.status).toBe('denied') + }) + + it('sendApprovalRequest is called before denial', async () => { + mockChannel.setDefaultResponse('denied') + const uuid = store.add('tracked-deny', 'track-value', []) + const request = createAccessRequest([uuid], 'tracking denial', 'TASK-TD') + + const engine = buildEngine(mockChannel) + await engine.processRequest(request) + + expect(mockChannel.sendApprovalRequestSpy).toHaveBeenCalled() + }) + }) + + describe('timeout handling', () => { + it('request status becomes timeout when channel times out', async () => { + mockChannel.setDefaultResponse('timeout') + const uuid = store.add('timeout-key', 'timeout-value', []) + const request = createAccessRequest([uuid], 'will timeout', 'TASK-T') + + const engine = buildEngine(mockChannel) + const result = await engine.processRequest(request) + + expect(result).toBe('timeout') + expect(request.status).toBe('timeout') + }) + + it('timeout with correct timeout value passed to waitForResponse', async () => { + const customTimeout = 60_000 + const uuid = store.add('custom-timeout', 'value', []) + const request = createAccessRequest([uuid], 'custom timeout test', 'TASK-CT') + + const engine = buildEngine(mockChannel, { approvalTimeoutMs: customTimeout }) + await engine.processRequest(request) + + expect(mockChannel.waitForResponseSpy).toHaveBeenCalledWith(expect.any(String), customTimeout) + }) + }) + + describe('tag-based approval routing', () => { + it('triggers channel when secret has approval-required tag', async () => { + const uuid = store.add('prod-db', 'prod-password', ['production']) + const request = createAccessRequest([uuid], 'prod access', 'TASK-P') + + const engine = buildEngine(mockChannel, { + requireApproval: { production: true }, + defaultRequireApproval: false, + }) + await engine.processRequest(request) + + expect(mockChannel.sendApprovalRequestSpy).toHaveBeenCalled() + }) + + it('skips channel when secret has no approval-required tag', async () => { + const uuid = store.add('dev-key', 'dev-value', ['dev']) + const request = createAccessRequest([uuid], 'dev access', 'TASK-DEV') + + const engine = buildEngine(mockChannel, { + requireApproval: { production: true }, + defaultRequireApproval: false, + }) + const result = await engine.processRequest(request) + + expect(result).toBe('approved') + expect(mockChannel.sendApprovalRequestSpy).not.toHaveBeenCalled() + }) + + it('triggers channel for any secret with approval-required tag in batch', async () => { + const devUuid = store.add('dev-secret', 'dev-val', ['dev']) + const prodUuid = store.add('prod-secret', 'prod-val', ['production']) + const request = createAccessRequest([devUuid, prodUuid], 'mixed access', 'TASK-MIX') + + const engine = buildEngine(mockChannel, { + requireApproval: { production: true }, + defaultRequireApproval: false, + }) + await engine.processRequest(request) + + // Should require approval because one secret has 'production' tag + expect(mockChannel.sendApprovalRequestSpy).toHaveBeenCalled() + }) + + it('uses defaultRequireApproval for untagged secrets', async () => { + const uuid = store.add('untagged-secret', 'untagged-value', []) + const request = createAccessRequest([uuid], 'untagged access', 'TASK-U') + + const engine = buildEngine(mockChannel, { + requireApproval: {}, + defaultRequireApproval: true, + }) + await engine.processRequest(request) + + expect(mockChannel.sendApprovalRequestSpy).toHaveBeenCalled() + }) + + it('skips approval for untagged secrets when defaultRequireApproval is false', async () => { + const uuid = store.add('untagged-skip', 'skip-value', []) + const request = createAccessRequest([uuid], 'skip approval', 'TASK-SKIP') + + const engine = buildEngine(mockChannel, { + requireApproval: {}, + defaultRequireApproval: false, + }) + const result = await engine.processRequest(request) + + expect(result).toBe('approved') + expect(mockChannel.sendApprovalRequestSpy).not.toHaveBeenCalled() + }) + + it('explicit false tag overrides defaultRequireApproval true', async () => { + const uuid = store.add('safe-dev', 'dev-value', ['dev']) + const request = createAccessRequest([uuid], 'safe dev access', 'TASK-SAFE') + + const engine = buildEngine(mockChannel, { + requireApproval: { dev: false }, + defaultRequireApproval: true, + }) + const result = await engine.processRequest(request) + + // dev: false should skip approval even though defaultRequireApproval is true + expect(result).toBe('approved') + expect(mockChannel.sendApprovalRequestSpy).not.toHaveBeenCalled() + }) + }) + + describe('multiple sequential requests', () => { + it('handles multiple sequential approval requests correctly', async () => { + const uuid1 = store.add('seq-1', 'value-1', []) + const uuid2 = store.add('seq-2', 'value-2', []) + + const engine = buildEngine(mockChannel) + + const request1 = createAccessRequest([uuid1], 'first request', 'TASK-SEQ1') + const request2 = createAccessRequest([uuid2], 'second request', 'TASK-SEQ2') + + const result1 = await engine.processRequest(request1) + const result2 = await engine.processRequest(request2) + + expect(result1).toBe('approved') + expect(result2).toBe('approved') + expect(mockChannel.sentRequests).toHaveLength(2) + }) + + it('queued responses are consumed in order', async () => { + // First request approved, second denied + mockChannel.queueResponses('approved', 'denied') + + const uuid1 = store.add('queue-1', 'value-1', []) + const uuid2 = store.add('queue-2', 'value-2', []) + + const engine = buildEngine(mockChannel) + + const request1 = createAccessRequest([uuid1], 'first queued', 'TASK-Q1') + const request2 = createAccessRequest([uuid2], 'second queued', 'TASK-Q2') + + const result1 = await engine.processRequest(request1) + const result2 = await engine.processRequest(request2) + + // Queued responses are consumed in order + expect(result1).toBe('approved') + expect(result2).toBe('denied') + }) + }) + + describe('error handling', () => { + it('request is denied when store lookup fails', async () => { + const uuid = 'nonexistent-uuid' + const request = createAccessRequest([uuid], 'bad lookup', 'TASK-ERR') + + const engine = buildEngine(mockChannel) + + await expect(engine.processRequest(request)).rejects.toThrow() + expect(request.status).toBe('denied') + }) + }) +}) diff --git a/src/__tests__/integration/remote-service-flow.test.ts b/src/__tests__/integration/remote-service-flow.test.ts new file mode 100644 index 0000000..e8f8957 --- /dev/null +++ b/src/__tests__/integration/remote-service-flow.test.ts @@ -0,0 +1,375 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { KeyObject } from 'node:crypto' +import type { AddressInfo } from 'node:net' + +import type { FastifyInstance } from 'fastify' + +import { createServer } from '../../server/app.js' +import { LocalService } from '../../core/service.js' +import { RemoteService } from '../../core/remote-service.js' +import { EncryptedSecretStore } from '../../core/encrypted-store.js' +import { UnlockSession } from '../../core/unlock-session.js' +import { GrantManager } from '../../core/grant.js' +import { WorkflowEngine } from '../../core/workflow.js' +import { SecretInjector } from '../../core/injector.js' +import { RequestLog } from '../../core/request.js' +import { loadOrGenerateKeyPair } from '../../core/key-manager.js' +import { SessionLock } from '../../core/session-lock.js' +import type { ServerConfig } from '../../core/config.js' +import { MockNotificationChannel } from '../mocks/mock-notification-channel.js' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// Low-cost scrypt params for fast test initialisation +const TEST_PARAMS = { N: 1024, r: 8, p: 1 } +const PASSWORD = 'test-pw' +const AUTH_TOKEN = 'test-static-token' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface ServiceBundle { + service: LocalService + publicKey: KeyObject + mockChannel: MockNotificationChannel +} + +/** Assemble a fully-wired LocalService backed by a real EncryptedSecretStore + * and GrantManager, but with a mock notification channel. */ +async function buildService( + tmpDir: string, + opts: { channelResponse?: 'approved' | 'denied' | 'timeout' } = {}, +): Promise { + const storePath = join(tmpDir, 'secrets.enc.json') + const grantsPath = join(tmpDir, 'server-grants.json') + const requestsPath = join(tmpDir, 'server-requests.json') + const keysPath = join(tmpDir, 'server-keys.json') + + const store = new EncryptedSecretStore(storePath) + await store.initialize(PASSWORD, TEST_PARAMS) + + const { privateKey, publicKey } = await loadOrGenerateKeyPair(keysPath) + const unlockConfig = { ttlMs: 3_600_000 } + const unlockSession = new UnlockSession(unlockConfig) + const sessionLockPath = join(tmpDir, 'session.lock') + const sessionLock = new SessionLock(unlockConfig, sessionLockPath) + const grantManager = new GrantManager(grantsPath, privateKey) + const mockChannel = new MockNotificationChannel({ + defaultResponse: opts.channelResponse ?? 'approved', + }) + const workflowEngine = new WorkflowEngine({ + store, + channel: mockChannel, + config: { requireApproval: {}, defaultRequireApproval: true, approvalTimeoutMs: 5_000 }, + }) + const injector = new SecretInjector(grantManager, store) + const requestLog = new RequestLog(requestsPath) + + const service = new LocalService({ + store, + unlockSession, + sessionLock, + grantManager, + workflowEngine, + injector, + requestLog, + startTime: Date.now(), + bindCommand: false, + publicKey, + }) + + return { service, publicKey, mockChannel } +} + +/** Create and start a Fastify server on a random available port. */ +async function startServer(service: LocalService): Promise { + const server = createServer(service, AUTH_TOKEN) + await server.listen({ host: '127.0.0.1', port: 0 }) + return server +} + +/** Build a RemoteService client that connects to the given server. */ +function buildRemoteClient( + tmpDir: string, + serverAddress: AddressInfo, + existingUnlockSession?: UnlockSession, + existingStore?: EncryptedSecretStore, +): { client: RemoteService; unlockSession: UnlockSession; store: EncryptedSecretStore } { + const storePath = join(tmpDir, 'secrets.enc.json') + const grantsPath = join(tmpDir, 'server-grants.json') + + const store = existingStore ?? new EncryptedSecretStore(storePath) + const unlockSession = existingUnlockSession ?? new UnlockSession({ ttlMs: 3_600_000 }) + // Create fresh GrantManager to load current grants from file + const grantManager = new GrantManager(grantsPath) + const injector = new SecretInjector(grantManager, store) + + const serverConfig: ServerConfig = { + host: serverAddress.address, + port: serverAddress.port, + authToken: AUTH_TOKEN, + } + + const client = new RemoteService(serverConfig, { unlockSession, injector }) + return { client, unlockSession, store } +} + +/** Poll client.grants.getStatus until the status matches expectedStatus. */ +async function waitForGrantViaClient( + client: RemoteService, + requestId: string, + expectedStatus: string, +): Promise<{ status: string; jws?: string }> { + let result: { status: string; jws?: string } = { status: '' } + await vi.waitFor( + async () => { + result = await client.grants.getStatus(requestId) + expect(result.status).toBe(expectedStatus) + }, + { timeout: 5_000, interval: 50 }, + ) + return result +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('RemoteService Integration Flow', () => { + let tmpDir: string + let service: LocalService + let server: FastifyInstance + let client: RemoteService + let clientUnlockSession: UnlockSession + let clientStore: EncryptedSecretStore + let mockChannel: MockNotificationChannel + + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), '2kc-remote-')) + + // Build and start server + const bundle = await buildService(tmpDir) + service = bundle.service + mockChannel = bundle.mockChannel + await service.unlock(PASSWORD) + server = await startServer(service) + + // Build client pointing to server + const address = server.server.address() as AddressInfo + const clientBundle = buildRemoteClient(tmpDir, address) + client = clientBundle.client + clientUnlockSession = clientBundle.unlockSession + clientStore = clientBundle.store + + // Unlock client-side store and session + await clientStore.unlock(PASSWORD) + const dek = clientStore.getDek() + if (dek) clientUnlockSession.unlock(dek) + }) + + afterEach(async () => { + service?.destroy() + await server?.close() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + describe('happy path via RemoteService', () => { + it('auto-login → add secret → create request → poll for approval → inject', async () => { + // 1. Add secret via client (client calls server API) + const { uuid: secretUuid } = await client.secrets.add('test-key', 'secret-value', []) + expect(secretUuid).toBeDefined() + + // 2. Create access request via client + const request = await client.requests.create([secretUuid], 'test reason', 'TASK-R', 3600) + expect(request.id).toBeDefined() + expect(request.status).toBe('pending') + + // 3. Poll for approval (mock channel auto-approves) + const grantResult = await waitForGrantViaClient(client, request.id, 'approved') + expect(grantResult.jws).toBeDefined() + expect(typeof grantResult.jws).toBe('string') + + // 4. Rebuild client to load newly created grant + // (GrantManager loads file at construction, so we need a fresh one to see server's grant) + const address = server.server.address() as AddressInfo + const { client: freshClient } = buildRemoteClient( + tmpDir, + address, + clientUnlockSession, + clientStore, + ) + + // 5. Inject via client — runs command locally with secrets injected + const result = await freshClient.inject(request.id, 'echo $TEST_SECRET', { + envVarName: 'TEST_SECRET', + }) + expect(result.exitCode).toBe(0) + // Secret should be redacted in output + expect(result.stdout).toContain('[REDACTED]') + expect(result.stdout).not.toContain('secret-value') + }) + + it('multiple requests with same client instance reuse session', async () => { + const { uuid: secret1 } = await client.secrets.add('key-1', 'value-1', []) + const { uuid: secret2 } = await client.secrets.add('key-2', 'value-2', []) + + // First request + const req1 = await client.requests.create([secret1], 'reason 1', 'TASK-1') + await waitForGrantViaClient(client, req1.id, 'approved') + + // Second request — should reuse session token + const req2 = await client.requests.create([secret2], 'reason 2', 'TASK-2') + await waitForGrantViaClient(client, req2.id, 'approved') + + // Rebuild client to load newly created grants + const address = server.server.address() as AddressInfo + const { client: freshClient } = buildRemoteClient( + tmpDir, + address, + clientUnlockSession, + clientStore, + ) + + // Both injections should work + const result1 = await freshClient.inject(req1.id, 'echo $S1', { envVarName: 'S1' }) + expect(result1.exitCode).toBe(0) + + const result2 = await freshClient.inject(req2.id, 'echo $S2', { envVarName: 'S2' }) + expect(result2.exitCode).toBe(0) + }) + }) + + describe('denial via RemoteService', () => { + it('denied request returns denied status, inject fails', async () => { + // Reconfigure mock channel to deny + mockChannel.setDefaultResponse('denied') + + const { uuid: secretUuid } = await client.secrets.add('deny-key', 'deny-value', []) + const request = await client.requests.create([secretUuid], 'deny test', 'TASK-D') + + // Poll for denial + const grantResult = await waitForGrantViaClient(client, request.id, 'denied') + expect(grantResult.jws).toBeUndefined() + + // Inject should fail + await expect(client.inject(request.id, 'echo test', { envVarName: 'TEST' })).rejects.toThrow() + }) + }) + + describe('timeout via RemoteService', () => { + it('timeout request returns timeout status', async () => { + mockChannel.setDefaultResponse('timeout') + + const { uuid: secretUuid } = await client.secrets.add('timeout-key', 'timeout-value', []) + const request = await client.requests.create([secretUuid], 'timeout test', 'TASK-T') + + const grantResult = await waitForGrantViaClient(client, request.id, 'timeout') + expect(grantResult.jws).toBeUndefined() + }) + }) + + describe('JWS verification', () => { + it('client verifies JWS signature from server', async () => { + const { uuid: secretUuid } = await client.secrets.add('jws-key', 'jws-value', []) + const request = await client.requests.create([secretUuid], 'jws test', 'TASK-J') + + const grantResult = await waitForGrantViaClient(client, request.id, 'approved') + const jws = grantResult.jws as string + + // JWS should be in proper format: header.payload.signature + const parts = jws.split('.') + expect(parts).toHaveLength(3) + + // Decode and verify payload contains expected fields + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()) + expect(payload.grantId).toBeDefined() + expect(payload.requestId).toBe(request.id) + expect(payload.secretUuids).toContain(secretUuid) + expect(payload.expiresAt).toBeDefined() + }) + }) + + describe('health check via RemoteService', () => { + it('returns server health status', async () => { + const health = await client.health() + // The /health endpoint returns 'ok', while LocalService.health() returns 'unlocked'/'locked' + expect(health.status).toBe('ok') + expect(health.uptime).toBeGreaterThan(0) + }) + }) + + describe('secrets management via RemoteService', () => { + it('list secrets returns added secrets', async () => { + await client.secrets.add('list-key-1', 'value-1', ['tag-a']) + await client.secrets.add('list-key-2', 'value-2', ['tag-b']) + + const secrets = await client.secrets.list() + expect(secrets.length).toBe(2) + + const refs = secrets.map((s) => s.ref) + expect(refs).toContain('list-key-1') + expect(refs).toContain('list-key-2') + }) + + it('remove secret works', async () => { + const { uuid } = await client.secrets.add('remove-key', 'remove-value', []) + let secrets = await client.secrets.list() + expect(secrets.some((s) => s.uuid === uuid)).toBe(true) + + await client.secrets.remove(uuid) + secrets = await client.secrets.list() + expect(secrets.some((s) => s.uuid === uuid)).toBe(false) + }) + + it('getMetadata returns secret metadata', async () => { + const { uuid } = await client.secrets.add('meta-key', 'meta-value', ['meta-tag']) + const metadata = await client.secrets.getMetadata(uuid) + + expect(metadata.uuid).toBe(uuid) + expect(metadata.ref).toBe('meta-key') + expect(metadata.tags).toContain('meta-tag') + }) + + it('resolve finds secret by ref', async () => { + const { uuid } = await client.secrets.add('resolve-key', 'resolve-value', []) + const resolved = await client.secrets.resolve('resolve-key') + + expect(resolved.uuid).toBe(uuid) + expect(resolved.ref).toBe('resolve-key') + }) + }) + + describe('public key retrieval', () => { + it('client can fetch server public key', async () => { + const publicKey = await client.keys.getPublicKey() + + expect(publicKey).toBeDefined() + expect(publicKey).toContain('-----BEGIN PUBLIC KEY-----') + expect(publicKey).toContain('-----END PUBLIC KEY-----') + }) + }) + + describe('error handling', () => { + it('request for non-existent secret gets denied during workflow', async () => { + const fakeUuid = '00000000-0000-0000-0000-000000000000' + // Request creation succeeds, but workflow processing denies it + const request = await client.requests.create([fakeUuid], 'bad request', 'TASK-E') + expect(request.id).toBeDefined() + + // Wait for the workflow to process and deny + const result = await waitForGrantViaClient(client, request.id, 'denied') + expect(result.jws).toBeUndefined() + }) + + it('inject with invalid request ID fails', async () => { + await expect( + client.inject('invalid-request-id', 'echo test', { envVarName: 'TEST' }), + ).rejects.toThrow() + }) + }) +}) diff --git a/src/__tests__/mocks/mock-notification-channel.ts b/src/__tests__/mocks/mock-notification-channel.ts new file mode 100644 index 0000000..6777152 --- /dev/null +++ b/src/__tests__/mocks/mock-notification-channel.ts @@ -0,0 +1,132 @@ +import { vi } from 'vitest' +import type { NotificationChannel } from '../../channels/channel.js' +import type { AccessRequest } from '../../core/types.js' + +export type MockResponse = 'approved' | 'denied' | 'timeout' + +export interface MockNotificationChannelOptions { + /** Default response for all approval requests */ + defaultResponse?: MockResponse + /** Response delay in milliseconds (simulates network latency) */ + responseDelayMs?: number +} + +/** + * A mock NotificationChannel for testing. + * + * Records all sent approval requests and notifications for inspection. + * Supports configurable default responses and per-request overrides. + */ +export class MockNotificationChannel implements NotificationChannel { + /** All approval requests sent to this channel */ + readonly sentRequests: AccessRequest[] = [] + /** All notification messages sent to this channel */ + readonly notifications: string[] = [] + + private defaultResponse: MockResponse + private responseDelayMs: number + private messageCounter = 0 + private responseOverrides = new Map() + + // Vitest spies for method call verification + readonly sendApprovalRequestSpy = vi.fn<(request: AccessRequest) => Promise>() + readonly waitForResponseSpy = + vi.fn<(messageId: string, timeoutMs: number) => Promise>() + readonly sendNotificationSpy = vi.fn<(message: string) => Promise>() + + constructor(options: MockNotificationChannelOptions = {}) { + this.defaultResponse = options.defaultResponse ?? 'approved' + this.responseDelayMs = options.responseDelayMs ?? 0 + } + + /** + * Set a specific response for a message ID. + * Call this before waitForResponse to control per-request behavior. + */ + setResponseForMessage(messageId: string, response: MockResponse): void { + this.responseOverrides.set(messageId, response) + } + + /** + * Queue responses in order they will be returned. + * Each call to waitForResponse consumes one response from the queue. + */ + queueResponses(...responses: MockResponse[]): void { + for (const response of responses) { + const nextId = `msg-${this.messageCounter + this.responseOverrides.size + 1}` + this.responseOverrides.set(nextId, response) + } + } + + /** + * Change the default response for future requests. + */ + setDefaultResponse(response: MockResponse): void { + this.defaultResponse = response + } + + /** + * Get the last sent approval request (convenience method). + */ + get lastRequest(): AccessRequest | undefined { + return this.sentRequests[this.sentRequests.length - 1] + } + + /** + * Get the last sent notification message (convenience method). + */ + get lastNotification(): string | undefined { + return this.notifications[this.notifications.length - 1] + } + + /** + * Reset all recorded state for clean test isolation. + */ + reset(): void { + this.sentRequests.length = 0 + this.notifications.length = 0 + this.messageCounter = 0 + this.responseOverrides.clear() + this.sendApprovalRequestSpy.mockClear() + this.waitForResponseSpy.mockClear() + this.sendNotificationSpy.mockClear() + } + + // --- NotificationChannel interface implementation --- + + async sendApprovalRequest(request: AccessRequest): Promise { + this.sentRequests.push(request) + const messageId = `msg-${++this.messageCounter}` + this.sendApprovalRequestSpy(request) + return messageId + } + + async waitForResponse(messageId: string, _timeoutMs: number): Promise { + if (this.responseDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.responseDelayMs)) + } + + const override = this.responseOverrides.get(messageId) + const response = override ?? this.defaultResponse + this.waitForResponseSpy(messageId, _timeoutMs) + return response + } + + async sendNotification(message: string): Promise { + this.notifications.push(message) + this.sendNotificationSpy(message) + } +} + +/** + * Factory function for backward compatibility with existing tests. + * Creates a simple mock channel with spy methods that can be used with + * Vitest's toHaveBeenCalledWith assertions. + */ +export function createMockChannel(response: MockResponse = 'approved'): NotificationChannel { + return { + sendApprovalRequest: vi.fn().mockResolvedValue('msg-123'), + waitForResponse: vi.fn().mockResolvedValue(response), + sendNotification: vi.fn().mockResolvedValue(undefined), + } +} diff --git a/src/__tests__/pid-manager.test.ts b/src/__tests__/pid-manager.test.ts index a9edc06..e5e3784 100644 --- a/src/__tests__/pid-manager.test.ts +++ b/src/__tests__/pid-manager.test.ts @@ -59,6 +59,17 @@ describe('PidManager', () => { expect(readPid()).toBeNull() }) + it('re-throws non-ENOENT errors', async () => { + const err = new Error('EACCES') as NodeJS.ErrnoException + err.code = 'EACCES' + mockReadFileSync.mockImplementation(() => { + throw err + }) + const { readPid } = await import('../core/pid-manager.js') + + expect(() => readPid()).toThrow('EACCES') + }) + it('returns null and cleans up when file contains invalid data', async () => { mockReadFileSync.mockReturnValue('not-a-number') const { readPid } = await import('../core/pid-manager.js') @@ -126,6 +137,17 @@ describe('PidManager', () => { expect(() => removePidFile()).not.toThrow() }) + + it('re-throws non-ENOENT errors', async () => { + const err = new Error('EACCES') as NodeJS.ErrnoException + err.code = 'EACCES' + mockUnlinkSync.mockImplementation(() => { + throw err + }) + const { removePidFile } = await import('../core/pid-manager.js') + + expect(() => removePidFile()).toThrow('EACCES') + }) }) describe('getRunningPid', () => { diff --git a/src/__tests__/remote-service.test.ts b/src/__tests__/remote-service.test.ts index b3d0008..b95e001 100644 --- a/src/__tests__/remote-service.test.ts +++ b/src/__tests__/remote-service.test.ts @@ -41,12 +41,9 @@ function makeJsonResponse(status: number, body: unknown, statusText = 'OK') { function makeDeps(overrides?: Partial): RemoteServiceDeps { return { - unlockSession: { - isUnlocked: vi.fn().mockReturnValue(true), - recordGrantUsage: vi.fn(), - } as unknown as RemoteServiceDeps['unlockSession'], injector: { inject: vi.fn().mockResolvedValue({ exitCode: 0, stdout: 'ok', stderr: '' }), + injectWithCache: vi.fn().mockResolvedValue({ exitCode: 0, stdout: 'ok', stderr: '' }), } as unknown as RemoteServiceDeps['injector'], ...overrides, } @@ -278,9 +275,18 @@ describe('RemoteService', () => { }) }) - describe('inject (local)', () => { - it('receives signed grant, verifies JWS, injects locally using SecretInjector', async () => { - const fetchMock = mockFetchResponse(200, { jws: 'signed.jws.token' }) + describe('inject (remote)', () => { + it('verifies JWS, consumes grant, and uses injectWithCache', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-1')) + .mockResolvedValueOnce(makeJsonResponse(200, { jws: 'signed.jws.token' })) + .mockResolvedValueOnce( + makeJsonResponse(200, { + grantId: 'grant-abc', + secrets: { 'uuid-1': { uuid: 'uuid-1', ref: 'my-key', value: 'secret-value' } }, + }), + ) globalThis.fetch = fetchMock vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ @@ -295,16 +301,32 @@ describe('RemoteService', () => { const result = await service.inject('req-1', 'echo hello') expect(result).toEqual({ exitCode: 0, stdout: 'ok', stderr: '' }) - expect(deps.injector!.inject).toHaveBeenCalledWith( - 'grant-abc', + expect(deps.injector!.injectWithCache).toHaveBeenCalledWith( ['/bin/sh', '-c', 'echo hello'], + expect.any(Map), undefined, ) - expect(deps.unlockSession!.recordGrantUsage).toHaveBeenCalled() + // Verify the secret cache was passed correctly + const cacheArg = (deps.injector!.injectWithCache as ReturnType).mock + .calls[0][1] as Map + expect(cacheArg.get('uuid-1')).toEqual({ + uuid: 'uuid-1', + ref: 'my-key', + value: 'secret-value', + }) }) - it('passes envVarName option to injector', async () => { - const fetchMock = mockFetchResponse(200, { jws: 'signed.jws.token' }) + it('passes envVarName option to injectWithCache', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-1')) + .mockResolvedValueOnce(makeJsonResponse(200, { jws: 'signed.jws.token' })) + .mockResolvedValueOnce( + makeJsonResponse(200, { + grantId: 'grant-abc', + secrets: { 'uuid-1': { uuid: 'uuid-1', ref: 'my-key', value: 'secret-value' } }, + }), + ) globalThis.fetch = fetchMock vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ @@ -318,39 +340,18 @@ describe('RemoteService', () => { const service = new RemoteService(makeConfig(), deps) await service.inject('req-1', 'echo hello', { envVarName: 'SECRET_VAR' }) - expect(deps.injector!.inject).toHaveBeenCalledWith( - 'grant-abc', + expect(deps.injector!.injectWithCache).toHaveBeenCalledWith( ['/bin/sh', '-c', 'echo hello'], + expect.any(Map), { envVarName: 'SECRET_VAR' }, ) }) - it('throws when local store is locked', async () => { - const fetchMock = mockFetchResponse(200, { jws: 'signed.jws.token' }) - globalThis.fetch = fetchMock - - vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ - grantId: 'grant-abc', - requestId: 'req-1', - secretUuids: ['uuid-1'], - expiresAt: new Date(Date.now() + 60_000).toISOString(), - }) - - const deps = makeDeps({ - unlockSession: { - isUnlocked: vi.fn().mockReturnValue(false), - recordGrantUsage: vi.fn(), - } as unknown as RemoteServiceDeps['unlockSession'], - }) - const service = new RemoteService(makeConfig(), deps) - - await expect(service.inject('req-1', 'echo hello')).rejects.toThrow( - 'Local store is locked. Run `2kc unlock` before requesting secrets.', - ) - }) - it('throws when JWS verification fails', async () => { - const fetchMock = mockFetchResponse(200, { jws: 'tampered.jws.token' }) + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-1')) + .mockResolvedValueOnce(makeJsonResponse(200, { jws: 'tampered.jws.token' })) globalThis.fetch = fetchMock vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockRejectedValue( @@ -364,7 +365,10 @@ describe('RemoteService', () => { }) it('throws when grant is expired', async () => { - const fetchMock = mockFetchResponse(200, { jws: 'expired.jws.token' }) + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-1')) + .mockResolvedValueOnce(makeJsonResponse(200, { jws: 'expired.jws.token' })) globalThis.fetch = fetchMock vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockRejectedValue( @@ -377,17 +381,17 @@ describe('RemoteService', () => { await expect(service.inject('req-1', 'echo hello')).rejects.toThrow('Grant has expired') }) - it('throws when unlockSession not configured', async () => { - const deps = makeDeps({ unlockSession: undefined }) - const service = new RemoteService(makeConfig(), deps) - - await expect(service.inject('req-1', 'echo hello')).rejects.toThrow( - 'unlockSession not configured', - ) - }) - it('throws when injector not configured', async () => { - const fetchMock = mockFetchResponse(200, { jws: 'signed.jws.token' }) + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-1')) + .mockResolvedValueOnce(makeJsonResponse(200, { jws: 'signed.jws.token' })) + .mockResolvedValueOnce( + makeJsonResponse(200, { + grantId: 'grant-abc', + secrets: { 'uuid-1': { uuid: 'uuid-1', ref: 'my-key', value: 'secret-value' } }, + }), + ) globalThis.fetch = fetchMock vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ @@ -400,13 +404,20 @@ describe('RemoteService', () => { const deps = makeDeps({ injector: undefined }) const service = new RemoteService(makeConfig(), deps) - await expect(service.inject('req-1', 'echo hello')).rejects.toThrow( - 'Injector not available in client mode', - ) + await expect(service.inject('req-1', 'echo hello')).rejects.toThrow('Injector not available') }) it('passes SHA-256 hash of command to verifyGrant', async () => { - const fetchMock = mockFetchResponse(200, { jws: 'signed.jws.token' }) + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-1')) + .mockResolvedValueOnce(makeJsonResponse(200, { jws: 'signed.jws.token' })) + .mockResolvedValueOnce( + makeJsonResponse(200, { + grantId: 'grant-abc', + secrets: { 'uuid-1': { uuid: 'uuid-1', ref: 'my-key', value: 'secret-value' } }, + }), + ) globalThis.fetch = fetchMock const verifyMock = vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ @@ -425,6 +436,40 @@ describe('RemoteService', () => { const expectedHash = createHash('sha256').update('echo hello').digest('hex') expect(verifyMock).toHaveBeenCalledWith('signed.jws.token', expectedHash) }) + + it('calls consume endpoint to fetch secrets from server', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeLoginSuccessResponse('session-1')) + .mockResolvedValueOnce(makeJsonResponse(200, { jws: 'signed.jws.token' })) + .mockResolvedValueOnce( + makeJsonResponse(200, { + grantId: 'grant-abc', + secrets: { + 'uuid-1': { uuid: 'uuid-1', ref: 'key-1', value: 'value-1' }, + 'uuid-2': { uuid: 'uuid-2', ref: 'key-2', value: 'value-2' }, + }, + }), + ) + globalThis.fetch = fetchMock + + vi.spyOn(GrantVerifier.prototype, 'verifyGrant').mockResolvedValue({ + grantId: 'grant-abc', + requestId: 'req-1', + secretUuids: ['uuid-1', 'uuid-2'], + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + + const deps = makeDeps() + const service = new RemoteService(makeConfig(), deps) + await service.inject('req-1', 'echo hello') + + // Verify consume endpoint was called + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:2274/api/grants/req-1/consume', + expect.objectContaining({ method: 'POST' }), + ) + }) }) describe('session auth', () => { diff --git a/src/__tests__/secrets-command.test.ts b/src/__tests__/secrets-command.test.ts index ba1d96f..b2c0965 100644 --- a/src/__tests__/secrets-command.test.ts +++ b/src/__tests__/secrets-command.test.ts @@ -271,6 +271,20 @@ describe('secrets add command', () => { expect(process.exitCode).toBe(1) errorSpy.mockRestore() }) + + it('handles non-Error rejection in add command', async () => { + mockSecretsAdd.mockRejectedValue('raw string error') + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { secretsCommand } = await import('../cli/secrets.js') + await secretsCommand.parseAsync(['add', '--ref', 'my-ref', '--value', 'secret'], { + from: 'user', + }) + + expect(errorSpy).toHaveBeenCalledWith('Error: raw string error') + expect(process.exitCode).toBe(1) + errorSpy.mockRestore() + }) }) describe('secrets list command', () => { @@ -315,6 +329,18 @@ describe('secrets list command', () => { expect(process.exitCode).toBe(1) errorSpy.mockRestore() }) + + it('handles non-Error rejection in list command', async () => { + mockSecretsList.mockRejectedValue('raw string error') + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { secretsCommand } = await import('../cli/secrets.js') + await secretsCommand.parseAsync(['list'], { from: 'user' }) + + expect(errorSpy).toHaveBeenCalledWith('Error: raw string error') + expect(process.exitCode).toBe(1) + errorSpy.mockRestore() + }) }) describe('secrets remove command', () => { @@ -358,4 +384,16 @@ describe('secrets remove command', () => { expect(mockSecretsRemove).not.toHaveBeenCalled() errorSpy.mockRestore() }) + + it('handles non-Error rejection in remove command', async () => { + mockSecretsResolve.mockRejectedValue('raw string error') + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { secretsCommand } = await import('../cli/secrets.js') + await secretsCommand.parseAsync(['remove', 'some-ref'], { from: 'user' }) + + expect(errorSpy).toHaveBeenCalledWith('Error: raw string error') + expect(process.exitCode).toBe(1) + errorSpy.mockRestore() + }) }) diff --git a/src/__tests__/session-lock.test.ts b/src/__tests__/session-lock.test.ts index 76851af..d2e436e 100644 --- a/src/__tests__/session-lock.test.ts +++ b/src/__tests__/session-lock.test.ts @@ -191,6 +191,37 @@ describe('SessionLock', () => { expect(() => sessionLock.touch()).not.toThrow() }) + + it('returns early when session file has invalid version', () => { + const sessionLock = new SessionLock(config, sessionPath) + const dek = Buffer.alloc(32, 0xab) + sessionLock.save(dek) + + // Corrupt the version + const data = JSON.parse(readFileSync(sessionPath, 'utf-8')) + const originalLastAccessAt = data.lastAccessAt + data.version = 99 + writeFileSync(sessionPath, JSON.stringify(data)) + + // touch() should return early without modifying the file + sessionLock.touch() + + const dataAfter = JSON.parse(readFileSync(sessionPath, 'utf-8')) + expect(dataAfter.lastAccessAt).toBe(originalLastAccessAt) + expect(dataAfter.version).toBe(99) + }) + + it('handles errors during touch gracefully', () => { + const sessionLock = new SessionLock(config, sessionPath) + const dek = Buffer.alloc(32, 0xab) + sessionLock.save(dek) + + // Corrupt the file so JSON.parse fails + writeFileSync(sessionPath, 'not valid json{{{') + + // touch() should not throw + expect(() => sessionLock.touch()).not.toThrow() + }) }) describe('exists()', () => { diff --git a/src/__tests__/workflow.test.ts b/src/__tests__/workflow.test.ts index 4d333a2..6d90568 100644 --- a/src/__tests__/workflow.test.ts +++ b/src/__tests__/workflow.test.ts @@ -1,11 +1,11 @@ /// import { WorkflowEngine } from '../core/workflow.js' -import type { NotificationChannel } from '../channels/channel.js' import type { SecretStore } from '../core/secret-store.js' import type { SecretMetadata } from '../core/types.js' import type { AccessRequest } from '../core/request.js' import type { AppConfig } from '../core/config.js' +import { createMockChannel } from './mocks/mock-notification-channel.js' function createMockStore(metadataMap: Record): SecretStore { return { @@ -23,13 +23,6 @@ function createSingleMockStore(metadata: SecretMetadata): SecretStore { } } -function createMockChannel(response: 'approved' | 'denied' | 'timeout' = 'approved') { - return { - sendApprovalRequest: vi.fn().mockResolvedValue('msg-123'), - waitForResponse: vi.fn().mockResolvedValue(response), - } satisfies NotificationChannel -} - function createRequest(overrides?: Partial): AccessRequest { return { id: 'req-1', diff --git a/src/cli/config.ts b/src/cli/config.ts index 4e5af6d..d2049c0 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -86,6 +86,7 @@ config server: { host: validated.serverHost, port: validated.serverPort, + pollIntervalMs: 3000, ...(validated.serverAuthToken !== undefined ? { authToken: validated.serverAuthToken } : {}), diff --git a/src/core/grant-verifier.ts b/src/core/grant-verifier.ts index d5d3ed8..8086c62 100644 --- a/src/core/grant-verifier.ts +++ b/src/core/grant-verifier.ts @@ -82,10 +82,9 @@ export class GrantVerifier { throw new Error('Grant has expired') } - if (expectedCommandHash !== undefined) { - if (payload.commandHash === undefined) { - throw new Error('Grant is missing command hash') - } + // If the grant has a commandHash, verify it matches the expected value. + // If the grant does NOT have a commandHash, any command is allowed (server had bindCommand: false). + if (payload.commandHash !== undefined && expectedCommandHash !== undefined) { if (payload.commandHash !== expectedCommandHash) { throw new Error('Grant command hash does not match the requested command') } diff --git a/src/core/grant.ts b/src/core/grant.ts index fff991d..6224279 100644 --- a/src/core/grant.ts +++ b/src/core/grant.ts @@ -1,7 +1,8 @@ -import { randomUUID, sign } from 'node:crypto' +import { randomUUID } from 'node:crypto' import type { KeyObject } from 'node:crypto' import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs' import { dirname, join } from 'node:path' +import { SignJWT, importPKCS8 } from 'jose' import { CONFIG_DIR } from './config.js' import type { AccessRequest } from './request.js' @@ -30,7 +31,9 @@ export class GrantManager { this.load() } - createGrant(request: AccessRequest): { grant: AccessGrant; jws: string | undefined } { + async createGrant( + request: AccessRequest, + ): Promise<{ grant: AccessGrant; jws: string | undefined }> { if (request.status !== 'approved') { throw new Error(`Cannot create grant for request with status: ${request.status}`) } @@ -48,7 +51,7 @@ export class GrantManager { let jws: string | undefined if (this.privateKey) { - jws = signGrant(grant, this.privateKey) + jws = await signGrant(grant, this.privateKey) grant.jws = jws } @@ -137,13 +140,17 @@ export class GrantManager { } } -function signGrant(grant: AccessGrant, privateKey: KeyObject): string { - const header = Buffer.from(JSON.stringify({ alg: 'EdDSA' })).toString('base64url') +async function keyObjectToCryptoKey(keyObject: KeyObject): Promise { + const pem = keyObject.export({ type: 'pkcs8', format: 'pem' }) as string + return importPKCS8(pem, 'EdDSA') +} + +async function signGrant(grant: AccessGrant, privateKey: KeyObject): Promise { + const cryptoKey = await keyObjectToCryptoKey(privateKey) // Omit the jws field so the signature doesn't cover itself + // Rename 'id' to 'grantId' to match GrantVerifier's expected payload shape // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { jws: _, ...grantWithoutJws } = grant - const payload = Buffer.from(JSON.stringify(grantWithoutJws)).toString('base64url') - const signingInput = `${header}.${payload}` - const sigBytes = sign(null, Buffer.from(signingInput), privateKey) - return `${signingInput}.${sigBytes.toString('base64url')}` + const { jws: _, id, ...rest } = grant + + return new SignJWT({ grantId: id, ...rest }).setProtectedHeader({ alg: 'EdDSA' }).sign(cryptoKey) } diff --git a/src/core/injector.ts b/src/core/injector.ts index 53d0a4e..97a2153 100644 --- a/src/core/injector.ts +++ b/src/core/injector.ts @@ -2,8 +2,12 @@ import { spawn } from 'node:child_process' import type { GrantManager } from './grant.js' import type { ISecretStore } from './secret-store.js' import type { ProcessResult } from './types.js' + import { RedactTransform } from './redact.js' +/** Cache of secrets fetched from server for remote injection */ +export type GrantSecretCache = Map + export interface InjectOptions { timeoutMs?: number envVarName?: string @@ -16,8 +20,8 @@ const PLACEHOLDER_RE = /^2k:\/\/(.+)$/ export class SecretInjector { constructor( - private readonly grantManager: GrantManager, - private readonly secretStore: ISecretStore, + private readonly grantManager: GrantManager | null, + private readonly secretStore: ISecretStore | null, ) {} async inject( @@ -29,6 +33,14 @@ export class SecretInjector { throw new Error('Command must not be empty') } + if (!this.grantManager) { + throw new Error('GrantManager not available') + } + + if (!this.secretStore) { + throw new Error('SecretStore not available') + } + // 1. Validate grant -- reject immediately if invalid/expired if (!this.grantManager.validateGrant(grantId)) { throw new Error(`Grant is not valid: ${grantId}`) @@ -67,7 +79,7 @@ export class SecretInjector { const secrets = grant.secretUuids .map((uuid) => { try { - return this.secretStore.getValue(uuid) + return this.secretStore!.getValue(uuid) } catch { return null } @@ -91,10 +103,52 @@ export class SecretInjector { } } + /** + * Inject secrets from a pre-fetched cache (for remote/client mode). + * Grant validation is done server-side; this method just runs the process + * with the provided secrets. + */ + async injectWithCache( + command: string[], + secretCache: GrantSecretCache, + options?: InjectOptions, + ): Promise { + if (command.length === 0) { + throw new Error('Command must not be empty') + } + + // 1. Build env object + const env: Record = {} + for (const [key, val] of Object.entries(process.env)) { + if (val !== undefined) { + env[key] = val + } + } + + // 2. If envVarName is provided, inject the first secret explicitly + if (options?.envVarName && secretCache.size > 0) { + const [firstSecret] = secretCache.values() + env[options.envVarName] = firstSecret.value + } + + // 3. Scan and replace 2k:// placeholders using cache + const allowedUuids = [...secretCache.keys()] + const finalEnv = this.scanAndReplaceWithCache(env, secretCache, allowedUuids) + + // 4. Collect secrets for redaction + const secrets = [...secretCache.values()].map((s) => s.value) + + // 5. Spawn process (grant already marked used on server) + return this.spawnProcess(command, finalEnv, options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, secrets) + } + private scanAndReplace( env: Record, allowedSecretUuids: string[], ): Record { + if (!this.secretStore) { + throw new Error('SecretStore not available') + } const result = { ...env } for (const [key, value] of Object.entries(result)) { const match = PLACEHOLDER_RE.exec(value) @@ -112,6 +166,27 @@ export class SecretInjector { return result } + private scanAndReplaceWithCache( + env: Record, + secretCache: GrantSecretCache, + allowedUuids: string[], + ): Record { + const result = { ...env } + for (const [key, value] of Object.entries(result)) { + const match = PLACEHOLDER_RE.exec(value) + if (match) { + const ref = match[1] + // Look up by ref or UUID in cache + const secret = [...secretCache.values()].find((s) => s.ref === ref || s.uuid === ref) + if (!secret || !allowedUuids.includes(secret.uuid)) { + throw new Error(`Placeholder 2k://${ref} not covered by grant`) + } + result[key] = secret.value + } + } + return result + } + private spawnProcess( command: string[], env: Record, diff --git a/src/core/remote-service.ts b/src/core/remote-service.ts index adef2c3..78295b6 100644 --- a/src/core/remote-service.ts +++ b/src/core/remote-service.ts @@ -4,12 +4,10 @@ import type { Service, SecretSummary } from './service.js' import type { SecretMetadata, ProcessResult } from './types.js' import type { AccessRequest, AccessRequestStatus } from './request.js' import type { AccessGrant } from './grant.js' -import type { UnlockSession } from './unlock-session.js' -import type { SecretInjector } from './injector.js' +import type { SecretInjector, GrantSecretCache } from './injector.js' import { GrantVerifier } from './grant-verifier.js' export interface RemoteServiceDeps { - unlockSession?: UnlockSession injector?: SecretInjector } @@ -181,6 +179,11 @@ export class RemoteService implements Service { 'GET', `/api/grants/${encodeURIComponent(requestId)}`, ), + consume: (requestId: string) => + this.request<{ + grantId: string + secrets: Record + }>('POST', `/api/grants/${encodeURIComponent(requestId)}/consume`), } async inject( @@ -188,12 +191,7 @@ export class RemoteService implements Service { command: string, options?: { envVarName?: string }, ): Promise { - // 0. Check deps - if (!this.deps.unlockSession) { - throw new Error('unlockSession not configured') - } - - // 1. Fetch signed grant JWS from server + // 1. Fetch signed grant JWS from server and verify const { jws: jwsToken } = await this.request<{ jws: string }>( 'GET', `/api/grants/${encodeURIComponent(requestId)}/signed`, @@ -201,27 +199,20 @@ export class RemoteService implements Service { // 2. Verify JWS signature + expiry, binding to this command's hash const commandHash = createHash('sha256').update(command).digest('hex') - const grantPayload = await this.grantVerifier.verifyGrant(jwsToken, commandHash) + await this.grantVerifier.verifyGrant(jwsToken, commandHash) - // 3. Check that local store is unlocked - if (!this.deps.unlockSession.isUnlocked()) { - throw new Error('Local store is locked. Run `2kc unlock` before requesting secrets.') - } + // 3. Consume grant and fetch all secrets from server in one atomic request + const { secrets } = await this.grants.consume(requestId) - if (!this.deps.injector) { - throw new Error('Injector not available in client mode') - } - - // 4. Inject locally using the SecretInjector with the grant ID from the payload - const result = await this.deps.injector.inject( - grantPayload.grantId, - ['/bin/sh', '-c', command], - options, + // 4. Convert to Map for cache + const secretCache: GrantSecretCache = new Map( + Object.entries(secrets).map(([uuid, data]) => [uuid, data]), ) - // 5. Record grant usage - this.deps.unlockSession?.recordGrantUsage() - - return result + // 5. Run injection with pre-fetched secrets (no local store needed) + if (!this.deps.injector) { + throw new Error('Injector not available') + } + return this.deps.injector.injectWithCache(['/bin/sh', '-c', command], secretCache, options) } } diff --git a/src/core/service.ts b/src/core/service.ts index 68021e4..930ee9a 100644 --- a/src/core/service.ts +++ b/src/core/service.ts @@ -49,6 +49,15 @@ export interface Service { getStatus( requestId: string, ): Promise<{ status: AccessRequestStatus; grant?: AccessGrant; jws?: string }> + /** + * Consume a grant and return all secret values in a single atomic operation. + * The grant is marked as used before secrets are returned (prevents replay). + * Used by client mode to fetch secrets from the server. + */ + consume(requestId: string): Promise<{ + grantId: string + secrets: Record + }> } inject( @@ -175,13 +184,42 @@ export class LocalService implements Service { jws: grant?.jws, } }, + consume: async (requestId) => { + 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}`) + } + + // Validate grant (not expired, not already used) + if (!this.deps.grantManager.validateGrant(grant.id)) { + throw new Error('Grant is invalid, expired, or already consumed') + } + + // Mark as used BEFORE returning secrets (prevents concurrent requests) + this.deps.grantManager.markUsed(grant.id) + this.deps.unlockSession.recordGrantUsage() + + // Return all secret values covered by the grant + const secrets: Record = {} + for (const uuid of grant.secretUuids) { + const meta = this.deps.store.getMetadata(uuid) + const value = this.deps.store.getValue(uuid) + secrets[uuid] = { uuid, ref: meta.ref, value } + } + + return { grantId: grant.id, secrets } + }, } private async runWorkflow(request: AccessRequest): Promise { const outcome = await this.deps.workflowEngine.processRequest(request) if (outcome === 'approved') { try { - this.deps.grantManager.createGrant(request) + await this.deps.grantManager.createGrant(request) } catch (err: unknown) { console.error('createGrant failed after workflow approval:', err) request.status = 'error' @@ -213,12 +251,10 @@ export class LocalService implements Service { export async function resolveService(config: AppConfig): Promise { if (config.mode === 'client') { - const store = new EncryptedSecretStore(config.store.path) - const unlockSession = new UnlockSession(config.unlock) - const grantsPath = join(dirname(config.store.path), 'grants.json') - const grantManager = new GrantManager(grantsPath) - const injector = new SecretInjector(grantManager, store) - return new RemoteService(config.server, { unlockSession, injector }) + // No local store needed in client mode - secrets come from server + // Create a minimal injector for spawning processes with pre-fetched secrets + const injector = new SecretInjector(null, null) + return new RemoteService(config.server, { injector }) } const grantsPath = join(dirname(config.store.path), 'server-grants.json') diff --git a/src/server/routes.ts b/src/server/routes.ts index f837d47..dd802a7 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -177,6 +177,25 @@ export const routePlugin = fp( async (request) => service.grants.getStatus(request.params.requestId).catch(handleError), ) + // POST /api/grants/:requestId/consume — consume grant and return all secret values + // This endpoint atomically validates the grant, marks it as used, and returns secrets. + // Can only be called once per grant (replay protection). + fastify.post<{ Params: { requestId: string } }>( + '/api/grants/:requestId/consume', + { + schema: { + params: { + type: 'object', + required: ['requestId'], + properties: { + requestId: { type: 'string', minLength: 1 }, + }, + }, + }, + }, + async (request) => service.grants.consume(request.params.requestId).catch(handleError), + ) + // GET /api/grants/:requestId/signed — get signed JWS token only fastify.get<{ Params: { requestId: string } }>( '/api/grants/:requestId/signed',