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))