Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/__tests__/integration/client-server-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ async function buildService(tmpDir: string, opts: BuildServiceOpts = {}): Promis
requestLog,
startTime: Date.now(),
bindCommand: opts.bindCommand ?? false,
publicKey,
})

return { service, publicKey }
Expand Down
34 changes: 34 additions & 0 deletions src/__tests__/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ function makeGrantMock(overrides?: Partial<AccessGrant>): 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 }),
Expand Down Expand Up @@ -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()
Expand Down
8 changes: 8 additions & 0 deletions src/__tests__/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -137,6 +141,7 @@ function makeService() {
requestLog,
startTime,
bindCommand: false,
publicKey,
})
return {
service,
Expand Down Expand Up @@ -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'
Expand Down
5 changes: 5 additions & 0 deletions src/core/remote-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SecretSummary[]>('GET', '/api/secrets'),
add: (ref: string, value: string, tags?: string[]) =>
Expand Down
13 changes: 12 additions & 1 deletion src/core/service.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -22,6 +23,10 @@ export type SecretSummary = SecretListItem
export interface Service {
health(): Promise<{ status: string; uptime?: number }>

keys: {
getPublicKey(): Promise<string>
}

secrets: {
list(): Promise<SecretSummary[]>
add(ref: string, value: string, tags?: string[]): Promise<{ uuid: string }>
Expand Down Expand Up @@ -63,6 +68,7 @@ interface LocalServiceDeps {
requestLog: RequestLog
startTime: number
bindCommand: boolean
publicKey: KeyObject
}

export class LocalService implements Service {
Expand Down Expand Up @@ -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(),

Expand Down Expand Up @@ -214,7 +224,7 @@ export async function resolveService(config: AppConfig): Promise<Service> {
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)
Expand Down Expand Up @@ -262,5 +272,6 @@ export async function resolveService(config: AppConfig): Promise<Service> {
requestLog,
startTime,
bindCommand: config.bindCommand,
publicKey,
})
}
6 changes: 6 additions & 0 deletions src/server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down