Skip to content

Commit 0573155

Browse files
committed
Guidepoint: Feat - Add root/jailbreak detection (defense-in-depth)
1 parent 8d1e091 commit 0573155

13 files changed

Lines changed: 496 additions & 3 deletions

File tree

ios/Podfile.lock

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,35 @@ PODS:
7878
- hermes-engine/Pre-built (= 0.82.0)
7979
- hermes-engine/Pre-built (0.82.0)
8080
- InputMask (6.1.0)
81+
- jail-monkey (3.0.0):
82+
- boost
83+
- DoubleConversion
84+
- fast_float
85+
- fmt
86+
- glog
87+
- hermes-engine
88+
- RCT-Folly
89+
- RCT-Folly/Fabric
90+
- RCTRequired
91+
- RCTTypeSafety
92+
- React-Codegen
93+
- React-Core
94+
- React-debug
95+
- React-Fabric
96+
- React-featureflags
97+
- React-graphics
98+
- React-ImageManager
99+
- React-jsi
100+
- React-NativeModulesApple
101+
- React-RCTFabric
102+
- React-renderercss
103+
- React-rendererdebug
104+
- React-utils
105+
- ReactCodegen
106+
- ReactCommon/turbomodule/bridging
107+
- ReactCommon/turbomodule/core
108+
- SocketRocket
109+
- Yoga
81110
- libwebp (1.5.0):
82111
- libwebp/demux (= 1.5.0)
83112
- libwebp/mux (= 1.5.0)
@@ -3546,6 +3575,7 @@ DEPENDENCIES:
35463575
- fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`)
35473576
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
35483577
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
3578+
- jail-monkey (from `../node_modules/jail-monkey`)
35493579
- MixpanelReactNative (from `../node_modules/mixpanel-react-native`)
35503580
- NitroFetch (from `../node_modules/react-native-nitro-fetch`)
35513581
- NitroModules (from `../node_modules/react-native-nitro-modules`)
@@ -3709,6 +3739,8 @@ EXTERNAL SOURCES:
37093739
hermes-engine:
37103740
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
37113741
:tag: hermes-2025-09-01-RNv0.82.0-265ef62ff3eb7289d17e366664ac0da82303e101
3742+
jail-monkey:
3743+
:path: "../node_modules/jail-monkey"
37123744
MixpanelReactNative:
37133745
:path: "../node_modules/mixpanel-react-native"
37143746
NitroFetch:
@@ -3968,6 +4000,7 @@ SPEC CHECKSUMS:
39684000
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
39694001
hermes-engine: 8642d8f14a548ab718ec112e9bebdfdd154138b5
39704002
InputMask: 71d291dc54d2deaeac6512afb6ec2304228c0bb7
4003+
jail-monkey: 5fb4464044974d42743eb25a8bbb367054b8735b
39714004
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
39724005
Mixpanel-swift: de454db5987bf6f601106520a44663fc556e8cfa
39734006
MixpanelReactNative: e6b7c6c37e88263744c9a5d46a1f05d209b68348

locales/en/translation.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@
232232
"Your account is secure.": "Your account is secure.",
233233
"Error creating passkey": "Error creating passkey",
234234
"Could not create a passkey, please try again later.": "Could not create a passkey, please try again later.",
235+
"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.",
235236
"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.",
236237
"Enter the code generated by your authenticator app.": "Enter the code generated by your authenticator app.",
237238
"Code": "Code",

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
"https-browserify": "1.0.0",
104104
"i18next": "21.6.4",
105105
"intl": "1.2.5",
106+
"jail-monkey": "3.0.0",
106107
"lodash.clonedeep": "4.5.0",
107108
"lodash.debounce": "4.0.8",
108109
"lodash.isequal": "4.5.0",

src/store/app/app.effects.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ import {TabsScreens} from '../../navigation/tabs/TabsStack';
3434
import {WalletScreens} from '../../navigation/wallet/WalletGroup';
3535
import {isAxiosError} from '../../utils/axios';
3636
import {sleep} from '../../utils/helper-methods';
37+
import {
38+
getDeviceIntegrity,
39+
reportDeviceIntegrityToSentry,
40+
} from '../../utils/deviceIntegrity';
3741
import {Analytics} from '../analytics/analytics.effects';
3842
import {BitPayIdEffects} from '../bitpay-id';
3943
import {CardActions, CardEffects} from '../card';
@@ -165,6 +169,32 @@ const SSL_PINS = {
165169
GOOGLE_WE1: 'kIdp6NNEd8wsugYyyIYFsi1ylMCED3hZbSR8ZFsa/A4=',
166170
};
167171

172+
// Silent-tier root/jailbreak telemetry: tag Sentry + analytics on detection.
173+
export const reportDeviceIntegrity = (): Effect => dispatch => {
174+
try {
175+
const result = getDeviceIntegrity();
176+
reportDeviceIntegrityToSentry(result);
177+
178+
if (result.isCompromised) {
179+
logManager.warn(
180+
`[deviceIntegrity] compromised device detected (reason: ${result.reason})`,
181+
);
182+
dispatch(
183+
Analytics.track('Device Integrity Compromised', {
184+
hookDetected: result.hookDetected,
185+
mockLocationEnabled: result.mockLocationEnabled,
186+
reason: result.reason ?? 'unknown',
187+
}),
188+
);
189+
}
190+
} catch (err) {
191+
logManager.error(
192+
'[deviceIntegrity] failed to report: ' +
193+
(err instanceof Error ? err.message : JSON.stringify(err)),
194+
);
195+
}
196+
};
197+
168198
export const startAppInit = (): Effect => async (dispatch, getState) => {
169199
try {
170200
logManager.info(
@@ -206,6 +236,8 @@ export const startAppInit = (): Effect => async (dispatch, getState) => {
206236
// init analytics -> post onboarding or migration
207237
dispatch(initAnalytics());
208238

239+
dispatch(reportDeviceIntegrity());
240+
209241
try {
210242
const walletStoreInitResult = dispatch(startWalletStoreInit());
211243
void Promise.resolve(walletStoreInitResult).catch(error => {

src/store/bitpay-id/bitpay-id.effects.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,18 @@ describe('checkLoginWithPasskey', () => {
425425
),
426426
).rejects.toThrow('Unexpected passkey failure');
427427
});
428+
429+
it('falls back to basic auth (resolves false) when the device is compromised', async () => {
430+
MockGetPasskeyStatus.mockResolvedValueOnce({passkey: true});
431+
MockSignInWithPasskey.mockRejectedValueOnce(
432+
Object.assign(new Error('blocked'), {code: 'DEVICE_COMPROMISED'}),
433+
);
434+
const store = baseStore();
435+
const result = await store.dispatch(
436+
checkLoginWithPasskey('alice@example.com', Network.mainnet, 'csrf'),
437+
);
438+
expect(result).toBe(false);
439+
});
428440
});
429441

430442
// ---------------------------------------------------------------------------

src/store/bitpay-id/bitpay-id.effects.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
setPasskeyCredentials,
3535
setPasskeyStatus,
3636
} from '../../store/bitpay-id/bitpay-id.actions';
37+
import {isDeviceCompromisedError} from '../../utils/deviceIntegrity';
3738
import {logManager} from '../../managers/LogManager';
3839
import {ongoingProcessManager} from '../../managers/OngoingProcessManager';
3940
import {clearAllCookiesEverywhere} from '../../utils/cookieAuth';
@@ -240,6 +241,14 @@ export const checkLoginWithPasskey =
240241
}
241242
return Promise.resolve(true);
242243
} catch (err: any) {
244+
// Compromised device: fall back to basic auth instead of failing login.
245+
if (isDeviceCompromisedError(err)) {
246+
logManager.warn(
247+
'[checkLoginWithPasskey] passkey auth blocked on compromised device, falling back to basic auth',
248+
);
249+
return Promise.resolve(false);
250+
}
251+
243252
const errMsg = err.message || JSON.stringify(err);
244253

245254
// No show error, user canceled

src/store/wallet/effects/send/send.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
SolanaTokenData,
5858
toggleTSSModal,
5959
} from '../../../../utils/helper-methods';
60+
import {assertDeviceIntegrityForSensitiveAction} from '../../../../utils/deviceIntegrity';
6061
import {toFiat, checkEncryptPassword} from '../../utils/wallet';
6162
import {startGetRates} from '../rates/rates';
6263
import {startUpdateWalletStatus} from '../status/status';
@@ -1297,6 +1298,12 @@ export const publishAndSign =
12971298
}): Effect<Promise<Partial<TransactionProposal> | void>> =>
12981299
async (dispatch, getState) => {
12991300
return new Promise(async (resolve, reject) => {
1301+
try {
1302+
assertDeviceIntegrityForSensitiveAction('crypto-sign');
1303+
} catch (error) {
1304+
return reject(error);
1305+
}
1306+
13001307
const {APP} = getState();
13011308

13021309
if (APP.biometricLockActive && !signingMultipleProposals) {
@@ -1564,6 +1571,8 @@ export const publishAndSignMultipleProposals =
15641571
async (dispatch, getState) => {
15651572
return new Promise(async (resolve, reject) => {
15661573
try {
1574+
assertDeviceIntegrityForSensitiveAction('crypto-sign');
1575+
15671576
const signingMultipleProposals = true;
15681577
let password: string | undefined;
15691578
const {

src/utils/deviceIntegrity.spec.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Tests for src/utils/deviceIntegrity.ts (jail-monkey + Sentry mocked).
2+
const mockIsJailBroken = jest.fn();
3+
const mockHookDetected = jest.fn();
4+
const mockCanMockLocation = jest.fn();
5+
6+
jest.mock('jail-monkey', () => ({
7+
__esModule: true,
8+
default: {
9+
isJailBroken: () => mockIsJailBroken(),
10+
hookDetected: () => mockHookDetected(),
11+
canMockLocation: () => mockCanMockLocation(),
12+
},
13+
}));
14+
15+
const mockSetTag = jest.fn();
16+
const mockSetContext = jest.fn();
17+
const mockCaptureMessage = jest.fn();
18+
jest.mock('@sentry/react-native', () => ({
19+
setTag: (...a: any[]) => mockSetTag(...a),
20+
setContext: (...a: any[]) => mockSetContext(...a),
21+
captureMessage: (...a: any[]) => mockCaptureMessage(...a),
22+
}));
23+
24+
jest.mock('i18next', () => ({
25+
t: (key: string) => key,
26+
}));
27+
28+
jest.mock('../managers/LogManager', () => ({
29+
logManager: {warn: jest.fn(), error: jest.fn(), info: jest.fn()},
30+
}));
31+
32+
// Helper: fresh module instance with a clean cache.
33+
const loadModule = () => {
34+
let mod: typeof import('./deviceIntegrity');
35+
jest.isolateModules(() => {
36+
mod = require('./deviceIntegrity');
37+
});
38+
// @ts-ignore assigned inside isolateModules
39+
return mod;
40+
};
41+
42+
describe('deviceIntegrity', () => {
43+
beforeEach(() => {
44+
jest.clearAllMocks();
45+
mockIsJailBroken.mockReturnValue(false);
46+
mockHookDetected.mockReturnValue(false);
47+
mockCanMockLocation.mockReturnValue(false);
48+
});
49+
50+
describe('getDeviceIntegrity', () => {
51+
it('reports a clean device', () => {
52+
const {getDeviceIntegrity} = loadModule();
53+
expect(getDeviceIntegrity()).toEqual({
54+
isCompromised: false,
55+
hookDetected: false,
56+
mockLocationEnabled: false,
57+
reason: null,
58+
});
59+
});
60+
61+
it('flags a jailbroken/rooted device with reason "jailbroken"', () => {
62+
mockIsJailBroken.mockReturnValue(true);
63+
const {getDeviceIntegrity} = loadModule();
64+
const result = getDeviceIntegrity();
65+
expect(result.isCompromised).toBe(true);
66+
expect(result.reason).toBe('jailbroken');
67+
});
68+
69+
it('flags a hooked device and prefers "jailbroken" when both are true', () => {
70+
mockIsJailBroken.mockReturnValue(false);
71+
mockHookDetected.mockReturnValue(true);
72+
const {getDeviceIntegrity} = loadModule();
73+
expect(getDeviceIntegrity().reason).toBe('hook-detected');
74+
75+
mockIsJailBroken.mockReturnValue(true);
76+
const {getDeviceIntegrity: fresh} = loadModule();
77+
expect(fresh().reason).toBe('jailbroken');
78+
});
79+
80+
it('does not treat mock-location alone as a compromise', () => {
81+
mockCanMockLocation.mockReturnValue(true);
82+
const {getDeviceIntegrity} = loadModule();
83+
const result = getDeviceIntegrity();
84+
expect(result.mockLocationEnabled).toBe(true);
85+
expect(result.isCompromised).toBe(false);
86+
expect(result.reason).toBeNull();
87+
});
88+
89+
it('fails open when the native module throws', () => {
90+
mockIsJailBroken.mockImplementation(() => {
91+
throw new Error('native module unavailable');
92+
});
93+
const {getDeviceIntegrity} = loadModule();
94+
expect(getDeviceIntegrity().isCompromised).toBe(false);
95+
});
96+
97+
it('caches the result and only hits native once', () => {
98+
const {getDeviceIntegrity} = loadModule();
99+
getDeviceIntegrity();
100+
getDeviceIntegrity();
101+
expect(mockIsJailBroken).toHaveBeenCalledTimes(1);
102+
});
103+
});
104+
105+
describe('assertDeviceIntegrityForSensitiveAction', () => {
106+
it('is a no-op on a clean device', () => {
107+
const {assertDeviceIntegrityForSensitiveAction} = loadModule();
108+
expect(() =>
109+
assertDeviceIntegrityForSensitiveAction('crypto-sign'),
110+
).not.toThrow();
111+
});
112+
113+
it('throws DeviceCompromisedError on a compromised device (release builds)', () => {
114+
const prevDev = (global as any).__DEV__;
115+
(global as any).__DEV__ = false;
116+
mockIsJailBroken.mockReturnValue(true);
117+
const {
118+
assertDeviceIntegrityForSensitiveAction,
119+
isDeviceCompromisedError,
120+
} = loadModule();
121+
try {
122+
assertDeviceIntegrityForSensitiveAction('passkey-register');
123+
throw new Error('should have thrown');
124+
} catch (err) {
125+
expect(isDeviceCompromisedError(err)).toBe(true);
126+
}
127+
(global as any).__DEV__ = prevDev;
128+
});
129+
130+
it('does not block in development builds but still reports telemetry', () => {
131+
const prevDev = (global as any).__DEV__;
132+
(global as any).__DEV__ = true;
133+
mockIsJailBroken.mockReturnValue(true);
134+
const {assertDeviceIntegrityForSensitiveAction} = loadModule();
135+
expect(() =>
136+
assertDeviceIntegrityForSensitiveAction('crypto-sign'),
137+
).not.toThrow();
138+
expect(mockSetTag).toHaveBeenCalledWith('device.compromised', 'true');
139+
(global as any).__DEV__ = prevDev;
140+
});
141+
});
142+
143+
describe('reportDeviceIntegrityToSentry', () => {
144+
it('tags the scope and captures a message once for compromised devices', () => {
145+
mockIsJailBroken.mockReturnValue(true);
146+
const {getDeviceIntegrity, reportDeviceIntegrityToSentry} = loadModule();
147+
const result = getDeviceIntegrity();
148+
reportDeviceIntegrityToSentry(result);
149+
reportDeviceIntegrityToSentry(result);
150+
expect(mockSetTag).toHaveBeenCalledWith('device.compromised', 'true');
151+
expect(mockCaptureMessage).toHaveBeenCalledTimes(1);
152+
});
153+
154+
it('never throws even if Sentry throws', () => {
155+
mockSetTag.mockImplementation(() => {
156+
throw new Error('sentry down');
157+
});
158+
const {getDeviceIntegrity, reportDeviceIntegrityToSentry} = loadModule();
159+
expect(() =>
160+
reportDeviceIntegrityToSentry(getDeviceIntegrity()),
161+
).not.toThrow();
162+
});
163+
});
164+
165+
describe('dev override seam', () => {
166+
it('simulate() forces detection and blocks sensitive actions', () => {
167+
const {getDeviceIntegrity, assertDeviceIntegrityForSensitiveAction} =
168+
loadModule();
169+
(global as any).__deviceIntegrity.simulate('hook-detected');
170+
expect(getDeviceIntegrity().reason).toBe('hook-detected');
171+
expect(() =>
172+
assertDeviceIntegrityForSensitiveAction('crypto-sign'),
173+
).toThrow();
174+
});
175+
176+
it('clear() restores native detection', () => {
177+
const {getDeviceIntegrity} = loadModule();
178+
(global as any).__deviceIntegrity.simulate();
179+
(global as any).__deviceIntegrity.clear();
180+
expect(getDeviceIntegrity().isCompromised).toBe(false);
181+
});
182+
});
183+
});

0 commit comments

Comments
 (0)