From d1f506dc5d011f87c4b58455c0e0be07d3b3dcda Mon Sep 17 00:00:00 2001 From: Aman koli <2025.amana@isu.ac.in> Date: Sat, 17 Oct 2026 23:00:38 +0530 Subject: [PATCH] feat(security): add tamper and jailbreak detection with graceful degradation (#1772) --- docs/DEVICE_INTEGRITY_POLICY.md | 142 +++++ docs/security.md | 4 + src/components/BiometricGate.tsx | 18 +- .../security/DeviceIntegrityBanner.tsx | 201 ++++++ .../__tests__/DeviceIntegrityBanner.test.tsx | 131 ++++ .../__tests__/useDeviceIntegrity.test.ts | 126 ++++ src/hooks/useDeviceIntegrity.ts | 85 ++- src/screens/HomeScreen.tsx | 2 + .../deviceAttestationService.test.ts | 403 ++++++++++++ src/services/auth/deviceAttestationService.ts | 576 ++++++++++++++++-- 10 files changed, 1621 insertions(+), 67 deletions(-) create mode 100644 docs/DEVICE_INTEGRITY_POLICY.md create mode 100644 src/components/security/DeviceIntegrityBanner.tsx create mode 100644 src/components/security/__tests__/DeviceIntegrityBanner.test.tsx create mode 100644 src/hooks/__tests__/useDeviceIntegrity.test.ts create mode 100644 src/services/auth/__tests__/deviceAttestationService.test.ts diff --git a/docs/DEVICE_INTEGRITY_POLICY.md b/docs/DEVICE_INTEGRITY_POLICY.md new file mode 100644 index 00000000..76f03732 --- /dev/null +++ b/docs/DEVICE_INTEGRITY_POLICY.md @@ -0,0 +1,142 @@ +# Device Integrity and Tamper Detection Policy + +SubTrackr is a cross-platform subscription management platform handling sensitive user data, automated recurring billing, Web3 wallet integrations, and payment credentials. To protect user assets and prevent token extraction or man-in-the-middle attacks on compromised environments, SubTrackr implements real-time **tamper, root, and jailbreak detection** coupled with a **graceful degradation** model. + +--- + +## 1. Threat Model + +A compromised mobile client poses substantial risks to subscription payment credentials, private key material, and local cache confidentiality: + +| Threat Vector | Platform | Mechanism | Potential Impact | +| --- | --- | --- | --- | +| **Rooted Device (Superuser / Magisk)** | Android | Unrestricted `su` binaries, root daemon, Zygisk / Magisk modules | Bypassing OS sandbox, reading plaintext tokens from SQLite/AsyncStorage, memory scraping | +| **Jailbroken Device (Checkra1n, unc0ver, Taurine)** | iOS | Modified system partition, patched kernel / dyld, Cydia/Sileo package managers | Bypassing keychain protections, sandbox escape, hooking keychain APIs | +| **Dynamic Hooking & Instrumentation** | Both | Frida, Xposed Framework, Substrate, Cycript | Intercepting cryptographic signing, forging biometric auth success callbacks, altering payment amounts | +| **Runtime Debugging / Tampering** | Both | GDB/LLDB attachment, modified APK/IPA repackaging, patched prototypes | Stealing session secrets, modifying authorization headers in flight | +| **Simulated / Emulator Environments** | Both | Android SDK emulator, iOS Simulator | Testbed for automated credential stuffing or mock payment exploitation | + +--- + +## 2. Detection Signals and Indicators + +SubTrackr evaluates device posture across multiple defensive layers without relying on any single brittle check: + +### 2.1 Android Root Indicators +- **Known Root Binaries & Paths**: Checks for standard binaries in system execution directories: + - `/system/app/Superuser.apk`, `/sbin/su`, `/system/bin/su`, `/system/xbin/su` + - `/data/local/xbin/su`, `/data/local/bin/su`, `/system/sd/xbin/su` + - `/system/bin/failsafe/su`, `/data/local/su`, `/su/bin/su` +- **Magisk & Root Artifacts**: + - `/sbin/.magisk`, `/data/adb/magisk`, `/cache/.disable_magisk`, `/data/adb/modules` + - `/system/xbin/busybox`, `/system/bin/busybox` +- **Root Management Packages**: Detection of known manager identifiers: + - `com.topjohnwu.magisk`, `eu.chainfire.supersu`, `com.koushikdutta.superuser` + - `com.noshufou.android.su`, `com.kingroot.kinguser`, `com.thirdparty.superuser` +- **OS Build Integrity & Tags**: Inspection of `Build.TAGS` for `test-keys` indicating an unofficial or insecure third-party custom ROM. +- **Insecure System Properties**: Flags such as `ro.debuggable=1` or `ro.secure=0`. + +### 2.2 iOS Jailbreak Indicators +- **Filesystem Paths & Tools**: + - `/Applications/Cydia.app`, `/Applications/Sileo.app`, `/Applications/Zebra.app` + - `/Library/MobileSubstrate/MobileSubstrate.dylib` + - `/bin/bash`, `/bin/sh`, `/usr/sbin/sshd`, `/usr/bin/ssh` + - `/etc/apt`, `/private/var/lib/apt/`, `/private/var/stash` +- **URL Schemes**: Probing for package manager registration: + - `cydia://`, `sileo://`, `zbra://`, `undecimus://` +- **Sandbox Confinement Check**: + - Attempting write operations outside the designated iOS sandbox container (e.g. `/private/`). On standard non-jailbroken systems, sandbox restrictions block the write; a successful write confirms jailbreak compromise. + +### 2.3 Application Tampering & Hooking Indicators +- **Frida Framework Presence**: Detection of injected `globalThis.frida`, Frida agent threads, or runtime symbols. +- **Xposed Framework**: Detection of `globalThis.XposedBridge` or `de.robv.android.xposed`. +- **Prototype Tampering**: Verifying integrity of critical JavaScript primitives (e.g., verifying `Function.prototype.toString` retains native code signature). +- **Production Debugger Attachment**: Active debugger connection in production mode (`__DEV__ === false`). + +--- + +## 3. Risk Classification + +Signals are evaluated into four risk tiers: + +| Tier | Risk Level | Description | Criteria | +| --- | --- | --- | --- | +| **0** | **Low (Intact)** | Standard production device | No root, jailbreak, tamper, or emulator indicators detected | +| **1** | **Medium (Emulated)** | Development / simulator runtime | Official simulator/emulator detected without root or hooking | +| **2** | **High (Suspicious)** | Suspicious modifications | Test-keys, insecure system properties, or debugger attached | +| **3** | **Critical (Compromised)**| Confirmed compromise | Root binaries found, Cydia/jailbreak paths present, or active Frida/Xposed hooks | + +--- + +## 4. Graceful Degradation Policy + +Rather than an abrupt process crash or unrecoverable lock screen (which frustrates legitimate users on edge cases and provides poor accessibility), SubTrackr **degrades gracefully**: +- **Safe features remain accessible** so users can always view their subscriptions, categories, analytics, documentation, and app settings. +- **High-risk financial operations are restricted** to prevent credential extraction, unauthorized payments, or private key exfiltration. + +### 4.1 Operation Permission Matrix + +| Operation | Low (Intact) | Medium (Emulated) | High (Suspicious) | Critical (Compromised) | Rationale | +| --- | :---: | :---: | :---: | :---: | --- | +| **View Subscriptions (`VIEW_SUBSCRIPTIONS`)** | Allowed | Allowed | Allowed | Allowed | Safe read-only data; preserves basic utility | +| **View Analytics (`VIEW_ANALYTICS`)** | Allowed | Allowed | Allowed | Allowed | Safe aggregated charts | +| **Manage Categories (`MANAGE_CATEGORIES`)** | Allowed | Allowed | Allowed | Allowed | Low-risk metadata organization | +| **Change App Settings (`CHANGE_SETTINGS`)** | Allowed | Allowed | Allowed | Allowed | Theme, locale, and presentation options | +| **Execute Payment (`EXECUTE_PAYMENT`)** | Allowed | Allowed | Blocked | Blocked | Prevents compromised client from authorizing card charges | +| **Sign Crypto Tx (`SIGN_TRANSACTION`)** | Allowed | Allowed | Blocked | Blocked | Protects Web3 wallets against memory scrapers / key theft | +| **Export Private Keys (`EXPORT_PRIVATE_KEYS`)** | Allowed | Allowed | Blocked | Blocked | Restricts raw key / seed phrase disclosure | +| **Biometric Auth (`BIOMETRIC_AUTH`)** | Allowed | Blocked | Blocked | Blocked | Biometrics on rooted devices can be hooked via Frida | +| **Export Sensitive Data (`EXPORT_SENSITIVE_DATA`)** | Allowed | Allowed | Blocked | Blocked | Disables unencrypted financial exports | +| **Persist Plaintext Tokens (`PERSIST_PLAINTEXT_TOKENS`)** | Allowed | Allowed | Allowed | Blocked | Tokens stored in ephemeral memory only on critical devices | + +### 4.2 Degradation Enforcement + +The service exposes programmatic assertion and checking APIs: + +```typescript +import { deviceAttestationService, DeviceIntegrityError } from './services/auth/deviceAttestationService'; + +// Non-throwing check for UI conditionality +if (!deviceAttestationService.isOperationAllowed('SIGN_TRANSACTION')) { + // Disable transaction button and display warning badge +} + +// Throwing assertion for guard clauses in services/stores +try { + deviceAttestationService.assertOperationAllowed('EXPORT_PRIVATE_KEYS'); + // proceed with export +} catch (error) { + if (error instanceof DeviceIntegrityError) { + // Graceful error notification explaining the restriction + } +} +``` + +--- + +## 5. User Communication and Transparency + +When a device is determined to be at High or Critical risk: +1. **Notice, Not Termination**: The app displays an accessible, non-blocking `DeviceIntegrityBanner` informing the user that restricted mode is active. +2. **Transparent Explanation**: Users can view the detected signals (e.g. `android_root_path:/sbin/su`) and understand why transactions are locked. +3. **Dismissable Alert**: Users can dismiss the warning to continue using read-only subscription tracking without interruption. + +--- + +## 6. Developer and Testing Environments + +To ensure developer productivity and CI testability: +- **Pluggable Probe Provider**: `deviceAttestationService.setCustomProbeProvider(provider)` enables mocking file checks, URL schemes, and system properties during unit and integration tests. +- **Simulators / Emulators**: In local development (`__DEV__ === true`), emulator indicators are classified as Medium risk, allowing development without false-positive blocks. +- **Cache TTL**: Attestation results are cached in AsyncStorage with a 24-hour expiration (`checkIntegrity(force?: boolean)`). Calling `checkIntegrity(true)` forces an immediate re-evaluation. + +--- + +## 7. Compliance and Auditing + +This policy adheres to: +- **OWASP Mobile Application Security Verification Standard (MASVS)**: + - *MASVS-RESILIENCE-1*: Device integrity checks (root / jailbreak detection). + - *MASVS-RESILIENCE-2*: Anti-tampering & reverse engineering resistance. + - *MASVS-RESILIENCE-3*: Graceful degradation under compromised execution environments. +- **PCI DSS Section 6.5**: Protecting payment credentials on untrusted client endpoints. diff --git a/docs/security.md b/docs/security.md index 69292fc1..b487d770 100644 --- a/docs/security.md +++ b/docs/security.md @@ -64,3 +64,7 @@ attempts. Configure these environment variables if the sandbox changes: 4. For High issues, request security-team review through the protected `security-review` environment and create a tracked remediation issue. 5. Add any approved false positive to `.snyk` or `.semgrep/semgrep-suppressions.yml` with a clear reason and expiry. 6. Re-run the workflow and verify the dashboard is clear or the accepted risk is documented. + +## Client Device Integrity & Anti-Tampering + +For mobile clients (iOS and Android), SubTrackr enforces runtime tamper, root, and jailbreak detection with graceful degradation to protect sensitive financial operations, crypto wallet key material, and user data. See [DEVICE_INTEGRITY_POLICY.md](DEVICE_INTEGRITY_POLICY.md) for full detection indicators, risk tiers, and operation permission matrices. diff --git a/src/components/BiometricGate.tsx b/src/components/BiometricGate.tsx index 1953e6ea..22fe077f 100644 --- a/src/components/BiometricGate.tsx +++ b/src/components/BiometricGate.tsx @@ -22,6 +22,7 @@ import { AppStateStatus, } from 'react-native'; import { useBiometricAuth } from '../hooks/useBiometricAuth'; +import { useDeviceIntegrity } from '../hooks/useDeviceIntegrity'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; const BIOMETRIC_ICON: Record = { @@ -48,29 +49,32 @@ const BiometricGate: React.FC = ({ children }) => { clearError, } = useBiometricAuth(); + const { isOperationAllowed } = useDeviceIntegrity(); + const biometricsAllowed = isOperationAllowed('BIOMETRIC_AUTH'); + const [appState, setAppState] = useState(AppState.currentState); // Re-lock when app comes back from background useEffect(() => { const sub = AppState.addEventListener('change', (next) => { - if (appState === 'background' && next === 'active' && settings.enabled) { + if (appState === 'background' && next === 'active' && settings.enabled && biometricsAllowed) { // Re-prompt on foreground void authenticate(); } setAppState(next); }); return () => sub.remove(); - }, [appState, settings.enabled, authenticate]); + }, [appState, settings.enabled, biometricsAllowed, authenticate]); - // Auto-prompt on mount when biometrics are enabled + // Auto-prompt on mount when biometrics are enabled and permitted useEffect(() => { - if (!isLoading && settings.enabled && isAvailable && !isAuthenticated) { + if (!isLoading && settings.enabled && isAvailable && biometricsAllowed && !isAuthenticated) { void authenticate(); } - }, [isLoading, settings.enabled, isAvailable]); // eslint-disable-line react-hooks/exhaustive-deps + }, [isLoading, settings.enabled, isAvailable, biometricsAllowed]); // eslint-disable-line react-hooks/exhaustive-deps - // Pass through immediately when biometrics are disabled or unavailable - if (!settings.enabled || !isAvailable) { + // Pass through immediately when biometrics are disabled, unavailable, or restricted by integrity policy + if (!settings.enabled || !isAvailable || !biometricsAllowed) { return <>{children}; } diff --git a/src/components/security/DeviceIntegrityBanner.tsx b/src/components/security/DeviceIntegrityBanner.tsx new file mode 100644 index 00000000..1ca5c685 --- /dev/null +++ b/src/components/security/DeviceIntegrityBanner.tsx @@ -0,0 +1,201 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + AccessibilityInfo, +} from 'react-native'; +import { useDeviceIntegrity } from '../../hooks/useDeviceIntegrity'; +import { colors, spacing, typography, borderRadius } from '../../utils/constants'; + +export interface DeviceIntegrityBannerProps { + onLearnMore?: () => void; +} + +export const DeviceIntegrityBanner: React.FC = ({ + onLearnMore, +}) => { + const { result, isCompromised, riskLevel, bannerDismissed, dismissBanner } = + useDeviceIntegrity(); + const [showDetails, setShowDetails] = useState(false); + + // Only render if device is compromised or at high/critical risk and not dismissed + if (!isCompromised && riskLevel !== 'critical' && riskLevel !== 'high') { + return null; + } + + if (bannerDismissed) { + return null; + } + + const isCritical = riskLevel === 'critical'; + const bannerColor = isCritical ? colors.error : colors.warning; + const badgeText = isCritical ? 'SECURITY ALERT' : 'SECURITY NOTICE'; + const title = result?.isRooted + ? 'Rooted Android Device Detected' + : result?.isJailbroken + ? 'Jailbroken iOS Device Detected' + : result?.isTampered + ? 'Application Tampering Detected' + : 'Device Integrity Compromised'; + + const defaultMessage = + result?.degradationPolicy?.warningMessage || + 'Your device environment is modified. Sensitive actions (such as crypto signing and private key exports) are restricted for your safety while standard tracking remains active.'; + + const handleToggleDetails = () => { + setShowDetails((prev) => !prev); + AccessibilityInfo.announceForAccessibility( + showDetails ? 'Details collapsed' : 'Details expanded' + ); + }; + + return ( + + + + {badgeText} + + + ✕ + + + + {title} + {defaultMessage} + + {showDetails && result?.compromiseIndicators && ( + + Detected Signals: + {result.compromiseIndicators.map((indicator, index) => ( + + • {indicator} + + ))} + + Mode: Graceful Degradation (Read-only / Safe operations permitted) + + + )} + + + + + {showDetails ? 'Hide details' : 'Show details'} + + + + {onLearnMore && ( + + + Security policy + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + marginHorizontal: spacing.md, + marginVertical: spacing.sm, + padding: spacing.md, + backgroundColor: colors.surface, + borderRadius: borderRadius.lg, + borderWidth: 1.5, + }, + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.xs, + }, + badge: { + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: borderRadius.sm, + }, + badgeText: { + color: '#000000', + fontSize: 10, + fontWeight: '700', + letterSpacing: 0.5, + }, + dismissButton: { + padding: spacing.xs, + }, + dismissText: { + color: colors.textSecondary, + fontSize: 14, + fontWeight: '600', + }, + titleText: { + color: colors.textPrimary, + fontSize: 15, + fontWeight: '600', + marginTop: spacing.xs, + marginBottom: 4, + }, + bodyText: { + color: colors.textSecondary, + fontSize: 13, + lineHeight: 18, + }, + detailsBox: { + marginTop: spacing.sm, + padding: spacing.sm, + backgroundColor: colors.surfaceVariant, + borderRadius: borderRadius.md, + }, + detailsTitle: { + color: colors.textPrimary, + fontSize: 12, + fontWeight: '600', + marginBottom: 4, + }, + indicatorText: { + color: colors.textSecondary, + fontSize: 11, + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + marginBottom: 2, + }, + policyNotice: { + marginTop: 6, + color: colors.warning, + fontSize: 11, + fontStyle: 'italic', + }, + actionRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.sm, + gap: spacing.md, + }, + linkButton: { + paddingVertical: 2, + }, + linkText: { + fontSize: 12, + fontWeight: '600', + }, +}); diff --git a/src/components/security/__tests__/DeviceIntegrityBanner.test.tsx b/src/components/security/__tests__/DeviceIntegrityBanner.test.tsx new file mode 100644 index 00000000..72a3248c --- /dev/null +++ b/src/components/security/__tests__/DeviceIntegrityBanner.test.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react-native'; +import { DeviceIntegrityBanner } from '../DeviceIntegrityBanner'; +import { useDeviceIntegrity } from '../../../hooks/useDeviceIntegrity'; + +jest.mock('../../../hooks/useDeviceIntegrity'); + +describe('DeviceIntegrityBanner component', () => { + const mockDismissBanner = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders nothing when device is intact with low risk', () => { + (useDeviceIntegrity as jest.Mock).mockReturnValue({ + result: { + isIntact: true, + isRooted: false, + isJailbroken: false, + isTampered: false, + riskLevel: 'low', + compromiseIndicators: [], + }, + isCompromised: false, + riskLevel: 'low', + bannerDismissed: false, + dismissBanner: mockDismissBanner, + }); + + render(); + expect(screen.queryByTestID('device-integrity-banner')).toBeNull(); + }); + + it('renders alert when rooted Android device is detected', () => { + (useDeviceIntegrity as jest.Mock).mockReturnValue({ + result: { + isIntact: false, + isRooted: true, + isJailbroken: false, + isTampered: false, + riskLevel: 'critical', + compromiseIndicators: ['android_root_path:/sbin/su'], + degradationPolicy: { + warningMessage: 'Critical security risk: Root detected.', + }, + }, + isCompromised: true, + riskLevel: 'critical', + bannerDismissed: false, + dismissBanner: mockDismissBanner, + }); + + render(); + expect(screen.getByTestID('device-integrity-banner')).toBeTruthy(); + expect(screen.getByText('Rooted Android Device Detected')).toBeTruthy(); + expect(screen.getByText('SECURITY ALERT')).toBeTruthy(); + }); + + it('renders alert when jailbroken iOS device is detected', () => { + (useDeviceIntegrity as jest.Mock).mockReturnValue({ + result: { + isIntact: false, + isRooted: false, + isJailbroken: true, + isTampered: false, + riskLevel: 'critical', + compromiseIndicators: ['ios_jailbreak_path:/Applications/Cydia.app'], + degradationPolicy: { + warningMessage: 'Critical security risk: Jailbreak detected.', + }, + }, + isCompromised: true, + riskLevel: 'critical', + bannerDismissed: false, + dismissBanner: mockDismissBanner, + }); + + render(); + expect(screen.getByTestID('device-integrity-banner')).toBeTruthy(); + expect(screen.getByText('Jailbroken iOS Device Detected')).toBeTruthy(); + }); + + it('toggles details when show details is pressed', () => { + (useDeviceIntegrity as jest.Mock).mockReturnValue({ + result: { + isIntact: false, + isRooted: true, + isJailbroken: false, + isTampered: false, + riskLevel: 'critical', + compromiseIndicators: ['android_root_path:/sbin/su'], + }, + isCompromised: true, + riskLevel: 'critical', + bannerDismissed: false, + dismissBanner: mockDismissBanner, + }); + + render(); + expect(screen.queryByTestID('device-integrity-details')).toBeNull(); + + fireEvent.press(screen.getByText('Show details')); + expect(screen.getByTestID('device-integrity-details')).toBeTruthy(); + expect(screen.getByText('• android_root_path:/sbin/su')).toBeTruthy(); + + fireEvent.press(screen.getByText('Hide details')); + expect(screen.queryByTestID('device-integrity-details')).toBeNull(); + }); + + it('calls dismissBanner when close button is clicked', () => { + (useDeviceIntegrity as jest.Mock).mockReturnValue({ + result: { + isIntact: false, + isRooted: true, + isJailbroken: false, + isTampered: false, + riskLevel: 'critical', + compromiseIndicators: ['android_root_path:/sbin/su'], + }, + isCompromised: true, + riskLevel: 'critical', + bannerDismissed: false, + dismissBanner: mockDismissBanner, + }); + + render(); + fireEvent.press(screen.getByTestID('device-integrity-dismiss')); + expect(mockDismissBanner).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/__tests__/useDeviceIntegrity.test.ts b/src/hooks/__tests__/useDeviceIntegrity.test.ts new file mode 100644 index 00000000..b82bd69c --- /dev/null +++ b/src/hooks/__tests__/useDeviceIntegrity.test.ts @@ -0,0 +1,126 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useDeviceIntegrity } from '../useDeviceIntegrity'; +import { + deviceAttestationService, + DeviceIntegrityResult, +} from '../../services/auth/deviceAttestationService'; + +jest.mock('../../services/auth/deviceAttestationService', () => { + const actual = jest.requireActual('../../services/auth/deviceAttestationService'); + return { + ...actual, + deviceAttestationService: { + checkIntegrity: jest.fn(), + getCachedIntegrity: jest.fn(), + isOperationAllowed: jest.fn(), + assertOperationAllowed: jest.fn(), + }, + }; +}); + +describe('useDeviceIntegrity hook', () => { + const mockIntactResult: DeviceIntegrityResult = { + isIntact: true, + isRooted: false, + isJailbroken: false, + isEmulator: false, + isTampered: false, + riskLevel: 'low', + compromiseIndicators: [], + degradationPolicy: { + riskLevel: 'low', + allowBasicAccess: true, + allowSensitiveTransactions: true, + allowBiometrics: true, + allowLocalKeyExport: true, + allowOfflineSecretsStorage: true, + requireStepUpAuth: false, + }, + attestedAt: Date.now(), + }; + + const mockCompromisedResult: DeviceIntegrityResult = { + isIntact: false, + isRooted: true, + isJailbroken: false, + isEmulator: false, + isTampered: false, + riskLevel: 'critical', + compromiseIndicators: ['android_root_path:/sbin/su'], + degradationPolicy: { + riskLevel: 'critical', + allowBasicAccess: true, + allowSensitiveTransactions: false, + allowBiometrics: false, + allowLocalKeyExport: false, + allowOfflineSecretsStorage: false, + requireStepUpAuth: true, + warningMessage: 'Device compromised', + }, + attestedAt: Date.now(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('initializes with cached integrity when available and fresh', async () => { + (deviceAttestationService.getCachedIntegrity as jest.Mock).mockResolvedValue(mockIntactResult); + + const { result, waitForNextUpdate } = renderHook(() => useDeviceIntegrity()); + + expect(result.current.isChecking).toBe(true); + await waitForNextUpdate(); + + expect(result.current.isChecking).toBe(false); + expect(result.current.result).toEqual(mockIntactResult); + expect(result.current.isCompromised).toBe(false); + expect(result.current.riskLevel).toBe('low'); + }); + + it('runs fresh check when cached integrity is missing', async () => { + (deviceAttestationService.getCachedIntegrity as jest.Mock).mockResolvedValue(null); + (deviceAttestationService.checkIntegrity as jest.Mock).mockResolvedValue(mockCompromisedResult); + + const { result, waitForNextUpdate } = renderHook(() => useDeviceIntegrity()); + + await waitForNextUpdate(); + + expect(deviceAttestationService.checkIntegrity).toHaveBeenCalled(); + expect(result.current.isChecking).toBe(false); + expect(result.current.result).toEqual(mockCompromisedResult); + expect(result.current.isCompromised).toBe(true); + expect(result.current.riskLevel).toBe('critical'); + }); + + it('allows dismissing banner state', async () => { + (deviceAttestationService.getCachedIntegrity as jest.Mock).mockResolvedValue(mockCompromisedResult); + + const { result, waitForNextUpdate } = renderHook(() => useDeviceIntegrity()); + await waitForNextUpdate(); + + expect(result.current.bannerDismissed).toBe(false); + + act(() => { + result.current.dismissBanner(); + }); + + expect(result.current.bannerDismissed).toBe(true); + }); + + it('recheck invokes checkIntegrity with force refresh', async () => { + (deviceAttestationService.getCachedIntegrity as jest.Mock).mockResolvedValue(mockIntactResult); + (deviceAttestationService.checkIntegrity as jest.Mock).mockResolvedValue(mockCompromisedResult); + + const { result, waitForNextUpdate } = renderHook(() => useDeviceIntegrity()); + await waitForNextUpdate(); + + await act(async () => { + await result.current.recheck(); + }); + + expect(deviceAttestationService.checkIntegrity).toHaveBeenCalledWith(true); + expect(result.current.result).toEqual(mockCompromisedResult); + expect(result.current.isCompromised).toBe(true); + }); +}); diff --git a/src/hooks/useDeviceIntegrity.ts b/src/hooks/useDeviceIntegrity.ts index bf0ca920..7b23bf83 100644 --- a/src/hooks/useDeviceIntegrity.ts +++ b/src/hooks/useDeviceIntegrity.ts @@ -1,46 +1,107 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { deviceAttestationService, DeviceIntegrityResult, + RiskLevel, + SensitiveOperation, } from '../services/auth/deviceAttestationService'; -interface DeviceIntegrityState { +export interface DeviceIntegrityState { result: DeviceIntegrityResult | null; isChecking: boolean; error: string | null; + isCompromised: boolean; + riskLevel: RiskLevel; + isOperationAllowed: (operation: SensitiveOperation) => boolean; + assertOperationAllowed: (operation: SensitiveOperation) => void; recheck: () => Promise; + bannerDismissed: boolean; + dismissBanner: () => void; } export function useDeviceIntegrity(): DeviceIntegrityState { const [result, setResult] = useState(null); const [isChecking, setIsChecking] = useState(true); const [error, setError] = useState(null); + const [bannerDismissed, setBannerDismissed] = useState(false); - const check = async () => { + const check = useCallback(async () => { setIsChecking(true); setError(null); try { - const integrity = await deviceAttestationService.checkIntegrity(); + const integrity = await deviceAttestationService.checkIntegrity(true); setResult(integrity); } catch (e) { setError(e instanceof Error ? e.message : 'Device integrity check failed'); } finally { setIsChecking(false); } - }; + }, []); + + const isOperationAllowed = useCallback( + (operation: SensitiveOperation): boolean => { + return deviceAttestationService.isOperationAllowed( + operation, + result?.degradationPolicy + ); + }, + [result] + ); + + const assertOperationAllowed = useCallback( + (operation: SensitiveOperation): void => { + deviceAttestationService.assertOperationAllowed(operation, result ?? undefined); + }, + [result] + ); + + const dismissBanner = useCallback(() => { + setBannerDismissed(true); + }, []); useEffect(() => { + let mounted = true; const init = async () => { - const cached = await deviceAttestationService.getCachedIntegrity(); - if (cached && Date.now() - cached.attestedAt < 24 * 60 * 60 * 1000) { - setResult(cached); - setIsChecking(false); - } else { - await check(); + try { + const cached = await deviceAttestationService.getCachedIntegrity(); + if (cached && Date.now() - cached.attestedAt < 24 * 60 * 60 * 1000) { + if (mounted) { + setResult(cached); + setIsChecking(false); + } + } else { + const fresh = await deviceAttestationService.checkIntegrity(); + if (mounted) { + setResult(fresh); + setIsChecking(false); + } + } + } catch (e) { + if (mounted) { + setError(e instanceof Error ? e.message : 'Device integrity check failed'); + setIsChecking(false); + } } }; init(); + return () => { + mounted = false; + }; }, []); - return { result, isChecking, error, recheck: check }; + const riskLevel = result?.riskLevel ?? 'low'; + const isCompromised = !result?.isIntact && (result?.isRooted || result?.isJailbroken || result?.isTampered || false); + + return { + result, + isChecking, + error, + isCompromised, + riskLevel, + isOperationAllowed, + assertOperationAllowed, + recheck: check, + bannerDismissed, + dismissBanner, + }; } diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index f88ed479..30e2e0a8 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -31,6 +31,7 @@ import { TrackingOverview } from '../components/home/TrackingOverview'; import { SubscriptionList } from '../components/home/SubscriptionList'; import { useThemeColors } from '../hooks/useThemeColors'; import { StatsCardSkeleton, SubscriptionListSkeleton } from '../components/common/SkeletonLoader'; +import { DeviceIntegrityBanner } from '../components/security/DeviceIntegrityBanner'; type HomeNavigationProp = NativeStackNavigationProp; @@ -166,6 +167,7 @@ const HomeScreen: React.FC = () => { + = {}; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(async (key: string) => mockStore[key] ?? null), + setItem: jest.fn(async (key: string, value: string) => { + mockStore[key] = value; + }), + removeItem: jest.fn(async (key: string) => { + delete mockStore[key]; + }), +})); + +describe('deviceAttestationService', () => { + const originalPlatformOS = Platform.OS; + const originalPlatformConstants = (Platform as any).constants; + + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(mockStore).forEach((key) => delete mockStore[key]); + deviceAttestationService.setCustomProbeProvider(null); + delete (globalThis as any).frida; + delete (globalThis as any).Process; + delete (globalThis as any).Module; + delete (globalThis as any).XposedBridge; + delete (globalThis as any).__xposed_intercept; + }); + + afterEach(() => { + Object.defineProperty(Platform, 'OS', { value: originalPlatformOS, configurable: true }); + (Platform as any).constants = originalPlatformConstants; + deviceAttestationService.setCustomProbeProvider(null); + }); + + describe('Clean / Intact Device Evaluation', () => { + it('evaluates clean iOS device as intact with low risk', async () => { + Object.defineProperty(Platform, 'OS', { value: 'ios', configurable: true }); + (Platform as any).constants = { isSimulator: false }; + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isIntact).toBe(true); + expect(result.isRooted).toBe(false); + expect(result.isJailbroken).toBe(false); + expect(result.isTampered).toBe(false); + expect(result.isEmulator).toBe(false); + expect(result.riskLevel).toBe('low'); + expect(result.compromiseIndicators).toEqual([]); + expect(result.degradationPolicy.allowBasicAccess).toBe(true); + expect(result.degradationPolicy.allowSensitiveTransactions).toBe(true); + expect(result.degradationPolicy.allowBiometrics).toBe(true); + expect(result.degradationPolicy.allowLocalKeyExport).toBe(true); + }); + + it('evaluates clean Android device as intact with low risk', async () => { + Object.defineProperty(Platform, 'OS', { value: 'android', configurable: true }); + (Platform as any).constants = { + Brand: 'Samsung', + Model: 'Galaxy S24', + Fingerprint: 'samsung/galaxy/official:user/release-keys', + Hardware: 'exynos', + }; + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isIntact).toBe(true); + expect(result.isRooted).toBe(false); + expect(result.isJailbroken).toBe(false); + expect(result.isTampered).toBe(false); + expect(result.isEmulator).toBe(false); + expect(result.riskLevel).toBe('low'); + expect(result.compromiseIndicators).toEqual([]); + }); + }); + + describe('Android Root Detection', () => { + beforeEach(() => { + Object.defineProperty(Platform, 'OS', { value: 'android', configurable: true }); + (Platform as any).constants = { + Brand: 'Samsung', + Model: 'SM-G998B', + }; + }); + + it('detects root when common su / magisk paths are present', async () => { + const mockProvider: IntegrityProbeProvider = { + checkPathsExist: jest.fn().mockResolvedValue(['/system/xbin/su', '/sbin/.magisk']), + }; + deviceAttestationService.setCustomProbeProvider(mockProvider); + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isRooted).toBe(true); + expect(result.isIntact).toBe(false); + expect(result.riskLevel).toBe('critical'); + expect(result.compromiseIndicators).toContain('android_root_path:/system/xbin/su'); + expect(result.compromiseIndicators).toContain('android_root_path:/sbin/.magisk'); + }); + + it('detects root when known root management packages are installed', async () => { + const mockProvider: IntegrityProbeProvider = { + getInstalledPackages: jest.fn().mockResolvedValue(['com.topjohnwu.magisk', 'com.other.app']), + }; + deviceAttestationService.setCustomProbeProvider(mockProvider); + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isRooted).toBe(true); + expect(result.isIntact).toBe(false); + expect(result.riskLevel).toBe('critical'); + expect(result.compromiseIndicators).toContain('android_root_package:com.topjohnwu.magisk'); + }); + + it('detects untrusted ROM when build tags contain test-keys', async () => { + (Platform as any).constants = { + Brand: 'Pixel', + Fingerprint: 'google/marlin/test-keys', + }; + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isRooted).toBe(true); + expect(result.compromiseIndicators).toContain('android_build_test_keys'); + expect(result.riskLevel).toBe('critical'); + }); + + it('detects insecure system properties (ro.debuggable=1 or ro.secure=0)', async () => { + const mockProvider: IntegrityProbeProvider = { + getSystemProperties: jest.fn().mockReturnValue({ + 'ro.debuggable': '1', + 'ro.secure': '0', + }), + }; + deviceAttestationService.setCustomProbeProvider(mockProvider); + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isRooted).toBe(true); + expect(result.compromiseIndicators).toContain('android_insecure_system_properties'); + }); + }); + + describe('iOS Jailbreak Detection', () => { + beforeEach(() => { + Object.defineProperty(Platform, 'OS', { value: 'ios', configurable: true }); + (Platform as any).constants = { isSimulator: false }; + }); + + it('detects jailbreak filesystem artifacts (Cydia, MobileSubstrate, bash)', async () => { + const mockProvider: IntegrityProbeProvider = { + checkPathsExist: jest.fn().mockResolvedValue(['/Applications/Cydia.app', '/bin/bash']), + }; + deviceAttestationService.setCustomProbeProvider(mockProvider); + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isJailbroken).toBe(true); + expect(result.isIntact).toBe(false); + expect(result.riskLevel).toBe('critical'); + expect(result.compromiseIndicators).toContain('ios_jailbreak_path:/Applications/Cydia.app'); + expect(result.compromiseIndicators).toContain('ios_jailbreak_path:/bin/bash'); + }); + + it('detects jailbreak URL schemes (cydia://, sileo://)', async () => { + const mockProvider: IntegrityProbeProvider = { + checkUrlSchemes: jest.fn().mockResolvedValue(['cydia://']), + }; + deviceAttestationService.setCustomProbeProvider(mockProvider); + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isJailbroken).toBe(true); + expect(result.riskLevel).toBe('critical'); + expect(result.compromiseIndicators).toContain('ios_jailbreak_url_scheme:cydia://'); + }); + + it('detects sandbox escape / restricted path write violations', async () => { + const mockProvider: IntegrityProbeProvider = { + canWriteRestrictedPath: jest.fn().mockResolvedValue(true), + }; + deviceAttestationService.setCustomProbeProvider(mockProvider); + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isJailbroken).toBe(true); + expect(result.compromiseIndicators).toContain('ios_sandbox_violation'); + expect(result.riskLevel).toBe('critical'); + }); + }); + + describe('Tampering and Dynamic Hooking Detection', () => { + it('detects Frida global injection artifacts', async () => { + (globalThis as any).frida = { version: '16.1.4' }; + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isTampered).toBe(true); + expect(result.isIntact).toBe(false); + expect(result.riskLevel).toBe('critical'); + expect(result.compromiseIndicators).toContain('tamper_frida_global_detected'); + }); + + it('detects Xposed framework globals', async () => { + (globalThis as any).XposedBridge = {}; + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isTampered).toBe(true); + expect(result.riskLevel).toBe('critical'); + expect(result.compromiseIndicators).toContain('tamper_xposed_detected'); + }); + + it('detects debugger attachment in production mode', async () => { + const oldDev = (globalThis as any).__DEV__; + const oldEnv = process.env.NODE_ENV; + try { + (globalThis as any).__DEV__ = false; + process.env.NODE_ENV = 'production'; + + const mockProvider: IntegrityProbeProvider = { + isDebuggerAttached: jest.fn().mockReturnValue(true), + }; + deviceAttestationService.setCustomProbeProvider(mockProvider); + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isTampered).toBe(true); + expect(result.compromiseIndicators).toContain('tamper_debugger_attached_in_production'); + } finally { + (globalThis as any).__DEV__ = oldDev; + process.env.NODE_ENV = oldEnv; + } + }); + }); + + describe('Emulator and Simulator Detection', () => { + it('detects Android emulator via goldfish/ranchu hardware flags', async () => { + Object.defineProperty(Platform, 'OS', { value: 'android', configurable: true }); + (Platform as any).constants = { + Brand: 'google', + Model: 'sdk_gphone64_arm64', + Hardware: 'ranchu', + Fingerprint: 'generic_x86', + }; + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isEmulator).toBe(true); + expect(result.riskLevel).toBe('medium'); + expect(result.compromiseIndicators).toContain('emulator_android_detected'); + }); + + it('detects iOS Simulator', async () => { + Object.defineProperty(Platform, 'OS', { value: 'ios', configurable: true }); + (Platform as any).constants = { isSimulator: true }; + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.isEmulator).toBe(true); + expect(result.riskLevel).toBe('medium'); + expect(result.compromiseIndicators).toContain('emulator_ios_simulator_detected'); + }); + }); + + describe('Graceful Degradation Engine', () => { + it('allows basic operations in degraded mode when device is compromised', async () => { + const mockProvider: IntegrityProbeProvider = { + checkPathsExist: jest.fn().mockResolvedValue(['/sbin/su']), + }; + Object.defineProperty(Platform, 'OS', { value: 'android', configurable: true }); + deviceAttestationService.setCustomProbeProvider(mockProvider); + + const result = await deviceAttestationService.checkIntegrity(true); + + expect(result.riskLevel).toBe('critical'); + + // Safe / Basic operations are allowed under graceful degradation + expect(deviceAttestationService.isOperationAllowed('VIEW_SUBSCRIPTIONS', result.degradationPolicy)).toBe(true); + expect(deviceAttestationService.isOperationAllowed('VIEW_ANALYTICS', result.degradationPolicy)).toBe(true); + expect(deviceAttestationService.isOperationAllowed('MANAGE_CATEGORIES', result.degradationPolicy)).toBe(true); + expect(deviceAttestationService.isOperationAllowed('CHANGE_SETTINGS', result.degradationPolicy)).toBe(true); + expect(deviceAttestationService.isOperationAllowed('READ_DOCUMENTATION', result.degradationPolicy)).toBe(true); + + // Sensitive operations are RESTRICTED under graceful degradation + expect(deviceAttestationService.isOperationAllowed('EXECUTE_PAYMENT', result.degradationPolicy)).toBe(false); + expect(deviceAttestationService.isOperationAllowed('SIGN_TRANSACTION', result.degradationPolicy)).toBe(false); + expect(deviceAttestationService.isOperationAllowed('EXPORT_PRIVATE_KEYS', result.degradationPolicy)).toBe(false); + expect(deviceAttestationService.isOperationAllowed('BIOMETRIC_AUTH', result.degradationPolicy)).toBe(false); + expect(deviceAttestationService.isOperationAllowed('EXPORT_SENSITIVE_DATA', result.degradationPolicy)).toBe(false); + expect(deviceAttestationService.isOperationAllowed('PERSIST_PLAINTEXT_TOKENS', result.degradationPolicy)).toBe(false); + }); + + it('assertOperationAllowed throws DeviceIntegrityError for restricted operations on compromised device', async () => { + const mockProvider: IntegrityProbeProvider = { + checkPathsExist: jest.fn().mockResolvedValue(['/Applications/Cydia.app']), + }; + Object.defineProperty(Platform, 'OS', { value: 'ios', configurable: true }); + deviceAttestationService.setCustomProbeProvider(mockProvider); + + const result = await deviceAttestationService.checkIntegrity(true); + + // Basic operation succeeds without error + expect(() => { + deviceAttestationService.assertOperationAllowed('VIEW_SUBSCRIPTIONS', result); + }).not.toThrow(); + + // Sensitive operation throws DeviceIntegrityError + expect(() => { + deviceAttestationService.assertOperationAllowed('SIGN_TRANSACTION', result); + }).toThrow(DeviceIntegrityError); + + try { + deviceAttestationService.assertOperationAllowed('SIGN_TRANSACTION', result); + } catch (e: any) { + expect(e).toBeInstanceOf(DeviceIntegrityError); + expect(e.riskLevel).toBe('critical'); + expect(e.operation).toBe('SIGN_TRANSACTION'); + expect(e.indicators).toContain('ios_jailbreak_path:/Applications/Cydia.app'); + } + }); + + it('allows all operations on intact device', async () => { + Object.defineProperty(Platform, 'OS', { value: 'ios', configurable: true }); + (Platform as any).constants = { isSimulator: false }; + + const result = await deviceAttestationService.checkIntegrity(true); + + const allOps: SensitiveOperation[] = [ + 'VIEW_SUBSCRIPTIONS', + 'VIEW_ANALYTICS', + 'MANAGE_CATEGORIES', + 'CHANGE_SETTINGS', + 'READ_DOCUMENTATION', + 'EXECUTE_PAYMENT', + 'SIGN_TRANSACTION', + 'EXPORT_PRIVATE_KEYS', + 'BIOMETRIC_AUTH', + 'EXPORT_SENSITIVE_DATA', + 'PERSIST_PLAINTEXT_TOKENS', + ]; + + for (const op of allOps) { + expect(deviceAttestationService.isOperationAllowed(op, result.degradationPolicy)).toBe(true); + expect(() => deviceAttestationService.assertOperationAllowed(op, result)).not.toThrow(); + } + }); + }); + + describe('Caching and Lifecycle', () => { + it('persists evaluation to AsyncStorage and reuses fresh cache', async () => { + Object.defineProperty(Platform, 'OS', { value: 'ios', configurable: true }); + (Platform as any).constants = { isSimulator: false }; + + const first = await deviceAttestationService.checkIntegrity(true); + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + '@subtrackr/device_integrity', + expect.any(String) + ); + + // Mock provider that would detect root if called + const mockProvider: IntegrityProbeProvider = { + checkPathsExist: jest.fn().mockResolvedValue(['/bin/bash']), + }; + deviceAttestationService.setCustomProbeProvider(mockProvider); + + // checkIntegrity(false) should return cached without calling mockProvider + const cached = await deviceAttestationService.checkIntegrity(false); + expect(mockProvider.checkPathsExist).not.toHaveBeenCalled(); + expect(cached.isIntact).toBe(true); + + // checkIntegrity(true) bypasses cache and runs probes + const fresh = await deviceAttestationService.checkIntegrity(true); + expect(mockProvider.checkPathsExist).toHaveBeenCalled(); + expect(fresh.isJailbroken).toBe(true); + }); + + it('getCachedIntegrity handles backwards-compatible schema upgrades', async () => { + const legacyResult = { + isIntact: false, + isRooted: true, + isEmulator: false, + attestedAt: Date.now(), + }; + mockStore['@subtrackr/device_integrity'] = JSON.stringify(legacyResult); + + const loaded = await deviceAttestationService.getCachedIntegrity(); + expect(loaded).not.toBeNull(); + expect(loaded?.riskLevel).toBe('critical'); + expect(loaded?.degradationPolicy).toBeDefined(); + expect(loaded?.degradationPolicy.allowBasicAccess).toBe(true); + expect(loaded?.degradationPolicy.allowSensitiveTransactions).toBe(false); + }); + }); +}); diff --git a/src/services/auth/deviceAttestationService.ts b/src/services/auth/deviceAttestationService.ts index e9f08b76..d563376b 100644 --- a/src/services/auth/deviceAttestationService.ts +++ b/src/services/auth/deviceAttestationService.ts @@ -1,97 +1,577 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Platform } from 'react-native'; +import { Platform, Linking } from 'react-native'; const DEVICE_INTEGRITY_KEY = '@subtrackr/device_integrity'; +export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'; + +export type SensitiveOperation = + | 'VIEW_SUBSCRIPTIONS' + | 'VIEW_ANALYTICS' + | 'MANAGE_CATEGORIES' + | 'CHANGE_SETTINGS' + | 'READ_DOCUMENTATION' + | 'EXECUTE_PAYMENT' + | 'SIGN_TRANSACTION' + | 'EXPORT_PRIVATE_KEYS' + | 'BIOMETRIC_AUTH' + | 'EXPORT_SENSITIVE_DATA' + | 'PERSIST_PLAINTEXT_TOKENS'; + +export interface DegradationPolicy { + riskLevel: RiskLevel; + allowBasicAccess: boolean; + allowSensitiveTransactions: boolean; + allowBiometrics: boolean; + allowLocalKeyExport: boolean; + allowOfflineSecretsStorage: boolean; + requireStepUpAuth: boolean; + warningMessage?: string; +} + export interface DeviceIntegrityResult { isIntact: boolean; isRooted: boolean; + isJailbroken: boolean; isEmulator: boolean; + isTampered: boolean; + riskLevel: RiskLevel; + compromiseIndicators: string[]; + degradationPolicy: DegradationPolicy; attestedAt: number; } +export class DeviceIntegrityError extends Error { + readonly riskLevel: RiskLevel; + readonly operation: SensitiveOperation; + readonly indicators: string[]; + + constructor(operation: SensitiveOperation, riskLevel: RiskLevel, indicators: string[]) { + super( + `Operation "${operation}" is restricted due to compromised device integrity (risk: ${riskLevel}). Indicators: ${indicators.join(', ')}` + ); + this.name = 'DeviceIntegrityError'; + this.riskLevel = riskLevel; + this.operation = operation; + this.indicators = indicators; + } +} + +/** + * Common file system paths indicating a rooted Android device. + */ +export const ANDROID_ROOT_PATHS = [ + '/system/app/Superuser.apk', + '/system/app/Superuser/Superuser.apk', + '/system/priv-app/Superuser.apk', + '/sbin/su', + '/system/bin/su', + '/system/xbin/su', + '/data/local/xbin/su', + '/data/local/bin/su', + '/system/sd/xbin/su', + '/system/bin/failsafe/su', + '/data/local/su', + '/su/bin/su', + '/su/xbin/su', + '/system/xbin/daemonsu', + '/system/etc/init.d/99SuperSUDaemon', + '/sbin/.magisk', + '/data/adb/magisk', + '/cache/.disable_magisk', + '/data/adb/modules', + '/system/xbin/busybox', + '/system/bin/busybox', +]; + +/** + * Common package names associated with Android root managers. + */ +export const ANDROID_ROOT_PACKAGES = [ + 'com.topjohnwu.magisk', + 'eu.chainfire.supersu', + 'com.koushikdutta.superuser', + 'com.noshufou.android.su', + 'com.kingroot.kinguser', + 'com.thirdparty.superuser', + 'com.yellowes.su', + 'com.chelpus.lackypatch', + 'com.ramdroid.appquarantine', +]; + +/** + * Common paths indicating a jailbroken iOS device. + */ +export const IOS_JAILBREAK_PATHS = [ + '/Applications/Cydia.app', + '/Applications/Sileo.app', + '/Applications/Zebra.app', + '/Applications/blackra1n.app', + '/Library/MobileSubstrate/MobileSubstrate.dylib', + '/Library/PreferenceBundles/SubstitutePrefs.bundle', + '/Library/PreferenceBundles/CydiaSubstratePrefs.bundle', + '/bin/bash', + '/bin/sh', + '/usr/sbin/sshd', + '/usr/libexec/ssh-keysign', + '/etc/apt', + '/etc/ssh/sshd_config', + '/private/var/lib/apt/', + '/private/var/lib/cydia', + '/private/var/stash', + '/private/var/tmp/cydia.log', + '/usr/bin/ssh', + '/var/binpack', + '/var/checkra1n.dylib', +]; + +/** + * Custom URL schemes registered by jailbreak package managers. + */ +export const IOS_JAILBREAK_SCHEMES = [ + 'cydia://', + 'sileo://', + 'zbra://', + 'undecimus://', +]; + +/** + * Pluggable probe provider to allow native module integration or test mocking. + */ +export interface IntegrityProbeProvider { + checkPathsExist?: (paths: string[]) => Promise | string[]; + checkUrlSchemes?: (schemes: string[]) => Promise | string[]; + canWriteRestrictedPath?: (path: string) => Promise | boolean; + getInstalledPackages?: () => Promise | string[]; + getSystemProperties?: () => Record; + isDebuggerAttached?: () => boolean; +} + class DeviceAttestationService { - async checkIntegrity(): Promise { - const isRooted = await this.detectRootOrJailbreak(); - const isEmulator = this.detectEmulator(); + private customProvider: IntegrityProbeProvider | null = null; + + /** + * Inject a custom probe provider (useful for native modules or unit tests). + */ + setCustomProbeProvider(provider: IntegrityProbeProvider | null): void { + this.customProvider = provider; + } + + /** + * Run full device integrity evaluation. + */ + async checkIntegrity(force = false): Promise { + if (!force) { + const cached = await this.getCachedIntegrity(); + if (cached && Date.now() - cached.attestedAt < 24 * 60 * 60 * 1000) { + return cached; + } + } + + const indicators: string[] = []; + + // 1. Android Root Detection + const isRooted = await this.detectRoot(indicators); + + // 2. iOS Jailbreak Detection + const isJailbroken = await this.detectJailbreak(indicators); + + // 3. Tamper & Hooking Detection + const isTampered = await this.detectTamper(indicators); + + // 4. Emulator Detection + const isEmulator = this.detectEmulator(indicators); + + // 5. Evaluate Risk Level + const riskLevel = this.computeRiskLevel({ + isRooted, + isJailbroken, + isTampered, + isEmulator, + indicators, + }); + + // 6. Graceful Degradation Policy + const degradationPolicy = this.computeDegradationPolicy(riskLevel, indicators); + + const isIntact = !isRooted && !isJailbroken && !isTampered && !isEmulator; const result: DeviceIntegrityResult = { - isIntact: !isRooted && !isEmulator, + isIntact, isRooted, + isJailbroken, isEmulator, + isTampered, + riskLevel, + compromiseIndicators: indicators, + degradationPolicy, attestedAt: Date.now(), }; - await AsyncStorage.setItem(DEVICE_INTEGRITY_KEY, JSON.stringify(result)); + try { + await AsyncStorage.setItem(DEVICE_INTEGRITY_KEY, JSON.stringify(result)); + } catch { + // Non-fatal if storage fails + } + return result; } + /** + * Retrieve cached integrity evaluation without re-running probes. + */ async getCachedIntegrity(): Promise { try { const raw = await AsyncStorage.getItem(DEVICE_INTEGRITY_KEY); - return raw ? JSON.parse(raw) : null; + if (!raw) return null; + const parsed = JSON.parse(raw); + // Ensure backwards compatibility with older schema + if (!parsed.riskLevel || !parsed.degradationPolicy) { + const riskLevel = this.computeRiskLevel({ + isRooted: !!parsed.isRooted, + isJailbroken: !!parsed.isJailbroken, + isTampered: !!parsed.isTampered, + isEmulator: !!parsed.isEmulator, + indicators: parsed.compromiseIndicators || [], + }); + parsed.riskLevel = riskLevel; + parsed.isJailbroken = parsed.isJailbroken ?? false; + parsed.isTampered = parsed.isTampered ?? false; + parsed.compromiseIndicators = parsed.compromiseIndicators ?? []; + parsed.degradationPolicy = this.computeDegradationPolicy(riskLevel, parsed.compromiseIndicators); + } + return parsed; } catch { return null; } } - private async detectRootOrJailbreak(): Promise { - if (Platform.OS === 'ios') { - return this.detectJailbreak(); + /** + * Determine whether a sensitive operation is allowed under current device state. + */ + isOperationAllowed(operation: SensitiveOperation, policy?: DegradationPolicy): boolean { + const activePolicy = policy ?? this.computeDegradationPolicy('low', []); + + switch (operation) { + case 'VIEW_SUBSCRIPTIONS': + case 'VIEW_ANALYTICS': + case 'MANAGE_CATEGORIES': + case 'CHANGE_SETTINGS': + case 'READ_DOCUMENTATION': + return activePolicy.allowBasicAccess; + + case 'EXECUTE_PAYMENT': + case 'SIGN_TRANSACTION': + return activePolicy.allowSensitiveTransactions; + + case 'BIOMETRIC_AUTH': + return activePolicy.allowBiometrics; + + case 'EXPORT_PRIVATE_KEYS': + return activePolicy.allowLocalKeyExport; + + case 'EXPORT_SENSITIVE_DATA': + return activePolicy.allowSensitiveTransactions && activePolicy.allowLocalKeyExport; + + case 'PERSIST_PLAINTEXT_TOKENS': + return activePolicy.allowOfflineSecretsStorage; + + default: + return false; } - if (Platform.OS === 'android') { - return this.detectRoot(); + } + + /** + * Assert an operation is allowed, throwing DeviceIntegrityError if restricted. + */ + assertOperationAllowed(operation: SensitiveOperation, result?: DeviceIntegrityResult): void { + const policy = result?.degradationPolicy ?? this.computeDegradationPolicy('low', []); + if (!this.isOperationAllowed(operation, policy)) { + throw new DeviceIntegrityError( + operation, + result?.riskLevel ?? 'critical', + result?.compromiseIndicators ?? ['device_compromised'] + ); } - return false; } - private detectJailbreak(): boolean { - // Check for common jailbreak indicators - // In production, use a native module for file system checks - try { - // const indicators = [ - // '/Applications/Cydia.app', - // '/Library/MobileSubstrate/MobileSubstrate.dylib', - // '/bin/bash', - // '/usr/sbin/sshd', - // '/etc/apt', - // '/private/var/lib/apt/', - // ]; - // React Native can't directly check file existence without native module - // This is a placeholder — in production use react-native-device-info or jail-monkey - return false; - } catch { - return false; + // ── Detection Implementations ───────────────────────────────────────────── + + private async detectRoot(indicators: string[]): Promise { + if (Platform.OS !== 'android') return false; + + let detected = false; + + // 1. Filesystem paths check via provider or Node fs in tests + if (this.customProvider?.checkPathsExist) { + try { + const found = await this.customProvider.checkPathsExist(ANDROID_ROOT_PATHS); + if (found && found.length > 0) { + detected = true; + indicators.push(...found.map((p) => `android_root_path:${p}`)); + } + } catch { + // ignore probe error + } + } + + // 2. Installed packages check + if (this.customProvider?.getInstalledPackages) { + try { + const installed = await this.customProvider.getInstalledPackages(); + for (const pkg of ANDROID_ROOT_PACKAGES) { + if (installed.includes(pkg)) { + detected = true; + indicators.push(`android_root_package:${pkg}`); + } + } + } catch { + // ignore probe error + } + } + + // 3. Android build tags check (test-keys indicates custom/untrusted build) + const constants = (Platform as any).constants || {}; + const tags = (constants.Release?.tags || constants.Fingerprint || '').toLowerCase(); + const serverHost = (constants.ServerHost || '').toLowerCase(); + if (tags.includes('test-keys') || serverHost.includes('test-keys')) { + detected = true; + indicators.push('android_build_test_keys'); + } + + // 4. System properties check + if (this.customProvider?.getSystemProperties) { + try { + const props = this.customProvider.getSystemProperties(); + if (props['ro.debuggable'] === '1' || props['ro.secure'] === '0') { + detected = true; + indicators.push('android_insecure_system_properties'); + } + } catch { + // ignore probe error + } + } + + return detected; + } + + private async detectJailbreak(indicators: string[]): Promise { + if (Platform.OS !== 'ios') return false; + + let detected = false; + + // 1. Filesystem paths check via provider + if (this.customProvider?.checkPathsExist) { + try { + const found = await this.customProvider.checkPathsExist(IOS_JAILBREAK_PATHS); + if (found && found.length > 0) { + detected = true; + indicators.push(...found.map((p) => `ios_jailbreak_path:${p}`)); + } + } catch { + // ignore probe error + } + } + + // 2. URL Schemes check (Cydia, Sileo, Zebra) + if (this.customProvider?.checkUrlSchemes) { + try { + const opened = await this.customProvider.checkUrlSchemes(IOS_JAILBREAK_SCHEMES); + if (opened && opened.length > 0) { + detected = true; + indicators.push(...opened.map((s) => `ios_jailbreak_url_scheme:${s}`)); + } + } catch { + // ignore probe error + } + } else if (Linking && typeof Linking.canOpenURL === 'function') { + for (const scheme of IOS_JAILBREAK_SCHEMES) { + try { + const supported = await Linking.canOpenURL(scheme); + if (supported) { + detected = true; + indicators.push(`ios_jailbreak_url_scheme:${scheme}`); + } + } catch { + // ignore linking error + } + } + } + + // 3. Sandbox write test + if (this.customProvider?.canWriteRestrictedPath) { + try { + const canWrite = await this.customProvider.canWriteRestrictedPath('/private/subtrackr_jailbreak.txt'); + if (canWrite) { + detected = true; + indicators.push('ios_sandbox_violation'); + } + } catch { + // ignore probe error + } } + + return detected; } - private detectRoot(): boolean { - // Check for common root indicators on Android - // In production, use SafetyNet/Play Integrity API + private async detectTamper(indicators: string[]): Promise { + let detected = false; + + // 1. Frida / hooking globals + const g = (typeof globalThis !== 'undefined' ? globalThis : global) as any; + if (g && (g.frida || g.Process?.arch || g.Module?.findBaseAddressByName)) { + detected = true; + indicators.push('tamper_frida_global_detected'); + } + + // 2. Xposed framework check + if (g && (g.XposedBridge || g.__xposed_intercept)) { + detected = true; + indicators.push('tamper_xposed_detected'); + } + + // 3. Hooked prototype check (function tampering) try { - // const indicators = [ - // '/system/app/Superuser.apk', - // '/sbin/su', - // '/system/bin/su', - // '/system/xbin/su', - // '/data/local/xbin/su', - // '/data/local/bin/su', - // '/system/sd/xbin/su', - // ]; - return false; + const toStringSource = Function.prototype.toString.call(Function.prototype.toString); + if (!toStringSource.includes('[native code]')) { + detected = true; + indicators.push('tamper_native_prototype_hooked'); + } } catch { - return false; + // ignore } + + // 4. Debugger attachment in production + const isDev = typeof __DEV__ !== 'undefined' ? __DEV__ : process.env.NODE_ENV !== 'production'; + if (!isDev) { + if (this.customProvider?.isDebuggerAttached && this.customProvider.isDebuggerAttached()) { + detected = true; + indicators.push('tamper_debugger_attached_in_production'); + } + } + + return detected; } - private detectEmulator(): boolean { + private detectEmulator(indicators: string[]): boolean { if (Platform.OS === 'android') { - const brand = (Platform as any).constants?.Brand?.toLowerCase() || ''; - const model = (Platform as any).constants?.Model?.toLowerCase() || ''; - return brand === 'google' && model.includes('sdk'); + const brand = ((Platform as any).constants?.Brand || '').toLowerCase(); + const model = ((Platform as any).constants?.Model || '').toLowerCase(); + const fingerprint = ((Platform as any).constants?.Fingerprint || '').toLowerCase(); + const hardware = ((Platform as any).constants?.Hardware || '').toLowerCase(); + + const isAndroidEmu = + (brand === 'google' && model.includes('sdk')) || + model.includes('emulator') || + model.includes('google_sdk') || + fingerprint.includes('generic') || + hardware.includes('goldfish') || + hardware.includes('ranchu'); + + if (isAndroidEmu) { + indicators.push('emulator_android_detected'); + return true; + } + } + + if (Platform.OS === 'ios') { + const isSim = + (Platform as any).constants?.isSimulator || + process.env.SIMULATOR_DEVICE_NAME !== undefined; + + if (isSim) { + indicators.push('emulator_ios_simulator_detected'); + return true; + } } + return false; } + + // ── Policy Computation ──────────────────────────────────────────────────── + + private computeRiskLevel({ + isRooted, + isJailbroken, + isTampered, + isEmulator, + indicators, + }: { + isRooted: boolean; + isJailbroken: boolean; + isTampered: boolean; + isEmulator: boolean; + indicators: string[]; + }): RiskLevel { + if (isRooted || isJailbroken) { + return 'critical'; + } + if (isTampered) { + return 'critical'; + } + if (indicators.some((i) => i.includes('test_keys') || i.includes('insecure'))) { + return 'high'; + } + if (isEmulator) { + return 'medium'; + } + return 'low'; + } + + private computeDegradationPolicy(riskLevel: RiskLevel, indicators: string[]): DegradationPolicy { + switch (riskLevel) { + case 'critical': + return { + riskLevel: 'critical', + allowBasicAccess: true, + allowSensitiveTransactions: false, + allowBiometrics: false, + allowLocalKeyExport: false, + allowOfflineSecretsStorage: false, + requireStepUpAuth: true, + warningMessage: + 'Critical security risk detected: This device appears to be rooted, jailbroken, or tampered. Sensitive operations like crypto signing and private key exports are disabled to protect your data.', + }; + + case 'high': + return { + riskLevel: 'high', + allowBasicAccess: true, + allowSensitiveTransactions: false, + allowBiometrics: false, + allowLocalKeyExport: false, + allowOfflineSecretsStorage: true, + requireStepUpAuth: true, + warningMessage: + 'High security risk: Suspicious system modifications detected. High-value transactions require secondary verification.', + }; + + case 'medium': + return { + riskLevel: 'medium', + allowBasicAccess: true, + allowSensitiveTransactions: true, + allowBiometrics: false, + allowLocalKeyExport: true, + allowOfflineSecretsStorage: true, + requireStepUpAuth: false, + warningMessage: + 'Development or simulated environment detected. Biometric hardware is not available.', + }; + + case 'low': + default: + return { + riskLevel: 'low', + allowBasicAccess: true, + allowSensitiveTransactions: true, + allowBiometrics: true, + allowLocalKeyExport: true, + allowOfflineSecretsStorage: true, + requireStepUpAuth: false, + }; + } + } } export const deviceAttestationService = new DeviceAttestationService();