-
Notifications
You must be signed in to change notification settings - Fork 20
feat: add optional OpenBao Transit KMS with Holder key routing #437
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } |
| 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
|
||
|
|
||
| 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) }) | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
| } | ||
| } | ||
| 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
|
||
| 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
|
||
| 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, | ||
| } | ||
| } | ||
| 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) | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.