@umutcansu/react-native-pinvault 0.0.0-stage → 2.3.2

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +386 -2
  4. package/RNPinVault.podspec +43 -0
  5. package/android/build.gradle +68 -0
  6. package/android/consumer-rules.pro +25 -0
  7. package/android/src/main/AndroidManifest.xml +16 -0
  8. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +300 -0
  9. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/JsEnvironmentGuard.kt +53 -0
  10. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +239 -0
  11. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +410 -0
  12. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +296 -0
  13. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultPackage.kt +25 -0
  14. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinnedFetch.kt +131 -0
  15. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ResultMapper.kt +188 -0
  16. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/StrictJson.kt +225 -0
  17. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/VaultTokenStore.kt +63 -0
  18. package/ios/Core/ConfigParser.swift +276 -0
  19. package/ios/Core/JSEnvironmentGuard.swift +129 -0
  20. package/ios/Core/NativeSecurity.swift +197 -0
  21. package/ios/Core/PinnedFetch.swift +119 -0
  22. package/ios/Core/ReactNetworking.swift +172 -0
  23. package/ios/Core/ResultMapper.swift +163 -0
  24. package/ios/Core/StrictJSON.swift +214 -0
  25. package/ios/Core/VaultTokenStore.swift +66 -0
  26. package/ios/PinVaultBridge.swift +412 -0
  27. package/ios/PinVaultReactNetworking.swift +47 -0
  28. package/ios/RNPinVault.h +7 -0
  29. package/ios/RNPinVault.mm +293 -0
  30. package/ios/RNPinVaultURLRequestHandler.mm +92 -0
  31. package/lib/module/NativePinVault.js +13 -0
  32. package/lib/module/NativePinVault.js.map +1 -0
  33. package/lib/module/index.js +312 -0
  34. package/lib/module/index.js.map +1 -0
  35. package/lib/module/package.json +1 -0
  36. package/lib/module/types.js +2 -0
  37. package/lib/module/types.js.map +1 -0
  38. package/lib/typescript/package.json +1 -0
  39. package/lib/typescript/src/NativePinVault.d.ts +50 -0
  40. package/lib/typescript/src/NativePinVault.d.ts.map +1 -0
  41. package/lib/typescript/src/index.d.ts +150 -0
  42. package/lib/typescript/src/index.d.ts.map +1 -0
  43. package/lib/typescript/src/types.d.ts +346 -0
  44. package/lib/typescript/src/types.d.ts.map +1 -0
  45. package/package.json +128 -4
  46. package/src/NativePinVault.ts +72 -0
  47. package/src/index.ts +385 -0
  48. package/src/types.ts +351 -0
