Skip to content
Open
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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,43 @@ 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`. 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.

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
Expand Down
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
fileServerToken?: string
apiKey?: string
updateJwtSecret?: boolean
openBaoKms?: AriesRestConfig['openBaoKms']

Check warning on line 51 in src/cli.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider removing 'undefined' type or '?' specifier, one of them is redundant.

See more on https://sonarcloud.io/project/issues?id=credebl_afj-controller&issues=AaA2_czb_mnE9GclC3Z5&open=AaA2_czb_mnE9GclC3Z5&pullRequest=437
keyManagement?: AriesRestConfig['keyManagement']

Check warning on line 52 in src/cli.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider removing 'undefined' type or '?' specifier, one of them is redundant.

See more on https://sonarcloud.io/project/issues?id=credebl_afj-controller&issues=AaA3Gz4HRqqdtjHnOOnO&open=AaA3Gz4HRqqdtjHnOOnO&pullRequest=437
}

interface InboundTransport {
Expand Down Expand Up @@ -216,5 +218,7 @@
fileServerToken: parsed.fileServerToken,
apiKey: parsed['apiKey'],
updateJwtSecret: parsed['updateJwtSecret'],
openBaoKms: parsed.openBaoKms,
keyManagement: parsed.keyManagement,
} as AriesRestConfig)
}
8 changes: 8 additions & 0 deletions src/cliAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ import express from 'express'
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,
Expand Down Expand Up @@ -131,6 +133,8 @@ export interface AriesRestConfig {
schemaFileServerURL?: string
apiKey: string
updateJwtSecret?: boolean
openBaoKms?: OpenBaoKmsConfig
keyManagement?: KeyManagementPolicyOptions
}

export async function readRestConfig(path: string) {
Expand Down Expand Up @@ -500,6 +504,10 @@ export async function runRestAgent(restConfig: AriesRestConfig) {
config: agentConfig,
modules: {
...modules,
...(afjConfig.openBaoKms ? { openBaoKms: new OpenBaoKmsModule(afjConfig.openBaoKms) } : {}),
...(afjConfig.keyManagement
? { keyManagementPolicy: new KeyManagementPolicyModule(afjConfig.keyManagement) }
: {}),
},
dependencies: agentDependencies,
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
}: {
Expand All @@ -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
Expand Down Expand Up @@ -56,7 +59,7 @@ export function getCredentialBindingResolver({
kms
.createKeyForSignatureAlgorithm({
algorithm: signatureAlgorithm!,
backend: 'askar',
backend,
})
.then((key) => Kms.PublicJwk.fromUnknown(key.publicJwk)),
),
Expand Down
29 changes: 29 additions & 0 deletions src/kms/openbao/OpenBaoError.ts
Original file line number Diff line number Diff line change
@@ -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)
}
156 changes: 156 additions & 0 deletions src/kms/openbao/OpenBaoKeyManagementService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { ResolvedOpenBaoKmsConfig } from './OpenBaoKmsConfig'

import { Kms, type AgentContext } from '@credo-ts/core'
import { createHash, createPublicKey, randomBytes } from 'crypto'

Check warning on line 4 in src/kms/openbao/OpenBaoKeyManagementService.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:crypto` over `crypto`.

See more on https://sonarcloud.io/project/issues?id=credebl_afj-controller&issues=AaA2_cyC_mnE9GclC3Z4&open=AaA2_cyC_mnE9GclC3Z4&pullRequest=437

import { toSafeOpenBaoError } from './OpenBaoError'
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<Type extends Kms.KmsCreateKeyType>(
agentContext: AgentContext,
options: Kms.KmsCreateKeyOptions<Type>,
): Promise<Kms.KmsCreateKeyReturn<Type>> {
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<Type>
} catch (error) {
if (error instanceof Kms.KeyManagementError) throw error
throw new Kms.KeyManagementError('Error creating OpenBao key', { cause: this.asError(error) })
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

public async getPublicKey(agentContext: AgentContext, keyId: string): Promise<Kms.KmsJwkPublic | null> {
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<Kms.KmsSignReturn> {
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<Kms.KmsVerifyReturn> {
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<boolean> {
this.parseKeyId(agentContext, options.keyId)
throw new Kms.KeyManagementAlgorithmNotSupportedError('deleting Transit keys', this.backend)
}

public async importKey<Jwk extends Kms.KmsJwkPrivate>(
_agentContext: AgentContext,
_options: Kms.KmsImportKeyOptions<Jwk>,
): Promise<Kms.KmsImportKeyReturn<Jwk>> {
throw new Kms.KeyManagementAlgorithmNotSupportedError('importing keys', this.backend)
}

public async encrypt(_agentContext: AgentContext, _options: Kms.KmsEncryptOptions): Promise<Kms.KmsEncryptReturn> {
throw new Kms.KeyManagementAlgorithmNotSupportedError('encryption', this.backend)
}

public async decrypt(_agentContext: AgentContext, _options: Kms.KmsDecryptOptions): Promise<Kms.KmsDecryptReturn> {
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 toSafeOpenBaoError(error)
}
}
69 changes: 69 additions & 0 deletions src/kms/openbao/OpenBaoKmsConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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, '')

Check warning on line 28 in src/kms/openbao/OpenBaoKmsConfig.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=credebl_afj-controller&issues=AaA2_cv8_mnE9GclC3Z2&open=AaA2_cv8_mnE9GclC3Z2&pullRequest=437
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(/\/+$/, '')

Check warning on line 36 in src/kms/openbao/OpenBaoKmsConfig.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=credebl_afj-controller&issues=AaA2_cv8_mnE9GclC3Z3&open=AaA2_cv8_mnE9GclC3Z3&pullRequest=437
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')

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,
}
}
18 changes: 18 additions & 0 deletions src/kms/openbao/OpenBaoKmsModule.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading