diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index 50ded6a524..561c6df3ab 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -20,6 +20,8 @@ services: AUTH_SERVER_DOMAIN: ${CLOUD_NINE_AUTH_SERVER_DOMAIN:-http://localhost:3006} TESTNET_AUTOPEER_URL: ${TESTNET_AUTOPEER_URL} GRAPHQL_URL: http://cloud-nine-wallet-backend:3001/graphql + SIGNATURE_VERSION: 1 + SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= volumes: - ../cloud-nine-wallet/seed.yml:/workspace/seed.yml - ../cloud-nine-wallet/private-key.pem:/workspace/private-key.pem @@ -55,6 +57,7 @@ services: AUTH_SERVER_INTROSPECTION_URL: http://cloud-nine-wallet-auth:3007 ILP_ADDRESS: ${ILP_ADDRESS:-test.cloud-nine-wallet} STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= + API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= OPEN_PAYMENTS_URL: ${CLOUD_NINE_OPEN_PAYMENTS_URL:-http://cloud-nine-wallet-backend} WEBHOOK_URL: http://cloud-nine-wallet/webhooks EXCHANGE_RATES_URL: http://cloud-nine-wallet/rates @@ -118,6 +121,8 @@ services: GRAPHQL_URL: http://cloud-nine-wallet-backend:3001/graphql OPEN_PAYMENTS_URL: https://cloud-nine-wallet-backend/ ENABLE_INSECURE_MESSAGE_COOKIE: true + SIGNATURE_VERSION: 1 + SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= depends_on: - cloud-nine-backend diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 9882975b57..1fa87a0ca9 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -16,6 +16,8 @@ services: KEY_FILE: /workspace/private-key.pem OPEN_PAYMENTS_URL: ${HAPPY_LIFE_BANK_OPEN_PAYMENTS_URL:-https://happy-life-bank-backend} GRAPHQL_URL: http://happy-life-bank-backend:3001/graphql + SIGNATURE_VERSION: 1 + SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= volumes: - ../happy-life-bank/seed.yml:/workspace/seed.yml - ../happy-life-bank/private-key.pem:/workspace/private-key.pem @@ -48,6 +50,7 @@ services: AUTH_SERVER_INTROSPECTION_URL: http://happy-life-bank-auth:3007 ILP_ADDRESS: test.happy-life-bank STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= + API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= WEBHOOK_URL: http://happy-life-bank/webhooks OPEN_PAYMENTS_URL: ${HAPPY_LIFE_BANK_OPEN_PAYMENTS_URL:-http://happy-life-bank-backend} EXCHANGE_RATES_URL: http://happy-life-bank/rates @@ -86,6 +89,8 @@ services: GRAPHQL_URL: http://happy-life-bank-backend:3001/graphql OPEN_PAYMENTS_URL: https://happy-life-bank-backend/ ENABLE_INSECURE_MESSAGE_COOKIE: true + SIGNATURE_VERSION: 1 + SIGNATURE_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= depends_on: - cloud-nine-admin - happy-life-backend diff --git a/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts b/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts index ce74355a32..cd6065d73a 100644 --- a/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts +++ b/localenv/mock-account-servicing-entity/app/lib/apolloClient.ts @@ -1,3 +1,7 @@ +import { createHmac } from 'crypto' + +import { canonicalize } from 'json-canonicalize' +import { print } from 'graphql/language/printer' import type { NormalizedCacheObject } from '@apollo/client' import { createHttpLink, @@ -29,10 +33,27 @@ const errorLink = onError(({ graphQLErrors }) => { } }) -const authLink = setContext((_, { headers }) => { +const authLink = setContext((request, { headers }) => { + if (!process.env.SIGNATURE_SECRET || !process.env.SIGNATURE_VERSION) + return { headers } + const timestamp = Math.round(new Date().getTime() / 1000) + const version = process.env.SIGNATURE_VERSION + + const { query, variables, operationName } = request + const formattedRequest = { + variables, + operationName, + query: print(query) + } + + const payload = `${timestamp}.${canonicalize(formattedRequest)}` + const hmac = createHmac('sha256', process.env.SIGNATURE_SECRET) + hmac.update(payload) + const digest = hmac.digest('hex') return { headers: { - ...headers + ...headers, + signature: `t=${timestamp}, v${version}=${digest}` } } }) diff --git a/localenv/mock-account-servicing-entity/package.json b/localenv/mock-account-servicing-entity/package.json index 1bfd46d22f..6b423af9f8 100644 --- a/localenv/mock-account-servicing-entity/package.json +++ b/localenv/mock-account-servicing-entity/package.json @@ -18,6 +18,7 @@ "@types/uuid": "^9.0.8", "axios": "^1.6.8", "graphql": "^16.8.1", + "json-canonicalize": "^1.0.6", "mock-account-service-lib": "workspace:*", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 5b4e265e9c..d960c1d179 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -86,6 +86,8 @@ import { PaymentMethodHandlerService } from './payment-method/handler/service' import { IlpPaymentService } from './payment-method/ilp/service' import { TelemetryService } from './telemetry/service' import { ApolloArmor } from '@escape.tech/graphql-armor' +import { verifyApiSignature } from './shared/utils' + export interface AppContextData { logger: Logger container: AppContainer @@ -348,6 +350,7 @@ export class App { await this.apolloServer.start() + koa.use(cors()) koa.use(bodyParser()) koa.use( @@ -366,6 +369,17 @@ export class App { } ) + koa.use(async (ctx, next: Koa.Next): Promise => { + this.logger.info( + { requestBody: ctx.request.body, headers: ctx.request.headers }, + 'body to be hashed, headers' + ) + if (!verifyApiSignature(ctx, this.config) && this.config.apiSecret) { + ctx.throw(401, 'Unauthorized') + } + return next() + }) + koa.use( koaMiddleware(this.apolloServer, { context: async (): Promise => { diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index 4de99d4f5b..271cac0402 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -137,6 +137,8 @@ export const Config = { signatureSecret: process.env.SIGNATURE_SECRET, // optional signatureVersion: envInt('SIGNATURE_VERSION', 1), + apiSecret: process.env.API_SECRET, // optional + keyId: envString('KEY_ID', 'rafiki'), privateKey: loadOrGenerateKey(envString('PRIVATE_KEY_FILE', '')), diff --git a/packages/backend/src/shared/utils.ts b/packages/backend/src/shared/utils.ts index a4047ffc0c..f9fb18375d 100644 --- a/packages/backend/src/shared/utils.ts +++ b/packages/backend/src/shared/utils.ts @@ -1,5 +1,9 @@ import { validate, version } from 'uuid' import { URL, type URL as URLType } from 'url' +import { Context } from 'koa' +import { createHmac } from 'crypto' +import { canonicalize } from 'json-canonicalize' +import { IAppConfig } from '../config/app' export function validateId(id: string): boolean { return validate(id) && version(id) === 4 @@ -91,3 +95,22 @@ export async function poll(args: PollArgs): Promise { // eslint-disable-next-line no-constant-condition } while (true) } + +export function verifyApiSignature(ctx: Context, config: IAppConfig): boolean { + const { headers, body } = ctx.request + const signature = headers['signature'] + if (!signature) { + return false + } + + const signatureParts = (signature as string)?.split(', ') + const timestamp = signatureParts[0].split('=')[1] + const signatureDigest = signatureParts[1].split('=')[1] + + const payload = `${timestamp}.${canonicalize(body)}` + const hmac = createHmac('sha256', config.apiSecret as string) + hmac.update(payload) + const digest = hmac.digest('hex') + + return digest === signatureDigest +} diff --git a/packages/frontend/app/lib/apollo.server.ts b/packages/frontend/app/lib/apollo.server.ts index 8296b7b500..e8d4dfccba 100644 --- a/packages/frontend/app/lib/apollo.server.ts +++ b/packages/frontend/app/lib/apollo.server.ts @@ -1,5 +1,15 @@ -import { ApolloClient, InMemoryCache } from '@apollo/client' +import { createHmac } from 'crypto' + +import { + ApolloClient, + ApolloLink, + InMemoryCache, + createHttpLink +} from '@apollo/client' import type { NormalizedCacheObject } from '@apollo/client' +import { setContext } from '@apollo/client/link/context' +import { canonicalize } from 'json-canonicalize' +import { print } from 'graphql/language/printer' /* eslint-disable no-var */ declare global { @@ -16,9 +26,40 @@ BigInt.prototype.toJSON = function (this: bigint) { return this.toString() } +const authLink = setContext((request, { headers }) => { + if (!process.env.SIGNATURE_SECRET || !process.env.SIGNATURE_VERSION) + return { headers } + const timestamp = Math.round(new Date().getTime() / 1000) + const version = process.env.SIGNATURE_VERSION + + const { query, variables, operationName } = request + const formattedRequest = { + variables, + operationName, + query: print(query) + } + + const payload = `${timestamp}.${canonicalize(formattedRequest)}` + const hmac = createHmac('sha256', process.env.SIGNATURE_SECRET) + hmac.update(payload) + const digest = hmac.digest('hex') + + return { + headers: { + ...headers, + signature: `t=${timestamp}, v${version}=${digest}` + } + } +}) + +const httpLink = createHttpLink({ + uri: process.env.GRAPHQL_URL ?? 'http://localhost:3001/graphql' +}) + if (!global.__apolloClient) { global.__apolloClient = new ApolloClient({ cache: new InMemoryCache({}), + link: ApolloLink.from([authLink, httpLink]), defaultOptions: { query: { fetchPolicy: 'no-cache' @@ -26,8 +67,7 @@ if (!global.__apolloClient) { mutate: { fetchPolicy: 'no-cache' } - }, - uri: process.env.GRAPHQL_URL ?? 'http://localhost:3001/graphql' + } }) } const apolloClient = global.__apolloClient diff --git a/packages/frontend/package.json b/packages/frontend/package.json index d6075a3c95..7f59e2117d 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -20,6 +20,7 @@ "graphql": "^16.8.1", "ilp-packet": "3.1.4-alpha.2", "isbot": "^5.1.2", + "json-canonicalize": "^1.0.6", "react": "^18.2.0", "react-dom": "^18.2.0", "uuid": "^9.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d085af9f1d..4d7faddaac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: graphql: specifier: ^16.8.1 version: 16.8.1 + json-canonicalize: + specifier: ^1.0.6 + version: 1.0.6 mock-account-service-lib: specifier: workspace:* version: link:../../packages/mock-account-service-lib @@ -524,6 +527,9 @@ importers: isbot: specifier: ^5.1.2 version: 5.1.2 + json-canonicalize: + specifier: ^1.0.6 + version: 1.0.6 react: specifier: ^18.2.0 version: 18.2.0