@@ -0,0 +1,410 @@
1
+ package io.github.umutcansu.pinvault.reactnative
2
+
3
+ import android.content.pm.ApplicationInfo
4
+ import android.provider.Settings
5
+ import androidx.fragment.app.FragmentActivity
6
+ import com.facebook.react.bridge.Arguments
7
+ import com.facebook.react.bridge.Promise
8
+ import com.facebook.react.bridge.ReactApplicationContext
9
+ import com.facebook.react.bridge.WritableArray
10
+ import com.facebook.react.bridge.WritableMap
11
+ import io.github.umutcansu.pinvault.PinVault
12
+ import io.github.umutcansu.pinvault.api.PinVaultConnectionListener
13
+ import io.github.umutcansu.pinvault.model.VaultFileStatus
14
+ import kotlinx.coroutines.CoroutineScope
15
+ import kotlinx.coroutines.Dispatchers
16
+ import kotlinx.coroutines.SupervisorJob
17
+ import kotlinx.coroutines.cancel
18
+ import kotlinx.coroutines.launch
19
+ import kotlinx.coroutines.sync.Mutex
20
+ import kotlinx.coroutines.sync.withLock
21
+
22
+ /**
23
+ * The TurboModule (`RNPinVault`). Every method runs off the JS thread and
24
+ * answers through its promise; every structured input is parsed strictly
25
+ * ([ConfigParser], [PinnedFetch]); every result is mapped by [ResultMapper].
26
+ * Nothing here pins, decrypts or stores anything itself: it calls the
27
+ * `PinVault` object of the Android library.
28
+ */
29
+ class PinVaultModule(reactContext: ReactApplicationContext) : NativePinVaultSpec(reactContext) {
30
+
31
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
32
+ private val app get() = reactApplicationContext.applicationContext
33
+ private val mapper = ResultMapper(tokens)
34
+
35
+ init {
36
+ current = this
37
+ }
38
+
39
+ override fun getName(): String = NAME
40
+
41
+ override fun invalidate() {
42
+ scope.cancel()
43
+ super.invalidate()
44
+ }
45
+
46
+ // ── helpers ──────────────────────────────────────────────────────────────
47
+
48
+ private fun run(promise: Promise, block: suspend () -> Any?) {
49
+ scope.launch {
50
+ try {
51
+ promise.resolve(toBridge(block()))
52
+ } catch (_: AlreadyAnswered) {
53
+ // The block rejected the promise itself.
54
+ } catch (e: BridgeInputException) {
55
+ promise.reject(E_INVALID_ARGUMENT, tokens.redact(e.message))
56
+ } catch (e: IllegalStateException) {
57
+ // The library throws this before init.
58
+ promise.reject(E_NOT_STARTED, tokens.redact(e.message), exceptionInfo(e))
59
+ } catch (e: Exception) {
60
+ promise.reject(E_NATIVE, tokens.redact("${e.javaClass.simpleName}: ${e.message}"), exceptionInfo(e))
61
+ }
62
+ }
63
+ }
64
+
65
+ /** A getter that, like iOS, answers an empty value before `start()` instead of failing. */
66
+ private fun <T> started(default: T, block: () -> T): T =
67
+ if (!startedInProcess) default else try { block() } catch (_: IllegalStateException) { default }
68
+
69
+ private fun exceptionInfo(e: Throwable): WritableMap = Arguments.createMap().apply {
70
+ putString("exceptionName", e.javaClass.simpleName)
71
+ putString("exceptionMessage", tokens.redact(e.message))
72
+ }
73
+
74
+ private fun emitGuard(requestId: String, operation: String) {
75
+ emitOnGuardRequest(Arguments.createMap().apply {
76
+ putString("requestId", requestId)
77
+ putString("operation", operation)
78
+ })
79
+ }
80
+
81
+ // The library outlives a JS reload: events and guard questions go to the live module.
82
+ private val connectionListener = PinVaultConnectionListener { event ->
83
+ try {
84
+ val module = current ?: return@PinVaultConnectionListener
85
+ module.emitOnConnectionEvent(toBridge(module.mapper.event(event)) as WritableMap)
86
+ } catch (_: Exception) {
87
+ // A JS runtime that is going away; telemetry must never break a handshake.
88
+ }
89
+ }
90
+
91
+ // ── Start / config ───────────────────────────────────────────────────────
92
+
93
+ override fun start(configJson: String, promise: Promise) {
94
+ scope.launch {
95
+ startLock.withLock {
96
+ var newGuard: JsEnvironmentGuard? = null
97
+ val parsed = try {
98
+ ConfigParser.parse(
99
+ configJson, tokens,
100
+ guardFactory = { timeout ->
101
+ JsEnvironmentGuard(timeout) { id, op -> current?.emitGuard(id, op) }.also { newGuard = it }
102
+ },
103
+ listener = connectionListener,
104
+ native = NativeSecurity.load(app),
105
+ release = NativeSecurity.isReleaseBuild(app),
106
+ )
107
+ } catch (e: IllegalArgumentException) {
108
+ // The guard of the running config stays: a refused config changes nothing.
109
+ promise.reject(E_INVALID_CONFIG, tokens.redact(e.message))
110
+ return@launch
111
+ }
112
+ try {
113
+ if (!parsed.pinGlobalNetworking && PinVaultNetworking.isInstalled) {
114
+ promise.reject(
115
+ E_INVALID_CONFIG,
116
+ "config.android.pinGlobalNetworking: false, but React Native's networking is already pinned " +
117
+ "(the plugin's content provider, or PinVaultNetworking.install); opt out natively: " +
118
+ "remove the provider in the app's manifest (tools:node=\"remove\", README \"Networking\")",
119
+ )
120
+ return@launch
121
+ }
122
+ if (parsed.pinGlobalNetworking) PinVaultNetworking.install(app)
123
+ val hooks = PinVaultNetworking.warnIfNotPinned("PinVault.start")
124
+ if (parsed.networking.requirePinned && !hooks.pinned) {
125
+ promise.reject(E_NETWORKING_NOT_PINNED, "requirePinnedReactNativeNetworking: ${hooks.describe()}")
126
+ return@launch
127
+ }
128
+ guard = newGuard
129
+ // A second start applies the new config: the library keeps the
130
+ // first one otherwise (the samples restart the same way).
131
+ if (startedInProcess) {
132
+ PinVaultNetworking.pinning.deactivate()
133
+ PinVault.reset()
134
+ }
135
+ PinVaultNetworking.pinning.options = parsed.networking
136
+ val result = PinVault.init(app, parsed.config)
137
+ startedInProcess = true
138
+ if (PinVaultNetworking.isInstalled) {
139
+ try {
140
+ PinVaultNetworking.pinning.activate(parsed.networking)
141
+ } catch (_: IllegalStateException) {
142
+ // Setup failed (InitResult.Failed): RN's https stays refused.
143
+ }
144
+ }
145
+ promise.resolve(toBridge(mapper.init(result)))
146
+ } catch (e: Exception) {
147
+ promise.reject(E_NATIVE, tokens.redact("${e.javaClass.simpleName}: ${e.message}"), exceptionInfo(e))
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ override fun updateNow(promise: Promise) = run(promise) { mapper.update(PinVault.updateNow()) }
154
+
155
+ override fun currentVersion(promise: Promise) = run(promise) { started(0) { PinVault.currentVersion() } }
156
+
157
+ override fun hostPinVersions(promise: Promise) = run(promise) { started(emptyMap()) { PinVault.hostPinVersions() } }
158
+
159
+ override fun pinsForHost(hostname: String, promise: Promise) = run(promise) {
160
+ mapOf("pins" to started(null) { PinVault.pinsForHost(hostname) })
161
+ }
162
+
163
+ override fun signingStatus(configApiId: String?, promise: Promise) = run(promise) {
164
+ started(null) { PinVault.signingStatus(configApiId) }?.let(mapper::signing)
165
+ }
166
+
167
+ override fun isForceUpdate(promise: Promise) = run(promise) { started(false) { PinVault.isForceUpdate() } }
168
+
169
+ override fun reset(promise: Promise) = run(promise) {
170
+ started(Unit) { PinVault.reset() }
171
+ null
172
+ }
173
+
174
+ override fun schedulePeriodicUpdates(intervalHours: Double?, promise: Promise) {
175
+ if (!startedInProcess) return promise.resolve(false)
176
+ val hours = intervalHours?.toLong()
177
+ if (hours != null && (hours < 1 || hours > 24L * 30)) {
178
+ return promise.reject(E_INVALID_ARGUMENT, "intervalHours: must be between 1 and 720")
179
+ }
180
+ try {
181
+ val callback: (Boolean) -> Unit = { promise.resolve(it) }
182
+ if (hours == null) PinVault.schedulePeriodicUpdates(onScheduled = callback)
183
+ else PinVault.schedulePeriodicUpdates(hours, callback)
184
+ } catch (e: Exception) {
185
+ promise.reject(E_NATIVE, tokens.redact(e.message), exceptionInfo(e))
186
+ }
187
+ }
188
+
189
+ override fun cancelPeriodicUpdates(promise: Promise) = run(promise) {
190
+ started(Unit) { PinVault.cancelPeriodicUpdates() }
191
+ null
192
+ }
193
+
194
+ override fun enableDebugLogging(promise: Promise) = run(promise) {
195
+ val debuggable = (app.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
196
+ if (debuggable) PinVault.enableDebugLogging()
197
+ debuggable
198
+ }
199
+
200
+ // ── Pinned HTTP ──────────────────────────────────────────────────────────
201
+
202
+ override fun fetch(requestJson: String, promise: Promise) {
203
+ scope.launch {
204
+ // RN's own fetch is pinned only while both hooks are PinVault's: say so when another library replaced one.
205
+ if (PinVaultNetworking.isInstalled) PinVaultNetworking.warnIfNotPinned("PinVault.fetch")
206
+ val request = try {
207
+ PinnedFetch.parse(requestJson)
208
+ } catch (e: IllegalArgumentException) {
209
+ promise.reject(E_INVALID_ARGUMENT, tokens.redact(e.message))
210
+ return@launch
211
+ }
212
+ val client = try {
213
+ if (request.settings != null) PinVault.getClient(request.settings) else PinVault.getClient()
214
+ } catch (e: IllegalStateException) {
215
+ promise.reject(E_NOT_STARTED, "PinVault has not started: call start() first")
216
+ return@launch
217
+ }
218
+ try {
219
+ promise.resolve(toBridge(PinnedFetch.execute(client, request)))
220
+ } catch (e: Exception) {
221
+ val name = e.javaClass.simpleName
222
+ promise.reject(E_FETCH, tokens.redact("$name: ${e.message}"), exceptionInfo(e))
223
+ }
224
+ }
225
+ }
226
+
227
+ // ── Enrollment ───────────────────────────────────────────────────────────
228
+
229
+ override fun deviceId(promise: Promise) = run(promise) {
230
+ @Suppress("HardwareIds")
231
+ Settings.Secure.getString(app.contentResolver, Settings.Secure.ANDROID_ID)
232
+ }
233
+
234
+ override fun enrollForResult(token: String, label: String?, promise: Promise) = run(promise) {
235
+ if (token.isEmpty() || token.length > 4096) throw BridgeInputException("token: must be 1 to 4096 characters")
236
+ tokens.withTransientSecretSuspend(token) { mapper.enrollment(PinVault.enrollForResult(app, token, label)) }
237
+ }
238
+
239
+ override fun autoEnrollForResult(promise: Promise) = run(promise) { mapper.enrollment(PinVault.autoEnrollForResult(app)) }
240
+
241
+ override fun checkPendingEnrollment(promise: Promise) = run(promise) { mapper.enrollment(PinVault.checkPendingEnrollment(app)) }
242
+
243
+ override fun isEnrolled(label: String?, promise: Promise) = run(promise) { PinVault.isEnrolled(app, label) }
244
+
245
+ override fun isEnrollmentPending(label: String?, promise: Promise) = run(promise) { PinVault.isEnrollmentPending(app, label) }
246
+
247
+ override fun enrollmentVerificationCode(label: String?, promise: Promise) = run(promise) {
248
+ PinVault.enrollmentVerificationCode(app, label)
249
+ }
250
+
251
+ override fun enrolledClientCN(label: String?, promise: Promise) = run(promise) { PinVault.enrolledClientCN(app, label) }
252
+
253
+ override fun enrolledClientNotAfter(label: String?, promise: Promise) = run(promise) {
254
+ PinVault.enrolledClientNotAfter(app, label)
255
+ }
256
+
257
+ override fun unenroll(label: String?, wipeVaultFiles: Boolean, promise: Promise) = run(promise) {
258
+ if (wipeVaultFiles) PinVault.unenroll(app, label, true) else PinVault.unenroll(app, label)
259
+ null
260
+ }
261
+
262
+ override fun identityKeySecurityLevel(label: String?, promise: Promise) = run(promise) {
263
+ PinVault.identityKeySecurityLevel(label)?.name
264
+ }
265
+
266
+ // ── Vault files ──────────────────────────────────────────────────────────
267
+
268
+ override fun setVaultToken(key: String, token: String?, promise: Promise) = run(promise) {
269
+ checkKey(key)
270
+ if (token != null && token.length > 4096) throw BridgeInputException("token: longer than 4096 characters")
271
+ tokens.put(key, token)
272
+ null
273
+ }
274
+
275
+ override fun clearVaultTokens(promise: Promise) = run(promise) { tokens.clear() }
276
+
277
+ override fun fetchFile(key: String, token: String?, promise: Promise) = run(promise) {
278
+ checkKey(key)
279
+ if (token != null) {
280
+ if (token.length > 4096) throw BridgeInputException("token: longer than 4096 characters")
281
+ tokens.put(key, token)
282
+ }
283
+ mapper.vaultFile(PinVault.fetchFile(key))
284
+ }
285
+
286
+ override fun loadFile(key: String, encoding: String, promise: Promise) = run(promise) {
287
+ checkKey(key)
288
+ val enc = ResultMapper.checkEncoding(encoding)
289
+ started(null) { PinVault.loadFile(key) }?.let { ResultMapper.encode(it, enc) }
290
+ }
291
+
292
+ override fun fileStatus(key: String, promise: Promise) = run(promise) {
293
+ checkKey(key)
294
+ started(VaultFileStatus.NOT_STORED) { PinVault.fileStatus(key) }.name
295
+ }
296
+
297
+ override fun unlockFile(key: String, promptJson: String, promise: Promise) = run(promise) {
298
+ checkKey(key)
299
+ val (prompt, encoding) = ConfigParser.unlockPrompt(promptJson)
300
+ val activity = reactApplicationContext.currentActivity as? FragmentActivity
301
+ ?: return@run rejectNow(promise, E_NO_ACTIVITY, "unlockFile needs a FragmentActivity in the foreground")
302
+ // The library shows BiometricPrompt on the main thread itself.
303
+ mapper.unlock(PinVault.unlockFile(activity, key, prompt), encoding)
304
+ }
305
+
306
+ override fun isFileLocked(key: String, promise: Promise) = run(promise) { checkKey(key); started(false) { PinVault.isFileLocked(key) } }
307
+
308
+ override fun hasFile(key: String, promise: Promise) = run(promise) { checkKey(key); started(false) { PinVault.hasFile(key) } }
309
+
310
+ override fun fileVersion(key: String, promise: Promise) = run(promise) { checkKey(key); started(0) { PinVault.fileVersion(key) } }
311
+
312
+ override fun clearFile(key: String, promise: Promise) = run(promise) {
313
+ checkKey(key)
314
+ started(Unit) { PinVault.clearFile(key) }
315
+ null
316
+ }
317
+
318
+ override fun syncAllFiles(promise: Promise) = run(promise) {
319
+ PinVault.syncAllFiles().mapValues { (_, r) -> mapper.vaultFile(r) }
320
+ }
321
+
322
+ // ── Attestation ──────────────────────────────────────────────────────────
323
+
324
+ override fun attestNow(configApiId: String?, promise: Promise) = run(promise) { mapper.attestation(PinVault.attestNow(configApiId)) }
325
+
326
+ override fun fetchAttestationToken(host: String?, promise: Promise) = run(promise) {
327
+ mapper.attestationToken(PinVault.fetchAttestationToken(host))
328
+ }
329
+
330
+ override fun attestationStatus(configApiId: String?, promise: Promise) = run(promise) {
331
+ mapper.attestation(PinVault.attestationStatus(configApiId))
332
+ }
333
+
334
+ // ── environmentGuard ─────────────────────────────────────────────────────
335
+
336
+ override fun answerGuard(requestId: String, allowed: Boolean) {
337
+ guard?.answer(requestId, allowed)
338
+ }
339
+
340
+ private fun checkKey(key: String) {
341
+ if (key.isEmpty() || key.length > 128) throw BridgeInputException("key: must be 1 to 128 characters")
342
+ }
343
+
344
+ private fun rejectNow(promise: Promise, code: String, message: String): Nothing {
345
+ promise.reject(code, message)
346
+ throw AlreadyAnswered()
347
+ }
348
+
349
+ /** Thrown after the promise was already rejected; [run] must not answer again. */
350
+ private class AlreadyAnswered : RuntimeException()
351
+
352
+ companion object {
353
+ const val NAME = NativePinVaultSpec.NAME
354
+
355
+ const val E_INVALID_CONFIG = "E_INVALID_CONFIG"
356
+ const val E_INVALID_ARGUMENT = "E_INVALID_ARGUMENT"
357
+ const val E_NOT_STARTED = "E_NOT_STARTED"
358
+ const val E_FETCH = "E_FETCH"
359
+ const val E_NO_ACTIVITY = "E_NO_ACTIVITY"
360
+ const val E_NATIVE = "E_NATIVE"
361
+ const val E_NETWORKING_NOT_PINNED = "E_NETWORKING_NOT_PINNED"
362
+
363
+ /** Process-wide: the library is a process singleton, a JS reload makes a new module. */
364
+ @Volatile private var current: PinVaultModule? = null
365
+ @Volatile private var startedInProcess = false
366
+ @Volatile private var guard: JsEnvironmentGuard? = null
367
+ private val startLock = Mutex()
368
+ private val tokens = VaultTokenStore()
369
+
370
+ /** Plain values → bridge values (maps, lists, numbers as doubles). */
371
+ internal fun toBridge(value: Any?): Any? = when (value) {
372
+ null, is Unit -> null
373
+ is String, is Boolean -> value
374
+ is Int -> value
375
+ is Long -> value.toDouble()
376
+ is Number -> value.toDouble()
377
+ is Map<*, *> -> Arguments.createMap().also { map ->
378
+ value.forEach { (k, v) -> putInto(map, k as String, toBridge(v)) }
379
+ }
380
+ is List<*> -> Arguments.createArray().also { array -> value.forEach { pushInto(array, toBridge(it)) } }
381
+ else -> value.toString()
382
+ }
383
+
384
+ private fun putInto(map: WritableMap, key: String, v: Any?) {
385
+ when (v) {
386
+ null -> map.putNull(key)
387
+ is String -> map.putString(key, v)
388
+ is Boolean -> map.putBoolean(key, v)
389
+ is Int -> map.putInt(key, v)
390
+ is Double -> map.putDouble(key, v)
391
+ is WritableMap -> map.putMap(key, v)
392
+ is WritableArray -> map.putArray(key, v)
393
+ else -> map.putString(key, v.toString())
394
+ }
395
+ }
396
+
397
+ private fun pushInto(array: WritableArray, v: Any?) {
398
+ when (v) {
399
+ null -> array.pushNull()
400
+ is String -> array.pushString(v)
401
+ is Boolean -> array.pushBoolean(v)
402
+ is Int -> array.pushInt(v)
403
+ is Double -> array.pushDouble(v)
404
+ is WritableMap -> array.pushMap(v)
405
+ is WritableArray -> array.pushArray(v)
406
+ else -> array.pushString(v.toString())
407
+ }
408
+ }
409
+ }
410
+ }