Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions localenv/cloud-nine-wallet/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ services:
OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787
CARD_SERVICE_URL: 'http://cloud-nine-wallet-card-service:3007'
CARD_WEBHOOK_SERVICE_URL: 'http://cloud-nine-wallet-card-service:3007/webhook'
DB_ENCRYPTION_SECRET: 'zO9KogehJECHReHgQr+ZWGkmgOD4AYa4ksUxALSwgM8='
depends_on:
shared-database:
condition: service_healthy
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ export async function handleOutgoingPaymentCreated(
variables: {
input: {
outgoingPaymentId: payment.id,
idempotencyKey: uuid()
idempotencyKey: uuid(),
dataToTransmit: 'sample kyc data'
}
}
})
Expand Down
2 changes: 2 additions & 0 deletions localenv/mock-account-servicing-entity/generated/graphql.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.alterTable('outgoingPayments', function (table) {
table.string('dataToTransmit').nullable()
})
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.alterTable('outgoingPayments', function (table) {
table.dropColumn('dataToTransmit')
})
}
3 changes: 2 additions & 1 deletion packages/backend/src/config/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@ export const Config = {
cardServiceUrl: optional(envString, 'CARD_SERVICE_URL'),
posServiceUrl: optional(envString, 'POS_SERVICE_URL'),
posWebhookServiceUrl: optional(envString, 'POS_WEBHOOK_SERVICE_URL'),
cardWebhookUrl: optional(envString, 'CARD_WEBHOOK_SERVICE_URL')
cardWebhookUrl: optional(envString, 'CARD_WEBHOOK_SERVICE_URL'),
dbEncryptionSecret: optional(envString, 'DB_ENCRYPTION_SECRET')
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
12 changes: 12 additions & 0 deletions packages/backend/src/graphql/generated/graphql.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/backend/src/graphql/generated/graphql.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion packages/backend/src/graphql/resolvers/liquidity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
import { GraphQLErrorCode } from '../errors'
import { Tenant } from '../../tenants/model'
import { createTenant } from '../../tests/tenant'
import { faker } from '@faker-js/faker'

describe('Liquidity Resolvers', (): void => {
let deps: IocContract<AppServices>
Expand Down Expand Up @@ -3493,6 +3494,9 @@ describe('Liquidity Resolvers', (): void => {

test('Can deposit account liquidity', async (): Promise<void> => {
const depositSpy = jest.spyOn(accountingService, 'createDeposit')
const dataToTransmit = JSON.stringify({
data: faker.internet.email()
})
const response = await appContainer.apolloClient
.mutate({
mutation: gql`
Expand All @@ -3507,7 +3511,8 @@ describe('Liquidity Resolvers', (): void => {
variables: {
input: {
outgoingPaymentId: outgoingPayment.id,
idempotencyKey: uuid()
idempotencyKey: uuid(),
dataToTransmit
}
}
})
Expand All @@ -3529,6 +3534,13 @@ describe('Liquidity Resolvers', (): void => {
await expect(
accountingService.getBalance(outgoingPayment.id)
).resolves.toEqual(outgoingPayment.debitAmount.value)
await expect(
OutgoingPayment.query(knex).findById(outgoingPayment.id)
).resolves.toEqual(
expect.objectContaining({
dataToTransmit
})
)
})

test("Can't deposit for non-existent outgoing payment id", async (): Promise<void> => {
Expand Down
5 changes: 3 additions & 2 deletions packages/backend/src/graphql/resolvers/liquidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ export const depositOutgoingPaymentLiquidity: MutationResolvers<TenantedApolloCo
)

try {
const { outgoingPaymentId } = args.input
const { outgoingPaymentId, dataToTransmit } = args.input
const webhookService = await ctx.container.use('webhookService')
const stopTimerWh = telemetry.startTimer('wh_get_latest_ms', {
callName: 'WebhookService:getLatestByResourceId'
Expand Down Expand Up @@ -581,7 +581,8 @@ export const depositOutgoingPaymentLiquidity: MutationResolvers<TenantedApolloCo
id: outgoingPaymentId,
tenantId: ctx.tenant.id,
amount: BigInt(event.data.debitAmount.value),
transferId: event.id
transferId: event.id,
dataToTransmit
})
stopTimerFund()

Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/graphql/resolvers/outgoing_payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,11 @@ export const createOutgoingPayment: MutationResolvers<TenantedApolloContext>['cr
code: errorToCode[outgoingPaymentOrError]
}
})
} else
} else {
return {
payment: paymentToGraphql(outgoingPaymentOrError)
}
}
}

export const createOutgoingPaymentFromIncomingPayment: MutationResolvers<TenantedApolloContext>['createOutgoingPaymentFromIncomingPayment'] =
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,8 @@ input DepositOutgoingPaymentLiquidityInput {
outgoingPaymentId: String!
"Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency)."
idempotencyKey: String!
"Data to be encrypted and sent to the receiver."
dataToTransmit: String
}

input CreateIncomingPaymentWithdrawalInput {
Expand Down
107 changes: 105 additions & 2 deletions packages/backend/src/open_payments/payment/outgoing/model.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import crypto from 'node:crypto'
import { v4 as uuid } from 'uuid'
import assert from 'assert'
import { Knex } from 'knex'
import { Config } from '../../../config/app'
import { Config, IAppConfig } from '../../../config/app'
import { createTestApp, TestContainer } from '../../../tests/app'
import { IocContract } from '@adonisjs/fold'
import { initIocContainer } from '../../..'
Expand All @@ -8,18 +11,30 @@ import { truncateTables } from '../../../tests/tableManager'
import {
OutgoingPaymentEventError,
OutgoingPaymentEvent,
OutgoingPaymentEventType
OutgoingPaymentEventType,
OutgoingPayment
} from './model'
import { createOutgoingPayment } from '../../../tests/outgoingPayment'
import { createWalletAddress } from '../../../tests/walletAddress'
import { IncomingPaymentInitiationReason } from '../incoming/types'
import { createIncomingPayment } from '../../../tests/incomingPayment'
import { OutgoingPaymentService } from './service'
import { faker } from '@faker-js/faker'
import { isFundingError, isOutgoingPaymentError } from './errors'
import { withConfigOverride } from '../../../tests/helpers'
import { WalletAddress } from '../../wallet_address/model'

describe('Outgoing Payment Event Model', (): void => {
let deps: IocContract<AppServices>
let appContainer: TestContainer
let knex: Knex
let config: IAppConfig

beforeAll(async (): Promise<void> => {
deps = initIocContainer(Config)
appContainer = await createTestApp(deps)
knex = await deps.use('knex')
config = await deps.use('config')
})

afterEach(async (): Promise<void> => {
Expand Down Expand Up @@ -47,4 +62,92 @@ describe('Outgoing Payment Event Model', (): void => {
}
)
})

describe('getDataToTransmit', (): void => {
let outgoingPaymentService: OutgoingPaymentService
let walletAddress: WalletAddress
let payment: OutgoingPayment
const dbEncryptionOverride: Partial<IAppConfig> = {
dbEncryptionSecret: crypto.randomBytes(32).toString('base64')
}
beforeAll(async (): Promise<void> => {
outgoingPaymentService = await deps.use('outgoingPaymentService')
})

beforeEach(async (): Promise<void> => {
walletAddress = await createWalletAddress(deps)
const incomingPayment = await createIncomingPayment(deps, {
walletAddressId: walletAddress.id,
tenantId: walletAddress.tenantId,
initiationReason: IncomingPaymentInitiationReason.Admin
})
const receiver = incomingPayment.getUrl(config.openPaymentsUrl)
payment = await createOutgoingPayment(deps, {
tenantId: walletAddress.tenantId,
walletAddressId: walletAddress.id,
receiver,
method: 'ilp',
debitAmount: {
value: BigInt(123),
assetCode: walletAddress.asset.code,
assetScale: walletAddress.asset.scale
}
})
})

test(
'can decrypt data',
withConfigOverride(
() => config,
dbEncryptionOverride,
async (): Promise<void> => {
const decipherSpy = jest.spyOn(crypto, 'createDecipheriv')
const dataToTransmit = { data: faker.internet.email() }
const paymentWithData = await outgoingPaymentService.fund({
id: payment.id,
tenantId: walletAddress.tenantId,
amount: payment.debitAmount.value,
transferId: uuid(),
dataToTransmit: JSON.stringify(dataToTransmit)
})

assert.ok(!isOutgoingPaymentError(paymentWithData))
assert.ok(!isFundingError(paymentWithData))
expect(
paymentWithData.getDataToTransmit(config.dbEncryptionSecret)
).toEqual(JSON.stringify(dataToTransmit))
expect(decipherSpy).toHaveBeenCalled()
}
)
)

test(
'returns data as-is without configured key env variable',
withConfigOverride(
() => config,
{
...dbEncryptionOverride,
dbEncryptionSecret: undefined
},
async (): Promise<void> => {
const decipherSpy = jest.spyOn(crypto, 'createDecipheriv')
const dataToTransmit = { data: faker.internet.email() }
const paymentWithData = await outgoingPaymentService.fund({
id: payment.id,
tenantId: walletAddress.tenantId,
amount: payment.debitAmount.value,
transferId: uuid(),
dataToTransmit: JSON.stringify(dataToTransmit)
})

assert.ok(!isOutgoingPaymentError(paymentWithData))
assert.ok(!isFundingError(paymentWithData))
expect(
paymentWithData.getDataToTransmit(config.dbEncryptionSecret)
).toEqual(JSON.stringify(dataToTransmit))
expect(decipherSpy).not.toHaveBeenCalled()
}
)
)
})
})
19 changes: 19 additions & 0 deletions packages/backend/src/open_payments/payment/outgoing/model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Model, ModelOptions, QueryContext } from 'objection'
import { DbErrors } from 'objection-db-errors'
import { createDecipheriv } from 'node:crypto'

import { LiquidityAccount } from '../../../accounting/service'
import { Asset } from '../../../asset/model'
Expand Down Expand Up @@ -133,6 +134,24 @@ export class OutgoingPayment

public tenantId!: string

public dataToTransmit?: string
public getDataToTransmit(key?: string): string | null {
if (!this.dataToTransmit) return null
if (!key) return this.dataToTransmit
const { tag, cipherText, iv } = JSON.parse(this.dataToTransmit)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Key rotation might be a bit tricky here, since we could end up encrypting with one key, change the key, try to decrypt with the old one and fail. We can focus on this on a follow-up PR.
(maybe we allow to configure a list of keys instead?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Captured in RAF-1207.


const decipher = createDecipheriv(
'aes-256-gcm',
Uint8Array.from(Buffer.from(key, 'base64')),
iv
)
decipher.setAuthTag(Uint8Array.from(Buffer.from(tag, 'base64')))
let decryptedDataToTransmit = decipher.update(cipherText, 'base64', 'utf8')
decryptedDataToTransmit += decipher.final('utf8')

return decryptedDataToTransmit
}

static get relationMappings() {
return {
...super.relationMappings,
Expand Down
Loading
Loading