diff --git a/packages/http-signature-utils/src/index.ts b/packages/http-signature-utils/src/index.ts index 9f3f293..90aa44f 100644 --- a/packages/http-signature-utils/src/index.ts +++ b/packages/http-signature-utils/src/index.ts @@ -6,6 +6,10 @@ export { loadOrGenerateKey, loadBase64Key } from './utils/key' -export { createSignatureHeaders, type RequestLike } from './utils/signatures' +export { + createSignatureHeaders, + type RequestLike, + type Signer +} from './utils/signatures' export { validateSignatureHeaders, validateSignature } from './utils/validation' export { generateTestKeys, TestKeys } from './test-utils/keys' diff --git a/packages/http-signature-utils/src/utils/headers.ts b/packages/http-signature-utils/src/utils/headers.ts index 81bfd3b..cff4560 100644 --- a/packages/http-signature-utils/src/utils/headers.ts +++ b/packages/http-signature-utils/src/utils/headers.ts @@ -26,8 +26,7 @@ const createContentHeaders = (body: string): ContentHeaders => { export const createHeaders = async ({ request, - privateKey, - keyId + ...signOptions }: SignOptions): Promise => { const contentHeaders = request.body && createContentHeaders(request.body as string) @@ -38,8 +37,7 @@ export const createHeaders = async ({ const signatureHeaders = await createSignatureHeaders({ request, - privateKey, - keyId + ...signOptions }) return { diff --git a/packages/http-signature-utils/src/utils/signatures.ts b/packages/http-signature-utils/src/utils/signatures.ts index 2d47b72..b45bb10 100644 --- a/packages/http-signature-utils/src/utils/signatures.ts +++ b/packages/http-signature-utils/src/utils/signatures.ts @@ -1,26 +1,56 @@ import { type KeyLike } from 'crypto' -import { httpbis, createSigner, Request } from 'http-message-signatures' +import { + httpbis, + createSigner, + Request, + type SigningKey +} from 'http-message-signatures' export interface RequestLike extends Request { body?: string } -export interface SignOptions { +export interface Signer { + sign(data: Uint8Array): Uint8Array | Promise +} + +interface BaseSignOptions { request: RequestLike - privateKey: KeyLike keyId: string } +export interface PrivateKeySignOptions extends BaseSignOptions { + privateKey: KeyLike +} + +export interface SignerSignOptions extends BaseSignOptions { + signer: Signer +} + +export type SignOptions = PrivateKeySignOptions | SignerSignOptions + export interface SignatureHeaders { Signature: string 'Signature-Input': string } -export const createSignatureHeaders = async ({ - request, - privateKey, - keyId -}: SignOptions): Promise => { +const createSigningKey = (options: SignOptions): SigningKey => { + if ('signer' in options) { + return { + id: options.keyId, + alg: 'ed25519', + sign: async (data: Buffer): Promise => + Buffer.from(await options.signer.sign(data)) + } + } + + return createSigner(options.privateKey, 'ed25519', options.keyId) +} + +export const createSignatureHeaders = async ( + options: SignOptions +): Promise => { + const { request } = options const components = ['@method', '@target-uri'] if (request.headers['Authorization'] || request.headers['authorization']) { components.push('authorization') @@ -29,7 +59,7 @@ export const createSignatureHeaders = async ({ components.push('content-digest', 'content-length', 'content-type') } - const signingKey = createSigner(privateKey, 'ed25519', keyId) + const signingKey = createSigningKey(options) const { headers } = await httpbis.signMessage( { diff --git a/packages/open-payments/README.md b/packages/open-payments/README.md index eddcb08..76d7efe 100644 --- a/packages/open-payments/README.md +++ b/packages/open-payments/README.md @@ -61,7 +61,7 @@ const incomingPayment = await client.walletAddress.get({ ### `AuthenticatedClient` -An `AuthenticatedClient` provides all of the methods that `UnauthenticatedClient` does, as well as the rest of the Open Payment APIs (both the authorizaton and resource specs). Each request requiring authentication will be signed (using [HTTP Message Signatures](https://github.com/interledger/open-payments/tree/main/packages/http-signature-utils)) with the given private key. +An `AuthenticatedClient` provides all of the methods that `UnauthenticatedClient` does, as well as the rest of the Open Payment APIs (both the authorizaton and resource specs). Each request requiring authentication will be signed (using [HTTP Message Signatures](https://github.com/interledger/open-payments/tree/main/packages/http-signature-utils)) with the given private key or signer. ```ts import { createAuthenticatedClient } from '@interledger/open-payments' @@ -81,6 +81,22 @@ In order to create the client, three properties need to be provided: `keyId`, th | `privateKey` | The private EdDSA-Ed25519 key (or the relative or absolute path to the key) bound to the wallet address, and used to sign the authenticated requests with. As mentioned above, a public JWK document signed with this key MUST be available at the `{walletAddressUrl}/jwks.json` url. | | `keyId` | The key identifier of the given private key and the corresponding public JWK document. | +For deployments where the private key is managed by a KMS, HSM, Secure Enclave, or another non-extractable key store, provide a signer instead of `privateKey`: + +```ts +import { createAuthenticatedClient } from '@interledger/open-payments' + +const client = await createAuthenticatedClient({ + keyId: KEY_ID, + signer: { + async sign(data: Uint8Array): Promise { + return kms.sign(data) + } + }, + walletAddressUrl: WALLET_ADDRESS_URL +}) +``` + > **Note** > > To simplify EdDSA-Ed25519 key provisioning and JWK generation, you can use methods from the [`@interledger/http-signature-utils`](https://github.com/interledger/open-payments/tree/main/packages/http-signature-utils) package. diff --git a/packages/open-payments/src/client/index.ts b/packages/open-payments/src/client/index.ts index e047399..c5a740e 100644 --- a/packages/open-payments/src/client/index.ts +++ b/packages/open-payments/src/client/index.ts @@ -1,4 +1,4 @@ -import { loadKey } from '@interledger/http-signature-utils' +import { loadKey, type Signer } from '@interledger/http-signature-utils' import fs from 'fs' import { OpenAPI } from '@interledger/openapi' import path from 'path' @@ -167,6 +167,7 @@ const createAuthenticatedClientDeps = async ({ ...args }: | CreateAuthenticatedClientArgs + | CreateAuthenticatedClientWithSignerArgs | CreateAuthenticatedClientWithReqInterceptorArgs): Promise => { const logger = args.logger ?? @@ -186,6 +187,16 @@ const createAuthenticatedClientDeps = async ({ authenticatedRequestInterceptor: args.authenticatedRequestInterceptor } }) + } else if ('signer' in args) { + httpClient = await createHttpClient({ + logger, + requestTimeoutMs: + args.requestTimeoutMs ?? config.DEFAULT_REQUEST_TIMEOUT_MS, + requestSigningArgs: { + signer: args.signer, + keyId: args.keyId + } + }) } else { let privateKey: KeyObject try { @@ -295,6 +306,13 @@ interface PrivateKeyConfig { keyId: string } +interface SignerConfig { + /** The external signer with which requests will be signed */ + signer: Signer + /** The key identifier referring to the signer */ + keyId: string +} + interface InterceptorConfig { /** The custom authenticated request interceptor to use. */ authenticatedRequestInterceptor: InterceptorFn @@ -303,6 +321,9 @@ interface InterceptorConfig { export type CreateAuthenticatedClientArgs = BaseAuthenticatedClientArgs & PrivateKeyConfig +export type CreateAuthenticatedClientWithSignerArgs = + BaseAuthenticatedClientArgs & SignerConfig + export type CreateAuthenticatedClientWithReqInterceptorArgs = BaseAuthenticatedClientArgs & InterceptorConfig @@ -322,6 +343,13 @@ export interface AuthenticatedClient export async function createAuthenticatedClient( args: CreateAuthenticatedClientArgs ): Promise +/** + * Creates an Open Payments client that signs authenticated requests with an external signer. + * This is useful for KMS, HSM, Secure Enclave, or other non-extractable key deployments. + */ +export async function createAuthenticatedClient( + args: CreateAuthenticatedClientWithSignerArgs +): Promise /** * @experimental The `authenticatedRequestInterceptor` feature is currently experimental and might be removed * in upcoming versions. Use at your own risk! It offers the capability to add a custom method for @@ -335,17 +363,27 @@ export async function createAuthenticatedClient( export async function createAuthenticatedClient( args: | CreateAuthenticatedClientArgs + | CreateAuthenticatedClientWithSignerArgs | CreateAuthenticatedClientWithReqInterceptorArgs ): Promise { + const authConfigCount = [ + 'authenticatedRequestInterceptor' in args, + 'privateKey' in args, + 'signer' in args + ].filter(Boolean).length + const hasKeyId = 'keyId' in args && !!args.keyId + const needsKeyId = 'privateKey' in args || 'signer' in args + if ( - 'authenticatedRequestInterceptor' in args && - ('privateKey' in args || 'keyId' in args) + authConfigCount !== 1 || + (needsKeyId && !hasKeyId) || + ('authenticatedRequestInterceptor' in args && 'keyId' in args) ) { throw new OpenPaymentsClientError( 'Invalid arguments when creating authenticated client.', { description: - 'Both `authenticatedRequestInterceptor` and `privateKey`/`keyId` were provided. Please use only one of these options.' + 'Exactly one of `authenticatedRequestInterceptor`, `privateKey`/`keyId`, or `signer`/`keyId` must be provided.' } ) } diff --git a/packages/open-payments/src/client/requests.ts b/packages/open-payments/src/client/requests.ts index bc8208f..adba54f 100644 --- a/packages/open-payments/src/client/requests.ts +++ b/packages/open-payments/src/client/requests.ts @@ -1,7 +1,7 @@ import { KeyObject } from 'crypto' import { ResponseValidator, isValidationError } from '@interledger/openapi' import { BaseDeps } from '.' -import { createHeaders } from '@interledger/http-signature-utils' +import { createHeaders, type Signer } from '@interledger/http-signature-utils' import { OpenPaymentsClientError } from './error' import { Logger } from 'pino' @@ -240,6 +240,7 @@ interface CreateHttpClientArgs { type AuthenticatedHttpClientArgs = | { privateKey: KeyObject; keyId: string } + | { signer: Signer; keyId: string } | { authenticatedRequestInterceptor: InterceptorFn } export type HttpClient = KyInstance @@ -318,10 +319,8 @@ export const createHttpClient = async ( } } else { requestInterceptor = (request) => { - const { privateKey, keyId } = requestSigningArgs - if (requestShouldBeAuthorized(request)) { - return signRequest(request, { privateKey, keyId }) + return signRequest(request, requestSigningArgs) } return request @@ -347,27 +346,40 @@ export const signRequest = async ( request: Request, args: { privateKey?: KeyObject + signer?: Signer keyId?: string } ): Promise => { - const { privateKey, keyId } = args + const { privateKey, signer, keyId } = args - if (!privateKey || !keyId) { + if (!keyId) { return request } const requestBody = request.body ? await request.clone().json() : undefined // Request body can only ever be read once, so we clone the original request + const requestToSign = { + method: request.method.toUpperCase(), + url: request.url, + headers: Object.fromEntries(request.headers.entries()), + body: requestBody ? JSON.stringify(requestBody) : undefined + } - const contentAndSigHeaders = await createHeaders({ - request: { - method: request.method.toUpperCase(), - url: request.url, - headers: Object.fromEntries(request.headers.entries()), - body: requestBody ? JSON.stringify(requestBody) : undefined - }, - privateKey, - keyId - }) + let contentAndSigHeaders: Awaited> + if (signer) { + contentAndSigHeaders = await createHeaders({ + request: requestToSign, + signer, + keyId + }) + } else if (privateKey) { + contentAndSigHeaders = await createHeaders({ + request: requestToSign, + privateKey, + keyId + }) + } else { + return request + } if (requestBody) { request.headers.set( diff --git a/packages/open-payments/src/openapi/generated/wallet-address-server-types.ts b/packages/open-payments/src/openapi/generated/wallet-address-server-types.ts index ff42b6e..391094f 100644 --- a/packages/open-payments/src/openapi/generated/wallet-address-server-types.ts +++ b/packages/open-payments/src/openapi/generated/wallet-address-server-types.ts @@ -136,11 +136,66 @@ export interface components { }; /** * DID Document - * @description A DID Document using JSON encoding + * @description A W3C DID Document using JSON encoding */ "did-document": { + "@context"?: string | string[]; + /** + * @description The DID subject identifier. + * @example did:web:example.com + */ + id: string; + /** @description An entity that is authorized to make changes to the DID document. */ + controller?: string | string[]; + /** @description A set of verification methods associated with the DID subject. */ + verificationMethod?: components["schemas"]["did-verification-method"][]; + /** @description Authentication verification relationships. */ + authentication?: (string | components["schemas"]["did-verification-method"])[]; + /** @description Assertion method verification relationships. */ + assertionMethod?: (string | components["schemas"]["did-verification-method"])[]; + /** @description Capability invocation verification relationships. */ + capabilityInvocation?: (string | components["schemas"]["did-verification-method"])[]; + /** @description Capability delegation verification relationships. */ + capabilityDelegation?: (string | components["schemas"]["did-verification-method"])[]; + /** @description Key agreement verification relationships. */ + keyAgreement?: (string | components["schemas"]["did-verification-method"])[]; + /** @description Services associated with the DID subject. */ + service?: components["schemas"]["did-service"][]; + /** @description Alternative identifiers for the DID subject. */ + alsoKnownAs?: string[]; + } & { [key: string]: unknown; }; + /** + * Verification Method + * @description A verification method associated with a DID subject. + */ + "did-verification-method": { + /** @description The identifier of the verification method. */ + id: string; + /** @description The type of the verification method. */ + type: string; + /** @description The controller of the verification method. */ + controller: string; + /** @description A JSON Web Key representation of the public key. */ + publicKeyJwk?: { + [key: string]: unknown; + }; + /** @description A Multibase-encoded public key. */ + publicKeyMultibase?: string; + }; + /** + * DID Service + * @description A service endpoint associated with a DID subject. + */ + "did-service": { + /** @description The identifier of the service. */ + id: string; + /** @description The type of the service. */ + type: string; + /** @description The service endpoint URI or URIs. */ + serviceEndpoint: string | string[]; + }; }; responses: never; parameters: never;