react-native-app-attestation 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,451 @@
1
+ # react-native-app-attestation
2
+
3
+ A complete mobile app security solution for React Native apps.
4
+
5
+ ## The Problem
6
+
7
+ Without this library, anyone can send fake requests to your API:
8
+
9
+ ```bash
10
+ # Anyone can do this from their terminal
11
+ curl -X POST https://api.yourapp.com/send-otp \
12
+ -d '{"phone": "9999999999"}'
13
+
14
+ # Or loop it 1000 times
15
+ for i in {1..1000}; do
16
+ curl -X POST https://api.yourapp.com/send-otp \
17
+ -d '{"phone": "9999999999"}'
18
+ done
19
+ ```
20
+
21
+ This leads to:
22
+ - OTP bombing — spamming any phone number with OTPs
23
+ - Fake account creation using bots
24
+ - API abuse causing server crashes
25
+ - Data scraping
26
+
27
+ ## The Solution
28
+
29
+ This library proves to your server that every request comes from your **genuine, official app** installed on a **real device** — not from scripts or bots.
30
+
31
+ It does this using:
32
+ - **Android**: Google Play Integrity API — Google guarantees the request is from your genuine app
33
+ - **iOS**: Apple App Attest — Apple guarantees the request is from your genuine app
34
+
35
+ Every API request automatically includes security headers that your server can verify.
36
+
37
+ ---
38
+
39
+ ## How It Works
40
+
41
+ ```
42
+ Your App Your Backend
43
+ | |
44
+ | 1. Get nonce |
45
+ | ─────────────────────────> |
46
+ | |
47
+ | 2. nonce received |
48
+ | <───────────────────────── |
49
+ | |
50
+ | 3. Send nonce to Google/Apple
51
+ | ──────────> Google/Apple |
52
+ | |
53
+ | 4. Signed attestation token|
54
+ | <────────── Google/Apple |
55
+ | |
56
+ | 5. API request with token |
57
+ | ─────────────────────────> |
58
+ | |
59
+ | 6. Server verifies token |
60
+ | with Google/Apple |
61
+ | |
62
+ | 7. Request allowed |
63
+ | <───────────────────────── |
64
+ ```
65
+
66
+ ---
67
+
68
+ ## What Gets Sent With Every Request
69
+
70
+ ```
71
+ POST /api/send-otp
72
+ User-Agent: App/1.0.0 (Android)
73
+ X-App-Platform: android
74
+ X-App-Version: 1.0.0
75
+ X-Device-ID: 8f3c7e9d-21ab-4d5e-b3c2-9a7f1e6d4c8b
76
+ X-Timestamp: 1710249381
77
+ X-Attestation: eyJhbGciOiJSUzI1NiJ9...
78
+ ```
79
+
80
+ Your server checks all of these on every request.
81
+
82
+ ---
83
+
84
+ ## Installation
85
+
86
+ ```bash
87
+ npm install react-native-app-attestation
88
+ cd ios && pod install
89
+ ```
90
+
91
+ That's it! Autolinking handles the native module setup automatically.
92
+
93
+ ---
94
+
95
+ ## Android Setup
96
+
97
+ These steps are one-time only.
98
+
99
+ ### Step 1: Link your app in Google Play Console
100
+
101
+ This tells Google that your app is allowed to use Play Integrity API.
102
+
103
+ ```
104
+ 1. Go to play.google.com/console
105
+ 2. Select your app
106
+ 3. Left menu: Release → Setup → App Integrity
107
+ 4. Find "Play Integrity API" section
108
+ 5. Click "Link"
109
+ 6. Select your Google Cloud project
110
+ 7. Confirm
111
+ ```
112
+
113
+ > Note: Your app must already be uploaded to Play Console at least once.
114
+
115
+ ### Step 2: Enable Play Integrity API in Google Cloud
116
+
117
+ ```
118
+ 1. Go to console.cloud.google.com
119
+ 2. Select the same project you linked above
120
+ 3. Left menu: APIs & Services → Library
121
+ 4. Search for "Play Integrity API"
122
+ 5. Click on it → Click "ENABLE"
123
+ ```
124
+
125
+ ### Step 3: Add SDK dependency
126
+
127
+ In `android/app/build.gradle`:
128
+
129
+ ```gradle
130
+ dependencies {
131
+ // ... your existing dependencies
132
+ implementation 'com.google.android.play:integrity:1.3.0'
133
+ }
134
+ ```
135
+
136
+ ---
137
+
138
+ ## iOS Setup
139
+
140
+ These steps are one-time only.
141
+
142
+ ### Step 1: Add App Attest capability in Xcode
143
+
144
+ ```
145
+ 1. Open your project in Xcode
146
+ 2. Click your project name in the left sidebar (blue icon)
147
+ 3. Select your app target
148
+ 4. Click "Signing & Capabilities" tab
149
+ 5. Click "+ Capability" button
150
+ 6. Search for "App Attest"
151
+ 7. Double click to add it
152
+ ```
153
+
154
+ ### Step 2: Set up Bridging Header
155
+
156
+ ```
157
+ 1. Open Xcode
158
+ 2. Click your project → Select your target
159
+ 3. Go to "Build Settings" tab
160
+ 4. Search for "Objective-C Bridging Header"
161
+ 5. Set the value to:
162
+ YourProjectName/ReactNativeAppAttestation-Bridging-Header.h
163
+ ```
164
+
165
+ ---
166
+
167
+ ## Code Setup
168
+
169
+ ### Step 1: Initialize in App.tsx
170
+
171
+ Call `initAttestation` once when your app starts.
172
+
173
+ ```typescript
174
+ import { initAttestation } from 'react-native-app-attestation';
175
+
176
+ // With MMKV (recommended — fast synchronous storage)
177
+ import { MMKV } from 'react-native-mmkv';
178
+ const mmkv = new MMKV();
179
+
180
+ initAttestation({
181
+ // Tell the library how to store the device ID
182
+ // You can use any storage — MMKV, AsyncStorage, etc.
183
+ storage: {
184
+ get: (key) => mmkv.getString(key) ?? null,
185
+ set: (key, val) => mmkv.set(key, val),
186
+ delete: (key) => mmkv.delete(key),
187
+ },
188
+
189
+ // Your backend endpoint that returns a nonce
190
+ nonceEndpoint: 'https://api.yourapp.com/auth/nonce',
191
+
192
+ // Your app version — sent in every request header
193
+ appVersion: '1.0.0',
194
+
195
+ // Show debug logs in development
196
+ debug: __DEV__,
197
+ });
198
+ ```
199
+
200
+ ```typescript
201
+ // With AsyncStorage
202
+ import AsyncStorage from '@react-native-async-storage/async-storage';
203
+
204
+ initAttestation({
205
+ storage: {
206
+ get: (key) => AsyncStorage.getItem(key),
207
+ set: (key, val) => AsyncStorage.setItem(key, val),
208
+ delete: (key) => AsyncStorage.removeItem(key),
209
+ },
210
+ nonceEndpoint: 'https://api.yourapp.com/auth/nonce',
211
+ appVersion: '1.0.0',
212
+ });
213
+ ```
214
+
215
+ ---
216
+
217
+ ### Step 2: Secure your API calls
218
+
219
+ #### With Axios
220
+
221
+ ```typescript
222
+ import axios from 'axios';
223
+ import { setupAxios } from 'react-native-app-attestation';
224
+
225
+ const api = axios.create({
226
+ baseURL: 'https://api.yourapp.com',
227
+ timeout: 15000,
228
+ });
229
+
230
+ // One line — all requests secured automatically
231
+ setupAxios(api);
232
+
233
+ // Use api normally — security headers added behind the scenes
234
+ const response = await api.get('/user/profile');
235
+ const response = await api.post('/send-otp', { phone: '9876543210' });
236
+ ```
237
+
238
+ #### With Fetch
239
+
240
+ ```typescript
241
+ import { secureGet, securePost } from 'react-native-app-attestation';
242
+
243
+ // GET request — replaces fetch()
244
+ const response = await secureGet('https://api.yourapp.com/user');
245
+ const data = await response.json();
246
+
247
+ // POST request — replaces fetch() with method POST
248
+ const response = await securePost(
249
+ 'https://api.yourapp.com/login',
250
+ { phone: '9876543210' }
251
+ );
252
+ ```
253
+
254
+ #### With any other HTTP client
255
+
256
+ ```typescript
257
+ import { getSecurityHeaders } from 'react-native-app-attestation';
258
+
259
+ // Get all headers as an object and add them manually
260
+ const headers = await getSecurityHeaders();
261
+
262
+ myHttpClient.request({
263
+ url: '/endpoint',
264
+ headers: {
265
+ ...headers,
266
+ 'Authorization': `Bearer ${token}`,
267
+ }
268
+ });
269
+ ```
270
+
271
+ ---
272
+
273
+ ### Step 3: Sensitive operations
274
+
275
+ For payments or login, always use a fresh token.
276
+ This prevents replay attacks on critical endpoints.
277
+
278
+ ```typescript
279
+ import { getFreshAttestationToken } from 'react-native-app-attestation';
280
+
281
+ const makePayment = async (paymentData) => {
282
+ // Force a fresh token — always bypasses cache
283
+ const freshToken = await getFreshAttestationToken();
284
+
285
+ await api.post('/payment', paymentData, {
286
+ headers: { 'X-Attestation': freshToken }
287
+ });
288
+ };
289
+ ```
290
+
291
+ ---
292
+
293
+ ### Step 4: Logout
294
+
295
+ Clear the attestation cache when the user logs out.
296
+
297
+ ```typescript
298
+ import { clearAttestationCache } from 'react-native-app-attestation';
299
+
300
+ const logout = () => {
301
+ clearAttestationCache();
302
+ // ... rest of your logout logic
303
+ };
304
+ ```
305
+
306
+ ---
307
+
308
+ ## Backend Setup
309
+
310
+ ### Nonce Endpoint
311
+
312
+ Create a `GET /auth/nonce` endpoint that returns a random one-time string.
313
+
314
+ ```javascript
315
+ // Node.js example
316
+ const crypto = require('crypto');
317
+
318
+ app.get('/auth/nonce', async (req, res) => {
319
+ const nonce = crypto.randomBytes(32).toString('hex');
320
+
321
+ await db.nonces.create({
322
+ nonce,
323
+ expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
324
+ used: false,
325
+ });
326
+
327
+ res.json({ nonce });
328
+ });
329
+ ```
330
+
331
+ ### Request Validation
332
+
333
+ Validate these headers on every incoming request:
334
+
335
+ ```javascript
336
+ const validateRequest = async (req) => {
337
+
338
+ // 1. Check User-Agent
339
+ if (!req.headers['user-agent']?.includes('App/')) {
340
+ throw new Error('Invalid client');
341
+ }
342
+
343
+ // 2. Reject requests older than 5 minutes (prevents replay attacks)
344
+ const timestamp = parseInt(req.headers['x-timestamp']);
345
+ const now = Math.floor(Date.now() / 1000);
346
+ if (Math.abs(now - timestamp) > 300) {
347
+ throw new Error('Request expired');
348
+ }
349
+
350
+ // 3. Check Device ID format
351
+ const deviceId = req.headers['x-device-id'];
352
+ if (!/^[0-9a-f-]{36}$/i.test(deviceId)) {
353
+ throw new Error('Invalid device ID');
354
+ }
355
+
356
+ // 4. Verify attestation token with Google/Apple
357
+ const isValid = await verifyAttestationToken(
358
+ req.headers['x-attestation'],
359
+ req.headers['x-app-platform']
360
+ );
361
+ if (!isValid) throw new Error('Attestation failed');
362
+ };
363
+ ```
364
+
365
+ ### Verify Android Token (Play Integrity)
366
+
367
+ ```javascript
368
+ const verifyAndroidToken = async (token) => {
369
+ const response = await fetch(
370
+ `https://playintegrity.googleapis.com/v1/${PACKAGE_NAME}:decodeIntegrityToken`,
371
+ {
372
+ method: 'POST',
373
+ headers: { Authorization: `Bearer ${GOOGLE_ACCESS_TOKEN}` },
374
+ body: JSON.stringify({ integrity_token: token }),
375
+ }
376
+ );
377
+
378
+ const data = await response.json();
379
+
380
+ return (
381
+ data.tokenPayloadExternal?.appIntegrity?.appRecognitionVerdict === 'PLAY_RECOGNIZED' &&
382
+ data.tokenPayloadExternal?.deviceIntegrity?.deviceRecognitionVerdict
383
+ ?.includes('MEETS_DEVICE_INTEGRITY')
384
+ );
385
+ };
386
+ ```
387
+
388
+ ### Verify iOS Token (App Attest)
389
+
390
+ ```javascript
391
+ const verifyIOSToken = async (token) => {
392
+ // Full guide: https://developer.apple.com/documentation/devicecheck/validating_apps_that_connect_to_your_server
393
+ const attestation = Buffer.from(token, 'base64');
394
+ // ... Apple certificate chain verification
395
+ };
396
+ ```
397
+
398
+ ---
399
+
400
+ ## Token Caching
401
+
402
+ The library automatically caches the attestation token for 10 minutes.
403
+ Google/Apple is only called once every 10 minutes — not on every request.
404
+
405
+ ```
406
+ 10:00 → App opens — fresh token fetched, cached for 10 min
407
+ 10:02 → API call — cached token used (no Google/Apple call)
408
+ 10:05 → API call — cached token used (no Google/Apple call)
409
+ 10:08 → API call — cached token used (no Google/Apple call)
410
+ 10:10 → Cache expired — fresh token fetched automatically
411
+ 10:15 → Payment — getFreshAttestationToken() always bypasses cache
412
+ ```
413
+
414
+ Customize the cache duration:
415
+
416
+ ```typescript
417
+ initAttestation({
418
+ tokenCacheDurationMs: 5 * 60 * 1000, // 5 minutes instead of default 10
419
+ });
420
+ ```
421
+
422
+ ---
423
+
424
+ ## API Reference
425
+
426
+ | Function | Description |
427
+ |----------|-------------|
428
+ | `initAttestation(config)` | Initialize once at app start |
429
+ | `setupAxios(instance)` | Setup axios interceptor — one line secures all requests |
430
+ | `secureGet(url, options?)` | Secure replacement for fetch GET |
431
+ | `securePost(url, body, options?)` | Secure replacement for fetch POST |
432
+ | `getDeviceID()` | Get persistent device UUID |
433
+ | `getAttestationToken(forceRefresh?)` | Get cached or fresh attestation token |
434
+ | `getFreshAttestationToken()` | Always fresh token — use for payments and login |
435
+ | `getSecurityHeaders()` | Get all security headers as an object |
436
+ | `clearAttestationCache()` | Clear token cache — call on logout |
437
+ | `resetDeviceID()` | Delete stored device ID |
438
+
439
+ ---
440
+
441
+ ## Requirements
442
+
443
+ - React Native >= 0.70
444
+ - iOS >= 14.0
445
+ - Android API level >= 24
446
+
447
+ ---
448
+
449
+ ## License
450
+
451
+ MIT
@@ -0,0 +1,31 @@
1
+ buildscript {
2
+ repositories {
3
+ google()
4
+ mavenCentral()
5
+ }
6
+ dependencies {
7
+ classpath "com.android.tools.build:gradle:7.4.2"
8
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0"
9
+ }
10
+ }
11
+
12
+ apply plugin: "com.android.library"
13
+ apply plugin: "kotlin-android"
14
+
15
+ android {
16
+ compileSdkVersion 33
17
+ defaultConfig {
18
+ minSdkVersion 24
19
+ targetSdkVersion 33
20
+ }
21
+ }
22
+
23
+ repositories {
24
+ google()
25
+ mavenCentral()
26
+ }
27
+
28
+ dependencies {
29
+ implementation "com.facebook.react:react-native:+"
30
+ implementation "com.google.android.play:integrity:1.3.0"
31
+ }
@@ -0,0 +1,38 @@
1
+ package com.reactnativeappattestation
2
+
3
+ import com.facebook.react.bridge.Promise
4
+ import com.facebook.react.bridge.ReactApplicationContext
5
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
6
+ import com.facebook.react.bridge.ReactMethod
7
+ import com.google.android.play.core.integrity.IntegrityManagerFactory
8
+ import com.google.android.play.core.integrity.IntegrityTokenRequest
9
+
10
+ class PlayIntegrityModule(reactContext: ReactApplicationContext)
11
+ : ReactContextBaseJavaModule(reactContext) {
12
+
13
+ override fun getName() = "PlayIntegrityModule"
14
+
15
+ @ReactMethod
16
+ fun getAttestationToken(nonce: String, promise: Promise) {
17
+ try {
18
+ val integrityManager = IntegrityManagerFactory
19
+ .create(reactApplicationContext)
20
+
21
+ val request = IntegrityTokenRequest.builder()
22
+ .setNonce(nonce)
23
+ .build()
24
+
25
+ integrityManager
26
+ .requestIntegrityToken(request)
27
+ .addOnSuccessListener { response ->
28
+ promise.resolve(response.token())
29
+ }
30
+ .addOnFailureListener { error ->
31
+ promise.reject("INTEGRITY_ERROR", error.message)
32
+ }
33
+
34
+ } catch (e: Exception) {
35
+ promise.reject("UNEXPECTED_ERROR", e.message)
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,21 @@
1
+ package com.reactnativeappattestation
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class PlayIntegrityPackage : ReactPackage {
9
+
10
+ override fun createNativeModules(
11
+ reactContext: ReactApplicationContext
12
+ ): List<NativeModule> {
13
+ return listOf(PlayIntegrityModule(reactContext))
14
+ }
15
+
16
+ override fun createViewManagers(
17
+ reactContext: ReactApplicationContext
18
+ ): List<ViewManager<*, *>> {
19
+ return emptyList()
20
+ }
21
+ }
@@ -0,0 +1,11 @@
1
+ #import <React/RCTBridgeModule.h>
2
+
3
+ @interface RCT_EXTERN_MODULE(AppAttestModule, NSObject)
4
+
5
+ RCT_EXTERN_METHOD(
6
+ getAttestationToken:(NSString *)challenge
7
+ resolve:(RCTPromiseResolveBlock)resolve
8
+ reject:(RCTPromiseRejectBlock)reject
9
+ )
10
+
11
+ @end
@@ -0,0 +1,56 @@
1
+ import Foundation
2
+ import DeviceCheck
3
+ import CryptoKit
4
+
5
+ @objc(AppAttestModule)
6
+ class AppAttestModule: NSObject {
7
+
8
+ @objc static func requiresMainQueueSetup() -> Bool {
9
+ return false
10
+ }
11
+
12
+ @objc func getAttestationToken(
13
+ _ challenge: String,
14
+ resolve: @escaping RCTPromiseResolveBlock,
15
+ reject: @escaping RCTPromiseRejectBlock
16
+ ) {
17
+ guard DCAppAttestService.shared.isSupported else {
18
+ reject("NOT_SUPPORTED", "App Attest not supported", nil)
19
+ return
20
+ }
21
+
22
+ DCAppAttestService.shared.generateKey { keyId, error in
23
+ if let error = error {
24
+ reject("KEY_ERROR", error.localizedDescription, error)
25
+ return
26
+ }
27
+
28
+ guard let keyId = keyId else {
29
+ reject("KEY_ERROR", "KeyId nil aaya", nil)
30
+ return
31
+ }
32
+
33
+ let challengeData = challenge.data(using: .utf8)!
34
+ let hash = Data(SHA256.hash(data: challengeData))
35
+
36
+ DCAppAttestService.shared.attestKey(
37
+ keyId,
38
+ clientDataHash: hash
39
+ ) { attestation, error in
40
+
41
+ if let error = error {
42
+ reject("ATTEST_ERROR", error.localizedDescription, error)
43
+ return
44
+ }
45
+
46
+ guard let attestation = attestation else {
47
+ reject("ATTEST_ERROR", "Attestation nil aaya", nil)
48
+ return
49
+ }
50
+
51
+ let token = attestation.base64EncodedString()
52
+ resolve(token)
53
+ }
54
+ }
55
+ }
56
+ }
@@ -0,0 +1 @@
1
+ #import <React/RCTBridgeModule.h>
@@ -0,0 +1,15 @@
1
+ import { AttestationConfig, AttestationResult } from './types';
2
+ export declare class AttestationService {
3
+ private config;
4
+ private cachedToken;
5
+ private tokenExpiry;
6
+ private cacheDuration;
7
+ constructor(config: AttestationConfig);
8
+ private log;
9
+ private fetchNonce;
10
+ private getAndroidToken;
11
+ private getIOSToken;
12
+ getToken(forceRefresh?: boolean): Promise<AttestationResult>;
13
+ getFreshToken(): Promise<AttestationResult>;
14
+ clearCache(): void;
15
+ }