diff --git a/.eslintignore b/.eslintignore index 2b51267516..b6bd735f63 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,4 +2,5 @@ public generated dist -build \ No newline at end of file +build +postman-scripts \ No newline at end of file diff --git a/.github/workflows/lint_test_build.yml b/.github/workflows/lint_test_build.yml index f13566eb1e..70d820fc98 100644 --- a/.github/workflows/lint_test_build.yml +++ b/.github/workflows/lint_test_build.yml @@ -80,9 +80,18 @@ jobs: steps: - uses: actions/checkout@v3 - uses: ./.github/workflows/rafiki/env-setup - - run: pnpm --filter openapi build + - run: pnpm --filter open-payments build:deps - run: pnpm --filter open-payments test + http-signature-utils: + runs-on: ubuntu-latest + needs: checkout + timeout-minutes: 5 + steps: + - uses: actions/checkout@v3 + - uses: ./.github/workflows/rafiki/env-setup + - run: pnpm --filter http-signature-utils test + build: runs-on: ubuntu-latest timeout-minutes: 5 @@ -93,6 +102,7 @@ jobs: - openapi - mock-account-provider - open-payments + - http-signature-utils steps: - uses: actions/checkout@v3 - uses: ./.github/workflows/rafiki/env-setup diff --git a/infrastructure/local/docker-compose.yml b/infrastructure/local/docker-compose.yml index f0302abf36..dc820382c2 100644 --- a/infrastructure/local/docker-compose.yml +++ b/infrastructure/local/docker-compose.yml @@ -14,7 +14,7 @@ services: NODE_ENV: development AUTH_DATABASE_URL: postgresql://auth:auth@database/auth INTROSPECTION_HTTPSIG: "false" - BYPASS_SIGNATURE_VALIDATION: "true" + BYPASS_SIGNATURE_VALIDATION: "false" depends_on: - tigerbeetle - database @@ -32,8 +32,10 @@ services: LOG_LEVEL: debug PORT: 80 SEED_FILE_LOCATION: /workspace/seed.primary.yml + KEY_FILE: /workspace/private-key.pem volumes: - ./seed.primary.yml:/workspace/seed.primary.yml + - ./private-key.pem:/workspace/private-key.pem depends_on: - backend backend: @@ -70,7 +72,7 @@ services: PRICES_URL: http://fynbos/prices REDIS_URL: redis://redis:6379/0 QUOTE_URL: http://fynbos/quotes - BYPASS_SIGNATURE_VALIDATION: "true" + BYPASS_SIGNATURE_VALIDATION: "false" PAYMENT_POINTER_URL: https://backend/.well-known/pay depends_on: - tigerbeetle @@ -121,6 +123,19 @@ services: restart: unless-stopped networks: - rafiki + signatures: + build: + context: ../.. + dockerfile: ./packages/http-signature-utils/Dockerfile + restart: always + ports: + - '3040:3000' + environment: + KEY_FILE: /workspace/private-key.pem + volumes: + - ./private-key.pem:/workspace/private-key.pem + networks: + - rafiki volumes: database-data: # named volumes can be managed easier using docker-compose tigerbeetle-data: # named volumes can be managed easier using docker-compose diff --git a/infrastructure/local/peer-docker-compose.yml b/infrastructure/local/peer-docker-compose.yml index 5e41b41714..902cceec2d 100644 --- a/infrastructure/local/peer-docker-compose.yml +++ b/infrastructure/local/peer-docker-compose.yml @@ -14,7 +14,7 @@ services: NODE_ENV: development AUTH_DATABASE_URL: postgresql://peerauth:peerauth@database/peerauth INTROSPECTION_HTTPSIG: "false" - BYPASS_SIGNATURE_VALIDATION: "true" + BYPASS_SIGNATURE_VALIDATION: "false" AUTH_SERVER_DOMAIN: "http://localhost:4006" peer-backend: image: ghcr.io/interledger/rafiki-backend:latest @@ -50,7 +50,7 @@ services: PRICES_URL: http://local-bank/prices REDIS_URL: redis://redis:6379/1 QUOTE_URL: http://local-bank/quote - BYPASS_SIGNATURE_VALIDATION: "true" + BYPASS_SIGNATURE_VALIDATION: "false" PAYMENT_POINTER_URL: https://peer-backend/.well-known/pay local-bank: build: @@ -66,10 +66,23 @@ services: LOG_LEVEL: debug PORT: 80 SEED_FILE_LOCATION: /workspace/seed.peer.yml + KEY_FILE: /workspace/private-key.pem volumes: - ./seed.peer.yml:/workspace/seed.peer.yml + - ./peer-private-key.pem:/workspace/private-key.pem depends_on: - peer-backend + peer-signatures: + build: + context: ../.. + dockerfile: ./packages/http-signature-utils/Dockerfile + restart: always + ports: + - '3041:3000' + environment: + KEY_FILE: /workspace/private-key.pem + volumes: + - ./peer-private-key.pem:/workspace/private-key.pem networks: local_rafiki: external: true diff --git a/infrastructure/local/peer-private-key.pem b/infrastructure/local/peer-private-key.pem new file mode 100644 index 0000000000..7fa97c747d --- /dev/null +++ b/infrastructure/local/peer-private-key.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIEqezmcPhOE8bkwN+jQrppfRYzGIdFTVWQGTHJIKpz88 +-----END PRIVATE KEY----- diff --git a/infrastructure/local/private-key.pem b/infrastructure/local/private-key.pem new file mode 100644 index 0000000000..5814233c8c --- /dev/null +++ b/infrastructure/local/private-key.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEICxfM9mUurUGnwlMMQEDclDEQnX7c49BoGKOB48URBxO +-----END PRIVATE KEY----- diff --git a/packages/auth/package.json b/packages/auth/package.json index 71c470097a..152d6e11f6 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -21,8 +21,8 @@ "@koa/router": "^12.0.0", "ajv": "^8.11.2", "axios": "^0.27.2", + "http-signature-utils": "workspace:../http-signature-utils", "httpbis-digest-headers": "github:interledger/httpbis-digest-headers", - "jose": "^4.9.0", "knex": "^0.95", "koa": "^2.13.4", "koa-bodyparser": "^4.3.0", @@ -39,6 +39,7 @@ "uuid": "^8.3.2" }, "devDependencies": { + "@faker-js/faker": "^7.4.0", "@types/jest": "^28.1.8", "@types/koa": "2.13.5", "@types/koa-bodyparser": "^4.3.7", diff --git a/packages/auth/src/accessToken/routes.test.ts b/packages/auth/src/accessToken/routes.test.ts index 7cfe504ffb..c2b2b7d185 100644 --- a/packages/auth/src/accessToken/routes.test.ts +++ b/packages/auth/src/accessToken/routes.test.ts @@ -17,8 +17,12 @@ import { AccessToken } from './model' import { Access } from '../access/model' import { AccessTokenRoutes } from './routes' import { createContext } from '../tests/context' -import { generateTestKeys } from '../tests/signature' -import { JWKWithRequired } from '../client/service' +import { + generateJwk, + generateTestKeys, + JWK, + TestKeys +} from 'http-signature-utils' describe('Access Token Routes', (): void => { let deps: IocContract @@ -26,7 +30,8 @@ describe('Access Token Routes', (): void => { let knex: Knex let trx: Knex.Transaction let accessTokenRoutes: AccessTokenRoutes - let testJwk: JWKWithRequired + let testKeys: TestKeys + let testClientKey: JWK beforeAll(async (): Promise => { deps = await initIocContainer(Config) @@ -36,8 +41,11 @@ describe('Access Token Routes', (): void => { const openApi = await deps.use('openApi') jestOpenAPI(openApi.authServerSpec) - const keys = await generateTestKeys() - testJwk = keys.publicKey + testKeys = await generateTestKeys() + testClientKey = generateJwk({ + privateKey: testKeys.privateKey, + keyId: testKeys.keyId + }) }) afterEach(async (): Promise => { @@ -98,7 +106,7 @@ describe('Access Token Routes', (): void => { beforeEach(async (): Promise => { grant = await Grant.query(trx).insertAndFetch({ ...BASE_GRANT, - clientKeyId: testJwk.kid + clientKeyId: testKeys.keyId }) access = await Access.query(trx).insertAndFetch({ grantId: grant.id, @@ -141,7 +149,7 @@ describe('Access Token Routes', (): void => { const scope = nock(CLIENT) .get('/jwks.json') .reply(200, { - keys: [testJwk] + keys: [testClientKey] }) const ctx = createContext( @@ -179,7 +187,7 @@ describe('Access Token Routes', (): void => { ], key: { proof: 'httpsig', - jwk: testJwk + jwk: testClientKey }, client_id: clientId }) @@ -190,7 +198,7 @@ describe('Access Token Routes', (): void => { const scope = nock(CLIENT) .get('/jwks.json') .reply(200, { - keys: [testJwk] + keys: [testClientKey] }) const tokenCreatedDate = new Date(token.createdAt) const now = new Date( @@ -238,7 +246,7 @@ describe('Access Token Routes', (): void => { beforeEach(async (): Promise => { grant = await Grant.query(trx).insertAndFetch({ ...BASE_GRANT, - clientKeyId: testJwk.kid + clientKeyId: testKeys.keyId }) token = await AccessToken.query(trx).insertAndFetch({ grantId: grant.id, @@ -269,7 +277,7 @@ describe('Access Token Routes', (): void => { const scope = nock(CLIENT) .get('/jwks.json') .reply(200, { - keys: [testJwk] + keys: [testClientKey] }) const ctx = createContext( @@ -298,7 +306,7 @@ describe('Access Token Routes', (): void => { const scope = nock(CLIENT) .get('/jwks.json') .reply(200, { - keys: [testJwk] + keys: [testClientKey] }) const ctx = createContext( @@ -333,7 +341,7 @@ describe('Access Token Routes', (): void => { beforeEach(async (): Promise => { grant = await Grant.query(trx).insertAndFetch({ ...BASE_GRANT, - clientKeyId: testJwk.kid + clientKeyId: testKeys.keyId }) access = await Access.query(trx).insertAndFetch({ grantId: grant.id, diff --git a/packages/auth/src/accessToken/service.test.ts b/packages/auth/src/accessToken/service.test.ts index 232c48a985..23f7d27493 100644 --- a/packages/auth/src/accessToken/service.test.ts +++ b/packages/auth/src/accessToken/service.test.ts @@ -8,7 +8,7 @@ import { v4 } from 'uuid' import { createTestApp, TestContainer } from '../tests/app' import { Config } from '../config/app' import { IocContract } from '@adonisjs/fold' -import { initIocContainer, JWKWithRequired } from '..' +import { initIocContainer } from '..' import { AppServices } from '../app' import { truncateTables } from '../tests/tableManager' import { FinishMethod, Grant, GrantState, StartMethod } from '../grant/model' @@ -16,7 +16,12 @@ import { AccessType, Action } from '../access/types' import { AccessToken } from './model' import { AccessTokenService } from './service' import { Access } from '../access/model' -import { generateTestKeys } from '../tests/signature' +import { + generateJwk, + generateTestKeys, + JWK, + TestKeys +} from 'http-signature-utils' describe('Access Token Service', (): void => { let deps: IocContract @@ -24,7 +29,8 @@ describe('Access Token Service', (): void => { let knex: Knex let trx: Knex.Transaction let accessTokenService: AccessTokenService - let testJwk: JWKWithRequired + let testKeys: TestKeys + let testClientKey: JWK beforeAll(async (): Promise => { deps = await initIocContainer(Config) @@ -32,8 +38,11 @@ describe('Access Token Service', (): void => { knex = await deps.use('knex') accessTokenService = await deps.use('accessTokenService') - const keys = await generateTestKeys() - testJwk = keys.publicKey + testKeys = await generateTestKeys() + testClientKey = generateJwk({ + privateKey: testKeys.privateKey, + keyId: testKeys.keyId + }) }) afterEach(async (): Promise => { @@ -81,7 +90,7 @@ describe('Access Token Service', (): void => { beforeEach(async (): Promise => { grant = await Grant.query(trx).insertAndFetch({ ...BASE_GRANT, - clientKeyId: testJwk.kid, + clientKeyId: testKeys.keyId, continueToken: crypto.randomBytes(8).toString('hex').toUpperCase(), continueId: v4(), interactId: v4(), @@ -146,7 +155,7 @@ describe('Access Token Service', (): void => { const scope = nock(CLIENT) .get('/jwks.json') .reply(200, { - keys: [testJwk] + keys: [testClientKey] }) const introspection = await accessTokenService.introspect(token.value) @@ -155,7 +164,7 @@ describe('Access Token Service', (): void => { expect(introspection).toMatchObject({ ...grant, access: [access], - key: { proof: 'httpsig', jwk: testJwk }, + key: { proof: 'httpsig', jwk: testClientKey }, clientId }) scope.isDone() @@ -194,7 +203,7 @@ describe('Access Token Service', (): void => { beforeEach(async (): Promise => { grant = await Grant.query(trx).insertAndFetch({ ...BASE_GRANT, - clientKeyId: testJwk.kid, + clientKeyId: testKeys.keyId, continueToken: crypto.randomBytes(8).toString('hex').toUpperCase(), continueId: v4(), interactId: v4(), @@ -241,7 +250,7 @@ describe('Access Token Service', (): void => { beforeEach(async (): Promise => { grant = await Grant.query(trx).insertAndFetch({ ...BASE_GRANT, - clientKeyId: testJwk.kid, + clientKeyId: testKeys.keyId, continueToken: crypto.randomBytes(8).toString('hex').toUpperCase(), continueId: v4(), interactId: v4(), diff --git a/packages/auth/src/accessToken/service.ts b/packages/auth/src/accessToken/service.ts index a445cbb3c2..69f13a1966 100644 --- a/packages/auth/src/accessToken/service.ts +++ b/packages/auth/src/accessToken/service.ts @@ -1,10 +1,11 @@ import * as crypto from 'crypto' import { v4 } from 'uuid' import { Transaction, TransactionOrKnex } from 'objection' +import { JWK } from 'http-signature-utils' import { BaseService } from '../shared/baseService' import { Grant, GrantState } from '../grant/model' -import { ClientService, JWKWithRequired } from '../client/service' +import { ClientService } from '../client/service' import { AccessToken } from './model' import { IAppConfig } from '../config/app' import { Access } from '../access/model' @@ -26,7 +27,7 @@ interface ServiceDependencies extends BaseService { export interface KeyInfo { proof: string - jwk: JWKWithRequired + jwk: JWK } export interface Introspection extends Partial { diff --git a/packages/auth/src/client/service.test.ts b/packages/auth/src/client/service.test.ts index e6ad56a9b4..50599d1f65 100644 --- a/packages/auth/src/client/service.test.ts +++ b/packages/auth/src/client/service.test.ts @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker' import nock from 'nock' -import { JWK } from 'open-payments' +import { JWK, generateJwk, generateTestKeys } from 'http-signature-utils' import { createTestApp, TestContainer } from '../tests/app' import { Config } from '../config/app' @@ -8,7 +8,6 @@ import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../' import { AppServices } from '../app' import { ClientService } from './service' -import { generateTestKeys } from '../tests/signature' const TEST_CLIENT_DISPLAY = { name: 'Test Client', @@ -78,8 +77,8 @@ describe('Client Service', (): void => { beforeAll(async (): Promise => { for (let i = 0; i < 3; i++) { - const { publicKey } = await generateTestKeys() - keys.push(publicKey) + const { privateKey, keyId } = await generateTestKeys() + keys.push(generateJwk({ privateKey, keyId })) } }) diff --git a/packages/auth/src/client/service.ts b/packages/auth/src/client/service.ts index 7c4da94aff..b18a151dc1 100644 --- a/packages/auth/src/client/service.ts +++ b/packages/auth/src/client/service.ts @@ -1,21 +1,10 @@ -import { JWK as JoseWk } from 'jose' -import { JWK, UnauthenticatedClient } from 'open-payments' +import { UnauthenticatedClient } from 'open-payments' +import { JWK } from 'http-signature-utils' import { BaseService } from '../shared/baseService' -export interface JWKWithRequired extends JoseWk { - kid: string - x: string - alg: string - kty: string - crv: string - exp?: number - nbf?: number - revoked?: boolean -} - export interface ClientKey { - jwk: JWKWithRequired + jwk: JWK client: ClientDetails } diff --git a/packages/auth/src/grant/routes.test.ts b/packages/auth/src/grant/routes.test.ts index 2e1fd6ca12..f6731bbf83 100644 --- a/packages/auth/src/grant/routes.test.ts +++ b/packages/auth/src/grant/routes.test.ts @@ -10,7 +10,7 @@ import { URL } from 'url' import { createContext as createAppContext } from '../tests/context' import { createTestApp, TestContainer } from '../tests/app' import { Config, IAppConfig } from '../config/app' -import { initIocContainer, JWKWithRequired } from '..' +import { initIocContainer } from '..' import { AppServices } from '../app' import { truncateTables } from '../tests/tableManager' import { GrantRoutes, GrantChoices } from './routes' @@ -19,7 +19,7 @@ import { Access } from '../access/model' import { Grant, StartMethod, FinishMethod, GrantState } from '../grant/model' import { AccessToken } from '../accessToken/model' import { AccessTokenService } from '../accessToken/service' -import { generateTestKeys } from '../tests/signature' +import { generateTestKeys } from 'http-signature-utils' import { KEY_REGISTRY_ORIGIN } from '../tests/signature' export { KEY_REGISTRY_ORIGIN } from '../tests/signature' @@ -86,7 +86,7 @@ describe('Grant Routes', (): void => { let grantRoutes: GrantRoutes let config: IAppConfig let accessTokenService: AccessTokenService - let clientKey: JWKWithRequired + let clientKeyId: string let grant: Grant @@ -99,7 +99,7 @@ describe('Grant Routes', (): void => { finishUri: 'https://example.com', clientNonce: crypto.randomBytes(8).toString('hex').toUpperCase(), client: CLIENT, - clientKeyId: clientKey.kid, + clientKeyId: clientKeyId, interactId: v4(), interactRef: v4(), interactNonce: crypto.randomBytes(8).toString('hex').toUpperCase() @@ -110,7 +110,7 @@ describe('Grant Routes', (): void => { params: Record ) => { const ctx = createAppContext(reqOpts, params) - ctx.clientKeyId = clientKey.kid + ctx.clientKeyId = clientKeyId return ctx } @@ -133,8 +133,7 @@ describe('Grant Routes', (): void => { jestOpenAPI(openApi.authServerSpec) accessTokenService = await deps.use('accessTokenService') - const { publicKey } = await generateTestKeys() - clientKey = publicKey + clientKeyId = (await generateTestKeys()).keyId }) afterEach(async (): Promise => { diff --git a/packages/auth/src/grant/service.test.ts b/packages/auth/src/grant/service.test.ts index 4a59b2740f..41de84a175 100644 --- a/packages/auth/src/grant/service.test.ts +++ b/packages/auth/src/grant/service.test.ts @@ -13,8 +13,7 @@ import { GrantService, GrantRequest } from '../grant/service' import { Grant, StartMethod, FinishMethod, GrantState } from '../grant/model' import { Action, AccessType } from '../access/types' import { Access } from '../access/model' -import { JWKWithRequired } from '../client/service' -import { generateTestKeys } from '../tests/signature' +import { generateTestKeys } from 'http-signature-utils' describe('Grant Service', (): void => { let deps: IocContract @@ -22,7 +21,7 @@ describe('Grant Service', (): void => { let grantService: GrantService let knex: Knex let trx: Knex.Transaction - let testJwk: JWKWithRequired + let testKeyId: string let grant: Grant @@ -32,8 +31,7 @@ describe('Grant Service', (): void => { knex = await deps.use('knex') appContainer = await createTestApp(deps) - const { publicKey } = await generateTestKeys() - testJwk = publicKey + testKeyId = (await generateTestKeys()).keyId }) const CLIENT = faker.internet.url() @@ -48,7 +46,7 @@ describe('Grant Service', (): void => { finishUri: 'https://example.com', clientNonce: crypto.randomBytes(8).toString('hex').toUpperCase(), client: CLIENT, - clientKeyId: testJwk.kid, + clientKeyId: testKeyId, interactId: v4(), interactRef: v4(), interactNonce: crypto.randomBytes(8).toString('hex').toUpperCase() @@ -90,7 +88,7 @@ describe('Grant Service', (): void => { test('Can initiate a grant', async (): Promise => { const grantRequest: GrantRequest = { ...BASE_GRANT_REQUEST, - clientKeyId: testJwk.kid, + clientKeyId: testKeyId, access_token: { access: [ { @@ -114,7 +112,7 @@ describe('Grant Service', (): void => { finishUri: BASE_GRANT_REQUEST.interact.finish.uri, clientNonce: BASE_GRANT_REQUEST.interact.finish.nonce, client: CLIENT, - clientKeyId: testJwk.kid, + clientKeyId: testKeyId, startMethod: expect.arrayContaining([StartMethod.Redirect]) }) @@ -131,7 +129,7 @@ describe('Grant Service', (): void => { test('Can issue a grant without interaction', async (): Promise => { const grantRequest: GrantRequest = { ...BASE_GRANT_REQUEST, - clientKeyId: testJwk.kid, + clientKeyId: testKeyId, access_token: { access: [ { @@ -149,7 +147,7 @@ describe('Grant Service', (): void => { state: GrantState.Granted, continueId: expect.any(String), continueToken: expect.any(String), - clientKeyId: testJwk.kid + clientKeyId: testKeyId }) await expect( diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 78dfeca29a..3e769dd848 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -17,8 +17,6 @@ import { createOpenAPI } from 'openapi' import { createUnauthenticatedClient as createOpenPaymentsClient } from 'open-payments' export { KeyInfo } from './accessToken/service' -export { JWKWithRequired } from './client/service' -export { HttpSigContext, verifySigAndChallenge } from './signature/middleware' const container = initIocContainer(Config) const app = new App(container) diff --git a/packages/auth/src/signature/middleware.test.ts b/packages/auth/src/signature/middleware.test.ts index 878e6fbb60..3e94e7e994 100644 --- a/packages/auth/src/signature/middleware.test.ts +++ b/packages/auth/src/signature/middleware.test.ts @@ -1,10 +1,14 @@ import crypto from 'crypto' import nock from 'nock' import { faker } from '@faker-js/faker' -import { importJWK } from 'jose' import { v4 } from 'uuid' import { Knex } from 'knex' -import { createContentDigestHeader } from 'httpbis-digest-headers' +import { + JWK, + generateTestKeys, + TestKeys, + generateJwk +} from 'http-signature-utils' import { createTestApp, TestContainer } from '../tests/app' import { truncateTables } from '../tests/tableManager' @@ -12,16 +16,12 @@ import { Config } from '../config/app' import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../' import { AppServices } from '../app' -import { JWKWithRequired } from '../client/service' import { createContext, createContextWithSigHeaders } from '../tests/context' -import { generateTestKeys } from '../tests/signature' import { Grant, GrantState, StartMethod, FinishMethod } from '../grant/model' import { Access } from '../access/model' import { AccessToken } from '../accessToken/model' import { AccessType, Action } from '../access/types' import { - verifySig, - sigInputToChallenge, tokenHttpsigMiddleware, grantContinueHttpsigMiddleware, grantInitiationHttpsigMiddleware @@ -32,16 +32,18 @@ describe('Signature Service', (): void => { let appContainer: TestContainer const CLIENT = faker.internet.url() - let privateKey: JWKWithRequired - let testClientKey: JWKWithRequired + let testKeys: TestKeys + let testClientKey: JWK beforeAll(async (): Promise => { deps = await initIocContainer(Config) appContainer = await createTestApp(deps) - const keys = await generateTestKeys() - privateKey = keys.privateKey - testClientKey = keys.publicKey + testKeys = await generateTestKeys() + testClientKey = generateJwk({ + privateKey: testKeys.privateKey, + keyId: testKeys.keyId + }) }) afterAll(async (): Promise => { @@ -49,113 +51,6 @@ describe('Signature Service', (): void => { appContainer.shutdown() }) - describe('signatures', (): void => { - test('can verify a signature', async (): Promise => { - const challenge = 'test-challenge' - const privateJwk = (await importJWK(privateKey)) as crypto.KeyLike - const signature = crypto.sign(null, Buffer.from(challenge), privateJwk) - await expect( - verifySig(signature.toString('base64'), testClientKey, challenge) - ).resolves.toBe(true) - }) - - const testRequestBody = { foo: 'bar' } - - test.each` - title | withAuthorization | withRequestBody - ${''} | ${true} | ${true} - ${' without an authorization header'} | ${false} | ${true} - ${' without a request body'} | ${true} | ${false} - `( - 'can construct a challenge from signature input$title', - ({ withAuthorization, withRequestBody }): void => { - let sigInputHeader = 'sig1=("@method" "@target-uri" "content-type"' - - const headers = { - 'Content-Type': 'application/json' - } - let expectedChallenge = `"@method": GET\n"@target-uri": http://example.com/test\n"content-type": application/json\n` - const contentDigest = createContentDigestHeader( - JSON.stringify(testRequestBody), - ['sha-512'] - ) - - if (withRequestBody) { - sigInputHeader += ' "content-digest" "content-length"' - headers['Content-Digest'] = contentDigest - headers['Content-Length'] = '1234' - expectedChallenge += `"content-digest": ${contentDigest}\n"content-length": 1234\n` - } - - if (withAuthorization) { - sigInputHeader += ' "authorization"' - headers['Authorization'] = 'GNAP test-access-token' - expectedChallenge += '"authorization": GNAP test-access-token\n' - } - - sigInputHeader += ');created=1618884473;keyid="gnap-key"' - headers['Signature-Input'] = sigInputHeader - expectedChallenge += `"@signature-params": ${sigInputHeader.replace( - 'sig1=', - '' - )}` - - const ctx = createContext( - { - headers, - method: 'GET', - url: 'example.com/test' - }, - {}, - deps - ) - - ctx.request.body = withRequestBody ? testRequestBody : {} - - const challenge = sigInputToChallenge(sigInputHeader, ctx) - - expect(challenge).toEqual(expectedChallenge) - } - ) - - test.each` - title | sigInputHeader - ${'fails if a component is not in lower case'} | ${'sig1=("@METHOD" "@target-uri" "content-digest" "content-length" "content-type" "authorization");created=1618884473;keyid="gnap-key"'} - ${'fails @method is missing'} | ${'sig1=("@target-uri" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} - ${'fails if @target-uri is missing'} | ${'sig1=("@method" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} - ${'fails if @content-digest is missing while body is present'} | ${'sig1=("@method" "@target-uri" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} - ${'fails if authorization header is present in headers but not in signature input'} | ${'sig1=("@method" "@target-uri" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} - `( - 'constructs signature input and $title', - async ({ sigInputHeader }): Promise => { - const ctx = createContext( - { - headers: { - 'Content-Type': 'application/json', - 'Content-Digest': createContentDigestHeader( - JSON.stringify(testRequestBody), - ['sha-512'] - ), - 'Content-Length': '1234', - 'Signature-Input': sigInputHeader, - Authorization: 'GNAP test-access-token' - }, - method: 'GET', - url: '/test' - }, - {}, - deps - ) - - ctx.request.body = testRequestBody - ctx.method = 'GET' - ctx.request.url = '/test' - - expect(sigInputToChallenge(sigInputHeader, ctx)).toBe(null) - } - ) - }) - describe('Signature middleware', (): void => { let grant: Grant let token: AccessToken @@ -207,7 +102,7 @@ describe('Signature Service', (): void => { beforeEach(async (): Promise => { grant = await Grant.query(trx).insertAndFetch({ ...BASE_GRANT, - clientKeyId: testClientKey.kid + clientKeyId: testKeys.keyId }) await Access.query(trx).insertAndFetch({ grantId: grant.id, @@ -250,15 +145,15 @@ describe('Signature Service', (): void => { { client: CLIENT }, - privateKey, - testClientKey.kid, + testKeys.privateKey, + testKeys.keyId, deps ) await grantInitiationHttpsigMiddleware(ctx, next) expect(ctx.response.status).toEqual(200) - expect(ctx.clientKeyId).toEqual(testClientKey.kid) + expect(ctx.clientKeyId).toEqual(testKeys.keyId) expect(next).toHaveBeenCalled() scope.done() @@ -282,14 +177,14 @@ describe('Signature Service', (): void => { }, { id: grant.continueId }, { interact_ref: grant.interactRef }, - privateKey, - testClientKey.kid, + testKeys.privateKey, + testKeys.keyId, deps ) await grantContinueHttpsigMiddleware(ctx, next) expect(ctx.response.status).toEqual(200) - expect(ctx.clientKeyId).toEqual(testClientKey.kid) + expect(ctx.clientKeyId).toEqual(testKeys.keyId) expect(next).toHaveBeenCalled() scope.done() @@ -316,8 +211,8 @@ describe('Signature Service', (): void => { proof: 'httpsig', resource_server: 'test' }, - privateKey, - testClientKey.kid, + testKeys.privateKey, + testKeys.keyId, deps ) @@ -325,7 +220,7 @@ describe('Signature Service', (): void => { expect(next).toHaveBeenCalled() expect(ctx.response.status).toEqual(200) - expect(ctx.clientKeyId).toEqual(testClientKey.kid) + expect(ctx.clientKeyId).toEqual(testKeys.keyId) scope.done() }) @@ -375,8 +270,8 @@ describe('Signature Service', (): void => { }, { id: grant.continueId }, { interact_ref: grant.interactRef }, - privateKey, - testClientKey.kid, + testKeys.privateKey, + testKeys.keyId, deps ) @@ -402,8 +297,8 @@ describe('Signature Service', (): void => { { client: CLIENT }, - privateKey, - testClientKey.kid, + testKeys.privateKey, + testKeys.keyId, deps ) @@ -428,8 +323,8 @@ describe('Signature Service', (): void => { { client: CLIENT }, - privateKey, - testClientKey.kid, + testKeys.privateKey, + testKeys.keyId, deps ) diff --git a/packages/auth/src/signature/middleware.ts b/packages/auth/src/signature/middleware.ts index 9f25a096cc..73eb0c2bdb 100644 --- a/packages/auth/src/signature/middleware.ts +++ b/packages/auth/src/signature/middleware.ts @@ -1,51 +1,26 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import * as crypto from 'crypto' -import { importJWK } from 'jose' -import { JWK } from 'open-payments' -import { verifyContentDigest } from 'httpbis-digest-headers' +import { + validateSignature, + validateSignatureHeaders, + RequestLike +} from 'http-signature-utils' import { AppContext } from '../app' import { Grant } from '../grant/model' -import { Context } from 'koa' -export async function verifySig( - sig: string, - jwk: JWK, - challenge: string -): Promise { - const publicKey = (await importJWK(jwk)) as crypto.KeyLike - const data = Buffer.from(challenge) - return crypto.verify(null, data, publicKey, Buffer.from(sig, 'base64')) -} - -export async function verifySigAndChallenge( - clientKey: JWK, - ctx: HttpSigContext -): Promise { - const sig = ctx.headers['signature'] as string - const sigInput = ctx.headers['signature-input'] as string - const challenge = sigInputToChallenge(sigInput, ctx) - if (!challenge) { - ctx.throw(400, 'invalid signature input', { error: 'invalid_request' }) - } - - const verified = await verifySig( - sig.replace('sig1=', ''), - clientKey, - challenge - ) - - if (verified) { - return true - } else { - ctx.throw(401, 'invalid signature') +function contextToRequestLike(ctx: AppContext): RequestLike { + return { + url: ctx.href, + method: ctx.method, + headers: ctx.headers, + body: ctx.request.body ? JSON.stringify(ctx.request.body) : undefined } } async function verifySigFromClient( client: string, - ctx: HttpSigContext + ctx: AppContext ): Promise { const clientService = await ctx.container.use('clientService') const clientKey = await clientService.getKey({ @@ -56,13 +31,12 @@ async function verifySigFromClient( if (!clientKey) { ctx.throw(400, 'invalid client', { error: 'invalid_client' }) } - - return verifySigAndChallenge(clientKey, ctx) + return validateSignature(clientKey, contextToRequestLike(ctx)) } async function verifySigFromBoundKey( grant: Grant, - ctx: HttpSigContext + ctx: AppContext ): Promise { const sigInput = ctx.headers['signature-input'] as string ctx.clientKeyId = getSigInputKeyId(sigInput) @@ -73,18 +47,6 @@ async function verifySigFromBoundKey( return verifySigFromClient(grant.client, ctx) } -// TODO: Replace with public httpsig library -function getSigInputComponents(sigInput: string): string[] | null { - // https://datatracker.ietf.org/doc/html/rfc8941#section-4.1.1.1 - const messageComponents = sigInput - .split('sig1=')[1] - ?.split(';')[0] - ?.split(' ') - return messageComponents - ? messageComponents.map((component) => component.replace(/[()"]/g, '')) - : null -} - const KEY_ID_PREFIX = 'keyid="' function getSigInputKeyId(sigInput: string): string | undefined { @@ -95,99 +57,11 @@ function getSigInputKeyId(sigInput: string): string | undefined { return keyIdParam?.slice(KEY_ID_PREFIX.length, -1) } -function validateSigInputComponents( - sigInputComponents: string[], - ctx: Context -): boolean { - // https://datatracker.ietf.org/doc/html/draft-ietf-gnap-core-protocol#section-7.3.1 - - for (const component of sigInputComponents) { - // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-09#section-2.1 - if (component !== component.toLowerCase()) return false - } - - const isValidContentDigest = - !sigInputComponents.includes('content-digest') || - (!!ctx.headers['content-digest'] && - ctx.request.body && - Object.keys(ctx.request.body).length > 0 && - sigInputComponents.includes('content-digest') && - verifyContentDigest( - JSON.stringify(ctx.request.body), - ctx.headers['content-digest'] as string - )) - - return !( - !isValidContentDigest || - !sigInputComponents.includes('@method') || - !sigInputComponents.includes('@target-uri') || - (ctx.headers['authorization'] && - !sigInputComponents.includes('authorization')) - ) -} - -export function sigInputToChallenge( - sigInput: string, - ctx: Context -): string | null { - const sigInputComponents = getSigInputComponents(sigInput) - - if ( - !sigInputComponents || - !validateSigInputComponents(sigInputComponents, ctx) - ) - return null - - // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-09#section-2.3 - let signatureBase = '' - for (const component of sigInputComponents) { - if (component === '@method') { - signatureBase += `"@method": ${ctx.request.method}\n` - } else if (component === '@target-uri') { - signatureBase += `"@target-uri": ${ctx.request.href}\n` - } else { - signatureBase += `"${component}": ${ctx.headers[component]}\n` - } - } - - signatureBase += `"@signature-params": ${( - ctx.headers['signature-input'] as string - )?.replace('sig1=', '')}` - return signatureBase -} - -type HttpSigHeaders = Record<'signature' | 'signature-input', string> - -type HttpSigRequest = Omit & { - headers: HttpSigHeaders -} - -export type HttpSigContext = Context & { - request: HttpSigRequest - headers: HttpSigHeaders -} - -function validateHttpSigHeaders(ctx: Context): ctx is HttpSigContext { - const sig = ctx.headers['signature'] - const sigInput = ctx.headers['signature-input'] as string - - const sigInputComponents = getSigInputComponents(sigInput ?? '') - if ( - !sigInputComponents || - !validateSigInputComponents(sigInputComponents, ctx) - ) - return false - - return ( - sig && sigInput && typeof sig === 'string' && typeof sigInput === 'string' - ) -} - export async function grantContinueHttpsigMiddleware( ctx: AppContext, next: () => Promise ): Promise { - if (!validateHttpSigHeaders(ctx)) { + if (!validateSignatureHeaders(contextToRequestLike(ctx))) { ctx.throw(400, 'invalid signature headers', { error: 'invalid_request' }) } @@ -222,7 +96,10 @@ export async function grantContinueHttpsigMiddleware( return } - await verifySigFromBoundKey(grant, ctx) + const sigVerified = await verifySigFromBoundKey(grant, ctx) + if (!sigVerified) { + ctx.throw(401, 'invalid signature') + } await next() } @@ -230,7 +107,7 @@ export async function grantInitiationHttpsigMiddleware( ctx: AppContext, next: () => Promise ): Promise { - if (!validateHttpSigHeaders(ctx)) { + if (!validateSignatureHeaders(contextToRequestLike(ctx))) { ctx.throw(400, 'invalid signature headers', { error: 'invalid_request' }) } @@ -242,7 +119,10 @@ export async function grantInitiationHttpsigMiddleware( ctx.throw(401, 'invalid signature input', { error: 'invalid_request' }) } - await verifySigFromClient(body.client, ctx) + const sigVerified = await verifySigFromClient(body.client, ctx) + if (!sigVerified) { + ctx.throw(401, 'invalid signature') + } await next() } @@ -250,7 +130,7 @@ export async function tokenHttpsigMiddleware( ctx: AppContext, next: () => Promise ): Promise { - if (!validateHttpSigHeaders(ctx)) { + if (!validateSignatureHeaders(contextToRequestLike(ctx))) { ctx.throw(400, 'invalid signature headers', { error: 'invalid_request' }) } @@ -269,6 +149,10 @@ export async function tokenHttpsigMiddleware( const grantService = await ctx.container.use('grantService') const grant = await grantService.get(accessToken.grantId) - await verifySigFromBoundKey(grant, ctx) + + const sigVerified = await verifySigFromBoundKey(grant, ctx) + if (!sigVerified) { + ctx.throw(401, 'invalid signature') + } await next() } diff --git a/packages/auth/src/tests/context.ts b/packages/auth/src/tests/context.ts index 9d43e45760..5727827df6 100644 --- a/packages/auth/src/tests/context.ts +++ b/packages/auth/src/tests/context.ts @@ -1,12 +1,12 @@ +import crypto from 'crypto' import EventEmitter from 'events' import * as httpMocks from 'node-mocks-http' import Koa from 'koa' import session from 'koa-session' import { IocContract } from '@adonisjs/fold' +import { createHeaders } from 'http-signature-utils' import { AppContext, AppContextData, AppServices } from '../app' -import { generateSigHeaders } from './signature' -import { JWKWithRequired } from '../client/service' export function createContext( reqOpts: httpMocks.RequestOptions, @@ -41,33 +41,29 @@ export async function createContextWithSigHeaders( reqOpts: httpMocks.RequestOptions, params: Record, requestBody: Record, - privateKey: JWKWithRequired, + privateKey: crypto.KeyObject, keyId: string, container?: IocContract ): Promise { const { headers, url, method } = reqOpts - const { signature, sigInput, contentDigest, contentLength, contentType } = - await generateSigHeaders({ - privateKey, - keyId, - url, - method, - optionalComponents: { - body: requestBody, - authorization: headers.Authorization as string - } - }) + const request = { + url, + method, + headers, + body: JSON.stringify(requestBody) + } + const sigHeaders = await createHeaders({ + request, + privateKey, + keyId + }) const ctx = createContext( { ...reqOpts, headers: { ...headers, - 'Content-Digest': contentDigest, - Signature: signature, - 'Signature-Input': sigInput, - 'Content-Type': contentType, - 'Content-Length': contentLength + ...sigHeaders } }, params, diff --git a/packages/auth/src/tests/signature.ts b/packages/auth/src/tests/signature.ts index c9c02d848b..8f8ad7fecd 100644 --- a/packages/auth/src/tests/signature.ts +++ b/packages/auth/src/tests/signature.ts @@ -1,8 +1,4 @@ -import crypto from 'crypto' import { v4 } from 'uuid' -import { createContentDigestHeader } from 'httpbis-digest-headers' -import { importJWK, exportJWK } from 'jose' -import { JWKWithRequired } from '../client/service' export const SIGNATURE_METHOD = 'GET' export const SIGNATURE_TARGET_URI = '/test' @@ -20,98 +16,3 @@ export const TEST_CLIENT_DISPLAY = { name: TEST_CLIENT.name, uri: TEST_CLIENT.uri } - -// TODO: refactor any oustanding key-using tests to generate them from here -const BASE_TEST_KEY_JWK = { - kty: 'OKP', - alg: 'EdDSA', - crv: 'Ed25519', - use: 'sig' -} - -export async function generateTestKeys(): Promise<{ - keyId: string - publicKey: JWKWithRequired - privateKey: JWKWithRequired -}> { - const { privateKey } = crypto.generateKeyPairSync('ed25519') - - const { x, d } = await exportJWK(privateKey) - const keyId = v4() - return { - keyId, - publicKey: { - ...BASE_TEST_KEY_JWK, - kid: keyId, - x - }, - privateKey: { - ...BASE_TEST_KEY_JWK, - kid: keyId, - x, - d - } - } -} - -export async function generateSigHeaders({ - privateKey, - url, - method, - keyId, - optionalComponents -}: { - privateKey: JWKWithRequired - url: string - method: string - keyId: string - optionalComponents?: { - body?: unknown - authorization?: string - } -}): Promise<{ - sigInput: string - signature: string - contentDigest?: string - contentLength?: string - contentType?: string -}> { - let sigInputComponents = 'sig1=("@method" "@target-uri"' - const { body, authorization } = optionalComponents ?? {} - if (body) - sigInputComponents += ' "content-digest" "content-length" "content-type"' - - if (authorization) sigInputComponents += ' "authorization"' - - const sigInput = sigInputComponents + `);created=1618884473;keyid="${keyId}"` - let challenge = `"@method": ${method}\n"@target-uri": ${url}\n` - let contentDigest - let contentLength - let contentType - if (body) { - contentDigest = createContentDigestHeader(JSON.stringify(body), ['sha-512']) - challenge += `"content-digest": ${contentDigest}\n` - - contentLength = Buffer.from(JSON.stringify(body), 'utf-8').length - challenge += `"content-length": ${contentLength}\n` - contentType = 'application/json' - challenge += `"content-type": ${contentType}\n` - } - - if (authorization) { - challenge += `"authorization": ${authorization}\n` - } - - challenge += `"@signature-params": ${sigInput.replace('sig1=', '')}` - - const privateJwk = (await importJWK(privateKey)) as crypto.KeyLike - const signature = crypto.sign(null, Buffer.from(challenge), privateJwk) - - return { - signature: signature.toString('base64'), - sigInput, - contentDigest, - contentLength, - contentType - } -} diff --git a/packages/backend/package.json b/packages/backend/package.json index 4b3a588bf1..bb13466d84 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -4,7 +4,7 @@ "test": "jest --passWithNoTests --maxWorkers=50%", "knex": "knex", "generate": "graphql-codegen --config codegen.yml", - "build:deps": "pnpm --filter auth build", + "build:deps": "pnpm --filter open-payments build", "build": "pnpm build:deps && tsc --build tsconfig.json && pnpm copy-files", "clean": "rm -fr dist/", "copy-files": "cp src/graphql/schema.graphql dist/graphql/", @@ -28,7 +28,6 @@ "@types/tmp": "^0.2.3", "@types/uuid": "^8.3.4", "apollo-server": "^3.10.1", - "auth": "workspace:../auth", "cross-fetch": "^3.1.4", "ilp-protocol-ildcp": "^2.2.3", "ilp-protocol-stream": "^2.7.1", @@ -67,6 +66,7 @@ "graphql": "^16.6.0", "graphql-scalars": "^1.18.0", "graphql-tools": "^8.3.3", + "http-signature-utils": "workspace:../http-signature-utils", "ilp-packet": "3.1.4-alpha.1", "ilp-protocol-ccp": "^1.2.2", "ilp-protocol-ildcp": "^2.2.3", diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index 3aaafe4258..f875621040 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -1,6 +1,7 @@ import * as crypto from 'crypto' import * as fs from 'fs' import { ConnectionOptions } from 'tls' +import { parseOrProvisionKey } from 'http-signature-utils' function envString(name: string, value: string): string { const envValue = process.env[name] @@ -24,9 +25,6 @@ function envBool(name: string, value: boolean): boolean { export type IAppConfig = typeof Config -const TMP_DIR = './tmp' -const PRIVATE_KEY_FILE = `${TMP_DIR}/private-key-${new Date().getTime()}.pem` - export const Config = { logLevel: envString('LOG_LEVEL', 'info'), // publicHost is for open payments URLs. @@ -139,32 +137,3 @@ function parseRedisTlsConfig( return Object.keys(options).length > 0 ? options : undefined } - -// exported for testing -export function parseOrProvisionKey( - keyFile: string | undefined -): crypto.KeyObject { - if (keyFile) { - try { - const key = crypto.createPrivateKey(fs.readFileSync(keyFile)) - const jwk = key.export({ format: 'jwk' }) - if (jwk.crv === 'Ed25519') { - return key - } else { - console.log('Private key is not EdDSA-Ed25519 key. Generating new key.') - } - } catch (err) { - console.log('Private key could not be loaded.') - throw err - } - } - const keypair = crypto.generateKeyPairSync('ed25519') - if (!fs.existsSync(TMP_DIR)) { - fs.mkdirSync(TMP_DIR) - } - fs.writeFileSync( - PRIVATE_KEY_FILE, - keypair.privateKey.export({ format: 'pem', type: 'pkcs8' }) - ) - return keypair.privateKey -} diff --git a/packages/backend/src/open_payments/auth/middleware.test.ts b/packages/backend/src/open_payments/auth/middleware.test.ts index 390a0bb635..6999cf8f74 100644 --- a/packages/backend/src/open_payments/auth/middleware.test.ts +++ b/packages/backend/src/open_payments/auth/middleware.test.ts @@ -2,6 +2,15 @@ import assert from 'assert' import nock, { Definition } from 'nock' import { URL } from 'url' import { v4 as uuid } from 'uuid' +import { Context } from 'koa' +import { + generateTestKeys, + JWK, + createHeaders, + Headers, + TestKeys, + generateJwk +} from 'http-signature-utils' import { createAuthMiddleware } from './middleware' import { GrantJSON, AccessType, AccessAction } from './grant' @@ -15,14 +24,9 @@ import { createTestApp, TestContainer } from '../../tests/app' import { createPaymentPointer } from '../../tests/paymentPointer' import { truncateTables } from '../../tests/tableManager' import { setup, SetupOptions } from '../payment_pointer/model.test' -import { HttpSigContext, JWKWithRequired, KeyInfo } from 'auth' -import { generateTestKeys, generateSigHeaders } from 'auth/src/tests/signature' -import { TokenInfo, TokenInfoJSON } from './service' +import { KeyInfo, TokenInfo, TokenInfoJSON } from './service' -type AppMiddleware = ( - ctx: HttpSigContext, - next: () => Promise -) => Promise +type AppMiddleware = (ctx: Context, next: () => Promise) => Promise type IntrospectionBody = { access_token: string @@ -34,29 +38,21 @@ describe('Auth Middleware', (): void => { let appContainer: TestContainer let authServerIntrospectionUrl: URL let middleware: AppMiddleware - let ctx: HttpSigContext + let ctx: Context let next: jest.MockedFunction<() => Promise> let validateRequest: RequestValidator let mockKeyInfo: KeyInfo const token = 'OS9M2PMHKUR64TB8N6BW7OZB8CDFONP219RP1LT0' - let generatedKeyPair: { - keyId: string - publicKey: JWKWithRequired - privateKey: JWKWithRequired - } + let testKeys: TestKeys let requestPath: string let requestAuthorization: string let requestBody: Body let requestUrl: string let requestMethod: RequestMethod - let requestSignatureHeaders: { - sigInput: string - signature: string - contentDigest?: string - } - let requestJwk: JWKWithRequired + let requestSignatureHeaders: Headers + let requestJwk: JWK - function setupHttpSigContext(options: SetupOptions): HttpSigContext { + function setupHttpSigContext(options: SetupOptions): Context { const context = setup(options) if ( !context.headers['signature'] || @@ -75,31 +71,28 @@ describe('Auth Middleware', (): void => { } async function prepareTest(includeBody: boolean) { - requestSignatureHeaders = await generateSigHeaders({ - privateKey: generatedKeyPair.privateKey, - keyId: generatedKeyPair.keyId, + const request = { url: requestUrl, method: requestMethod, - optionalComponents: { - body: includeBody ? requestBody : undefined, - authorization: requestAuthorization - } + headers: { authorization: requestAuthorization }, + body: includeBody ? JSON.stringify(requestBody) : undefined + } + requestSignatureHeaders = await createHeaders({ + request, + privateKey: testKeys.privateKey, + keyId: testKeys.keyId + }) + requestJwk = generateJwk({ + privateKey: testKeys.privateKey, + keyId: testKeys.keyId }) - requestJwk = generatedKeyPair.publicKey ctx = setupHttpSigContext({ reqOpts: { headers: { Accept: 'application/json', Authorization: `GNAP ${token}`, - Signature: `sig1=:${requestSignatureHeaders.signature}:`, - 'Signature-Input': requestSignatureHeaders.sigInput, - 'Content-Digest': includeBody - ? requestSignatureHeaders.contentDigest - : undefined, - 'Content-Length': includeBody - ? JSON.stringify(requestBody).length.toString() - : undefined + ...requestSignatureHeaders }, method: requestMethod, body: includeBody ? requestBody : undefined, @@ -129,7 +122,7 @@ describe('Auth Middleware', (): void => { path: requestPath, method: HttpMethod.POST }) - generatedKeyPair = await generateTestKeys() + testKeys = await generateTestKeys() requestMethod = HttpMethod.POST.toUpperCase() as RequestMethod requestBody = { access_token: token, @@ -344,7 +337,7 @@ describe('Auth Middleware', (): void => { Accept: 'application/json', Authorization: `GNAP ${token}`, Signature: 'aaaaaaaaaa=', - 'Signature-Input': requestSignatureHeaders.sigInput + 'Signature-Input': requestSignatureHeaders['Signature-Input'] }, method: requestMethod, url: requestUrl @@ -382,8 +375,8 @@ describe('Auth Middleware', (): void => { Accept: 'application/json', Authorization: `GNAP ${token}`, Signature: 'aaaaaaaaaa=', - 'Signature-Input': requestSignatureHeaders.sigInput, - 'Content-Digest': requestSignatureHeaders.contentDigest, + 'Signature-Input': requestSignatureHeaders['Signature-Input'], + 'Content-Digest': requestSignatureHeaders['Content-Digest'], 'Content-Length': JSON.stringify(requestBody).length.toString() }, method: requestMethod, @@ -482,9 +475,12 @@ describe('Auth Middleware', (): void => { mockKeyInfo ) const scope = mockAuthServer(grant.toJSON()) - ctx.request.headers['signature-input'] = ctx.request.headers[ - 'signature-input' - ].replace('gnap-key', 'mismatched-key') + let sigInput = ctx.request.headers['signature-input'] as string + sigInput = sigInput.replace( + /(keyid=")[0-9a-z-]{36}/g, + '$1' + 'mismatched-key' + ) + ctx.request.headers['signature-input'] = sigInput await expect(middleware(ctx, next)).resolves.toBeUndefined() expect(ctx.status).toBe(401) expect(next).not.toHaveBeenCalled() @@ -508,9 +504,12 @@ describe('Auth Middleware', (): void => { mockKeyInfo ) const scope = mockAuthServer(grant.toJSON()) - ctx.request.headers['signature-input'] = ctx.request.headers[ - 'signature-input' - ].replace('gnap-key', 'mismatched-key') + let sigInput = ctx.request.headers['signature-input'] as string + sigInput = sigInput.replace( + /(keyid=")[0-9a-z-]{36}/g, + '$1' + 'mismatched-key' + ) + ctx.request.headers['signature-input'] = sigInput await expect(middleware(ctx, next)).resolves.toBeUndefined() expect(ctx.status).toBe(401) expect(next).not.toHaveBeenCalled() @@ -523,8 +522,8 @@ describe('Auth Middleware', (): void => { headers: { Accept: 'application/json', Authorization: `GNAP ${token}`, - Signature: `sig1=:${requestSignatureHeaders.signature}:`, - 'Signature-Input': requestSignatureHeaders.sigInput, + Signature: `sig1=:${requestSignatureHeaders['Signature']}:`, + 'Signature-Input': requestSignatureHeaders['Signature-Input'], 'Content-Digest': 'aaaaaaaaaa=', 'Content-Length': JSON.stringify(requestBody).length.toString() }, diff --git a/packages/backend/src/open_payments/auth/middleware.ts b/packages/backend/src/open_payments/auth/middleware.ts index 12791b1e2b..a22c939161 100644 --- a/packages/backend/src/open_payments/auth/middleware.ts +++ b/packages/backend/src/open_payments/auth/middleware.ts @@ -1,6 +1,15 @@ +import { RequestLike, validateSignature } from 'http-signature-utils' import { AccessType, AccessAction } from './grant' -import { HttpSigContext, verifySigAndChallenge } from 'auth' +import { PaymentPointerContext } from '../../app' +function contextToRequestLike(ctx: PaymentPointerContext): RequestLike { + return { + url: ctx.href, + method: ctx.method, + headers: ctx.headers, + body: ctx.request.body ? JSON.stringify(ctx.request.body) : undefined + } +} export function createAuthMiddleware({ type, action @@ -9,7 +18,7 @@ export function createAuthMiddleware({ action: AccessAction }) { return async ( - ctx: HttpSigContext, + ctx: PaymentPointerContext, next: () => Promise ): Promise => { const config = await ctx.container.use('config') @@ -41,7 +50,9 @@ export function createAuthMiddleware({ } if (!config.bypassSignatureValidation) { try { - if (!(await verifySigAndChallenge(grant.key.jwk, ctx))) { + if ( + !(await validateSignature(grant.key.jwk, contextToRequestLike(ctx))) + ) { ctx.throw(401, 'Invalid signature') } } catch (e) { diff --git a/packages/backend/src/open_payments/auth/service.ts b/packages/backend/src/open_payments/auth/service.ts index ddda107729..0d0c252f58 100644 --- a/packages/backend/src/open_payments/auth/service.ts +++ b/packages/backend/src/open_payments/auth/service.ts @@ -1,5 +1,4 @@ import axios from 'axios' -import { KeyInfo } from 'auth' import { Logger } from 'pino' import { @@ -10,6 +9,12 @@ import { GrantAccessJSON } from './grant' import { OpenAPI, HttpMethod, ResponseValidator } from 'openapi' +import { JWK } from 'http-signature-utils' + +export interface KeyInfo { + proof: string + jwk: JWK +} export interface TokenInfoJSON extends GrantJSON { key: KeyInfo diff --git a/packages/backend/src/paymentPointerKey/model.ts b/packages/backend/src/paymentPointerKey/model.ts index 11c593aae8..8fbb725061 100644 --- a/packages/backend/src/paymentPointerKey/model.ts +++ b/packages/backend/src/paymentPointerKey/model.ts @@ -1,6 +1,6 @@ import { BaseModel } from '../shared/baseModel' -import { JWKWithRequired } from 'auth' +import { JWK } from 'http-signature-utils' export class PaymentPointerKey extends BaseModel { public static get tableName(): string { @@ -10,5 +10,5 @@ export class PaymentPointerKey extends BaseModel { public id!: string public paymentPointerId!: string - public jwk!: JWKWithRequired + public jwk!: JWK } diff --git a/packages/backend/src/paymentPointerKey/routes.test.ts b/packages/backend/src/paymentPointerKey/routes.test.ts index d8f3df9beb..4c0f05865c 100644 --- a/packages/backend/src/paymentPointerKey/routes.test.ts +++ b/packages/backend/src/paymentPointerKey/routes.test.ts @@ -1,6 +1,6 @@ import jestOpenAPI from 'jest-openapi' import { Knex } from 'knex' -import { generateJwk } from 'open-payments' +import { generateJwk } from 'http-signature-utils' import { v4 as uuid } from 'uuid' import { createContext } from '../tests/context' @@ -20,7 +20,6 @@ const TEST_KEY = { kty: 'OKP', alg: 'EdDSA', crv: 'Ed25519', - key_ops: ['sign', 'verify'], use: 'sig' } diff --git a/packages/backend/src/paymentPointerKey/routes.ts b/packages/backend/src/paymentPointerKey/routes.ts index 54eb3c232f..2c3910d9cc 100644 --- a/packages/backend/src/paymentPointerKey/routes.ts +++ b/packages/backend/src/paymentPointerKey/routes.ts @@ -1,4 +1,4 @@ -import { generateJwk, JWK } from 'open-payments' +import { generateJwk, JWK } from 'http-signature-utils' import { PaymentPointerContext } from '../app' import { IAppConfig } from '../config/app' diff --git a/packages/backend/src/paymentPointerKey/service.ts b/packages/backend/src/paymentPointerKey/service.ts index 5da8c17578..46290016b2 100644 --- a/packages/backend/src/paymentPointerKey/service.ts +++ b/packages/backend/src/paymentPointerKey/service.ts @@ -2,7 +2,7 @@ import { TransactionOrKnex } from 'objection' import { PaymentPointerKey } from './model' import { BaseService } from '../shared/baseService' -import { JWKWithRequired } from 'auth' +import { JWK } from 'http-signature-utils' export interface PaymentPointerKeyService { create(options: CreateOptions): Promise @@ -37,7 +37,7 @@ export async function createPaymentPointerKeyService({ interface CreateOptions { paymentPointerId: string - jwk: JWKWithRequired + jwk: JWK } async function create( diff --git a/packages/http-signature-utils/Dockerfile b/packages/http-signature-utils/Dockerfile new file mode 100644 index 0000000000..35de887463 --- /dev/null +++ b/packages/http-signature-utils/Dockerfile @@ -0,0 +1,20 @@ +FROM node:16.17.1-slim as builder + +WORKDIR /workspace + +RUN apt update +RUN apt install -y curl xz-utils python3 build-essential + +# version in curl is not the version used. Dependent on the last command +RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm@7.9.3 + +# pnpm fetch does require only lockfile +COPY pnpm-lock.yaml ./ +RUN pnpm fetch + +ADD . ./ +RUN pnpm install -r --offline + +RUN pnpm --filter http-signature-utils build + +CMD ["node", "./packages/http-signature-utils/dist/app.js"] diff --git a/packages/http-signature-utils/README.md b/packages/http-signature-utils/README.md new file mode 100644 index 0000000000..55c438149b --- /dev/null +++ b/packages/http-signature-utils/README.md @@ -0,0 +1,139 @@ +# HTTP Signature Utils Library + +The Library includes + +- loading Ed25519 keys from file or creating them +- generating JWKs from Ed25519 keys +- creating HTTP signature headers +- validate and verify HTTP signature headers + +Additionally, the package includes an app that generates HTTP digests and signatures for Postman. + +## Local Development + +### Building + +From the monorepo root directory: + +```shell +pnpm --filter http-signature-utils build +``` + +### Testing + +From the monorepo root directory: + +```shell +pnpm --filter http-signature-utils test +``` + +## Usage + +Load or generate a private key + +```ts +const key = parseOrProvisionKey('/PATH/TO/private-key.pem') +``` + +Create JWK from private key + +```ts +const jwk = generateJwk({ + privateKey: key, + keyId: '5cd52c55-05f1-41be-9474-a5c432cd4375' +}) +``` + +Create Signature Headers + +```ts +const signatureHeaders = await createSignatureHeaders({ + request: { + method: 'POST', + url: 'https://example.com', + headers: { + authorization: 'GNAP 123454321' + }, + body: JSON.stringify(body) + } + privateKey: key, + keyId: '5cd52c55-05f1-41be-9474-a5c432cd4375' +}) +``` + +Create Signature and Content Headers + +```ts +const headers = await createHeaders({ + request: { + method: 'POST', + url: 'https://example.com', + headers: { + authorization: 'GNAP 123454321' + }, + body: JSON.stringify(body) + } + privateKey: key, + keyId: '5cd52c55-05f1-41be-9474-a5c432cd4375' +}) +``` + +Validate Signature and Content Headers + +```ts +const isValidHeader = validateSignatureHeaders(request: { + method: 'POST', + url: 'https://example.com', + headers: { + 'content-type': 'application/json', + 'content-length': '1234', + 'content-digest': "sha-512=:vMVGexd7h7oBvi9aTwj05YvuCBTJaAYFPTwaxzu41/TyjXTueuKjxLlnTOhQfxE+YdA/QTiSXEkWh4gZ5zDZLg==:", + signature: "sig1=:Tk6ZvOqKxPysDpLPyjDRah76Uskr8OYxcuJasg4tSrD8qRaGBTji+WdMHxkkTqUX1cASaoqAdE3s7YDUFmlnCw==:", + 'signature-input': 'sig1=("@method" "@target-uri" "authorization" "content-digest" "content-length" "content-type");created=1670837620;keyid="keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5";alg="ed25519"', + authorization: 'GNAP 123454321' + }, + body: JSON.stringify(body) + }) +``` + +Verify signature + +```ts +const isValidSig = await validateSignature( + clientKey: jwk, + request: { + method: 'POST', + url: 'https://example.com', + headers: { + 'content-type': 'application/json', + 'content-length': '1234', + 'content-digest': "sha-512=:vMVGexd7h7oBvi9aTwj05YvuCBTJaAYFPTwaxzu41/TyjXTueuKjxLlnTOhQfxE+YdA/QTiSXEkWh4gZ5zDZLg==:", + signature: "sig1=:Tk6ZvOqKxPysDpLPyjDRah76Uskr8OYxcuJasg4tSrD8qRaGBTji+WdMHxkkTqUX1cASaoqAdE3s7YDUFmlnCw==:", + 'signature-input': 'sig1=("@method" "@target-uri" "authorization" "content-digest" "content-length" "content-type");created=1670837620;keyid="keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5";alg="ed25519"', + authorization: 'GNAP 123454321' + }, + body: JSON.stringify(body) + } +): +``` + +## Running the Postman signature app + +### Prerequisites + +- [Docker](https://docs.docker.com/engine/install/) configured to [run as non-root user](https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user) + +### Docker build + +In order to build the docker container to run the signature app, run the following command. + +```shell +# from the root +docker build -f packages/http-signature-utils/Dockerfile -t rafiki-signatures . +``` + +The following environment variables can be set. + +| Name | Description | Note | +| -------- | -------------------------- | ----------------------------------------------------- | +| KEY_FILE | `/PATH/TO/private-key.pem` | Key file needs to be copied into the docker container | diff --git a/packages/http-signature-utils/jest.config.js b/packages/http-signature-utils/jest.config.js new file mode 100644 index 0000000000..a2667d704f --- /dev/null +++ b/packages/http-signature-utils/jest.config.js @@ -0,0 +1,17 @@ +'use strict' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const baseConfig = require('../../jest.config.base.js') +// eslint-disable-next-line @typescript-eslint/no-var-requires +const packageName = require('./package.json').name + +module.exports = { + ...baseConfig, + clearMocks: true, + roots: [`/packages/${packageName}`], + testRegex: `(packages/${packageName}/.*/__tests__/.*|\\.(test|spec))\\.tsx?$`, + moduleDirectories: [`node_modules`, `packages/${packageName}/node_modules`], + modulePaths: [`/packages/${packageName}/src/`], + id: packageName, + displayName: packageName, + rootDir: '../..' +} diff --git a/packages/http-signature-utils/package.json b/packages/http-signature-utils/package.json new file mode 100644 index 0000000000..5f1e84f92d --- /dev/null +++ b/packages/http-signature-utils/package.json @@ -0,0 +1,34 @@ +{ + "name": "http-signature-utils", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "scripts": { + "build": "tsc --build tsconfig.json", + "test": "jest --passWithNoTests --maxWorkers=50%" + }, + "dependencies": { + "http-message-signatures": "^0.1.2", + "httpbis-digest-headers": "github:interledger/httpbis-digest-headers", + "jose": "^4.9.0", + "koa": "^2.13.4", + "koa-bodyparser": "^4.3.0", + "koa-json": "^2.0.2", + "koa-logger": "^3.2.1", + "koa-router": "^12.0.0", + "uuid": "^8.3.2" + }, + "devDependencies": { + "@types/koa": "2.13.5", + "@types/koa-bodyparser": "^4.3.7", + "@types/koa-json": "^2.0.20", + "@types/koa-logger": "^3.1.2", + "@types/koa-router": "^7.4.4", + "@types/node": "^18.7.12", + "@types/uuid": "^8.3.4", + "node-mocks-http": "^1.12.1", + "typescript": "^4.9.3" + } +} diff --git a/packages/http-signature-utils/postman-scripts/preRequestSignatures.js b/packages/http-signature-utils/postman-scripts/preRequestSignatures.js new file mode 100644 index 0000000000..555decdef5 --- /dev/null +++ b/packages/http-signature-utils/postman-scripts/preRequestSignatures.js @@ -0,0 +1,45 @@ +const requestUrl = request.url + .replace(/{{([A-Za-z]\w+)}}/g, (_, key) => pm.collectionVariables.get(key)) + .replace(/localhost:([3,4])000/g, (_, key) => + key === '3' ? 'backend' : 'peer-backend' + ) +const requestBody = + request.method === 'POST' + ? request.data.replace(/{{([A-Za-z]\w+)}}/g, (_, key) => + pm.collectionVariables.get(key) + ) + : undefined +const requestHeaders = JSON.parse( + JSON.stringify(request.headers).replace(/{{([A-Za-z]\w+)}}/g, (_, key) => + pm.collectionVariables.get(key) + ) +) + +// Request Signature Headers +pm.sendRequest( + { + url: pm.collectionVariables.get('signatureUrl'), + method: 'POST', + header: { + 'content-type': 'application/json' + }, + body: { + mode: 'raw', + raw: JSON.stringify({ + keyId: pm.collectionVariables.get('keyId'), + request: { + url: requestUrl, + method: request.method, + headers: requestHeaders, + body: requestBody + } + }) + } + }, + (_, res) => { + const headers = res.json() + for (let [key, value] of Object.entries(headers)) { + pm.request.headers.add({ key, value }) + } + } +) diff --git a/packages/http-signature-utils/postman-scripts/preRequestSignaturesGrantRequest.js b/packages/http-signature-utils/postman-scripts/preRequestSignaturesGrantRequest.js new file mode 100644 index 0000000000..b241b70e4b --- /dev/null +++ b/packages/http-signature-utils/postman-scripts/preRequestSignaturesGrantRequest.js @@ -0,0 +1,69 @@ +const url = require('url') + +const body = JSON.parse(request.data) +const client = url.parse(body.client) +const jwkUrl = `http://localhost:${client.host === 'backend' ? '3' : '4'}000${ + client.path +}/jwks.json` +pm.collectionVariables.set( + 'signatureUrl', + pm.collectionVariables.get( + client.host === 'backend' ? 'SignatureHost' : 'PeerSignatureHost' + ) +) + +const requestUrl = request.url.replace(/{{([A-Za-z]\w+)}}/g, (_, key) => + pm.collectionVariables.get(key) +) +const requestBody = request.data.replace(/{{([A-Za-z]\w+)}}/g, (_, key) => + pm.collectionVariables.get(key) +) +const requestHeaders = JSON.parse( + JSON.stringify(request.headers).replace(/{{([A-Za-z]\w+)}}/g, (_, key) => + pm.collectionVariables.get(key) + ) +) + +// Request Client JWK +pm.sendRequest( + { + url: jwkUrl, + method: 'GET', + header: { + Host: client.host + } + }, + (err, res) => { + const keys = res.json() + pm.collectionVariables.set('keyId', keys.keys[0].kid) + + // Request Signature Headers + pm.sendRequest( + { + url: pm.collectionVariables.get('signatureUrl'), + method: 'POST', + header: { + 'content-type': 'application/json' + }, + body: { + mode: 'raw', + raw: JSON.stringify({ + keyId: pm.collectionVariables.get('keyId'), + request: { + url: requestUrl, + method: request.method, + headers: requestHeaders, + body: requestBody + } + }) + } + }, + (_, res) => { + const headers = res.json() + for (let [key, value] of Object.entries(headers)) { + pm.request.headers.add({ key, value }) + } + } + ) + } +) diff --git a/packages/http-signature-utils/src/app.ts b/packages/http-signature-utils/src/app.ts new file mode 100644 index 0000000000..01a01ac305 --- /dev/null +++ b/packages/http-signature-utils/src/app.ts @@ -0,0 +1,40 @@ +import Koa from 'koa' +import Router from 'koa-router' + +import logger from 'koa-logger' +import json from 'koa-json' +import bodyParser from 'koa-bodyparser' + +import { parseOrProvisionKey } from './utils/key' +import { createHeaders } from './utils/headers' + +const app = new Koa() +const router = new Router() + +// Load key +const privateKey = parseOrProvisionKey(process.env.KEY_FILE) + +// Router +router.post('/', async (ctx): Promise => { + const { request, keyId } = ctx.request.body + if (!keyId || !request.headers || !request.method || !request.url) { + ctx.status = 400 + return + } + const headers = await createHeaders({ request, privateKey, keyId }) + delete headers['Content-Length'] + delete headers['Content-Type'] + ctx.body = headers +}) + +// Middlewares +app.use(json()) +app.use(logger()) +app.use(bodyParser()) + +// Routes +app.use(router.routes()).use(router.allowedMethods()) + +app.listen(3000, () => { + console.log('HTTP Signature Manager started.') +}) diff --git a/packages/http-signature-utils/src/index.ts b/packages/http-signature-utils/src/index.ts new file mode 100644 index 0000000000..d326119e75 --- /dev/null +++ b/packages/http-signature-utils/src/index.ts @@ -0,0 +1,7 @@ +export { createHeaders, Headers } from './utils/headers' +export { generateJwk, JWK } from './utils/jwk' +export { parseOrProvisionKey } from './utils/key' +export { createSignatureHeaders } from './utils/signatures' +export { validateSignatureHeaders, validateSignature } from './utils/validation' +export { generateTestKeys, TestKeys } from './test-utils/keys' +export { RequestLike } from 'http-message-signatures' diff --git a/packages/http-signature-utils/src/test-utils/keys.ts b/packages/http-signature-utils/src/test-utils/keys.ts new file mode 100644 index 0000000000..e821ae9bed --- /dev/null +++ b/packages/http-signature-utils/src/test-utils/keys.ts @@ -0,0 +1,14 @@ +import crypto from 'crypto' +import { v4 } from 'uuid' + +export type TestKeys = { + keyId: string + publicKey: crypto.KeyObject + privateKey: crypto.KeyObject +} + +export async function generateTestKeys(): Promise { + const keyId = v4() + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519') + return { keyId, privateKey, publicKey } +} diff --git a/packages/http-signature-utils/src/utils/headers.ts b/packages/http-signature-utils/src/utils/headers.ts new file mode 100644 index 0000000000..d5f12bd852 --- /dev/null +++ b/packages/http-signature-utils/src/utils/headers.ts @@ -0,0 +1,50 @@ +import { createContentDigestHeader } from 'httpbis-digest-headers' +import { + createSignatureHeaders, + SignatureHeaders, + SignOptions +} from './signatures' + +interface ContentHeaders { + 'Content-Digest': string + 'Content-Length': string + 'Content-Type': string +} + +export interface Headers extends SignatureHeaders, Partial {} + +const createContentHeaders = (body: string): ContentHeaders => { + return { + 'Content-Digest': createContentDigestHeader( + JSON.stringify(JSON.parse(body)), + ['sha-512'] + ), + 'Content-Length': Buffer.from(body as string, 'utf-8').length.toString(), + 'Content-Type': 'application/json' + } +} + +export const createHeaders = async ({ + request, + privateKey, + keyId +}: SignOptions): Promise => { + let contentHeaders: ContentHeaders + if (request.body) { + contentHeaders = createContentHeaders(request.body as string) + request.headers = { ...request.headers, ...contentHeaders } + } + const signatureHeaders = await createSignatureHeaders({ + request, + privateKey, + keyId + }) + if (contentHeaders) { + return { + ...contentHeaders, + ...signatureHeaders + } + } else { + return signatureHeaders + } +} diff --git a/packages/open-payments/src/jwk.test.ts b/packages/http-signature-utils/src/utils/jwk.test.ts similarity index 100% rename from packages/open-payments/src/jwk.test.ts rename to packages/http-signature-utils/src/utils/jwk.test.ts diff --git a/packages/open-payments/src/jwk.ts b/packages/http-signature-utils/src/utils/jwk.ts similarity index 76% rename from packages/open-payments/src/jwk.ts rename to packages/http-signature-utils/src/utils/jwk.ts index 19fc51e954..2a7f81b72e 100644 --- a/packages/open-payments/src/jwk.ts +++ b/packages/http-signature-utils/src/utils/jwk.ts @@ -1,5 +1,17 @@ import { createPublicKey, generateKeyPairSync, KeyObject } from 'crypto' -import { JWK } from './types' +import { JWK as JoseWk } from 'jose' + +export interface JWK extends JoseWk { + kid: string + alg: string + kty: string + crv: string + x: string + use?: string + exp?: number + nbf?: number + revoked?: boolean +} export const generateJwk = ({ privateKey: providedPrivateKey, diff --git a/packages/backend/src/config/app.test.ts b/packages/http-signature-utils/src/utils/key.test.ts similarity index 98% rename from packages/backend/src/config/app.test.ts rename to packages/http-signature-utils/src/utils/key.test.ts index 2e3ceee129..06212e5433 100644 --- a/packages/backend/src/config/app.test.ts +++ b/packages/http-signature-utils/src/utils/key.test.ts @@ -1,7 +1,7 @@ import * as assert from 'assert' import * as crypto from 'crypto' import * as fs from 'fs' -import { parseOrProvisionKey } from './app' +import { parseOrProvisionKey } from './key' describe('Config', (): void => { describe('parseOrProvisionKey', (): void => { diff --git a/packages/http-signature-utils/src/utils/key.ts b/packages/http-signature-utils/src/utils/key.ts new file mode 100644 index 0000000000..e17c878325 --- /dev/null +++ b/packages/http-signature-utils/src/utils/key.ts @@ -0,0 +1,32 @@ +import * as crypto from 'crypto' +import * as fs from 'fs' + +export function parseOrProvisionKey( + keyFile: string | undefined +): crypto.KeyObject { + const TMP_DIR = './tmp' + if (keyFile) { + try { + const key = crypto.createPrivateKey(fs.readFileSync(keyFile)) + const jwk = key.export({ format: 'jwk' }) + if (jwk.crv === 'Ed25519') { + console.log(`Key ${keyFile} loaded.`) + return key + } else { + console.log('Private key is not EdDSA-Ed25519 key. Generating new key.') + } + } catch (err) { + console.log('Private key could not be loaded.') + throw err + } + } + const keypair = crypto.generateKeyPairSync('ed25519') + if (!fs.existsSync(TMP_DIR)) { + fs.mkdirSync(TMP_DIR) + } + fs.writeFileSync( + `${TMP_DIR}/private-key-${new Date().getTime()}.pem`, + keypair.privateKey.export({ format: 'pem', type: 'pkcs8' }) + ) + return keypair.privateKey +} diff --git a/packages/open-payments/src/client/signatures.ts b/packages/http-signature-utils/src/utils/signatures.ts similarity index 88% rename from packages/open-payments/src/client/signatures.ts rename to packages/http-signature-utils/src/utils/signatures.ts index 8bc48bec93..95a4ee419d 100644 --- a/packages/open-payments/src/client/signatures.ts +++ b/packages/http-signature-utils/src/utils/signatures.ts @@ -6,13 +6,13 @@ import { Signer } from 'http-message-signatures' -interface SignOptions { +export interface SignOptions { request: RequestLike privateKey: KeyLike keyId: string } -interface SignatureHeaders { +export interface SignatureHeaders { Signature: string 'Signature-Input': string } @@ -29,7 +29,7 @@ export const createSignatureHeaders = async ({ keyId }: SignOptions): Promise => { const components = ['@method', '@target-uri'] - if (request.headers['Authorization']) { + if (request.headers['Authorization'] || request.headers['authorization']) { components.push('authorization') } if (request.body) { diff --git a/packages/http-signature-utils/src/utils/validation.test.ts b/packages/http-signature-utils/src/utils/validation.test.ts new file mode 100644 index 0000000000..d342170679 --- /dev/null +++ b/packages/http-signature-utils/src/utils/validation.test.ts @@ -0,0 +1,91 @@ +import { validateSignatureHeaders, validateSignature } from './validation' +import { createHeaders } from './headers' +import { RequestLike } from 'http-message-signatures' +import { TestKeys, generateTestKeys } from '../test-utils/keys' +import { generateJwk, JWK } from './jwk' +import { createContentDigestHeader } from 'httpbis-digest-headers' + +describe('Signature Verification', (): void => { + let testKeys: TestKeys + let testClientKey: JWK + + beforeEach(async (): Promise => { + testKeys = await generateTestKeys() + testClientKey = generateJwk({ + privateKey: testKeys.privateKey, + keyId: testKeys.keyId + }) + }) + test.each` + title | withAuthorization | withRequestBody + ${''} | ${true} | ${true} + ${' without an authorization header'} | ${false} | ${true} + ${' without a request body'} | ${true} | ${false} + `( + 'can validate signature headers and signature', + async ({ withAuthorization, withRequestBody }): Promise => { + const testRequestBody = JSON.stringify({ foo: 'bar' }) + + const headers = {} + if (withAuthorization) { + headers['authorization'] = 'GNAP test-access-token' + } + + const request: RequestLike = { + headers, + method: 'GET', + url: 'http://example.com/test' + } + if (withRequestBody) { + request.body = testRequestBody + } + + const contentAndSigHeaders = await createHeaders({ + request, + privateKey: testKeys.privateKey, + keyId: testKeys.keyId + }) + const lowerHeaders = Object.fromEntries( + Object.entries(contentAndSigHeaders).map(([k, v]) => [ + k.toLowerCase(), + v + ]) + ) + request.headers = { ...request.headers, ...lowerHeaders } + + expect(validateSignatureHeaders(request)).toEqual(true) + await expect(validateSignature(testClientKey, request)).resolves.toEqual( + true + ) + } + ) + + test.each` + title | sigInputHeader + ${'fails if a component is not in lower case'} | ${'sig1=("@METHOD" "@target-uri" "content-digest" "content-length" "content-type" "authorization");created=1618884473;keyid="gnap-key"'} + ${'fails @method is missing'} | ${'sig1=("@target-uri" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} + ${'fails if @target-uri is missing'} | ${'sig1=("@method" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} + ${'fails if @content-digest is missing while body is present'} | ${'sig1=("@method" "@target-uri" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} + ${'fails if authorization header is present in headers but not in signature input'} | ${'sig1=("@method" "@target-uri" "content-digest" "content-length" "content-type");created=1618884473;keyid="gnap-key"'} + `( + 'validates signature header and $title', + async ({ sigInputHeader }): Promise => { + const testRequestBody = JSON.stringify({ foo: 'bar' }) + const request = { + headers: { + 'content-type': 'application/json', + 'content-digest': createContentDigestHeader(testRequestBody, [ + 'sha-512' + ]), + 'content-length': '1234', + 'signature-input': sigInputHeader, + authorization: 'GNAP test-access-token' + }, + method: 'GET', + url: 'http://example.com/test', + body: testRequestBody + } + expect(validateSignatureHeaders(request)).toBe(false) + } + ) +}) diff --git a/packages/http-signature-utils/src/utils/validation.ts b/packages/http-signature-utils/src/utils/validation.ts new file mode 100644 index 0000000000..964ea24922 --- /dev/null +++ b/packages/http-signature-utils/src/utils/validation.ts @@ -0,0 +1,116 @@ +import * as crypto from 'crypto' +import { RequestLike } from 'http-message-signatures' +import { verifyContentDigest } from 'httpbis-digest-headers' +import { importJWK } from 'jose' +import { JWK } from './jwk' + +export function validateSignatureHeaders(request: RequestLike): boolean { + const sig = request.headers['signature'] + const sigInput = request.headers['signature-input'] as string + + const sigInputComponents = getSigInputComponents(sigInput ?? '') + if ( + !sigInputComponents || + !validateSigInputComponents(sigInputComponents, request) + ) + return false + + return ( + sig && sigInput && typeof sig === 'string' && typeof sigInput === 'string' + ) +} + +export async function validateSignature( + clientKey: JWK, + request: RequestLike +): Promise { + const sig = request.headers['signature'] as string + const sigInput = request.headers['signature-input'] as string + const challenge = sigInputToChallenge(sigInput, request) + if (!challenge) { + return false + } + + const publicKey = (await importJWK(clientKey)) as crypto.KeyLike + const data = Buffer.from(challenge) + return crypto.verify( + null, + data, + publicKey, + Buffer.from(sig.replace('sig1=', ''), 'base64') + ) +} + +function sigInputToChallenge( + sigInput: string, + request: RequestLike +): string | null { + const sigInputComponents = getSigInputComponents(sigInput) + + if ( + !sigInputComponents || + !validateSigInputComponents(sigInputComponents, request) + ) + return null + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-09#section-2.3 + let signatureBase = '' + for (const component of sigInputComponents) { + if (component === '@method') { + signatureBase += `"@method": ${request.method}\n` + } else if (component === '@target-uri') { + signatureBase += `"@target-uri": ${request.url}\n` + } else { + signatureBase += `"${component}": ${request.headers[component]}\n` + } + } + + signatureBase += `"@signature-params": ${( + request.headers['signature-input'] as string + )?.replace('sig1=', '')}` + return signatureBase +} + +function getSigInputComponents(sigInput: string): string[] | null { + // https://datatracker.ietf.org/doc/html/rfc8941#section-4.1.1.1 + const messageComponents = sigInput + .split('sig1=')[1] + ?.split(';')[0] + ?.split(' ') + return messageComponents + ? messageComponents.map((component) => component.replace(/[()"]/g, '')) + : null +} + +function validateSigInputComponents( + sigInputComponents: string[], + request: RequestLike +): boolean { + // https://datatracker.ietf.org/doc/html/draft-ietf-gnap-core-protocol#section-7.3.1 + + for (const component of sigInputComponents) { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-09#section-2.1 + if (component !== component.toLowerCase()) return false + } + + const isValidContentDigest = + !sigInputComponents.includes('content-digest') || + (!!request.headers['content-digest'] && + !!request.headers['content-length'] && + !!request.headers['content-type'] && + request.body && + Object.keys(request.body).length > 0 && + sigInputComponents.includes('content-digest') && + verifyContentDigest( + request.body, + request.headers['content-digest'] as string + )) + + return !( + !isValidContentDigest || + !sigInputComponents.includes('@method') || + !sigInputComponents.includes('@target-uri') || + (request.headers['authorization'] && + !sigInputComponents.includes('authorization')) + ) +} diff --git a/packages/http-signature-utils/tsconfig.json b/packages/http-signature-utils/tsconfig.json new file mode 100644 index 0000000000..bc4252bfc2 --- /dev/null +++ b/packages/http-signature-utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "src/test/*"] +} diff --git a/packages/mock-account-provider/app/lib/apolloClient.ts b/packages/mock-account-provider/app/lib/apolloClient.ts index 2f83ff990f..9df955c8fb 100644 --- a/packages/mock-account-provider/app/lib/apolloClient.ts +++ b/packages/mock-account-provider/app/lib/apolloClient.ts @@ -10,7 +10,7 @@ import { CONFIG } from './parse_config' import { onError } from '@apollo/client/link/error' const httpLink = createHttpLink({ - uri: CONFIG.self.graphqlUrl + uri: CONFIG.seed.self.graphqlUrl }) const errorLink = onError(({ graphQLErrors }) => { diff --git a/packages/mock-account-provider/app/lib/crypto.server.ts b/packages/mock-account-provider/app/lib/crypto.server.ts deleted file mode 100644 index 3c9d7c8cbc..0000000000 --- a/packages/mock-account-provider/app/lib/crypto.server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createPublicKey, generateKeyPairSync, type JsonWebKey } from 'crypto' - -type OpenPaymentsJWK = JsonWebKey & { - alg: 'EdDSA' - kid: string -} - -export const generateJwk = ({ keyId }: { keyId: string }): OpenPaymentsJWK => { - const jwk = createPublicKey(generateKeyPairSync('ed25519').privateKey).export( - { - format: 'jwk' - } - ) - - return { - alg: 'EdDSA', - kid: keyId, - kty: jwk.kty, - crv: jwk.crv, - x: jwk.x - } -} diff --git a/packages/mock-account-provider/app/lib/parse_config.ts b/packages/mock-account-provider/app/lib/parse_config.ts index 2af4e39896..885fd87fbd 100644 --- a/packages/mock-account-provider/app/lib/parse_config.ts +++ b/packages/mock-account-provider/app/lib/parse_config.ts @@ -1,5 +1,7 @@ +import type * as crypto from 'crypto' import { parse } from 'yaml' import { readFileSync } from 'fs' +import { parseOrProvisionKey } from 'http-signature-utils' export interface Self { graphqlUrl: string @@ -40,8 +42,16 @@ export interface SeedInstance { fees: Array } -export const CONFIG: SeedInstance = parse( - readFileSync(process.env.SEED_FILE_LOCATION || `./seed.example.yml`).toString( - 'utf8' - ) -) +export interface Config { + seed: SeedInstance + key: crypto.KeyObject +} + +export const CONFIG: Config = { + seed: parse( + readFileSync( + process.env.SEED_FILE_LOCATION || `./seed.example.yml` + ).toString('utf8') + ), + key: parseOrProvisionKey(process.env.KEY_FILE) +} diff --git a/packages/mock-account-provider/app/lib/run_seed.ts b/packages/mock-account-provider/app/lib/run_seed.ts index 03d9d6a30c..bbceda3ffd 100644 --- a/packages/mock-account-provider/app/lib/run_seed.ts +++ b/packages/mock-account-provider/app/lib/run_seed.ts @@ -1,6 +1,5 @@ import * as _ from 'lodash' -import { CONFIG } from './parse_config' -import type { SeedInstance, Account, Peering } from './parse_config' +import { CONFIG, type Config, type Account, type Peering } from './parse_config' import { createPeer, addPeerLiquidity, @@ -9,11 +8,11 @@ import { } from './requesters' import { v4 } from 'uuid' import { mockAccounts } from './accounts.server' -import { generateJwk } from './crypto.server' +import { generateJwk } from 'http-signature-utils' -export async function setupFromSeed(config: SeedInstance): Promise { +export async function setupFromSeed(config: Config): Promise { const peerResponses = await Promise.all( - _.map(config.peers, async (peer: Peering) => { + _.map(config.seed.peers, async (peer: Peering) => { const peerResponse = await createPeer( peer.peerIlpAddress, peer.peerUrl, @@ -25,7 +24,7 @@ export async function setupFromSeed(config: SeedInstance): Promise { } const transferUid = v4() const liquidity = await addPeerLiquidity( - config.self.graphqlUrl, + config.seed.self.graphqlUrl, peerResponse.id, peer.initialLiquidity, transferUid @@ -40,7 +39,7 @@ export async function setupFromSeed(config: SeedInstance): Promise { await mockAccounts.clearAccounts() const accountResponses = await Promise.all( - _.map(config.accounts, async (account: Account) => { + _.map(config.seed.accounts, async (account: Account) => { await mockAccounts.create( account.id, account.name, @@ -55,9 +54,9 @@ export async function setupFromSeed(config: SeedInstance): Promise { ) } const paymentPointer = await createPaymentPointer( - config.self.graphqlUrl, + config.seed.self.graphqlUrl, account.name, - `https://${CONFIG.self.hostname}/${account.path}`, + `https://${CONFIG.seed.self.hostname}/${account.path}`, account.asset, account.scale ) @@ -70,15 +69,17 @@ export async function setupFromSeed(config: SeedInstance): Promise { await createPaymentPointerKey({ paymentPointerId: paymentPointer.id, - jwk: JSON.stringify(generateJwk({ keyId: `keyid-${account.id}` })) + jwk: JSON.stringify( + generateJwk({ keyId: `keyid-${account.id}`, privateKey: config.key }) + ) }) return paymentPointer }) ) console.log(JSON.stringify(accountResponses, null, 2)) - const envVarStrings = _.map(config.accounts, (account) => { - return `${account.postmanEnvVar}: http://localhost:${CONFIG.self.openPaymentPublishedPort}/${account.path} hostname: ${CONFIG.self.hostname}` + const envVarStrings = _.map(config.seed.accounts, (account) => { + return `${account.postmanEnvVar}: http://localhost:${CONFIG.seed.self.openPaymentPublishedPort}/${account.path} hostname: ${CONFIG.seed.self.hostname}` }) console.log(envVarStrings.join('\n')) } diff --git a/packages/mock-account-provider/app/routes/quotes.ts b/packages/mock-account-provider/app/routes/quotes.ts index d043f83e27..ee422e4ab3 100644 --- a/packages/mock-account-provider/app/routes/quotes.ts +++ b/packages/mock-account-provider/app/routes/quotes.ts @@ -32,7 +32,7 @@ export type Quote = { export async function action({ request }: ActionArgs) { const receivedQuote: Quote = await request.json() - const feeStructure = CONFIG.fees[0] + const feeStructure = CONFIG.seed.fees[0] if (receivedQuote.paymentType == PaymentType.FixedDelivery) { // TODO: handle quote fee calculation for different assets/scales if ( diff --git a/packages/mock-account-provider/package.json b/packages/mock-account-provider/package.json index 21c0e5dc13..842496656c 100644 --- a/packages/mock-account-provider/package.json +++ b/packages/mock-account-provider/package.json @@ -2,7 +2,8 @@ "name": "mock-account-provider", "sideEffects": false, "scripts": { - "build": "remix build", + "build:deps": "pnpm --filter http-signature-utils build", + "build": "pnpm build:deps && remix build", "dev": "PORT=3300 remix dev", "start": "remix-serve build" }, @@ -15,6 +16,7 @@ "@types/uuid": "^8.3.4", "axios": "^1.1.3", "graphql": "^16.6.0", + "http-signature-utils": "workspace:../http-signature-utils", "lodash": "^4.17.21", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/open-payments/package.json b/packages/open-payments/package.json index b6f09d4226..2ce1d02119 100644 --- a/packages/open-payments/package.json +++ b/packages/open-payments/package.json @@ -7,7 +7,7 @@ "dist/**/*" ], "scripts": { - "build:deps": "pnpm --filter openapi build", + "build:deps": "pnpm --filter openapi build && pnpm --filter http-signature-utils build", "build": "pnpm build:deps && pnpm clean && tsc --build tsconfig.json", "clean": "rm -fr dist/", "generate:types": "npx ts-node scripts/generate-types.ts", @@ -26,7 +26,7 @@ "dependencies": { "axios": "^1.1.2", "http-message-signatures": "^0.1.2", - "httpbis-digest-headers": "github:interledger/httpbis-digest-headers", + "http-signature-utils": "workspace:../http-signature-utils", "openapi": "workspace:../openapi", "pino": "^8.4.2" } diff --git a/packages/open-payments/src/client/requests.ts b/packages/open-payments/src/client/requests.ts index 5d6afed962..9d0474fc19 100644 --- a/packages/open-payments/src/client/requests.ts +++ b/packages/open-payments/src/client/requests.ts @@ -1,9 +1,8 @@ import axios, { AxiosInstance } from 'axios' import { KeyLike } from 'crypto' -import { createContentDigestHeader } from 'httpbis-digest-headers' import { ResponseValidator } from 'openapi' import { BaseDeps } from '.' -import { createSignatureHeaders } from './signatures' +import { createHeaders } from 'http-signature-utils' interface GetArgs { url: string @@ -142,26 +141,26 @@ export const createAxiosInstance = (args: { if (args.privateKey && args.keyId) { axiosInstance.interceptors.request.use( async (config) => { - if (config.data) { - const data = JSON.stringify(config.data) - config.headers['Content-Digest'] = createContentDigestHeader(data, [ - 'sha-512' - ]) - config.headers['Content-Length'] = Buffer.from(data, 'utf-8').length - config.headers['Content-Type'] = 'application/json' - } - const sigHeaders = await createSignatureHeaders({ + const contentAndSigHeaders = await createHeaders({ request: { method: config.method.toUpperCase(), url: config.url, headers: config.headers, - body: config.data + body: config.data ? JSON.stringify(config.data) : undefined }, privateKey: args.privateKey, keyId: args.keyId }) - config.headers['Signature'] = sigHeaders['Signature'] - config.headers['Signature-Input'] = sigHeaders['Signature-Input'] + if (config.data) { + config.headers['Content-Digest'] = + contentAndSigHeaders['Content-Digest'] + config.headers['Content-Length'] = + contentAndSigHeaders['Content-Length'] + config.headers['Content-Type'] = contentAndSigHeaders['Content-Type'] + } + config.headers['Signature'] = contentAndSigHeaders['Signature'] + config.headers['Signature-Input'] = + contentAndSigHeaders['Signature-Input'] return config }, null, diff --git a/packages/open-payments/src/index.ts b/packages/open-payments/src/index.ts index 737cbe553f..6bf9194c15 100644 --- a/packages/open-payments/src/index.ts +++ b/packages/open-payments/src/index.ts @@ -19,5 +19,3 @@ export { AuthenticatedClient, UnauthenticatedClient } from './client' - -export { generateJwk } from './jwk' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72fec3406a..058505df42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,7 @@ importers: packages/auth: specifiers: '@adonisjs/fold': ^8.1.0 + '@faker-js/faker': ^7.4.0 '@koa/cors': ^3.3.0 '@koa/router': ^12.0.0 '@types/jest': ^28.1.8 @@ -57,9 +58,9 @@ importers: '@types/uuid': ^8.3.4 ajv: ^8.11.2 axios: ^0.27.2 + http-signature-utils: workspace:../http-signature-utils httpbis-digest-headers: github:interledger/httpbis-digest-headers jest-openapi: ^0.14.2 - jose: ^4.9.0 knex: ^0.95 koa: ^2.13.4 koa-bodyparser: ^4.3.0 @@ -83,8 +84,8 @@ importers: '@koa/router': 12.0.0 ajv: 8.11.2 axios: 0.27.2 + http-signature-utils: link:../http-signature-utils httpbis-digest-headers: github.com/interledger/httpbis-digest-headers/787b7af5ba1752337d696a7b1587193058173284 - jose: 4.9.0 knex: 0.95.15_pg@8.7.3 koa: 2.13.4 koa-bodyparser: 4.3.0 @@ -100,6 +101,7 @@ importers: testcontainers: 8.16.0 uuid: 8.3.2 devDependencies: + '@faker-js/faker': 7.4.0 '@types/jest': 28.1.8 '@types/koa': 2.13.5 '@types/koa-bodyparser': 4.3.7 @@ -146,7 +148,6 @@ importers: ajv: ^8.11.2 apollo-server: ^3.10.1 apollo-server-koa: ^3.10.1 - auth: workspace:../auth axios: 0.26.1 base64url: ^3.0.1 bcrypt: ^5.0.1 @@ -156,6 +157,7 @@ importers: graphql: ^16.6.0 graphql-scalars: ^1.18.0 graphql-tools: ^8.3.3 + http-signature-utils: workspace:../http-signature-utils ilp-packet: 3.1.4-alpha.1 ilp-protocol-ccp: ^1.2.2 ilp-protocol-ildcp: ^2.2.3 @@ -213,6 +215,7 @@ importers: graphql: 16.6.0 graphql-scalars: 1.18.0_graphql@16.6.0 graphql-tools: 8.3.3_onqnqwb3ubg5opvemcqf7c2qhy + http-signature-utils: link:../http-signature-utils ilp-packet: 3.1.4-alpha.1 ilp-protocol-ccp: 1.2.3 ilp-protocol-ildcp: 2.2.3 @@ -252,7 +255,6 @@ importers: '@types/tmp': 0.2.3 '@types/uuid': 8.3.4 apollo-server: 3.10.1_graphql@16.6.0 - auth: link:../auth cross-fetch: 3.1.5 ilp-protocol-stream: 2.7.1 jest-openapi: 0.14.2 @@ -268,6 +270,47 @@ importers: packages/frontend: specifiers: {} + packages/http-signature-utils: + specifiers: + '@types/koa': 2.13.5 + '@types/koa-bodyparser': ^4.3.7 + '@types/koa-json': ^2.0.20 + '@types/koa-logger': ^3.1.2 + '@types/koa-router': ^7.4.4 + '@types/node': ^18.7.12 + '@types/uuid': ^8.3.4 + http-message-signatures: ^0.1.2 + httpbis-digest-headers: github:interledger/httpbis-digest-headers + jose: ^4.9.0 + koa: ^2.13.4 + koa-bodyparser: ^4.3.0 + koa-json: ^2.0.2 + koa-logger: ^3.2.1 + koa-router: ^12.0.0 + node-mocks-http: ^1.12.1 + typescript: ^4.9.3 + uuid: ^8.3.2 + dependencies: + http-message-signatures: 0.1.2 + httpbis-digest-headers: github.com/interledger/httpbis-digest-headers/787b7af5ba1752337d696a7b1587193058173284 + jose: 4.9.0 + koa: 2.13.4 + koa-bodyparser: 4.3.0 + koa-json: 2.0.2 + koa-logger: 3.2.1 + koa-router: 12.0.0 + uuid: 8.3.2 + devDependencies: + '@types/koa': 2.13.5 + '@types/koa-bodyparser': 4.3.7 + '@types/koa-json': 2.0.20 + '@types/koa-logger': 3.1.2 + '@types/koa-router': 7.4.4 + '@types/node': 18.11.9 + '@types/uuid': 8.3.4 + node-mocks-http: 1.12.1 + typescript: 4.9.3 + packages/mock-account-provider: specifiers: '@apollo/client': ^3.6.9 @@ -284,6 +327,7 @@ importers: axios: ^1.1.3 eslint: ^8.20.0 graphql: ^16.6.0 + http-signature-utils: workspace:../http-signature-utils lodash: ^4.17.21 react: ^18.2.0 react-dom: ^18.2.0 @@ -299,6 +343,7 @@ importers: '@types/uuid': 8.3.4 axios: 1.1.3 graphql: 16.6.0 + http-signature-utils: link:../http-signature-utils lodash: 4.17.21 react: 18.2.0 react-dom: 18.2.0_react@18.2.0 @@ -319,7 +364,7 @@ importers: axios: ^1.1.2 base64url: ^3.0.1 http-message-signatures: ^0.1.2 - httpbis-digest-headers: github:interledger/httpbis-digest-headers + http-signature-utils: workspace:../http-signature-utils nock: ^13.2.9 openapi: workspace:../openapi openapi-typescript: ^4.5.0 @@ -330,7 +375,7 @@ importers: dependencies: axios: 1.1.2 http-message-signatures: 0.1.2 - httpbis-digest-headers: github.com/interledger/httpbis-digest-headers/787b7af5ba1752337d696a7b1587193058173284 + http-signature-utils: link:../http-signature-utils openapi: link:../openapi pino: 8.4.2 devDependencies: @@ -2355,7 +2400,6 @@ packages: /@faker-js/faker/7.4.0: resolution: {integrity: sha512-xDd3Tvkt2jgkx1LkuwwxpNBy/Oe+LkZBTwkgEFTiWpVSZgQ5sc/LenbHKRHbFl0dq/KFeeq/szyyPtpJRKY0fg==} engines: {node: '>=14.0.0', npm: '>=6.0.0'} - dev: false /@gar/promisify/1.1.3: resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -3824,7 +3868,7 @@ packages: /@types/accepts/1.3.5: resolution: {integrity: sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==} dependencies: - '@types/node': 18.7.6 + '@types/node': 18.11.9 /@types/acorn/4.0.6: resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} @@ -3880,7 +3924,7 @@ packages: resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} dependencies: '@types/connect': 3.4.35 - '@types/node': 18.7.13 + '@types/node': 18.11.9 /@types/bytes/3.1.1: resolution: {integrity: sha512-lOGyCnw+2JVPKU3wIV0srU0NyALwTBJlVSx5DfMQOFuuohA8y9S8orImpuIQikZ0uIQ8gehrRjxgQC1rLRi11w==} @@ -3902,7 +3946,7 @@ packages: /@types/connect/3.4.35: resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} dependencies: - '@types/node': 18.7.13 + '@types/node': 18.11.9 /@types/content-disposition/0.5.5: resolution: {integrity: sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==} @@ -3920,7 +3964,7 @@ packages: '@types/connect': 3.4.35 '@types/express': 4.17.13 '@types/keygrip': 1.0.2 - '@types/node': 18.7.6 + '@types/node': 18.11.9 /@types/cors/2.8.12: resolution: {integrity: sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==} @@ -3962,7 +4006,7 @@ packages: /@types/express-serve-static-core/4.17.30: resolution: {integrity: sha512-gstzbTWro2/nFed1WXtf+TtrpwxH7Ggs4RLYTLbeVgIkUQOI3WG/JKjgeOU1zXDvezllupjrf8OPIdvTbIaVOQ==} dependencies: - '@types/node': 18.7.13 + '@types/node': 18.11.9 '@types/qs': 6.9.7 '@types/range-parser': 1.2.4 @@ -4073,6 +4117,24 @@ packages: dependencies: '@types/koa': 2.13.5 + /@types/koa-json/2.0.20: + resolution: {integrity: sha512-RuQ1Vlpsm/EC5wo2oWCgNnwneixnSQ9aHQAE7nwnbSiCibgfUO2wwSQN6rn8SfF97cRdN9hKAtoc9KSIuiGP6Q==} + dependencies: + '@types/koa': 2.13.5 + dev: true + + /@types/koa-logger/3.1.2: + resolution: {integrity: sha512-sioTA1xlKYiIgryANWPRHBkG3XGbWftw9slWADUPC+qvPIY/yRLSrhvX7zkJwMrntub5dPO0GuAoyGGf0yitfQ==} + dependencies: + '@types/koa': 2.13.5 + dev: true + + /@types/koa-router/7.4.4: + resolution: {integrity: sha512-3dHlZ6CkhgcWeF6wafEUvyyqjWYfKmev3vy1PtOmr0mBc3wpXPU5E8fBBd4YQo5bRpHPfmwC5yDaX7s4jhIN6A==} + dependencies: + '@types/koa': 2.13.5 + dev: true + /@types/koa-session/5.10.6: resolution: {integrity: sha512-p4rgkeRmiJu8XGC3eH2duRCNgnLUl6sjadEXH/AsieH/9fqYfXSZoZNC9CAe+FQK+QmM76hVyvuJ5Jrl5xxNeA==} dependencies: @@ -4102,7 +4164,7 @@ packages: '@types/http-errors': 1.8.2 '@types/keygrip': 1.0.2 '@types/koa-compose': 3.2.5 - '@types/node': 18.7.6 + '@types/node': 18.11.9 /@types/koa__cors/3.3.0: resolution: {integrity: sha512-FUN8YxcBakIs+walVe3+HcNP+Bxd0SB8BJHBWkglZ5C1XQWljlKcEFDG/dPiCIqwVCUbc5X0nYDlH62uEhdHMA==} @@ -4112,7 +4174,7 @@ packages: /@types/koa__router/8.0.11: resolution: {integrity: sha512-WXgKWpBsbS14kzmzD9LeFapOIa678h7zvUHxDwXwSx4ETKXhXLVUAToX6jZ/U7EihM7qwyD9W/BZvB0MRu7MTQ==} dependencies: - '@types/koa': 2.13.4 + '@types/koa': 2.13.5 dev: true /@types/lodash/4.14.184: @@ -4161,7 +4223,6 @@ packages: /@types/node/18.11.9: resolution: {integrity: sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==} - dev: true /@types/node/18.7.12: resolution: {integrity: sha512-caqFX7GwvZ4KLnhpI9CfiMkgHKp6kvFAIgpkha0cjO7bAQvB6dWe+q3fTHmm7fQvv59pd4tPj77nriq2M6U2dw==} @@ -4236,7 +4297,7 @@ packages: resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==} dependencies: '@types/mime': 3.0.1 - '@types/node': 18.7.13 + '@types/node': 18.11.9 /@types/ssh2-streams/0.1.9: resolution: {integrity: sha512-I2J9jKqfmvXLR5GomDiCoHrEJ58hAOmFrekfFqmCFd+A6gaEStvWnPykoWUwld1PNg4G5ag1LwdA+Lz1doRJqg==} @@ -4628,7 +4689,6 @@ packages: engines: {node: '>=4'} dependencies: color-convert: 1.9.3 - dev: true /ansi-styles/4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} @@ -5560,7 +5620,6 @@ packages: ansi-styles: 3.2.1 escape-string-regexp: 1.0.5 supports-color: 5.5.0 - dev: true /chalk/4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -5766,7 +5825,6 @@ packages: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: color-name: 1.1.3 - dev: true /color-convert/2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} @@ -5777,7 +5835,6 @@ packages: /color-name/1.1.3: resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - dev: true /color-name/1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -6730,7 +6787,6 @@ packages: /escape-string-regexp/1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} - dev: true /escape-string-regexp/2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} @@ -7940,7 +7996,6 @@ packages: /has-flag/3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} - dev: true /has-flag/4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} @@ -8147,6 +8202,10 @@ packages: engines: {node: '>=10.17.0'} dev: true + /humanize-number/0.0.2: + resolution: {integrity: sha512-un3ZAcNQGI7RzaWGZzQDH47HETM4Wrj6z6E4TId8Yeq9w5ZKUVB1nrT2jwFheTUjEmqcgTjXDc959jum+ai1kQ==} + dev: false + /husky/8.0.2: resolution: {integrity: sha512-Tkv80jtvbnkK3mYWxPZePGFpQ/tT3HNSs/sasF9P2YfkMezDl3ON37YN6jUUI4eTg5LcyVynlb6r4eyvOmspvg==} engines: {node: '>=14'} @@ -9346,7 +9405,6 @@ packages: /json-stringify-safe/5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - dev: true /json-to-pretty-yaml/1.2.2: resolution: {integrity: sha512-rvm6hunfCcqegwYaG5T4yKJWxc9FXFgBVrcTZ4XfSVRwa5HA/Xs+vB/Eo9treYYHCeNM0nrSUr82V/M31Urc7A==} @@ -9537,6 +9595,37 @@ packages: co: 4.6.0 koa-compose: 4.1.0 + /koa-is-json/1.0.0: + resolution: {integrity: sha512-+97CtHAlWDx0ndt0J8y3P12EWLwTLMXIfMnYDev3wOTwH/RpBGMlfn4bDXlMEg1u73K6XRE9BbUp+5ZAYoRYWw==} + dev: false + + /koa-json/2.0.2: + resolution: {integrity: sha512-8+dz0T2ekDuNN1svYoKPCV2txotQ3Ufg8Fn5bft1T48MPJWiC/HKmkk+3xj9EC/iNZuFYeLRazN2h2o3RSUXuQ==} + dependencies: + koa-is-json: 1.0.0 + streaming-json-stringify: 3.1.0 + dev: false + + /koa-logger/3.2.1: + resolution: {integrity: sha512-MjlznhLLKy9+kG8nAXKJLM0/ClsQp/Or2vI3a5rbSQmgl8IJBQO0KI5FA70BvW+hqjtxjp49SpH2E7okS6NmHg==} + engines: {node: '>= 7.6.0'} + dependencies: + bytes: 3.1.2 + chalk: 2.4.2 + humanize-number: 0.0.2 + passthrough-counter: 1.0.0 + dev: false + + /koa-router/12.0.0: + resolution: {integrity: sha512-zGrdiXygGYW8WvrzeGsHZvKnHs4DzyGoqJ9a8iHlRkiwuEAOAPyI27//OlhoWdgFAEIM3qbUgr0KCuRaP/TCag==} + engines: {node: '>= 12'} + dependencies: + http-errors: 2.0.0 + koa-compose: 4.1.0 + methods: 1.1.2 + path-to-regexp: 6.2.1 + dev: false + /koa-session/6.2.0: resolution: {integrity: sha512-l2ZC6D1BnRkIXhWkRgpewdqKn38/9/2WScmxyShuN408TxX+J/gUzdzGBIvGZaRwmezOU819sNpGmfFGLeDckg==} engines: {node: '>=7.6'} @@ -10594,6 +10683,22 @@ packages: range-parser: 1.2.1 type-is: 1.6.18 + /node-mocks-http/1.12.1: + resolution: {integrity: sha512-jrA7Sn3qI6GsHgWtUW3gMj0vO6Yz0nJjzg3jRZYjcfj4tzi8oWPauDK1qHVJoAxTbwuDHF1JiM9GISZ/ocI/ig==} + engines: {node: '>=0.6'} + dependencies: + accepts: 1.3.8 + content-disposition: 0.5.4 + depd: 1.1.2 + fresh: 0.5.2 + merge-descriptors: 1.0.1 + methods: 1.1.2 + mime: 1.6.0 + parseurl: 1.3.3 + range-parser: 1.2.1 + type-is: 1.6.18 + dev: true + /node-releases/2.0.6: resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==} dev: true @@ -11080,6 +11185,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /passthrough-counter/1.0.0: + resolution: {integrity: sha512-Wy8PXTLqPAN0oEgBrlnsXPMww3SYJ44tQ8aVrGAI4h4JZYCS0oYqsPqtPR8OhJpv6qFbpbB7XAn0liKV7EXubA==} + dev: false + /path-case/3.0.4: resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} dependencies: @@ -12380,6 +12489,13 @@ packages: /stream-slice/0.1.2: resolution: {integrity: sha512-QzQxpoacatkreL6jsxnVb7X5R/pGw9OUv2qWTYWnmLpg4NdN31snPy/f3TdQE1ZUXaThRvj1Zw4/OGg0ZkaLMA==} + /streaming-json-stringify/3.1.0: + resolution: {integrity: sha512-axtfs3BDxAsrZ9swD163FBrXZ8dhJJp6kUI6C97TvUZG9RHKfbg9nFbXqEheFNOb3IYMEt2ag9F62sWLFUZ4ug==} + dependencies: + json-stringify-safe: 5.0.1 + readable-stream: 2.3.7 + dev: false + /streamsearch/1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -12501,7 +12617,6 @@ packages: engines: {node: '>=4'} dependencies: has-flag: 3.0.0 - dev: true /supports-color/7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} @@ -13060,6 +13175,12 @@ packages: hasBin: true dev: true + /typescript/4.9.3: + resolution: {integrity: sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + /ua-parser-js/0.7.31: resolution: {integrity: sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==} dev: true diff --git a/tsconfig.json b/tsconfig.json index a816dfc8f2..42a52feee0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,8 @@ { "references": [ + { + "path": "packages/http-signature-utils" + }, { "path": "packages/openapi" },