diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 1b2f8cb005..27865a6727 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -49,7 +49,7 @@ import { Session } from './session/util' import { createValidatorMiddleware, HttpMethod, isHttpMethod } from 'openapi' import { PaymentPointerKeyService } from './paymentPointerKey/service' import { GrantReferenceService } from './open_payments/grantReference/service' -import { OpenPaymentsClient } from 'open-payments' +import { AuthenticatedClient } from 'open-payments' export interface AppContextData { logger: Logger @@ -147,7 +147,7 @@ export interface AppServices { sessionService: Promise paymentPointerKeyService: Promise grantReferenceService: Promise - openPaymentsClient: Promise + openPaymentsClient: Promise } export type AppContainer = IocContract diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index 4eb4390490..cf67c2d904 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -112,6 +112,7 @@ export const Config = { 'AUTH_SERVER_SPEC', 'https://raw.githubusercontent.com/interledger/open-payments/77462cd0872be8d0fa487a4b233defe2897a7ee4/auth-server-open-api-spec.yaml' ), + keyId: envString('KEY_ID', 'rafiki'), privateKey: parseOrProvisionKey(envString('PRIVATE_KEY_FILE', undefined)), /** Frontend **/ diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 920a5aab39..b27ca1820d 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -37,7 +37,7 @@ import { createConnectorService } from './connector' import { createSessionService } from './session/service' import { createApiKeyService } from './apiKey/service' import { createOpenAPI } from 'openapi' -import { createClient as createOpenPaymentsClient } from 'open-payments' +import { createAuthenticatedClient as createOpenPaymentsClient } from 'open-payments' import { createConnectionService } from './open_payments/connection/service' import { createConnectionRoutes } from './open_payments/connection/routes' import { createPaymentPointerKeyService } from './paymentPointerKey/service' @@ -115,8 +115,13 @@ export function initIocContainer( return await createOpenAPI(config.authServerSpec) }) container.singleton('openPaymentsClient', async (deps) => { + const config = await deps.use('config') const logger = await deps.use('logger') - return createOpenPaymentsClient({ logger }) + return createOpenPaymentsClient({ + logger, + keyId: config.keyId, + privateKey: config.privateKey + }) }) /** diff --git a/packages/backend/src/open_payments/receiver/service.test.ts b/packages/backend/src/open_payments/receiver/service.test.ts index 03781651b2..3cdb2922fa 100644 --- a/packages/backend/src/open_payments/receiver/service.test.ts +++ b/packages/backend/src/open_payments/receiver/service.test.ts @@ -12,14 +12,14 @@ import { createIncomingPayment } from '../../tests/incomingPayment' import { createPaymentPointer } from '../../tests/paymentPointer' import { truncateTables } from '../../tests/tableManager' import { ConnectionService } from '../connection/service' -import { OpenPaymentsClient } from 'open-payments' +import { AuthenticatedClient } from 'open-payments' import { PaymentPointerService } from '../payment_pointer/service' describe('Receiver Service', (): void => { let deps: IocContract let appContainer: TestContainer let receiverService: ReceiverService - let openPaymentsClient: OpenPaymentsClient + let openPaymentsClient: AuthenticatedClient let knex: Knex let connectionService: ConnectionService let paymentPointerService: PaymentPointerService diff --git a/packages/backend/src/open_payments/receiver/service.ts b/packages/backend/src/open_payments/receiver/service.ts index 491cb0a551..99fb9dfff4 100644 --- a/packages/backend/src/open_payments/receiver/service.ts +++ b/packages/backend/src/open_payments/receiver/service.ts @@ -1,5 +1,5 @@ import { - OpenPaymentsClient, + AuthenticatedClient, IncomingPayment as OpenPaymentsIncomingPayment, ILPStreamConnection as OpenPaymentsConnection } from 'open-payments' @@ -22,7 +22,7 @@ interface ServiceDependencies extends BaseService { incomingPaymentService: IncomingPaymentService openPaymentsUrl: string paymentPointerService: PaymentPointerService - openPaymentsClient: OpenPaymentsClient + openPaymentsClient: AuthenticatedClient } const CONNECTION_URL_REGEX = /\/connections\/(.){36}$/ diff --git a/packages/open-payments/package.json b/packages/open-payments/package.json index e67fe7d8e0..fdcfc77397 100644 --- a/packages/open-payments/package.json +++ b/packages/open-payments/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "axios": "^1.1.2", + "http-message-signatures": "^0.1.2", "openapi": "workspace:../openapi", "pino": "^8.4.2" } diff --git a/packages/open-payments/src/client/index.ts b/packages/open-payments/src/client/index.ts index 3a51c063c1..3d3f3730ba 100644 --- a/packages/open-payments/src/client/index.ts +++ b/packages/open-payments/src/client/index.ts @@ -1,3 +1,4 @@ +import { KeyLike } from 'crypto' import { createOpenAPI, OpenAPI } from 'openapi' import createLogger, { Logger } from 'pino' import config from '../config' @@ -16,33 +17,61 @@ import { import { createAxiosInstance } from './requests' import { AxiosInstance } from 'axios' -export interface CreateOpenPaymentClientArgs { - requestTimeoutMs?: number - logger?: Logger -} - export interface ClientDeps { axiosInstance: AxiosInstance openApi: OpenAPI logger: Logger } -export interface OpenPaymentsClient { - incomingPayment: IncomingPaymentRoutes - ilpStreamConnection: ILPStreamConnectionRoutes - paymentPointer: PaymentPointerRoutes -} - -export const createClient = async ( - args?: CreateOpenPaymentClientArgs -): Promise => { +const createDeps = async ( + args: Partial +): Promise => { const axiosInstance = createAxiosInstance({ + privateKey: args.privateKey, + keyId: args.keyId, requestTimeoutMs: args?.requestTimeoutMs ?? config.DEFAULT_REQUEST_TIMEOUT_MS }) const openApi = await createOpenAPI(config.OPEN_PAYMENTS_OPEN_API_URL) const logger = args?.logger ?? createLogger() - const deps = { axiosInstance, openApi, logger } + return { axiosInstance, openApi, logger } +} + +export interface CreateUnauthenticatedClientArgs { + requestTimeoutMs?: number + logger?: Logger +} + +export interface UnauthenticatedClient { + ilpStreamConnection: ILPStreamConnectionRoutes + paymentPointer: PaymentPointerRoutes +} + +export const createUnauthenticatedClient = async ( + args: CreateUnauthenticatedClientArgs +): Promise => { + const deps = await createDeps(args) + + return { + ilpStreamConnection: createILPStreamConnectionRoutes(deps), + paymentPointer: createPaymentPointerRoutes(deps) + } +} + +export interface CreateAuthenticatedClientArgs + extends CreateUnauthenticatedClientArgs { + privateKey: KeyLike + keyId: string +} + +export interface AuthenticatedClient extends UnauthenticatedClient { + incomingPayment: IncomingPaymentRoutes +} + +export const createAuthenticatedClient = async ( + args: CreateAuthenticatedClientArgs +): Promise => { + const deps = await createDeps(args) return { incomingPayment: createIncomingPaymentRoutes(deps), diff --git a/packages/open-payments/src/client/requests.test.ts b/packages/open-payments/src/client/requests.test.ts index 60b4d62860..2637bad79a 100644 --- a/packages/open-payments/src/client/requests.test.ts +++ b/packages/open-payments/src/client/requests.test.ts @@ -1,28 +1,35 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import { createAxiosInstance, get } from './requests' +import { generateKeyPairSync } from 'crypto' import nock from 'nock' import { mockOpenApiResponseValidators, silentLogger } from '../test/helpers' describe('requests', (): void => { const logger = silentLogger + const privateKey = generateKeyPairSync('ed25519').privateKey + const keyId = 'myId' describe('createAxiosInstance', (): void => { test('sets timeout properly', async (): Promise => { expect( - createAxiosInstance({ requestTimeoutMs: 1000 }).defaults.timeout + createAxiosInstance({ requestTimeoutMs: 1000, privateKey, keyId }) + .defaults.timeout ).toBe(1000) }) test('sets Content-Type header properly', async (): Promise => { expect( - createAxiosInstance({ requestTimeoutMs: 0 }).defaults.headers.common[ - 'Content-Type' - ] + createAxiosInstance({ requestTimeoutMs: 0, privateKey, keyId }).defaults + .headers.common['Content-Type'] ).toBe('application/json') }) }) describe('get', (): void => { - const axiosInstance = createAxiosInstance({ requestTimeoutMs: 0 }) + const axiosInstance = createAxiosInstance({ + requestTimeoutMs: 0, + privateKey, + keyId + }) const baseUrl = 'http://localhost:1000' const responseValidators = mockOpenApiResponseValidators() @@ -30,8 +37,37 @@ describe('requests', (): void => { jest.spyOn(axiosInstance, 'get') }) + afterEach(() => { + jest.useRealTimers() + }) + test('sets headers properly if accessToken provided', async (): Promise => { - nock(baseUrl).get('/incoming-payment').reply(200) + // https://github.com/nock/nock/issues/2200#issuecomment-1280957462 + jest + .useFakeTimers({ + doNotFake: [ + 'nextTick', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout' + ] + }) + .setSystemTime(new Date()) + + const scope = nock(baseUrl) + .matchHeader('Signature', /sig1=:([a-zA-Z0-9+/]){86}==:/) + .matchHeader( + 'Signature-Input', + `sig1=("@method" "@target-uri" "authorization");created=${Math.floor( + Date.now() / 1000 + )};keyid="${keyId}";alg="ed25519"` + ) + .get('/incoming-payment') + // TODO: verify signature + .reply(200) await get( { axiosInstance, logger }, @@ -42,20 +78,24 @@ describe('requests', (): void => { responseValidators.successfulValidator ) + scope.done() + expect(axiosInstance.get).toHaveBeenCalledWith( `${baseUrl}/incoming-payment`, { headers: { - Authorization: 'GNAP accessToken', - Signature: 'TODO', - 'Signature-Input': 'TODO' + Authorization: 'GNAP accessToken' } } ) }) test('sets headers properly if accessToken is not provided', async (): Promise => { - nock(baseUrl).get('/incoming-payment').reply(200) + const scope = nock(baseUrl) + .matchHeader('Signature', (sig) => sig === undefined) + .matchHeader('Signature-Input', (sigInput) => sigInput === undefined) + .get('/incoming-payment') + .reply(200) await get( { axiosInstance, logger }, @@ -64,6 +104,7 @@ describe('requests', (): void => { }, responseValidators.successfulValidator ) + scope.done() expect(axiosInstance.get).toHaveBeenCalledWith( `${baseUrl}/incoming-payment`, diff --git a/packages/open-payments/src/client/requests.ts b/packages/open-payments/src/client/requests.ts index cc9608a057..774bf57313 100644 --- a/packages/open-payments/src/client/requests.ts +++ b/packages/open-payments/src/client/requests.ts @@ -1,6 +1,8 @@ import axios, { AxiosInstance } from 'axios' +import { KeyLike } from 'crypto' import { ResponseValidator } from 'openapi' import { ClientDeps } from '.' +import { createSignatureHeaders } from './signatures' interface GetArgs { url: string @@ -26,9 +28,7 @@ export const get = async ( const { data, status } = await axiosInstance.get(url, { headers: accessToken ? { - Authorization: `GNAP ${accessToken}`, - Signature: 'TODO', - 'Signature-Input': 'TODO' + Authorization: `GNAP ${accessToken}` } : {} }) @@ -65,12 +65,35 @@ export const get = async ( export const createAxiosInstance = (args: { requestTimeoutMs: number + privateKey: KeyLike + keyId: string }): AxiosInstance => { const axiosInstance = axios.create({ timeout: args.requestTimeoutMs }) - axiosInstance.defaults.headers.common['Content-Type'] = 'application/json' + axiosInstance.interceptors.request.use( + async (config) => { + const sigHeaders = await createSignatureHeaders({ + request: { + method: config.method.toUpperCase(), + url: config.url, + headers: config.headers, + body: config.data + }, + privateKey: args.privateKey, + keyId: args.keyId + }) + config.headers['Signature'] = sigHeaders['Signature'] + config.headers['Signature-Input'] = sigHeaders['Signature-Input'] + return config + }, + null, + { + runWhen: (config) => !!config.headers['Authorization'] + } + ) + return axiosInstance } diff --git a/packages/open-payments/src/client/signatures.ts b/packages/open-payments/src/client/signatures.ts new file mode 100644 index 0000000000..f8de9fff93 --- /dev/null +++ b/packages/open-payments/src/client/signatures.ts @@ -0,0 +1,53 @@ +import { sign, KeyLike } from 'crypto' +import { + httpis as httpsig, + Algorithm, + RequestLike, + Signer +} from 'http-message-signatures' + +interface SignOptions { + request: RequestLike + privateKey: KeyLike + keyId: string +} + +interface SignatureHeaders { + Signature: string + 'Signature-Input': string +} + +const createSigner = (privateKey: KeyLike): Signer => { + const signer = async (data: Buffer) => sign(null, data, privateKey) + signer.alg = 'ed25519' as Algorithm + return signer +} + +export const createSignatureHeaders = async ({ + request, + privateKey, + keyId +}: SignOptions): Promise => { + const components = ['@method', '@target-uri'] + if (request.headers['Authorization']) { + components.push('authorization') + } + if (request.body) { + // TODO: 'content-digest' + // https://github.com/interledger/rafiki/issues/655 + components.push('content-length', 'content-type') + } + const { headers } = await httpsig.sign(request, { + components, + parameters: { + created: Math.floor(Date.now() / 1000) + }, + keyId, + signer: createSigner(privateKey), + format: 'httpbis' + }) + return { + Signature: headers['Signature'] as string, + 'Signature-Input': headers['Signature-Input'] as string + } +} diff --git a/packages/open-payments/src/index.ts b/packages/open-payments/src/index.ts index 0cc9edc2a2..1a656f9a4b 100644 --- a/packages/open-payments/src/index.ts +++ b/packages/open-payments/src/index.ts @@ -1,2 +1,7 @@ export { IncomingPayment, ILPStreamConnection } from './types' -export { createClient, OpenPaymentsClient } from './client' +export { + createAuthenticatedClient, + createUnauthenticatedClient, + AuthenticatedClient, + UnauthenticatedClient +} from './client' diff --git a/packages/open-payments/src/test/helpers.ts b/packages/open-payments/src/test/helpers.ts index 8afadd20bb..113e3b1507 100644 --- a/packages/open-payments/src/test/helpers.ts +++ b/packages/open-payments/src/test/helpers.ts @@ -1,3 +1,4 @@ +import { generateKeyPairSync } from 'crypto' import createLogger from 'pino' import { createAxiosInstance } from '../client/requests' import { ILPStreamConnection, IncomingPayment } from '../types' @@ -9,7 +10,13 @@ export const silentLogger = createLogger({ level: 'silent' }) -export const defaultAxiosInstance = createAxiosInstance({ requestTimeoutMs: 0 }) +export const keyId = 'default-key-id' + +export const defaultAxiosInstance = createAxiosInstance({ + requestTimeoutMs: 0, + keyId, + privateKey: generateKeyPairSync('ed25519').privateKey +}) export const mockOpenApiResponseValidators = () => ({ successfulValidator: ((data: unknown): data is unknown => diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d73da29fa0..b54a40bd74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,7 @@ importers: '@types/node': ^18.7.12 axios: ^1.1.2 base64url: ^3.0.1 + http-message-signatures: ^0.1.2 nock: ^13.2.9 openapi: workspace:../openapi openapi-typescript: ^4.5.0 @@ -321,6 +322,7 @@ importers: uuid: ^8.3.2 dependencies: axios: 1.1.2 + http-message-signatures: 0.1.2 openapi: link:../openapi pino: 8.4.2 devDependencies: @@ -8034,6 +8036,10 @@ packages: statuses: 2.0.1 toidentifier: 1.0.1 + /http-message-signatures/0.1.2: + resolution: {integrity: sha512-gjJYDgFBy+xnlAs2G0gIWpiorCv9Xi7pIlOnnd91zHAK7BtkLxonmm/JAtd5e6CakOuW03IwEuJzj2YMy8lfWQ==} + dev: false + /http-proxy-agent/5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'}