From 5da5664fdfb5311468a46f0bf12da7aa6b4a982c Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Wed, 26 Oct 2022 17:05:26 -0500 Subject: [PATCH 1/5] feat(open-payments): add HTTP message signatures --- packages/open-payments/package.json | 1 + packages/open-payments/src/client/index.ts | 7 ++- .../open-payments/src/client/requests.test.ts | 61 ++++++++++++++++--- packages/open-payments/src/client/requests.ts | 32 ++++++++-- .../open-payments/src/client/signatures.ts | 52 ++++++++++++++++ packages/open-payments/src/test/helpers.ts | 9 ++- pnpm-lock.yaml | 6 ++ 7 files changed, 152 insertions(+), 16 deletions(-) create mode 100644 packages/open-payments/src/client/signatures.ts 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..3fc00c02e1 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' @@ -19,6 +20,8 @@ import { AxiosInstance } from 'axios' export interface CreateOpenPaymentClientArgs { requestTimeoutMs?: number logger?: Logger + privateKey: KeyLike + keyId: string } export interface ClientDeps { @@ -34,9 +37,11 @@ export interface OpenPaymentsClient { } export const createClient = async ( - args?: CreateOpenPaymentClientArgs + args: CreateOpenPaymentClientArgs ): Promise => { const axiosInstance = createAxiosInstance({ + privateKey: args.privateKey, + keyId: args.keyId, requestTimeoutMs: args?.requestTimeoutMs ?? config.DEFAULT_REQUEST_TIMEOUT_MS }) 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..03d3e861c8 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,36 @@ 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, + // https://github.com/axios/axios/issues/5089#issuecomment-1297761617 + headers: JSON.parse(JSON.stringify(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..85fb0bad41 --- /dev/null +++ b/packages/open-payments/src/client/signatures.ts @@ -0,0 +1,52 @@ +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' + 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/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 fe1967317c..abd177b781 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: @@ -8056,6 +8058,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'} From 97db6d092c167be820f97b9c320f11a2f1272826 Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Fri, 4 Nov 2022 15:47:35 -0500 Subject: [PATCH 2/5] chore(backend): construct client with privateKey + keyId --- packages/backend/src/config/app.ts | 1 + packages/backend/src/index.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) 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..e0a7c718df 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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 + }) }) /** From eedca17730bdbfd3009c9f8f5862b26368673681 Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Tue, 15 Nov 2022 08:09:35 -0600 Subject: [PATCH 3/5] chore(open-payments): add unauthenticated client Remove Axios headers workaround. --- packages/open-payments/src/client/index.ts | 58 +++++++++++++------ packages/open-payments/src/client/requests.ts | 3 +- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/packages/open-payments/src/client/index.ts b/packages/open-payments/src/client/index.ts index 3fc00c02e1..f6464f9869 100644 --- a/packages/open-payments/src/client/index.ts +++ b/packages/open-payments/src/client/index.ts @@ -17,28 +17,15 @@ import { import { createAxiosInstance } from './requests' import { AxiosInstance } from 'axios' -export interface CreateOpenPaymentClientArgs { - requestTimeoutMs?: number - logger?: Logger - privateKey: KeyLike - keyId: string -} - 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, @@ -47,7 +34,44 @@ export const createClient = async ( }) 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: CreateOpenPaymentClientArgs +): Promise => { + const deps = await createDeps(args) + + return { + ilpStreamConnection: createILPStreamConnectionRoutes(deps), + paymentPointer: createPaymentPointerRoutes(deps) + } +} + +export interface CreateOpenPaymentClientArgs + extends CreateUnauthenticatedClientArgs { + privateKey: KeyLike + keyId: string +} + +export interface OpenPaymentsClient extends UnauthenticatedClient { + incomingPayment: IncomingPaymentRoutes +} + +export const createClient = async ( + args: CreateOpenPaymentClientArgs +): Promise => { + const deps = await createDeps(args) return { incomingPayment: createIncomingPaymentRoutes(deps), diff --git a/packages/open-payments/src/client/requests.ts b/packages/open-payments/src/client/requests.ts index 03d3e861c8..774bf57313 100644 --- a/packages/open-payments/src/client/requests.ts +++ b/packages/open-payments/src/client/requests.ts @@ -79,8 +79,7 @@ export const createAxiosInstance = (args: { request: { method: config.method.toUpperCase(), url: config.url, - // https://github.com/axios/axios/issues/5089#issuecomment-1297761617 - headers: JSON.parse(JSON.stringify(config.headers)), + headers: config.headers, body: config.data }, privateKey: args.privateKey, From 9e779f20303dc7e8965b97570888f80fdf863620 Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Wed, 16 Nov 2022 07:42:37 -0600 Subject: [PATCH 4/5] chore(open-payments): rename to (create)AuthenticatedClient --- packages/backend/src/app.ts | 4 ++-- packages/backend/src/index.ts | 2 +- .../src/open_payments/receiver/service.test.ts | 4 ++-- .../backend/src/open_payments/receiver/service.ts | 4 ++-- packages/open-payments/src/client/index.ts | 14 +++++++------- packages/open-payments/src/index.ts | 7 ++++++- 6 files changed, 20 insertions(+), 15 deletions(-) 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/index.ts b/packages/backend/src/index.ts index e0a7c718df..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' 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/src/client/index.ts b/packages/open-payments/src/client/index.ts index f6464f9869..3d3f3730ba 100644 --- a/packages/open-payments/src/client/index.ts +++ b/packages/open-payments/src/client/index.ts @@ -24,7 +24,7 @@ export interface ClientDeps { } const createDeps = async ( - args: Partial + args: Partial ): Promise => { const axiosInstance = createAxiosInstance({ privateKey: args.privateKey, @@ -48,7 +48,7 @@ export interface UnauthenticatedClient { } export const createUnauthenticatedClient = async ( - args: CreateOpenPaymentClientArgs + args: CreateUnauthenticatedClientArgs ): Promise => { const deps = await createDeps(args) @@ -58,19 +58,19 @@ export const createUnauthenticatedClient = async ( } } -export interface CreateOpenPaymentClientArgs +export interface CreateAuthenticatedClientArgs extends CreateUnauthenticatedClientArgs { privateKey: KeyLike keyId: string } -export interface OpenPaymentsClient extends UnauthenticatedClient { +export interface AuthenticatedClient extends UnauthenticatedClient { incomingPayment: IncomingPaymentRoutes } -export const createClient = async ( - args: CreateOpenPaymentClientArgs -): Promise => { +export const createAuthenticatedClient = async ( + args: CreateAuthenticatedClientArgs +): Promise => { const deps = await createDeps(args) return { 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' From 039445cc928f9754732c31a106b638b6c9e7bb72 Mon Sep 17 00:00:00 2001 From: Brandon Wilson Date: Wed, 16 Nov 2022 16:05:00 -0600 Subject: [PATCH 5/5] chore(open-payments): reference content-digest issue --- packages/open-payments/src/client/signatures.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/open-payments/src/client/signatures.ts b/packages/open-payments/src/client/signatures.ts index 85fb0bad41..f8de9fff93 100644 --- a/packages/open-payments/src/client/signatures.ts +++ b/packages/open-payments/src/client/signatures.ts @@ -34,6 +34,7 @@ export const createSignatureHeaders = async ({ } 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, {