Skip to content
Open
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
33 changes: 33 additions & 0 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,35 @@ PODS:
- hermes-engine/Pre-built (= 0.82.0)
- hermes-engine/Pre-built (0.82.0)
- InputMask (6.1.0)
- jail-monkey (3.0.0):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-Codegen
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- libwebp (1.5.0):
- libwebp/demux (= 1.5.0)
- libwebp/mux (= 1.5.0)
Expand Down Expand Up @@ -3546,6 +3575,7 @@ DEPENDENCIES:
- fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`)
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- jail-monkey (from `../node_modules/jail-monkey`)
- MixpanelReactNative (from `../node_modules/mixpanel-react-native`)
- NitroFetch (from `../node_modules/react-native-nitro-fetch`)
- NitroModules (from `../node_modules/react-native-nitro-modules`)
Expand Down Expand Up @@ -3709,6 +3739,8 @@ EXTERNAL SOURCES:
hermes-engine:
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
:tag: hermes-2025-09-01-RNv0.82.0-265ef62ff3eb7289d17e366664ac0da82303e101
jail-monkey:
:path: "../node_modules/jail-monkey"
MixpanelReactNative:
:path: "../node_modules/mixpanel-react-native"
NitroFetch:
Expand Down Expand Up @@ -3968,6 +4000,7 @@ SPEC CHECKSUMS:
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
hermes-engine: 8642d8f14a548ab718ec112e9bebdfdd154138b5
InputMask: 71d291dc54d2deaeac6512afb6ec2304228c0bb7
jail-monkey: 5fb4464044974d42743eb25a8bbb367054b8735b
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
Mixpanel-swift: de454db5987bf6f601106520a44663fc556e8cfa
MixpanelReactNative: e6b7c6c37e88263744c9a5d46a1f05d209b68348
Expand Down
1 change: 1 addition & 0 deletions locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@
"Your account is secure.": "Your account is secure.",
"Error creating passkey": "Error creating passkey",
"Could not create a passkey, please try again later.": "Could not create a passkey, please try again later.",
"For your security, this action isn't available on rooted or jailbroken devices.": "For your security, this action isn't available on rooted or jailbroken devices.",
"To keep your account secure, choose how you want to sign in. You can always update your preferences later.": "To keep your account secure, choose how you want to sign in. You can always update your preferences later.",
"Enter the code generated by your authenticator app.": "Enter the code generated by your authenticator app.",
"Code": "Code",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
"https-browserify": "1.0.0",
"i18next": "21.6.4",
"intl": "1.2.5",
"jail-monkey": "3.0.0",
"lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8",
"lodash.isequal": "4.5.0",
Expand Down
32 changes: 32 additions & 0 deletions src/store/app/app.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ import {TabsScreens} from '../../navigation/tabs/TabsStack';
import {WalletScreens} from '../../navigation/wallet/WalletGroup';
import {isAxiosError} from '../../utils/axios';
import {sleep} from '../../utils/helper-methods';
import {
getDeviceIntegrity,
reportDeviceIntegrityToSentry,
} from '../../utils/deviceIntegrity';
import {Analytics} from '../analytics/analytics.effects';
import {BitPayIdEffects} from '../bitpay-id';
import {CardActions, CardEffects} from '../card';
Expand Down Expand Up @@ -165,6 +169,32 @@ const SSL_PINS = {
GOOGLE_WE1: 'kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=',
};

// Silent-tier root/jailbreak telemetry: tag Sentry + analytics on detection.
export const reportDeviceIntegrity = (): Effect => dispatch => {
try {
const result = getDeviceIntegrity();
reportDeviceIntegrityToSentry(result);

if (result.isCompromised) {
logManager.warn(
`[deviceIntegrity] compromised device detected (reason: ${result.reason})`,
);
dispatch(
Analytics.track('Device Integrity Compromised', {
hookDetected: result.hookDetected,
mockLocationEnabled: result.mockLocationEnabled,
reason: result.reason ?? 'unknown',
}),
);
}
} catch (err) {
logManager.error(
'[deviceIntegrity] failed to report: ' +
(err instanceof Error ? err.message : JSON.stringify(err)),
);
}
};

export const startAppInit = (): Effect => async (dispatch, getState) => {
try {
logManager.info(
Expand Down Expand Up @@ -206,6 +236,8 @@ export const startAppInit = (): Effect => async (dispatch, getState) => {
// init analytics -> post onboarding or migration
dispatch(initAnalytics());

dispatch(reportDeviceIntegrity());

try {
const walletStoreInitResult = dispatch(startWalletStoreInit());
void Promise.resolve(walletStoreInitResult).catch(error => {
Expand Down
12 changes: 12 additions & 0 deletions src/store/bitpay-id/bitpay-id.effects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,18 @@ describe('checkLoginWithPasskey', () => {
),
).rejects.toThrow('Unexpected passkey failure');
});

it('falls back to basic auth (resolves false) when the device is compromised', async () => {
MockGetPasskeyStatus.mockResolvedValueOnce({passkey: true});
MockSignInWithPasskey.mockRejectedValueOnce(
Object.assign(new Error('blocked'), {code: 'DEVICE_COMPROMISED'}),
);
const store = baseStore();
const result = await store.dispatch(
checkLoginWithPasskey('alice@example.com', Network.mainnet, 'csrf'),
);
expect(result).toBe(false);
});
});

// ---------------------------------------------------------------------------
Expand Down
9 changes: 9 additions & 0 deletions src/store/bitpay-id/bitpay-id.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
setPasskeyCredentials,
setPasskeyStatus,
} from '../../store/bitpay-id/bitpay-id.actions';
import {isDeviceCompromisedError} from '../../utils/deviceIntegrity';
import {logManager} from '../../managers/LogManager';
import {ongoingProcessManager} from '../../managers/OngoingProcessManager';
import {clearAllCookiesEverywhere} from '../../utils/cookieAuth';
Expand Down Expand Up @@ -240,6 +241,14 @@ export const checkLoginWithPasskey =
}
return Promise.resolve(true);
} catch (err: any) {
// Compromised device: fall back to basic auth instead of failing login.
if (isDeviceCompromisedError(err)) {
logManager.warn(
'[checkLoginWithPasskey] passkey auth blocked on compromised device, falling back to basic auth',
);
return Promise.resolve(false);
}

const errMsg = err.message || JSON.stringify(err);

// No show error, user canceled
Expand Down
9 changes: 9 additions & 0 deletions src/store/wallet/effects/send/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
SolanaTokenData,
toggleTSSModal,
} from '../../../../utils/helper-methods';
import {assertDeviceIntegrityForSensitiveAction} from '../../../../utils/deviceIntegrity';
import {toFiat, checkEncryptPassword} from '../../utils/wallet';
import {startGetRates} from '../rates/rates';
import {startUpdateWalletStatus} from '../status/status';
Expand Down Expand Up @@ -1297,6 +1298,12 @@ export const publishAndSign =
}): Effect<Promise<Partial<TransactionProposal> | void>> =>
async (dispatch, getState) => {
return new Promise(async (resolve, reject) => {
try {
assertDeviceIntegrityForSensitiveAction('crypto-sign');
} catch (error) {
return reject(error);
}

const {APP} = getState();

if (APP.biometricLockActive && !signingMultipleProposals) {
Expand Down Expand Up @@ -1564,6 +1571,8 @@ export const publishAndSignMultipleProposals =
async (dispatch, getState) => {
return new Promise(async (resolve, reject) => {
try {
assertDeviceIntegrityForSensitiveAction('crypto-sign');

const signingMultipleProposals = true;
let password: string | undefined;
const {
Expand Down
183 changes: 183 additions & 0 deletions src/utils/deviceIntegrity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Tests for src/utils/deviceIntegrity.ts (jail-monkey + Sentry mocked).
const mockIsJailBroken = jest.fn();
const mockHookDetected = jest.fn();
const mockCanMockLocation = jest.fn();

jest.mock('jail-monkey', () => ({
__esModule: true,
default: {
isJailBroken: () => mockIsJailBroken(),
hookDetected: () => mockHookDetected(),
canMockLocation: () => mockCanMockLocation(),
},
}));

const mockSetTag = jest.fn();
const mockSetContext = jest.fn();
const mockCaptureMessage = jest.fn();
jest.mock('@sentry/react-native', () => ({
setTag: (...a: any[]) => mockSetTag(...a),
setContext: (...a: any[]) => mockSetContext(...a),
captureMessage: (...a: any[]) => mockCaptureMessage(...a),
}));

jest.mock('i18next', () => ({
t: (key: string) => key,
}));

jest.mock('../managers/LogManager', () => ({
logManager: {warn: jest.fn(), error: jest.fn(), info: jest.fn()},
}));

// Helper: fresh module instance with a clean cache.
const loadModule = () => {
let mod: typeof import('./deviceIntegrity');
jest.isolateModules(() => {
mod = require('./deviceIntegrity');
});
// @ts-ignore assigned inside isolateModules
return mod;
};

describe('deviceIntegrity', () => {
beforeEach(() => {
jest.clearAllMocks();
mockIsJailBroken.mockReturnValue(false);
mockHookDetected.mockReturnValue(false);
mockCanMockLocation.mockReturnValue(false);
});

describe('getDeviceIntegrity', () => {
it('reports a clean device', () => {
const {getDeviceIntegrity} = loadModule();
expect(getDeviceIntegrity()).toEqual({
isCompromised: false,
hookDetected: false,
mockLocationEnabled: false,
reason: null,
});
});

it('flags a jailbroken/rooted device with reason "jailbroken"', () => {
mockIsJailBroken.mockReturnValue(true);
const {getDeviceIntegrity} = loadModule();
const result = getDeviceIntegrity();
expect(result.isCompromised).toBe(true);
expect(result.reason).toBe('jailbroken');
});

it('flags a hooked device and prefers "jailbroken" when both are true', () => {
mockIsJailBroken.mockReturnValue(false);
mockHookDetected.mockReturnValue(true);
const {getDeviceIntegrity} = loadModule();
expect(getDeviceIntegrity().reason).toBe('hook-detected');

mockIsJailBroken.mockReturnValue(true);
const {getDeviceIntegrity: fresh} = loadModule();
expect(fresh().reason).toBe('jailbroken');
});

it('does not treat mock-location alone as a compromise', () => {
mockCanMockLocation.mockReturnValue(true);
const {getDeviceIntegrity} = loadModule();
const result = getDeviceIntegrity();
expect(result.mockLocationEnabled).toBe(true);
expect(result.isCompromised).toBe(false);
expect(result.reason).toBeNull();
});

it('fails open when the native module throws', () => {
mockIsJailBroken.mockImplementation(() => {
throw new Error('native module unavailable');
});
const {getDeviceIntegrity} = loadModule();
expect(getDeviceIntegrity().isCompromised).toBe(false);
});

it('caches the result and only hits native once', () => {
const {getDeviceIntegrity} = loadModule();
getDeviceIntegrity();
getDeviceIntegrity();
expect(mockIsJailBroken).toHaveBeenCalledTimes(1);
});
});

describe('assertDeviceIntegrityForSensitiveAction', () => {
it('is a no-op on a clean device', () => {
const {assertDeviceIntegrityForSensitiveAction} = loadModule();
expect(() =>
assertDeviceIntegrityForSensitiveAction('crypto-sign'),
).not.toThrow();
});

it('throws DeviceCompromisedError on a compromised device (release builds)', () => {
const prevDev = (global as any).__DEV__;
(global as any).__DEV__ = false;
mockIsJailBroken.mockReturnValue(true);
const {
assertDeviceIntegrityForSensitiveAction,
isDeviceCompromisedError,
} = loadModule();
try {
assertDeviceIntegrityForSensitiveAction('passkey-register');
throw new Error('should have thrown');
} catch (err) {
expect(isDeviceCompromisedError(err)).toBe(true);
}
(global as any).__DEV__ = prevDev;
});

it('does not block in development builds but still reports telemetry', () => {
const prevDev = (global as any).__DEV__;
(global as any).__DEV__ = true;
mockIsJailBroken.mockReturnValue(true);
const {assertDeviceIntegrityForSensitiveAction} = loadModule();
expect(() =>
assertDeviceIntegrityForSensitiveAction('crypto-sign'),
).not.toThrow();
expect(mockSetTag).toHaveBeenCalledWith('device.compromised', 'true');
(global as any).__DEV__ = prevDev;
});
});

describe('reportDeviceIntegrityToSentry', () => {
it('tags the scope and captures a message once for compromised devices', () => {
mockIsJailBroken.mockReturnValue(true);
const {getDeviceIntegrity, reportDeviceIntegrityToSentry} = loadModule();
const result = getDeviceIntegrity();
reportDeviceIntegrityToSentry(result);
reportDeviceIntegrityToSentry(result);
expect(mockSetTag).toHaveBeenCalledWith('device.compromised', 'true');
expect(mockCaptureMessage).toHaveBeenCalledTimes(1);
});

it('never throws even if Sentry throws', () => {
mockSetTag.mockImplementation(() => {
throw new Error('sentry down');
});
const {getDeviceIntegrity, reportDeviceIntegrityToSentry} = loadModule();
expect(() =>
reportDeviceIntegrityToSentry(getDeviceIntegrity()),
).not.toThrow();
});
});

describe('dev override seam', () => {
it('simulate() forces detection and blocks sensitive actions', () => {
const {getDeviceIntegrity, assertDeviceIntegrityForSensitiveAction} =
loadModule();
(global as any).__deviceIntegrity.simulate('hook-detected');
expect(getDeviceIntegrity().reason).toBe('hook-detected');
expect(() =>
assertDeviceIntegrityForSensitiveAction('crypto-sign'),
).toThrow();
});

it('clear() restores native detection', () => {
const {getDeviceIntegrity} = loadModule();
(global as any).__deviceIntegrity.simulate();
(global as any).__deviceIntegrity.clear();
expect(getDeviceIntegrity().isCompromised).toBe(false);
});
});
});
Loading
Loading