diff --git a/package.json b/package.json index 2124a5c1c..a13557b92 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@solana/sysvars": "3.0.2", "@solana/web3.js": "1.98.2", "@solana/webcrypto-ed25519-polyfill": "2.1.1", + "@sumsub/fisherman": "2.1.0", "@tradle/react-native-http": "2.0.1", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/react-native-compat": "2.21.8", diff --git a/scripts/get-sumsub-di-token.js b/scripts/get-sumsub-di-token.js new file mode 100644 index 000000000..5a33884bc --- /dev/null +++ b/scripts/get-sumsub-di-token.js @@ -0,0 +1,44 @@ +// Script to generate a Sumsub DEVICE INTELLIGENCE access token for development. +const crypto = require('crypto'); + +const APP_TOKEN = ''; // Application token from Sumsub dashboard +const SECRET_KEY = ''; // Secret key from Sumsub dashboard +const SESSION_ID = ''; // Unique identifier of the applicant session + +const method = 'POST'; +const path = '/resources/accessTokens/behavior'; +const ts = Math.floor(Date.now() / 1000).toString(); +const body = JSON.stringify({ + sessionId: SESSION_ID, + ttlInSecs: 1800, +}); + +const signature = crypto + .createHmac('sha256', SECRET_KEY) + .update(ts + method + path + body) + .digest('hex'); + +fetch(`https://api.sumsub.com${path}`, { + method, + headers: { + 'X-App-Token': APP_TOKEN, + 'X-App-Access-Ts': ts, + 'X-App-Access-Sig': signature, + 'Content-Type': 'application/json', + }, + body, +}) + .then(r => r.json()) + .then(data => { + // DI endpoint returns { accessToken }, unlike the KYC endpoint ({ token }). + const token = data.accessToken || data.token; + if (token) { + console.log( + '\nPaste into SUMSUB_DI_DEV_TOKEN in src/lib/sumsub/deviceIntelligence.ts:\n', + ); + console.log(`'${token}'\n`); + } else { + console.error('Unexpected response:', JSON.stringify(data, null, 2)); + } + }) + .catch(console.error); diff --git a/src/Root.tsx b/src/Root.tsx index f59a530f7..499fba0dd 100644 --- a/src/Root.tsx +++ b/src/Root.tsx @@ -297,8 +297,14 @@ const StartupGate = () => { ); const appWasInit = useAppSelector(({APP}) => APP.appWasInit); const appColorScheme = useAppSelector(({APP}) => APP.colorScheme); + const dispatch = useAppDispatch(); const hasRoutedRef = useRef(false); + // SumSub Device Intelligence + useEffect(() => { + dispatch(AppEffects.captureDeviceIntelligence()); + }, [dispatch]); + const scheme = !appColorScheme || appColorScheme === 'unspecified' ? Appearance.getColorScheme() diff --git a/src/lib/sumsub/deviceIntelligence.ts b/src/lib/sumsub/deviceIntelligence.ts new file mode 100644 index 000000000..a91660faf --- /dev/null +++ b/src/lib/sumsub/deviceIntelligence.ts @@ -0,0 +1,141 @@ +import axios from 'axios'; +import {getUniqueId} from 'react-native-device-info'; +import {init as fishermanInit} from '@sumsub/fisherman'; +import {Network} from '../../constants'; +import {BASE_BITPAY_URLS} from '../../constants/config'; + +/** + * SumSub Device Intelligence (fisherman) integration. + * + * Token strategy: + * - DEV: paste a token from `node scripts/get-sumsub-di-token.js` into + * `SUMSUB_DI_DEV_TOKEN` below. When set, the app runs in DEV mode against the SumSub + * sandbox and drives fisherman in SIMULATION (the sandbox has no real FingerprintJS + * apiKey, so a real fingerprint would come back empty). + * - PRODUCTION: leave `SUMSUB_DI_DEV_TOKEN` empty. The token is then fetched from the + * BitPay backend, which mints it server-side (HMAC-signed with the SumSub secret key) + * so the secret key never ships inside the app. + */ + +// DEV ONLY — paste a short-lived token from scripts/get-sumsub-di-token.js. Keep empty in +// commits/production. When non-empty, the app treats this as DEV (simulation) mode. +const SUMSUB_DI_DEV_TOKEN = ''; + +// All supported Device Intelligence simulation risk labels (DEV/simulation only). +export const SUMSUB_DI_RISK_LABELS = [ + 'adblock', + 'badBot', + 'clonedApp', + 'developerTools', + 'emulator', + 'fridaTool', + 'goodBot', + 'incognito', + 'jailbroken', + 'locationSpoofing', + 'mitmAttack', + 'privacySettingsMode', + 'factoryReset', + 'rooted', + 'tampering', + 'virtualMachine', +]; + +const isDevMode = () => !!SUMSUB_DI_DEV_TOKEN; + +/** + * Fetches a Device Intelligence access token from the BitPay backend. + * + * STUB — this endpoint does NOT exist yet. It is modeled on the BitPay API pattern + * (`BASE_BITPAY_URLS[network]/api/v2`) so the request shape is ready for the backend team. + * The backend must mint the token via SumSub's + * `POST https://api.sumsub.com/resources/accessTokens/behavior` + * (body `{sessionId, ttlInSecs}`, HMAC-signed with the SumSub secret key) and return the + * `token`. This keeps the SumSub secret key server-side, never in the app bundle. + */ +async function fetchTokenFromBackend( + sessionId: string, + network: Network, +): Promise { + const url = `${BASE_BITPAY_URLS[network]}/api/v2/sumsub/device-intelligence-token`; + const {data} = await axios.post<{token?: string; data?: {token?: string}}>( + url, + { + sessionId, + }, + ); + const token = data?.token ?? data?.data?.token; + if (!token) { + throw new Error('No Device Intelligence token returned from backend'); + } + return token; +} + +const getDeviceIntelligenceToken = ( + sessionId: string, + network: Network, +): Promise => + isDevMode() + ? Promise.resolve(SUMSUB_DI_DEV_TOKEN) + : fetchTokenFromBackend(sessionId, network); + +export interface AssociateDeviceWithUserParams { + network: Network; + visitorId: string; + bitpayId: string; +} + +/** + * Associates a captured device (visitorId) with a BitPay user, on login/signup. + * + * STUB — this endpoint does NOT exist yet. The backend should send the SumSub "applicant + * platform event with captured device" using `externalUserId = bitpayId`, so SumSub links + * the (previously anonymous) device signals to the user. Done server-side because it needs + * the SumSub secret key. + */ +export const associateDeviceWithUser = async ({ + network, + visitorId, + bitpayId, +}: AssociateDeviceWithUserParams): Promise => { + const url = `${BASE_BITPAY_URLS[network]}/api/v2/sumsub/associate-device`; + await axios.post(url, {visitorId, externalUserId: bitpayId}); +}; + +export interface CaptureDeviceIntelligenceParams { + network: Network; + sessionId?: string; +} + +/** + * Initializes SumSub fisherman and captures a device fingerprint. + * @returns the visitorId (in DEV/simulation this echoes the Unique Device ID). + */ +export const captureDeviceIntelligence = async ({ + network, + sessionId, +}: CaptureDeviceIntelligenceParams): Promise => { + const deviceId = getUniqueId(); + console.log('[SumSub DI] Device Unique ID:', deviceId); + const session = sessionId || deviceId; + const dev = isDevMode(); + + const token = await getDeviceIntelligenceToken(session, network); + + const fisherman = await fishermanInit({ + token, + ...(dev + ? { + simulationConfig: { + visitorId: deviceId, + riskLabels: SUMSUB_DI_RISK_LABELS, + }, + } + : {}), + onError: (err: any) => + console.log('[SumSub DI] onError:', err?.name, err?.message), + }); + + const {visitorId} = await fisherman.fingerprint(); + return visitorId; +}; diff --git a/src/store/app/app.actions.ts b/src/store/app/app.actions.ts index d68b3cf88..a68784489 100644 --- a/src/store/app/app.actions.ts +++ b/src/store/app/app.actions.ts @@ -30,6 +30,14 @@ export const networkChanged = (network: Network): AppActionType => ({ payload: network, }); +export const setSumsubDiVisitorId = (payload: { + network: Network; + visitorId: string; +}): AppActionType => ({ + type: AppActionTypes.SET_SUMSUB_DI_VISITOR_ID, + payload, +}); + export const successAppInit = (): AppActionType => ({ type: AppActionTypes.SUCCESS_APP_INIT, }); diff --git a/src/store/app/app.effects.ts b/src/store/app/app.effects.ts index 837464c2e..52969b50e 100644 --- a/src/store/app/app.effects.ts +++ b/src/store/app/app.effects.ts @@ -41,6 +41,10 @@ import {Card} from '../card/card.models'; import {coinbaseInitialize} from '../coinbase'; import {zenledgerInitialize} from '../zenledger'; import {Effect, RootState} from '../index'; +import { + captureDeviceIntelligence as captureFishermanFingerprint, + associateDeviceWithUser, +} from '../../lib/sumsub/deviceIntelligence'; import {LocationEffects} from '../location'; import {WalletActions} from '../wallet'; import { @@ -1530,3 +1534,45 @@ export const migrateShopCatalog = (): Effect => (dispatch, getState) => { ); } }; + +export const captureDeviceIntelligence = + (): Effect> => async (dispatch, getState) => { + const {APP} = getState(); + try { + const visitorId = await captureFishermanFingerprint({ + network: APP.network, + }); + if (visitorId) { + dispatch( + AppActions.setSumsubDiVisitorId({network: APP.network, visitorId}), + ); + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : JSON.stringify(err); + logManager.error( + `[SumSub] Device Intelligence capture failed: ${errorMsg}`, + ); + } + }; + +export const associateDeviceIntelligenceWithUser = + (bitpayId: string): Effect> => + async (_dispatch, getState) => { + const {APP} = getState(); + const visitorId = APP.sumsubDiVisitorId[APP.network]; + if (!visitorId || !bitpayId) { + return; + } + try { + await associateDeviceWithUser({ + network: APP.network, + visitorId, + bitpayId, + }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : JSON.stringify(err); + logManager.error( + `[SumSub] Device Intelligence association failed: ${errorMsg}`, + ); + } + }; diff --git a/src/store/app/app.reducer.ts b/src/store/app/app.reducer.ts index 7cd2ac510..4b37d1b11 100644 --- a/src/store/app/app.reducer.ts +++ b/src/store/app/app.reducer.ts @@ -171,6 +171,9 @@ export interface AppState { tokensDataLoaded: boolean; showArchaxBanner: boolean; dismissedMarketingCardIds: string[]; + sumsubDiVisitorId: { + [key in Network]: string | undefined; + }; } const initialState: AppState = { @@ -272,6 +275,11 @@ const initialState: AppState = { tokensDataLoaded: false, showArchaxBanner: false, dismissedMarketingCardIds: [], + sumsubDiVisitorId: { + [Network.mainnet]: undefined, + [Network.testnet]: undefined, + [Network.regtest]: undefined, + }, }; export const appReducer = ( @@ -291,6 +299,15 @@ export const appReducer = ( network: action.payload, }; + case AppActionTypes.SET_SUMSUB_DI_VISITOR_ID: + return { + ...state, + sumsubDiVisitorId: { + ...state.sumsubDiVisitorId, + [action.payload.network]: action.payload.visitorId, + }, + }; + case AppActionTypes.SUCCESS_APP_INIT: return { ...state, diff --git a/src/store/app/app.selectors.ts b/src/store/app/app.selectors.ts index 8ece9beed..611ba56ad 100644 --- a/src/store/app/app.selectors.ts +++ b/src/store/app/app.selectors.ts @@ -15,6 +15,10 @@ export const selectBrazeContentCards: AppSelector = ({APP}) => export const selectDismissedMarketingCardIds: AppSelector = ({APP}) => APP.dismissedMarketingCardIds; +export const selectSumsubDiVisitorId: AppSelector = ({ + APP, +}) => APP.sumsubDiVisitorId[APP.network]; + export const selectBrazeShopWithCrypto = createSelector( [selectBrazeContentCards], contentCards => contentCards.filter(isShopWithCrypto).sort(sortNewestFirst), diff --git a/src/store/app/app.types.ts b/src/store/app/app.types.ts index 56b920223..7e504c099 100644 --- a/src/store/app/app.types.ts +++ b/src/store/app/app.types.ts @@ -21,6 +21,7 @@ import {LocalAssetsDropdown} from '../../components/list/AssetsByChainRow'; export enum AppActionTypes { NETWORK_CHANGED = 'APP/NETWORK_CHANGED', + SET_SUMSUB_DI_VISITOR_ID = 'APP/SET_SUMSUB_DI_VISITOR_ID', SUCCESS_APP_INIT = 'APP/SUCCESS_APP_INIT', APP_INIT_COMPLETE = 'APP/APP_INIT_COMPLETE', FAILED_APP_INIT = 'APP/FAILED_APP_INIT', @@ -111,6 +112,11 @@ interface NetworkChanged { payload: Network; } +interface SetSumsubDiVisitorId { + type: typeof AppActionTypes.SET_SUMSUB_DI_VISITOR_ID; + payload: {network: Network; visitorId: string}; +} + interface SuccessAppInit { type: typeof AppActionTypes.SUCCESS_APP_INIT; } @@ -456,6 +462,7 @@ interface DismissMarketingContentCard { export type AppActionType = | NetworkChanged + | SetSumsubDiVisitorId | SuccessAppInit | AppInitComplete | FailedAppInit diff --git a/src/store/bitpay-id/bitpay-id.effects.ts b/src/store/bitpay-id/bitpay-id.effects.ts index f5f2c32ea..014a92191 100644 --- a/src/store/bitpay-id/bitpay-id.effects.ts +++ b/src/store/bitpay-id/bitpay-id.effects.ts @@ -13,7 +13,11 @@ import {BrazeWrapper} from '../../lib/Braze'; import {isAxiosError, isRateLimitError} from '../../utils/axios'; import {generateSalt, hashPassword} from '../../utils/password'; import {Analytics} from '../analytics/analytics.effects'; -import {isAnonymousBrazeEid, setEmailNotifications} from '../app/app.effects'; +import { + associateDeviceIntelligenceWithUser, + isAnonymousBrazeEid, + setEmailNotifications, +} from '../app/app.effects'; import {CardActions, CardEffects} from '../card'; import {Effect} from '../index'; import {ShopActions, ShopEffects} from '../shop'; @@ -548,6 +552,11 @@ export const startPairAndLoadUser = } dispatch(startBitPayIdStoreInit(data.user)); + // SumSub Device Intelligence: link the (anonymously) captured device to this user. + const bitpayId = data.user?.basicInfo?.eid; + if (bitpayId) { + dispatch(associateDeviceIntelligenceWithUser(bitpayId)); + } dispatch(CardEffects.startCardStoreInit(data.user)); dispatch(ShopEffects.startFetchCatalog()); dispatch(ShopEffects.startSyncGiftCards()).then(() => diff --git a/yarn.lock b/yarn.lock index dfc359c1c..b0f0cbe5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2432,6 +2432,13 @@ dependencies: cross-spawn "^7.0.3" +"@fingerprintjs/fingerprintjs-pro@3.11.11": + version "3.11.11" + resolved "https://registry.yarnpkg.com/@fingerprintjs/fingerprintjs-pro/-/fingerprintjs-pro-3.11.11.tgz#c21a6a6fd634c97affc3dccdc516f8fccfa6a610" + integrity sha512-2Pe92VJCWSbjh2ukmMPQCAq+JMupAWiU5ClBgSWGQ/H+Pgu+2SzSJWmdeI/hYH16lZ5m/ve+3y+aDHqQa/3Duw== + dependencies: + tslib "^2.4.1" + "@freakycoder/react-native-bounceable@0.2.5": version "0.2.5" resolved "https://registry.yarnpkg.com/@freakycoder/react-native-bounceable/-/react-native-bounceable-0.2.5.tgz#ad61bdafbf68465e9c2145e99c09b500ae091f0e" @@ -5129,6 +5136,15 @@ resolve-from "^5.0.0" ts-dedent "^1.1.0" +"@sumsub/fisherman@2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@sumsub/fisherman/-/fisherman-2.1.0.tgz#0f2635afa9fe787fb9e3097d3c89dd2d2f26c59c" + integrity sha512-BpMgRkYfrwr7vg/pmOBuT+xJ81O0dWB95Jwjmyk5xuWfKSWaO5oJ0GMNv/bKNrw2kxpXqk0r4Xv7pp13B3C9Nw== + dependencies: + "@fingerprintjs/fingerprintjs-pro" "3.11.11" + detectincognitojs "1.3.5" + fingerprintjs2 "2.1.0" + "@svgr/babel-plugin-add-jsx-attribute@8.0.0": version "8.0.0" resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz#4001f5d5dd87fa13303e36ee106e3ff3a7eb8b22" @@ -8427,6 +8443,11 @@ detect-node-es@^1.1.0: resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== +detectincognitojs@1.3.5: + version "1.3.5" + resolved "https://registry.yarnpkg.com/detectincognitojs/-/detectincognitojs-1.3.5.tgz#0b15545fd04d7785614b4be05e15230dd8002471" + integrity sha512-jX5vs3toLR8aEtc5ChGC3xt0/0N+g6HpIWskcf+U1pYp4kiCdUABZLzQlkf2BVsx/Abgy9Eh9G3VV6P7p3iFiQ== + diff-sequences@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" @@ -9675,6 +9696,11 @@ findit@^2.0.0: resolved "https://registry.yarnpkg.com/findit/-/findit-2.0.0.tgz#6509f0126af4c178551cfa99394e032e13a4d56e" integrity sha512-ENZS237/Hr8bjczn5eKuBohLgaD0JyUd0arxretR1f9RO46vZHA1b2y0VorgGV3WaOT3c+78P8h7v4JGJ1i/rg== +fingerprintjs2@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fingerprintjs2/-/fingerprintjs2-2.1.0.tgz#21dc3fee27d3b199056ef8eb873debccd8e06323" + integrity sha512-H1k/ESTD2rJ3liupyqWBPjZC+LKfCGixQzz/NDN4dkgbmG1bVFyMOh7luKSkVDoyfhgvRm62pviNMPI+eJTZcQ== + flat-cache@^3.0.4: version "3.2.0" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" @@ -16919,7 +16945,7 @@ tslib@2.7.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== -tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.6.2, tslib@^2.7.0, tslib@^2.8.0, tslib@^2.8.1: +tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.1, tslib@^2.6.2, tslib@^2.7.0, tslib@^2.8.0, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==