From a685f6422fc299e438c761c7c75aef555e0f7954 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 25 Aug 2026 11:35:31 +0800 Subject: [PATCH 1/3] feat: add optional OpenBao Transit KMS backend Signed-off-by: Mark --- README.md | 25 +++ src/cli.ts | 2 + src/cliAgent.ts | 3 + .../openbao/OpenBaoKeyManagementService.ts | 155 ++++++++++++++++++ src/kms/openbao/OpenBaoKmsConfig.ts | 55 +++++++ src/kms/openbao/OpenBaoKmsModule.ts | 18 ++ src/kms/openbao/OpenBaoTransitClient.ts | 126 ++++++++++++++ .../OpenBaoKeyManagementService.test.ts | 120 ++++++++++++++ .../__tests__/OpenBaoKmsConfig.test.ts | 24 +++ src/kms/openbao/index.ts | 4 + 10 files changed, 532 insertions(+) create mode 100644 src/kms/openbao/OpenBaoKeyManagementService.ts create mode 100644 src/kms/openbao/OpenBaoKmsConfig.ts create mode 100644 src/kms/openbao/OpenBaoKmsModule.ts create mode 100644 src/kms/openbao/OpenBaoTransitClient.ts create mode 100644 src/kms/openbao/__tests__/OpenBaoKeyManagementService.test.ts create mode 100644 src/kms/openbao/__tests__/OpenBaoKmsConfig.test.ts create mode 100644 src/kms/openbao/index.ts diff --git a/README.md b/README.md index f41a7d03..cbb49d6f 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,31 @@ The agent can be configured in three ways: 2. **JSON config file**: When providing a lot of configuration options, pass a JSON file with `--config`. All properties should use camelCase for the key names. See [samples/cliConfig.json](samples/cliConfig.json) for a complete example. 3. **Environment variables**: All properties are prefixed with `AFJ_REST` and use UPPER_SNAKE_CASE (e.g. `AFJ_REST_WALLET_KEY=my-secret-key ./bin/afj-rest.js start ...`). +### Optional OpenBao KMS backend + +OpenBao Transit can be registered as an additional KMS backend while Askar remains the wallet storage and default KMS backend. Add `openBaoKms` to the JSON config: + +```json +{ + "openBaoKms": { + "url": "https://openbao.example.com", + "transitMount": "transit", + "keyPrefix": "credebl", + "appRole": { + "roleId": "agent-controller", + "secretId": "provide-through-your-secret-manager", + "mountPath": "approle" + } + } +} +``` + +The backend identifier is `openbao`. Callers must explicitly select it when creating a key (for example, `backend: "openbao"`); existing operations continue to use Askar. Ed25519 and P-256 key creation, public-key lookup, signing, and verification are supported. Private keys are generated inside Transit and are configured as non-exportable. Import, encryption, decryption, and deletion are intentionally not advertised by this backend. + +Each Transit key name is scoped to the Credo agent context (the tenant record id in multi-tenant mode). A key id from one tenant is rejected in another tenant context. OpenBao failures are returned to the caller and never fall back to Askar. + +AppRole is recommended for deployments. A static `token` can be configured instead for development, but `token` and `appRole` are mutually exclusive. Do not commit either the AppRole secret id or a static token to source control. + ## Development ### Starting Your Own Server diff --git a/src/cli.ts b/src/cli.ts index 78f49d03..f48104e9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -48,6 +48,7 @@ interface Parsed { fileServerToken?: string apiKey?: string updateJwtSecret?: boolean + openBaoKms?: AriesRestConfig['openBaoKms'] } interface InboundTransport { @@ -216,5 +217,6 @@ export async function runCliServer() { fileServerToken: parsed.fileServerToken, apiKey: parsed['apiKey'], updateJwtSecret: parsed['updateJwtSecret'], + openBaoKms: parsed.openBaoKms, } as AriesRestConfig) } diff --git a/src/cliAgent.ts b/src/cliAgent.ts index c2575ddd..f5cf80d5 100644 --- a/src/cliAgent.ts +++ b/src/cliAgent.ts @@ -65,6 +65,7 @@ import express from 'express' import { readFile } from 'fs/promises' import { IndicioAcceptanceMechanism, IndicioTransactionAuthorAgreement, Network, NetworkName } from './enums' +import { OpenBaoKmsModule, type OpenBaoKmsConfig } from './kms/openbao' import { validatePurgeConfig } from './purge/PurgeConfigValidator' import { initPurgeSchedulers, @@ -131,6 +132,7 @@ export interface AriesRestConfig { schemaFileServerURL?: string apiKey: string updateJwtSecret?: boolean + openBaoKms?: OpenBaoKmsConfig } export async function readRestConfig(path: string) { @@ -500,6 +502,7 @@ export async function runRestAgent(restConfig: AriesRestConfig) { config: agentConfig, modules: { ...modules, + ...(afjConfig.openBaoKms ? { openBaoKms: new OpenBaoKmsModule(afjConfig.openBaoKms) } : {}), }, dependencies: agentDependencies, }) diff --git a/src/kms/openbao/OpenBaoKeyManagementService.ts b/src/kms/openbao/OpenBaoKeyManagementService.ts new file mode 100644 index 00000000..4f1f54f8 --- /dev/null +++ b/src/kms/openbao/OpenBaoKeyManagementService.ts @@ -0,0 +1,155 @@ +import type { ResolvedOpenBaoKmsConfig } from './OpenBaoKmsConfig' + +import { Kms, type AgentContext } from '@credo-ts/core' +import { createHash, createPublicKey, randomBytes } from 'crypto' + +import { OpenBaoTransitClient, type OpenBaoTransitKey } from './OpenBaoTransitClient' + +const backend = 'openbao' + +export class OpenBaoKeyManagementService implements Kms.KeyManagementService { + public readonly backend = backend + + public constructor( + private readonly config: ResolvedOpenBaoKmsConfig, + private readonly client = new OpenBaoTransitClient(config), + ) {} + + public isOperationSupported(agentContext: AgentContext, operation: Kms.KmsOperation): boolean { + if (operation.operation === 'createKey') return this.isSupportedType(operation.type) + if (operation.operation === 'sign' || operation.operation === 'verify') + return this.isSupportedAlg(operation.algorithm) + return false + } + + public async createKey( + agentContext: AgentContext, + options: Kms.KmsCreateKeyOptions, + ): Promise> { + if (!this.isSupportedType(options.type)) throw this.unsupported(`key type '${JSON.stringify(options.type)}'`) + const context = this.contextId(agentContext) + const logicalId = options.keyId ?? randomBytes(16).toString('hex') + if (!/^[a-zA-Z0-9_-]{1,128}$/.test(logicalId)) { + throw new Kms.KeyManagementError('OpenBao keyId must contain only letters, numbers, underscores, or hyphens') + } + const keyId = `${backend}:${context}:${logicalId}` + const transitName = this.transitName(context, logicalId) + try { + await this.client.createKey(transitName, options.type.kty === 'OKP' ? 'ed25519' : 'ecdsa-p256') + const key = await this.client.readKey(transitName) + if (!key) throw new Error('key was not readable after creation') + return { keyId, publicJwk: this.publicJwk(key, keyId) } as Kms.KmsCreateKeyReturn + } catch (error) { + if (error instanceof Kms.KeyManagementError) throw error + throw new Kms.KeyManagementError('Error creating OpenBao key', { cause: this.asError(error) }) + } + } + + public async getPublicKey(agentContext: AgentContext, keyId: string): Promise { + const { context, logicalId } = this.parseKeyId(agentContext, keyId) + const key = await this.client.readKey(this.transitName(context, logicalId)) + return key ? this.publicJwk(key, keyId) : null + } + + public async sign(agentContext: AgentContext, options: Kms.KmsSignOptions): Promise { + if (!this.isSupportedAlg(options.algorithm)) throw this.unsupported(`signing algorithm '${options.algorithm}'`) + const { context, logicalId } = this.parseKeyId(agentContext, options.keyId) + try { + const signature = await this.client.sign(this.transitName(context, logicalId), options.data, options.algorithm) + return { signature } + } catch (error) { + throw new Kms.KeyManagementError('Error signing with OpenBao key', { cause: this.asError(error) }) + } + } + + public async verify(agentContext: AgentContext, options: Kms.KmsVerifyOptions): Promise { + if (!this.isSupportedAlg(options.algorithm)) throw this.unsupported(`verification algorithm '${options.algorithm}'`) + if (!options.key.keyId) return { verified: false } + const { context, logicalId } = this.parseKeyId(agentContext, options.key.keyId) + try { + const transitName = this.transitName(context, logicalId) + const key = await this.client.readKey(transitName) + if (!key) return { verified: false } + const verified = await this.client.verify( + transitName, + options.data, + options.signature, + options.algorithm, + key.latest_version, + ) + if (!verified) return { verified: false } + return { verified: true, publicJwk: this.publicJwk(key, options.key.keyId) } + } catch (error) { + throw new Kms.KeyManagementError('Error verifying with OpenBao key', { cause: this.asError(error) }) + } + } + + public async deleteKey(agentContext: AgentContext, options: Kms.KmsDeleteKeyOptions): Promise { + this.parseKeyId(agentContext, options.keyId) + throw new Kms.KeyManagementAlgorithmNotSupportedError('deleting Transit keys', this.backend) + } + + public async importKey( + _agentContext: AgentContext, + _options: Kms.KmsImportKeyOptions, + ): Promise> { + throw new Kms.KeyManagementAlgorithmNotSupportedError('importing keys', this.backend) + } + + public async encrypt(_agentContext: AgentContext, _options: Kms.KmsEncryptOptions): Promise { + throw new Kms.KeyManagementAlgorithmNotSupportedError('encryption', this.backend) + } + + public async decrypt(_agentContext: AgentContext, _options: Kms.KmsDecryptOptions): Promise { + throw new Kms.KeyManagementAlgorithmNotSupportedError('decryption', this.backend) + } + + public randomBytes(_agentContext: AgentContext, options: Kms.KmsRandomBytesOptions): Kms.KmsRandomBytesReturn { + return new Uint8Array(randomBytes(options.length)) + } + + private isSupportedType(type: Kms.KmsCreateKeyType): type is Kms.KmsCreateKeyTypeOkp | Kms.KmsCreateKeyTypeEc { + return (type.kty === 'OKP' && type.crv === 'Ed25519') || (type.kty === 'EC' && type.crv === 'P-256') + } + + private isSupportedAlg(algorithm: string): algorithm is 'EdDSA' | 'Ed25519' | 'ES256' { + return algorithm === 'EdDSA' || algorithm === 'Ed25519' || algorithm === 'ES256' + } + + private contextId(agentContext: AgentContext) { + return createHash('sha256').update(agentContext.contextCorrelationId).digest('hex').slice(0, 20) + } + + private parseKeyId(agentContext: AgentContext, keyId: string) { + const match = /^openbao:([a-f0-9]{20}):([a-zA-Z0-9_-]{1,128})$/.exec(keyId) + if (!match || match[1] !== this.contextId(agentContext)) { + throw new Kms.KeyManagementKeyNotFoundError(keyId, [this.backend]) + } + return { context: match[1], logicalId: match[2] } + } + + private transitName(context: string, logicalId: string) { + return `${this.config.keyPrefix}-${context}-${logicalId}` + } + + private publicJwk(key: OpenBaoTransitKey, keyId: string): Kms.KmsJwkPublic & { kid: string } { + const version = key.keys[String(key.latest_version)] + const publicKey = typeof version === 'object' ? version.public_key : undefined + if (!publicKey) throw new Kms.KeyManagementError(`OpenBao key '${keyId}' has no public key`) + const jwk = + key.type === 'ed25519' + ? { kty: 'OKP', crv: 'Ed25519', x: Buffer.from(publicKey, 'base64').toString('base64url') } + : createPublicKey(publicKey).export({ format: 'jwk' }) + return Kms.PublicJwk.fromUnknown({ ...jwk, kid: keyId, use: 'sig', key_ops: ['verify'] }).toJson({ + includeKid: true, + }) as Kms.KmsJwkPublic & { kid: string } + } + + private unsupported(operation: string) { + return new Kms.KeyManagementAlgorithmNotSupportedError(operation, this.backend) + } + + private asError(error: unknown) { + return error instanceof Error ? error : new Error(String(error)) + } +} diff --git a/src/kms/openbao/OpenBaoKmsConfig.ts b/src/kms/openbao/OpenBaoKmsConfig.ts new file mode 100644 index 00000000..147c34ab --- /dev/null +++ b/src/kms/openbao/OpenBaoKmsConfig.ts @@ -0,0 +1,55 @@ +export interface OpenBaoKmsConfig { + url: string + transitMount?: string + keyPrefix?: string + namespace?: string + token?: string + appRole?: { + roleId: string + secretId: string + mountPath?: string + } +} + +export interface ResolvedOpenBaoKmsConfig { + url: string + transitMount: string + keyPrefix: string + namespace?: string + token?: string + appRole?: { + roleId: string + secretId: string + mountPath: string + } +} + +const pathPart = (value: string, name: string) => { + const normalized = value.replace(/^\/+|\/+$/g, '') + if (!normalized || !/^[a-zA-Z0-9_-]+$/.test(normalized)) { + throw new Error(`${name} must contain only letters, numbers, underscores, or hyphens`) + } + return normalized +} + +export const resolveOpenBaoKmsConfig = (config: OpenBaoKmsConfig): ResolvedOpenBaoKmsConfig => { + const url = config.url.replace(/\/+$/, '') + if (!/^https?:\/\//.test(url)) throw new Error('OpenBao KMS url must use http or https') + if (config.token && config.appRole) throw new Error('Configure either an OpenBao token or AppRole, not both') + if (!config.token && !config.appRole) throw new Error('OpenBao KMS requires a token or AppRole credentials') + + return { + url, + transitMount: pathPart(config.transitMount ?? 'transit', 'OpenBao Transit mount'), + keyPrefix: pathPart(config.keyPrefix ?? 'credebl', 'OpenBao key prefix'), + namespace: config.namespace, + token: config.token, + appRole: config.appRole + ? { + roleId: config.appRole.roleId, + secretId: config.appRole.secretId, + mountPath: pathPart(config.appRole.mountPath ?? 'approle', 'OpenBao AppRole mount'), + } + : undefined, + } +} diff --git a/src/kms/openbao/OpenBaoKmsModule.ts b/src/kms/openbao/OpenBaoKmsModule.ts new file mode 100644 index 00000000..3e19d355 --- /dev/null +++ b/src/kms/openbao/OpenBaoKmsModule.ts @@ -0,0 +1,18 @@ +import type { OpenBaoKmsConfig } from './OpenBaoKmsConfig' + +import { Kms, type DependencyManager, type Module } from '@credo-ts/core' + +import { OpenBaoKeyManagementService } from './OpenBaoKeyManagementService' +import { resolveOpenBaoKmsConfig } from './OpenBaoKmsConfig' + +export class OpenBaoKmsModule implements Module { + private readonly service: OpenBaoKeyManagementService + + public constructor(config: OpenBaoKmsConfig) { + this.service = new OpenBaoKeyManagementService(resolveOpenBaoKmsConfig(config)) + } + + public register(dependencyManager: DependencyManager) { + dependencyManager.resolve(Kms.KeyManagementModuleConfig).registerBackend(this.service) + } +} diff --git a/src/kms/openbao/OpenBaoTransitClient.ts b/src/kms/openbao/OpenBaoTransitClient.ts new file mode 100644 index 00000000..eedf97cf --- /dev/null +++ b/src/kms/openbao/OpenBaoTransitClient.ts @@ -0,0 +1,126 @@ +import type { ResolvedOpenBaoKmsConfig } from './OpenBaoKmsConfig' +import type { AxiosInstance } from 'axios' + +import axios, { AxiosError } from 'axios' + +type OpenBaoResponse = { data: T } + +export interface OpenBaoTransitKey { + name: string + type: string + latest_version: number + keys: Record +} + +export class OpenBaoTransitClient { + private readonly http: AxiosInstance + private token?: string + private tokenExpiresAt = 0 + private loginPromise?: Promise + + public constructor(private readonly config: ResolvedOpenBaoKmsConfig) { + this.token = config.token + this.http = axios.create({ + baseURL: `${config.url}/v1`, + timeout: 10_000, + headers: config.namespace ? { 'X-Vault-Namespace': config.namespace } : undefined, + }) + } + + public async createKey(name: string, type: 'ed25519' | 'ecdsa-p256') { + await this.request('post', `/${this.config.transitMount}/keys/${encodeURIComponent(name)}`, { + type, + exportable: false, + allow_plaintext_backup: false, + }) + } + + public async readKey(name: string): Promise { + try { + return await this.request( + 'get', + `/${this.config.transitMount}/keys/${encodeURIComponent(name)}`, + ) + } catch (error) { + if (error instanceof AxiosError && error.response?.status === 404) return null + throw error + } + } + + public async sign(name: string, input: Uint8Array, algorithm: 'EdDSA' | 'Ed25519' | 'ES256') { + const body: Record = { input: Buffer.from(input).toString('base64') } + if (algorithm === 'ES256') { + body.hash_algorithm = 'sha2-256' + body.marshaling_algorithm = 'jws' + } + const result = await this.request<{ signature: string }>( + 'post', + `/${this.config.transitMount}/sign/${encodeURIComponent(name)}`, + body, + ) + const encoded = result.signature.split(':').at(-1) + if (!encoded) throw new Error('OpenBao returned an invalid signature') + return new Uint8Array(Buffer.from(encoded, 'base64')) + } + + public async verify( + name: string, + input: Uint8Array, + signature: Uint8Array, + algorithm: 'EdDSA' | 'Ed25519' | 'ES256', + keyVersion: number, + ) { + const body: Record = { + input: Buffer.from(input).toString('base64'), + signature: `vault:v${keyVersion}:${Buffer.from(signature).toString(algorithm === 'ES256' ? 'base64url' : 'base64')}`, + } + if (algorithm === 'ES256') { + body.hash_algorithm = 'sha2-256' + body.marshaling_algorithm = 'jws' + } + const result = await this.request<{ valid: boolean }>( + 'post', + `/${this.config.transitMount}/verify/${encodeURIComponent(name)}`, + body, + ) + return result.valid + } + + private async request(method: 'get' | 'post', path: string, data?: unknown, retry = true): Promise { + const token = await this.getToken() + try { + const response = await this.http.request>({ + method, + url: path, + data, + headers: { 'X-Vault-Token': token }, + }) + return response.data.data + } catch (error) { + if (retry && this.config.appRole && error instanceof AxiosError && error.response?.status === 403) { + this.token = undefined + this.tokenExpiresAt = 0 + return this.request(method, path, data, false) + } + throw error + } + } + + private async getToken() { + if (this.token && (!this.config.appRole || Date.now() < this.tokenExpiresAt)) return this.token + if (!this.config.appRole) throw new Error('OpenBao token is not configured') + if (!this.loginPromise) this.loginPromise = this.login().finally(() => (this.loginPromise = undefined)) + return this.loginPromise + } + + private async login() { + const { roleId, secretId, mountPath } = this.config.appRole! + const response = await this.http.post<{ + auth: { client_token: string; lease_duration: number } + }>(`/auth/${mountPath}/login`, { role_id: roleId, secret_id: secretId }) + this.token = response.data.auth.client_token + const refreshAfterSeconds = Math.max(1, Math.floor(response.data.auth.lease_duration * 0.8)) + this.tokenExpiresAt = Date.now() + refreshAfterSeconds * 1000 + return this.token + } +} diff --git a/src/kms/openbao/__tests__/OpenBaoKeyManagementService.test.ts b/src/kms/openbao/__tests__/OpenBaoKeyManagementService.test.ts new file mode 100644 index 00000000..ee60b9b2 --- /dev/null +++ b/src/kms/openbao/__tests__/OpenBaoKeyManagementService.test.ts @@ -0,0 +1,120 @@ +import type { OpenBaoTransitClient, OpenBaoTransitKey } from '../OpenBaoTransitClient' +import type { AgentContext } from '@credo-ts/core' + +jest.mock('@credo-ts/core', () => { + class KeyManagementError extends Error { + public constructor(message: string, options?: ErrorOptions) { + super(message, options) + } + } + class KeyManagementAlgorithmNotSupportedError extends KeyManagementError {} + class KeyManagementKeyNotFoundError extends KeyManagementError { + public constructor(keyId: string) { + super(`Key '${keyId}' not found`) + } + } + return { + Kms: { + KeyManagementError, + KeyManagementAlgorithmNotSupportedError, + KeyManagementKeyNotFoundError, + PublicJwk: { + fromUnknown: (jwk: Record) => ({ toJson: () => jwk }), + }, + }, + } +}) + +import { OpenBaoKeyManagementService } from '../OpenBaoKeyManagementService' +import { resolveOpenBaoKmsConfig } from '../OpenBaoKmsConfig' + +const publicKey = Buffer.alloc(32, 7).toString('base64') + +const transitKey: OpenBaoTransitKey = { + name: 'test', + type: 'ed25519', + latest_version: 1, + keys: { '1': { public_key: publicKey } }, +} + +const context = (id: string) => ({ contextCorrelationId: id }) as AgentContext + +const createClient = () => + ({ + createKey: jest.fn().mockResolvedValue(undefined), + readKey: jest.fn().mockResolvedValue(transitKey), + sign: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), + verify: jest.fn().mockResolvedValue(true), + }) as unknown as jest.Mocked + +describe('OpenBaoKeyManagementService', () => { + const config = resolveOpenBaoKmsConfig({ url: 'https://bao.example', token: 'test-token', keyPrefix: 'wallet' }) + + test('creates non-exportable tenant-scoped Ed25519 keys', async () => { + const client = createClient() + const service = new OpenBaoKeyManagementService(config, client) + const result = await service.createKey(context('tenant-a'), { + keyId: 'issuer-signing', + type: { kty: 'OKP', crv: 'Ed25519' }, + }) + + expect(result.keyId).toMatch(/^openbao:[a-f0-9]{20}:issuer-signing$/) + expect(result.publicJwk).toMatchObject({ kty: 'OKP', crv: 'Ed25519', kid: result.keyId }) + expect(client.createKey).toHaveBeenCalledWith( + expect.stringMatching(/^wallet-[a-f0-9]{20}-issuer-signing$/), + 'ed25519', + ) + }) + + test('fails closed when a key belongs to another tenant', async () => { + const client = createClient() + const service = new OpenBaoKeyManagementService(config, client) + const created = await service.createKey(context('tenant-a'), { + keyId: 'holder-binding', + type: { kty: 'OKP', crv: 'Ed25519' }, + }) + + await expect(service.getPublicKey(context('tenant-b'), created.keyId)).rejects.toThrow('not found') + expect(client.readKey).toHaveBeenCalledTimes(1) + }) + + test('routes signing and verification to Transit', async () => { + const client = createClient() + const service = new OpenBaoKeyManagementService(config, client) + const created = await service.createKey(context('tenant-a'), { + keyId: 'credential', + type: { kty: 'OKP', crv: 'Ed25519' }, + }) + const data = new Uint8Array([4, 5, 6]) + const signed = await service.sign(context('tenant-a'), { + keyId: created.keyId, + algorithm: 'EdDSA', + data, + }) + const verified = await service.verify(context('tenant-a'), { + key: { keyId: created.keyId }, + algorithm: 'EdDSA', + data, + signature: signed.signature, + }) + + expect(signed.signature).toEqual(new Uint8Array([1, 2, 3])) + expect(verified.verified).toBe(true) + expect(client.sign).toHaveBeenCalledWith(expect.any(String), data, 'EdDSA') + expect(client.verify).toHaveBeenCalledWith(expect.any(String), data, signed.signature, 'EdDSA', 1) + }) + + test('does not claim unsupported key import, encryption, or random operations', () => { + const service = new OpenBaoKeyManagementService(config, createClient()) + const agentContext = context('tenant-a') + + expect(service.isOperationSupported(agentContext, { operation: 'importKey', privateJwk: {} as never })).toBe(false) + expect(service.isOperationSupported(agentContext, { operation: 'randomBytes' })).toBe(false) + expect( + service.isOperationSupported(agentContext, { + operation: 'createKey', + type: { kty: 'OKP', crv: 'X25519' }, + }), + ).toBe(false) + }) +}) diff --git a/src/kms/openbao/__tests__/OpenBaoKmsConfig.test.ts b/src/kms/openbao/__tests__/OpenBaoKmsConfig.test.ts new file mode 100644 index 00000000..4ca4de85 --- /dev/null +++ b/src/kms/openbao/__tests__/OpenBaoKmsConfig.test.ts @@ -0,0 +1,24 @@ +import { resolveOpenBaoKmsConfig } from '../OpenBaoKmsConfig' + +describe('resolveOpenBaoKmsConfig', () => { + test('requires exactly one authentication method', () => { + expect(() => resolveOpenBaoKmsConfig({ url: 'https://bao.example' })).toThrow('requires a token or AppRole') + expect(() => + resolveOpenBaoKmsConfig({ + url: 'https://bao.example', + token: 'token', + appRole: { roleId: 'role', secretId: 'secret' }, + }), + ).toThrow('either an OpenBao token or AppRole') + }) + + test('normalizes URL and mount paths', () => { + expect( + resolveOpenBaoKmsConfig({ + url: 'https://bao.example/', + token: 'token', + transitMount: '/wallet-transit/', + }), + ).toMatchObject({ url: 'https://bao.example', transitMount: 'wallet-transit', keyPrefix: 'credebl' }) + }) +}) diff --git a/src/kms/openbao/index.ts b/src/kms/openbao/index.ts new file mode 100644 index 00000000..bbf6bbe5 --- /dev/null +++ b/src/kms/openbao/index.ts @@ -0,0 +1,4 @@ +export * from './OpenBaoKmsConfig' +export * from './OpenBaoKmsModule' +export * from './OpenBaoKeyManagementService' +export * from './OpenBaoTransitClient' From 7606a9c56ae062e92575bd1300b8917c3efa95b3 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 25 Aug 2026 12:36:53 +0800 Subject: [PATCH 2/3] feat: add opt-in OpenBao routing for holder credential binding Signed-off-by: Mark --- README.md | 14 ++++++- src/cli.ts | 2 + src/cliAgent.ts | 5 +++ .../holder/credentialBindingResolver.ts | 5 ++- src/kms/policy/KeyManagementPolicyConfig.ts | 13 +++++++ src/kms/policy/KeyManagementPolicyModule.ts | 29 ++++++++++++++ .../KeyManagementPolicyConfig.test.ts | 13 +++++++ .../KeyManagementPolicyModule.test.ts | 39 +++++++++++++++++++ .../getHolderCredentialBindingBackend.test.ts | 26 +++++++++++++ .../getHolderCredentialBindingBackend.ts | 11 ++++++ src/kms/policy/index.ts | 3 ++ 11 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 src/kms/policy/KeyManagementPolicyConfig.ts create mode 100644 src/kms/policy/KeyManagementPolicyModule.ts create mode 100644 src/kms/policy/__tests__/KeyManagementPolicyConfig.test.ts create mode 100644 src/kms/policy/__tests__/KeyManagementPolicyModule.test.ts create mode 100644 src/kms/policy/__tests__/getHolderCredentialBindingBackend.test.ts create mode 100644 src/kms/policy/getHolderCredentialBindingBackend.ts create mode 100644 src/kms/policy/index.ts diff --git a/README.md b/README.md index cbb49d6f..a5a197e0 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,19 @@ OpenBao Transit can be registered as an additional KMS backend while Askar remai } ``` -The backend identifier is `openbao`. Callers must explicitly select it when creating a key (for example, `backend: "openbao"`); existing operations continue to use Askar. Ed25519 and P-256 key creation, public-key lookup, signing, and verification are supported. Private keys are generated inside Transit and are configured as non-exportable. Import, encryption, decryption, and deletion are intentionally not advertised by this backend. +The backend identifier is `openbao`. Existing operations continue to use Askar unless a purpose is explicitly routed to OpenBao. To protect keys created for Holder OpenID4VC credential binding proofs, add this alongside `openBaoKms`: + +```json +{ + "keyManagement": { + "holderCredentialBinding": "openbao" + } +} +``` + +If `keyManagement` or `holderCredentialBinding` is omitted, Holder credential binding continues to use Askar. Selecting `openbao` without configuring `openBaoKms` fails agent startup instead of silently falling back. Issuer signing, DIDComm keys, and all other key purposes remain unchanged. + +Ed25519 and P-256 key creation, public-key lookup, signing, and verification are supported. Private keys are generated inside Transit and are configured as non-exportable. Import, encryption, decryption, and deletion are intentionally not advertised by this backend. Each Transit key name is scoped to the Credo agent context (the tenant record id in multi-tenant mode). A key id from one tenant is rejected in another tenant context. OpenBao failures are returned to the caller and never fall back to Askar. diff --git a/src/cli.ts b/src/cli.ts index f48104e9..89aa21a6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -49,6 +49,7 @@ interface Parsed { apiKey?: string updateJwtSecret?: boolean openBaoKms?: AriesRestConfig['openBaoKms'] + keyManagement?: AriesRestConfig['keyManagement'] } interface InboundTransport { @@ -218,5 +219,6 @@ export async function runCliServer() { apiKey: parsed['apiKey'], updateJwtSecret: parsed['updateJwtSecret'], openBaoKms: parsed.openBaoKms, + keyManagement: parsed.keyManagement, } as AriesRestConfig) } diff --git a/src/cliAgent.ts b/src/cliAgent.ts index f5cf80d5..ad8b8426 100644 --- a/src/cliAgent.ts +++ b/src/cliAgent.ts @@ -66,6 +66,7 @@ import { readFile } from 'fs/promises' import { IndicioAcceptanceMechanism, IndicioTransactionAuthorAgreement, Network, NetworkName } from './enums' import { OpenBaoKmsModule, type OpenBaoKmsConfig } from './kms/openbao' +import { KeyManagementPolicyModule, type KeyManagementPolicyOptions } from './kms/policy' import { validatePurgeConfig } from './purge/PurgeConfigValidator' import { initPurgeSchedulers, @@ -133,6 +134,7 @@ export interface AriesRestConfig { apiKey: string updateJwtSecret?: boolean openBaoKms?: OpenBaoKmsConfig + keyManagement?: KeyManagementPolicyOptions } export async function readRestConfig(path: string) { @@ -503,6 +505,9 @@ export async function runRestAgent(restConfig: AriesRestConfig) { modules: { ...modules, ...(afjConfig.openBaoKms ? { openBaoKms: new OpenBaoKmsModule(afjConfig.openBaoKms) } : {}), + ...(afjConfig.keyManagement + ? { keyManagementPolicy: new KeyManagementPolicyModule(afjConfig.keyManagement) } + : {}), }, dependencies: agentDependencies, }) diff --git a/src/controllers/openid4vc/holder/credentialBindingResolver.ts b/src/controllers/openid4vc/holder/credentialBindingResolver.ts index 1f7ecdf2..8212c436 100644 --- a/src/controllers/openid4vc/holder/credentialBindingResolver.ts +++ b/src/controllers/openid4vc/holder/credentialBindingResolver.ts @@ -1,6 +1,8 @@ import { DidJwk, DidKey, DidsApi, type JwkDidCreateOptions, type KeyDidCreateOptions, Kms } from '@credo-ts/core' import { type OpenId4VciCredentialBindingResolver, OpenId4VciCredentialFormatProfile } from '@credo-ts/openid4vc' +import { getHolderCredentialBindingBackend } from '../../../kms/policy' + export function getCredentialBindingResolver({ requestBatch, }: { @@ -17,6 +19,7 @@ export function getCredentialBindingResolver({ agentContext, }) => { const kms = agentContext.resolve(Kms.KeyManagementApi) + const backend = getHolderCredentialBindingBackend(agentContext) // First, we try to pick a did method // Prefer did:jwk, otherwise use did:key, otherwise use undefined @@ -56,7 +59,7 @@ export function getCredentialBindingResolver({ kms .createKeyForSignatureAlgorithm({ algorithm: signatureAlgorithm!, - backend: 'askar', + backend, }) .then((key) => Kms.PublicJwk.fromUnknown(key.publicJwk)), ), diff --git a/src/kms/policy/KeyManagementPolicyConfig.ts b/src/kms/policy/KeyManagementPolicyConfig.ts new file mode 100644 index 00000000..508a8369 --- /dev/null +++ b/src/kms/policy/KeyManagementPolicyConfig.ts @@ -0,0 +1,13 @@ +export type KeyManagementBackend = 'askar' | 'openbao' + +export interface KeyManagementPolicyOptions { + holderCredentialBinding?: KeyManagementBackend +} + +export class KeyManagementPolicyConfig { + public readonly holderCredentialBinding: KeyManagementBackend + + public constructor(options: KeyManagementPolicyOptions = {}) { + this.holderCredentialBinding = options.holderCredentialBinding ?? 'askar' + } +} diff --git a/src/kms/policy/KeyManagementPolicyModule.ts b/src/kms/policy/KeyManagementPolicyModule.ts new file mode 100644 index 00000000..440c86be --- /dev/null +++ b/src/kms/policy/KeyManagementPolicyModule.ts @@ -0,0 +1,29 @@ +import type { AgentContext, DependencyManager, Module } from '@credo-ts/core' + +import { Kms } from '@credo-ts/core' + +import { KeyManagementPolicyConfig, type KeyManagementPolicyOptions } from './KeyManagementPolicyConfig' + +export class KeyManagementPolicyModule implements Module { + public readonly config: KeyManagementPolicyConfig + + public constructor(options: KeyManagementPolicyOptions) { + this.config = new KeyManagementPolicyConfig(options) + } + + public register(dependencyManager: DependencyManager) { + dependencyManager.registerInstance(KeyManagementPolicyConfig, this.config) + } + + public async initialize(agentContext: AgentContext) { + const registeredBackends = agentContext + .resolve(Kms.KeyManagementModuleConfig) + .backends.map(({ backend }) => backend) + + if (!registeredBackends.includes(this.config.holderCredentialBinding)) { + throw new Error( + `KMS backend '${this.config.holderCredentialBinding}' configured for holder credential binding is not registered`, + ) + } + } +} diff --git a/src/kms/policy/__tests__/KeyManagementPolicyConfig.test.ts b/src/kms/policy/__tests__/KeyManagementPolicyConfig.test.ts new file mode 100644 index 00000000..fe0d7a1e --- /dev/null +++ b/src/kms/policy/__tests__/KeyManagementPolicyConfig.test.ts @@ -0,0 +1,13 @@ +import { KeyManagementPolicyConfig } from '../KeyManagementPolicyConfig' + +describe('KeyManagementPolicyConfig', () => { + test('uses Askar for holder credential binding by default', () => { + expect(new KeyManagementPolicyConfig().holderCredentialBinding).toBe('askar') + }) + + test('allows holder credential binding to opt in to OpenBao', () => { + expect(new KeyManagementPolicyConfig({ holderCredentialBinding: 'openbao' }).holderCredentialBinding).toBe( + 'openbao', + ) + }) +}) diff --git a/src/kms/policy/__tests__/KeyManagementPolicyModule.test.ts b/src/kms/policy/__tests__/KeyManagementPolicyModule.test.ts new file mode 100644 index 00000000..82831db4 --- /dev/null +++ b/src/kms/policy/__tests__/KeyManagementPolicyModule.test.ts @@ -0,0 +1,39 @@ +jest.mock('@credo-ts/core', () => ({ + Kms: { KeyManagementModuleConfig: class KeyManagementModuleConfig {} }, +})) + +import type { AgentContext, DependencyManager } from '@credo-ts/core' + +import { KeyManagementPolicyConfig } from '../KeyManagementPolicyConfig' +import { KeyManagementPolicyModule } from '../KeyManagementPolicyModule' + +describe('KeyManagementPolicyModule', () => { + test('registers the resolved policy', () => { + const dependencyManager = { registerInstance: jest.fn() } as unknown as DependencyManager + const module = new KeyManagementPolicyModule({ holderCredentialBinding: 'openbao' }) + + module.register(dependencyManager) + + expect(dependencyManager.registerInstance).toHaveBeenCalledWith(KeyManagementPolicyConfig, module.config) + }) + + test('rejects a policy that selects an unavailable backend', async () => { + const module = new KeyManagementPolicyModule({ holderCredentialBinding: 'openbao' }) + const agentContext = { + resolve: jest.fn().mockReturnValue({ backends: [{ backend: 'askar' }] }), + } as unknown as AgentContext + + await expect(module.initialize(agentContext)).rejects.toThrow( + "KMS backend 'openbao' configured for holder credential binding is not registered", + ) + }) + + test('accepts a policy that selects a registered backend', async () => { + const module = new KeyManagementPolicyModule({ holderCredentialBinding: 'openbao' }) + const agentContext = { + resolve: jest.fn().mockReturnValue({ backends: [{ backend: 'askar' }, { backend: 'openbao' }] }), + } as unknown as AgentContext + + await expect(module.initialize(agentContext)).resolves.toBeUndefined() + }) +}) diff --git a/src/kms/policy/__tests__/getHolderCredentialBindingBackend.test.ts b/src/kms/policy/__tests__/getHolderCredentialBindingBackend.test.ts new file mode 100644 index 00000000..518c305e --- /dev/null +++ b/src/kms/policy/__tests__/getHolderCredentialBindingBackend.test.ts @@ -0,0 +1,26 @@ +jest.mock('@credo-ts/core', () => ({})) + +import type { AgentContext } from '@credo-ts/core' + +import { KeyManagementPolicyConfig } from '../KeyManagementPolicyConfig' +import { getHolderCredentialBindingBackend } from '../getHolderCredentialBindingBackend' + +const context = (policy?: KeyManagementPolicyConfig) => + ({ + dependencyManager: { + isRegistered: jest.fn().mockReturnValue(Boolean(policy)), + resolve: jest.fn().mockReturnValue(policy), + }, + }) as unknown as AgentContext + +describe('getHolderCredentialBindingBackend', () => { + test('preserves Askar when no policy is registered', () => { + expect(getHolderCredentialBindingBackend(context())).toBe('askar') + }) + + test('returns the explicitly configured backend', () => { + expect( + getHolderCredentialBindingBackend(context(new KeyManagementPolicyConfig({ holderCredentialBinding: 'openbao' }))), + ).toBe('openbao') + }) +}) diff --git a/src/kms/policy/getHolderCredentialBindingBackend.ts b/src/kms/policy/getHolderCredentialBindingBackend.ts new file mode 100644 index 00000000..92d3d156 --- /dev/null +++ b/src/kms/policy/getHolderCredentialBindingBackend.ts @@ -0,0 +1,11 @@ +import type { AgentContext } from '@credo-ts/core' + +import { KeyManagementPolicyConfig, type KeyManagementBackend } from './KeyManagementPolicyConfig' + +export const getHolderCredentialBindingBackend = (agentContext: AgentContext): KeyManagementBackend => { + const dependencyManager = agentContext.dependencyManager + + return dependencyManager.isRegistered(KeyManagementPolicyConfig, true) + ? dependencyManager.resolve(KeyManagementPolicyConfig).holderCredentialBinding + : 'askar' +} diff --git a/src/kms/policy/index.ts b/src/kms/policy/index.ts new file mode 100644 index 00000000..a348fd7c --- /dev/null +++ b/src/kms/policy/index.ts @@ -0,0 +1,3 @@ +export * from './getHolderCredentialBindingBackend' +export * from './KeyManagementPolicyConfig' +export * from './KeyManagementPolicyModule' From 562dc51ff38f61869d7f2bf50d683f29c1988f36 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 25 Aug 2026 13:10:18 +0800 Subject: [PATCH 3/3] fix: sanitize OpenBao errors and validate endpoints Signed-off-by: Mark --- src/kms/openbao/OpenBaoError.ts | 29 +++++++++++++++++ .../openbao/OpenBaoKeyManagementService.ts | 3 +- src/kms/openbao/OpenBaoKmsConfig.ts | 16 +++++++++- src/kms/openbao/OpenBaoTransitClient.ts | 17 +++++++--- .../openbao/__tests__/OpenBaoError.test.ts | 31 +++++++++++++++++++ .../__tests__/OpenBaoKmsConfig.test.ts | 10 ++++++ src/kms/openbao/index.ts | 1 + 7 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 src/kms/openbao/OpenBaoError.ts create mode 100644 src/kms/openbao/__tests__/OpenBaoError.test.ts diff --git a/src/kms/openbao/OpenBaoError.ts b/src/kms/openbao/OpenBaoError.ts new file mode 100644 index 00000000..e9ecad68 --- /dev/null +++ b/src/kms/openbao/OpenBaoError.ts @@ -0,0 +1,29 @@ +import { AxiosError } from 'axios' + +export class OpenBaoHttpError extends Error { + public constructor( + message: string, + public readonly status?: number, + ) { + super(message) + } +} + +export const toSafeOpenBaoError = (error: unknown): Error => { + if (error instanceof OpenBaoHttpError) return error + if (!(error instanceof AxiosError)) return error instanceof Error ? error : new Error(String(error)) + + const responseData = error.response?.data + const responseErrors = + typeof responseData === 'object' && responseData !== null && 'errors' in responseData + ? (responseData as { errors?: unknown }).errors + : undefined + const detail = Array.isArray(responseErrors) + ? responseErrors.filter((value): value is string => typeof value === 'string').join('; ') + : undefined + const status = error.response?.status + const statusSuffix = status ? ` (${status})` : '' + const detailSuffix = detail ? `: ${detail}` : '' + + return new OpenBaoHttpError(`OpenBao request failed${statusSuffix}${detailSuffix}`, status) +} diff --git a/src/kms/openbao/OpenBaoKeyManagementService.ts b/src/kms/openbao/OpenBaoKeyManagementService.ts index 4f1f54f8..231b7990 100644 --- a/src/kms/openbao/OpenBaoKeyManagementService.ts +++ b/src/kms/openbao/OpenBaoKeyManagementService.ts @@ -3,6 +3,7 @@ import type { ResolvedOpenBaoKmsConfig } from './OpenBaoKmsConfig' import { Kms, type AgentContext } from '@credo-ts/core' import { createHash, createPublicKey, randomBytes } from 'crypto' +import { toSafeOpenBaoError } from './OpenBaoError' import { OpenBaoTransitClient, type OpenBaoTransitKey } from './OpenBaoTransitClient' const backend = 'openbao' @@ -150,6 +151,6 @@ export class OpenBaoKeyManagementService implements Kms.KeyManagementService { } private asError(error: unknown) { - return error instanceof Error ? error : new Error(String(error)) + return toSafeOpenBaoError(error) } } diff --git a/src/kms/openbao/OpenBaoKmsConfig.ts b/src/kms/openbao/OpenBaoKmsConfig.ts index 147c34ab..82aa9e4e 100644 --- a/src/kms/openbao/OpenBaoKmsConfig.ts +++ b/src/kms/openbao/OpenBaoKmsConfig.ts @@ -34,7 +34,21 @@ const pathPart = (value: string, name: string) => { export const resolveOpenBaoKmsConfig = (config: OpenBaoKmsConfig): ResolvedOpenBaoKmsConfig => { const url = config.url.replace(/\/+$/, '') - if (!/^https?:\/\//.test(url)) throw new Error('OpenBao KMS url must use http or https') + let parsedUrl: URL + try { + parsedUrl = new URL(url) + } catch { + throw new Error('OpenBao KMS url must be a valid http or https URL with a hostname') + } + if ( + config.url !== config.url.trim() || + !['http:', 'https:'].includes(parsedUrl.protocol) || + !parsedUrl.hostname || + parsedUrl.username || + parsedUrl.password + ) { + throw new Error('OpenBao KMS url must be a valid http or https URL with a hostname and no credentials') + } if (config.token && config.appRole) throw new Error('Configure either an OpenBao token or AppRole, not both') if (!config.token && !config.appRole) throw new Error('OpenBao KMS requires a token or AppRole credentials') diff --git a/src/kms/openbao/OpenBaoTransitClient.ts b/src/kms/openbao/OpenBaoTransitClient.ts index eedf97cf..9dc80843 100644 --- a/src/kms/openbao/OpenBaoTransitClient.ts +++ b/src/kms/openbao/OpenBaoTransitClient.ts @@ -3,6 +3,8 @@ import type { AxiosInstance } from 'axios' import axios, { AxiosError } from 'axios' +import { OpenBaoHttpError, toSafeOpenBaoError } from './OpenBaoError' + type OpenBaoResponse = { data: T } export interface OpenBaoTransitKey { @@ -42,8 +44,8 @@ export class OpenBaoTransitClient { `/${this.config.transitMount}/keys/${encodeURIComponent(name)}`, ) } catch (error) { - if (error instanceof AxiosError && error.response?.status === 404) return null - throw error + if (error instanceof OpenBaoHttpError && error.status === 404) return null + throw toSafeOpenBaoError(error) } } @@ -115,9 +117,14 @@ export class OpenBaoTransitClient { private async login() { const { roleId, secretId, mountPath } = this.config.appRole! - const response = await this.http.post<{ - auth: { client_token: string; lease_duration: number } - }>(`/auth/${mountPath}/login`, { role_id: roleId, secret_id: secretId }) + let response + try { + response = await this.http.post<{ + auth: { client_token: string; lease_duration: number } + }>(`/auth/${mountPath}/login`, { role_id: roleId, secret_id: secretId }) + } catch (error) { + throw toSafeOpenBaoError(error) + } this.token = response.data.auth.client_token const refreshAfterSeconds = Math.max(1, Math.floor(response.data.auth.lease_duration * 0.8)) this.tokenExpiresAt = Date.now() + refreshAfterSeconds * 1000 diff --git a/src/kms/openbao/__tests__/OpenBaoError.test.ts b/src/kms/openbao/__tests__/OpenBaoError.test.ts new file mode 100644 index 00000000..30b3b9d3 --- /dev/null +++ b/src/kms/openbao/__tests__/OpenBaoError.test.ts @@ -0,0 +1,31 @@ +import { AxiosError, AxiosHeaders } from 'axios' + +import { OpenBaoHttpError, toSafeOpenBaoError } from '../OpenBaoError' + +describe('toSafeOpenBaoError', () => { + test('does not retain Axios request configuration or authentication headers', () => { + const error = new AxiosError( + 'Request failed', + 'ERR_BAD_RESPONSE', + { + headers: new AxiosHeaders({ 'X-Vault-Token': 'secret-token' }), + }, + undefined, + { + status: 403, + statusText: 'Forbidden', + headers: {}, + config: { headers: new AxiosHeaders() }, + data: { errors: ['permission denied'] }, + }, + ) + + const safeError = toSafeOpenBaoError(error) + + expect(safeError).toBeInstanceOf(OpenBaoHttpError) + expect(safeError.message).toBe('OpenBao request failed (403): permission denied') + expect(JSON.stringify(safeError)).not.toContain('secret-token') + expect(safeError).not.toHaveProperty('config') + expect(safeError).not.toHaveProperty('request') + }) +}) diff --git a/src/kms/openbao/__tests__/OpenBaoKmsConfig.test.ts b/src/kms/openbao/__tests__/OpenBaoKmsConfig.test.ts index 4ca4de85..b010a90b 100644 --- a/src/kms/openbao/__tests__/OpenBaoKmsConfig.test.ts +++ b/src/kms/openbao/__tests__/OpenBaoKmsConfig.test.ts @@ -21,4 +21,14 @@ describe('resolveOpenBaoKmsConfig', () => { }), ).toMatchObject({ url: 'https://bao.example', transitMount: 'wallet-transit', keyPrefix: 'credebl' }) }) + + test.each([ + 'not-a-url', + 'ftp://bao.example', + 'https://bao .example', + ' https://bao.example', + 'https://token@bao.example', + ])('rejects invalid URL %s', (url) => { + expect(() => resolveOpenBaoKmsConfig({ url, token: 'token' })).toThrow('valid http or https URL') + }) }) diff --git a/src/kms/openbao/index.ts b/src/kms/openbao/index.ts index bbf6bbe5..e0f4a89f 100644 --- a/src/kms/openbao/index.ts +++ b/src/kms/openbao/index.ts @@ -1,4 +1,5 @@ export * from './OpenBaoKmsConfig' +export * from './OpenBaoError' export * from './OpenBaoKmsModule' export * from './OpenBaoKeyManagementService' export * from './OpenBaoTransitClient'