From 46ef99fe7f2aec5476b70368624102179947b100 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 28 Sep 2026 17:03:21 -0700 Subject: [PATCH] fix(identity): issue Coinflow sessions for verified wallets --- packages/identity-service/README.md | 39 ++++ packages/identity-service/src/config.js | 13 ++ .../identity-service/src/routes/coinflow.js | 101 +++++++++ .../identity-service/test/coinflowTest.js | 195 ++++++++++++++++++ packages/identity-service/test/index.ts | 1 + 5 files changed, 349 insertions(+) create mode 100644 packages/identity-service/src/routes/coinflow.js create mode 100644 packages/identity-service/test/coinflowTest.js diff --git a/packages/identity-service/README.md b/packages/identity-service/README.md index 424fbea1dbc..698b823e817 100644 --- a/packages/identity-service/README.md +++ b/packages/identity-service/README.md @@ -3,3 +3,42 @@ The identity service maintains all the identity aspects of the Audius ecosystem such as storing encrypted auth ciphertexts, doing Twitter oauth and relay transactions on behalf of users Read [the wiki](https://github.com/AudiusProject/apps/wiki/Identity-Service:-Overview) for more info. + +## Coinflow session authentication + +Coinflow purchase and withdrawal components obtain their session key from +`POST /coinflow/session-key`. The endpoint requires identity-service auth headers +and a fresh Ed25519 ownership proof from the Solana root wallet. The root wallet +is different from the public `spl_wallet` user bank address. No database migration +is required. + +Configure these server-side environment variables before deploying the clients: + +- `coinflowApiKey`: the secret Coinflow merchant API key. Use the key for the + merchant configured in the clients (`audius` in development, `tikilabs` in + production). Do not put this key in web/mobile environment files. +- `coinflowEnvironment`: `sandbox` (default) or `prod`, matching the clients. + +Deploy the configured identity service before the web and mobile updates. +Existing mobile installations also need the client update; a backend deployment +alone does not migrate them. No Coinflow SDK upgrade is needed. + +The client sends `{ wallet, environment, timestamp, signature }`, with a Unix +millisecond timestamp and base64 Ed25519 signature over the UTF-8 string +`Audius Coinflow session::::`. +The proof is valid for five minutes and is bound to the authenticated identity. +The endpoint returns `{ key, expiresAt }` with `Cache-Control: no-store`. + +Session keys are kept in memory, scoped by identity, wallet, and environment, +and refreshed after 25 minutes. The components stop using an unrefreshed key +one minute before its documented 30-minute expiry. Upstream errors are sanitized +so merchant credentials are not logged or returned. + +Before production rollout, verify purchase and withdrawal on web, iOS, and +Android in sandbox, including guest checkout, session refresh, failed refresh +and retry, logout, and account switching. Verify that the embedded Coinflow +requests use session authentication. Confirm completion with Coinflow only after +production rollout and verification. + +References: [session-key API](https://docs.coinflow.cash/api-reference/api-reference/authentication/get-session-key), +[session lifetime](https://docs.coinflow.cash/guides/payouts/implementation-methods/bank-authentication-ui). diff --git a/packages/identity-service/src/config.js b/packages/identity-service/src/config.js index c53c9187e60..e60517fb9cb 100644 --- a/packages/identity-service/src/config.js +++ b/packages/identity-service/src/config.js @@ -485,6 +485,19 @@ const config = convict({ env: 'hCaptchaSecret', default: '' }, + coinflowApiKey: { + doc: 'Coinflow merchant API key. Must match the client merchant ID and environment.', + format: String, + env: 'coinflowApiKey', + sensitive: true, + default: '' + }, + coinflowEnvironment: { + doc: 'Coinflow API environment', + format: ['sandbox', 'prod'], + env: 'coinflowEnvironment', + default: 'sandbox' + }, plaidClientId: { doc: 'Plaid client ID', format: String, diff --git a/packages/identity-service/src/routes/coinflow.js b/packages/identity-service/src/routes/coinflow.js new file mode 100644 index 00000000000..b013e3756b7 --- /dev/null +++ b/packages/identity-service/src/routes/coinflow.js @@ -0,0 +1,101 @@ +const { createPublicKey, verify } = require('crypto') +const { PublicKey } = require('@solana/web3.js') +const axios = require('axios') +const axiosHttpAdapter = require('axios/lib/adapters/http') + +const config = require('../config') +const authMiddleware = require('../authMiddleware') +const { + handleResponse, + successResponse, + errorResponseBadRequest, + errorResponseForbidden, + errorResponseServerError +} = require('../apiHelpers') + +const PROOF_MAX_AGE_MS = 5 * 60 * 1000 +const SESSION_DURATION_MS = 30 * 60 * 1000 +// ASN.1 SubjectPublicKeyInfo prefix for an Ed25519 public key. +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') + +module.exports = function (app) { + app.post( + '/coinflow/session-key', + authMiddleware, + handleResponse(async (req, res) => { + res.set('Cache-Control', 'no-store') + const { wallet, signature, timestamp, environment } = req.body + if ( + typeof wallet !== 'string' || + typeof signature !== 'string' || + !Number.isSafeInteger(timestamp) || + timestamp > Date.now() + 30000 || + Date.now() - timestamp > PROOF_MAX_AGE_MS || + environment !== config.get('coinflowEnvironment') + ) { + return errorResponseBadRequest('Invalid Coinflow session request') + } + + // The root wallet is derived locally and is not the public spl_wallet + // (user bank). Require proof of ownership, bound to this identity and + // environment, rather than trusting a caller-supplied wallet address. + const message = `Audius Coinflow session:${req.user.walletAddress.toLowerCase()}:${wallet}:${environment}:${timestamp}` + try { + const publicKey = createPublicKey({ + key: Buffer.concat([ + ED25519_SPKI_PREFIX, + new PublicKey(wallet).toBuffer() + ]), + format: 'der', + type: 'spki' + }) + const signatureBytes = Buffer.from(signature, 'base64') + if ( + signatureBytes.length !== 64 || + !verify(null, Buffer.from(message), publicKey, signatureBytes) + ) { + return errorResponseForbidden('Invalid wallet ownership proof') + } + } catch { + return errorResponseForbidden('Invalid wallet ownership proof') + } + + const apiKey = config.get('coinflowApiKey') + if (!apiKey) { + return errorResponseServerError('Coinflow is not configured') + } + const baseUrl = + environment === 'prod' + ? 'https://api.coinflow.cash' + : 'https://api-sandbox.coinflow.cash' + const requestedAt = Date.now() + try { + const response = await axios({ + adapter: axiosHttpAdapter, + method: 'GET', + url: `${baseUrl}/api/auth/session-key`, + timeout: 10000, + headers: { + Authorization: apiKey, + 'x-coinflow-auth-wallet': wallet, + 'x-coinflow-auth-blockchain': 'solana' + } + }) + if (typeof response.data?.key !== 'string' || !response.data.key) { + throw new Error('Missing session key') + } + return successResponse({ + key: response.data.key, + expiresAt: requestedAt + SESSION_DURATION_MS + }) + } catch (error) { + // Axios errors include the merchant key in their request config. + req.logger.error( + { status: error.response?.status }, + 'Failed to create Coinflow session' + ) + return errorResponseServerError('Could not create Coinflow session') + } + }) + ) +} diff --git a/packages/identity-service/test/coinflowTest.js b/packages/identity-service/test/coinflowTest.js new file mode 100644 index 00000000000..859944ddd7f --- /dev/null +++ b/packages/identity-service/test/coinflowTest.js @@ -0,0 +1,195 @@ +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const vm = require('vm') +const { Keypair } = require('@solana/web3.js') +const { createPrivateKey, sign } = require('crypto') + +// Load the route with isolated service boundaries so these tests do not need +// Postgres, Redis, a merchant key, or a live payment provider. +function loadModule(filename, dependencies) { + const module = { exports: {} } + vm.runInNewContext( + fs.readFileSync(filename, 'utf8'), + { + module, + exports: module.exports, + require: (name) => dependencies[name] ?? require(name), + Buffer, + Date + }, + { filename } + ) + return module.exports +} + +const apiHelpers = loadModule(path.join(__dirname, '../src/apiHelpers.js'), { + './logging': { requestNotExcludedFromLogging: () => true } +}) + +const PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex') + +describe('Coinflow session authentication', function () { + let handler, authMiddleware, middleware, settings, calls, logs, response + const keypair = Keypair.generate() + const wallet = keypair.publicKey.toBase58() + const identity = '0x1234567890123456789012345678901234567890' + const privateKey = createPrivateKey({ + key: Buffer.concat([ + PKCS8_PREFIX, + Buffer.from(keypair.secretKey.slice(0, 32)) + ]), + type: 'pkcs8', + format: 'der' + }) + + beforeEach(() => { + calls = [] + logs = [] + settings = { + coinflowApiKey: 'merchant-secret', + coinflowEnvironment: 'sandbox' + } + response = { data: { key: 'session-secret' } } + authMiddleware = () => {} + const register = loadModule( + path.join(__dirname, '../src/routes/coinflow.js'), + { + '../config': { get: (key) => settings[key] }, + '../authMiddleware': authMiddleware, + '../apiHelpers': { ...apiHelpers, handleResponse: (fn) => fn }, + axios: async (request) => { + calls.push(request) + if (response instanceof Error) throw response + return response + }, + 'axios/lib/adapters/http': () => {} + } + ) + register({ + post: (route, auth, fn) => { + assert.strictEqual(route, '/coinflow/session-key') + middleware = auth + handler = fn + } + }) + }) + + function request(overrides = {}, userWallet = identity) { + const timestamp = Date.now() + const environment = settings.coinflowEnvironment + const message = `Audius Coinflow session:${identity}:${wallet}:${environment}:${timestamp}` + return { + body: { + wallet, + timestamp, + environment, + signature: sign(null, Buffer.from(message), privateKey).toString( + 'base64' + ), + ...overrides + }, + user: { walletAddress: userWallet }, + logger: { error: (...args) => logs.push(args) } + } + } + + async function invoke(req = request()) { + const headers = {} + const result = await handler(req, { + set: (key, value) => { + headers[key] = value + } + }) + assert.strictEqual(headers['Cache-Control'], 'no-store') + return result + } + + it('requires identity authentication and uses the verified root wallet in the upstream request', async () => { + assert.strictEqual(middleware, authMiddleware) + const before = Date.now() + const result = await invoke() + assert.strictEqual(result.statusCode, 200) + assert.strictEqual(result.object.key, 'session-secret') + assert(result.object.expiresAt >= before + 30 * 60 * 1000) + assert.strictEqual(calls.length, 1) + assert.strictEqual( + calls[0].url, + 'https://api-sandbox.coinflow.cash/api/auth/session-key' + ) + assert.strictEqual(calls[0].method, 'GET') + assert.strictEqual(calls[0].headers.Authorization, 'merchant-secret') + assert.strictEqual(calls[0].headers['x-coinflow-auth-wallet'], wallet) + assert.strictEqual(calls[0].headers['x-coinflow-auth-blockchain'], 'solana') + assert.strictEqual(calls[0].timeout, 10000) + }) + + it('uses the production API only when configured for production', async () => { + settings.coinflowEnvironment = 'prod' + assert.strictEqual((await invoke()).statusCode, 200) + assert.strictEqual( + calls[0].url, + 'https://api.coinflow.cash/api/auth/session-key' + ) + }) + + it('rejects another wallet, another identity, and tampered signatures', async () => { + for (const req of [ + request({ wallet: Keypair.generate().publicKey.toBase58() }), + request({}, '0x9999999999999999999999999999999999999999'), + request({ signature: Buffer.alloc(64).toString('base64') }), + request({ wallet: 'not-a-solana-wallet' }), + request({ signature: '' }) + ]) { + assert.strictEqual((await invoke(req)).statusCode, 403) + } + assert.strictEqual(calls.length, 0) + }) + + it('rejects stale, future, missing, or wrong-environment proofs', async () => { + for (const overrides of [ + { timestamp: Date.now() - 6 * 60 * 1000 }, + { timestamp: Date.now() + 60 * 1000 }, + { timestamp: undefined }, + { timestamp: '123' }, + { environment: 'prod' }, + { wallet: undefined }, + { signature: undefined } + ]) { + assert.strictEqual((await invoke(request(overrides))).statusCode, 400) + } + assert.strictEqual(calls.length, 0) + }) + + it('rejects changing the environment even when the new environment is configured', async () => { + const req = request() + settings.coinflowEnvironment = 'prod' + req.body.environment = 'prod' + assert.strictEqual((await invoke(req)).statusCode, 403) + assert.strictEqual(calls.length, 0) + }) + + it('fails closed without a configured merchant key', async () => { + settings.coinflowApiKey = '' + assert.strictEqual((await invoke()).statusCode, 500) + assert.strictEqual(calls.length, 0) + }) + + it('does not expose merchant credentials or upstream error bodies', async () => { + response = new Error('merchant-secret') + response.config = { headers: { Authorization: 'merchant-secret' } } + response.response = { status: 401, data: 'session-secret' } + const result = await invoke() + assert.strictEqual(result.statusCode, 500) + const output = JSON.stringify({ result, logs }) + assert(!output.includes('merchant-secret')) + assert(!output.includes('session-secret')) + }) + + it('rejects malformed upstream responses', async () => { + for (const data of [{}, { key: '' }, { key: 123 }, null]) { + response = { data } + assert.strictEqual((await invoke()).statusCode, 500) + } + }) +}) diff --git a/packages/identity-service/test/index.ts b/packages/identity-service/test/index.ts index 400cf4c849d..657853b6a47 100644 --- a/packages/identity-service/test/index.ts +++ b/packages/identity-service/test/index.ts @@ -1,5 +1,6 @@ require('./expressAppTest') require('./apiHelpersTest') +require('./coinflowTest') require('./authenticationTest') require('./relayTest') require('./configTest')