@umutcansu/react-native-pinvault 0.0.0-stage → 2.4.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +437 -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 +396 -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 +354 -0
  11. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +419 -0
  12. package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +379 -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 +437 -0
  19. package/ios/Core/JSEnvironmentGuard.swift +129 -0
  20. package/ios/Core/NativeSecurity.swift +303 -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 +424 -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 +331 -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 +359 -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 +398 -0
  48. package/src/types.ts +362 -0
@@ -0,0 +1,419 @@
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
+ noFileAllowed = NativeSecurity.noFileAllowed(app),
107
+ )
108
+ } catch (e: IllegalArgumentException) {
109
+ // The guard of the running config stays: a refused config changes nothing.
110
+ promise.reject(E_INVALID_CONFIG, tokens.redact(e.message))
111
+ return@launch
112
+ }
113
+ try {
114
+ if (!parsed.pinGlobalNetworking && PinVaultNetworking.isInstalled) {
115
+ promise.reject(
116
+ E_INVALID_CONFIG,
117
+ "config.android.pinGlobalNetworking: false, but React Native's networking is already pinned " +
118
+ "(the plugin's content provider, or PinVaultNetworking.install); opt out natively: " +
119
+ "remove the provider in the app's manifest (tools:node=\"remove\", README \"Networking\")",
120
+ )
121
+ return@launch
122
+ }
123
+ // Installed here only when the plugin's provider was removed and the app did not
124
+ // install it before React Native started: marked late (HookStatus.installedLate).
125
+ if (parsed.pinGlobalNetworking && !PinVaultNetworking.isInstalled) PinVaultNetworking.install(app, late = true)
126
+ val hooks = PinVaultNetworking.warnIfNotPinned("PinVault.start")
127
+ if (parsed.networking.requirePinned && !hooks.pinned) {
128
+ promise.reject(E_NETWORKING_NOT_PINNED, "requirePinnedReactNativeNetworking: ${hooks.describe()}")
129
+ return@launch
130
+ }
131
+ guard = newGuard
132
+ // A second start applies the new config: the library keeps the
133
+ // first one otherwise (the samples restart the same way).
134
+ if (startedInProcess) {
135
+ PinVaultNetworking.pinning.deactivate()
136
+ PinVault.reset()
137
+ }
138
+ PinVaultNetworking.pinning.options = parsed.networking
139
+ val result = PinVault.init(app, parsed.config)
140
+ startedInProcess = true
141
+ if (PinVaultNetworking.isInstalled) {
142
+ try {
143
+ PinVaultNetworking.pinning.activate(parsed.networking)
144
+ } catch (_: IllegalStateException) {
145
+ // Setup failed (InitResult.Failed): RN's https stays refused.
146
+ }
147
+ }
148
+ // Says whether the anchors came from the native security file (README "Native security file").
149
+ promise.resolve(toBridge(mapper.init(result) + ("nativeSecurityApplied" to parsed.nativeSecurityApplied)))
150
+ } catch (e: Exception) {
151
+ promise.reject(E_NATIVE, tokens.redact("${e.javaClass.simpleName}: ${e.message}"), exceptionInfo(e))
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ override fun updateNow(promise: Promise) = run(promise) { mapper.update(PinVault.updateNow()) }
158
+
159
+ override fun currentVersion(promise: Promise) = run(promise) { started(0) { PinVault.currentVersion() } }
160
+
161
+ override fun hostPinVersions(promise: Promise) = run(promise) { started(emptyMap()) { PinVault.hostPinVersions() } }
162
+
163
+ override fun pinsForHost(hostname: String, promise: Promise) = run(promise) {
164
+ mapOf("pins" to started(null) { PinVault.pinsForHost(hostname) })
165
+ }
166
+
167
+ override fun signingStatus(configApiId: String?, promise: Promise) = run(promise) {
168
+ started(null) { PinVault.signingStatus(configApiId) }?.let(mapper::signing)
169
+ }
170
+
171
+ override fun isForceUpdate(promise: Promise) = run(promise) { started(false) { PinVault.isForceUpdate() } }
172
+
173
+ override fun reset(promise: Promise) = run(promise) {
174
+ started(Unit) { PinVault.reset() }
175
+ null
176
+ }
177
+
178
+ override fun schedulePeriodicUpdates(intervalHours: Double?, promise: Promise) {
179
+ if (!startedInProcess) return promise.resolve(false)
180
+ val hours = intervalHours?.toLong()
181
+ if (hours != null && (hours < 1 || hours > 24L * 30)) {
182
+ return promise.reject(E_INVALID_ARGUMENT, "intervalHours: must be between 1 and 720")
183
+ }
184
+ try {
185
+ val callback: (Boolean) -> Unit = { promise.resolve(it) }
186
+ if (hours == null) PinVault.schedulePeriodicUpdates(onScheduled = callback)
187
+ else PinVault.schedulePeriodicUpdates(hours, callback)
188
+ } catch (e: Exception) {
189
+ promise.reject(E_NATIVE, tokens.redact(e.message), exceptionInfo(e))
190
+ }
191
+ }
192
+
193
+ override fun cancelPeriodicUpdates(promise: Promise) = run(promise) {
194
+ started(Unit) { PinVault.cancelPeriodicUpdates() }
195
+ null
196
+ }
197
+
198
+ override fun enableDebugLogging(promise: Promise) = run(promise) {
199
+ val debuggable = (app.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
200
+ if (debuggable) PinVault.enableDebugLogging()
201
+ debuggable
202
+ }
203
+
204
+ // ── Pinned HTTP ──────────────────────────────────────────────────────────
205
+
206
+ override fun fetch(requestJson: String, promise: Promise) {
207
+ scope.launch {
208
+ // RN's own fetch is pinned only while both hooks are PinVault's: say so when another library replaced one.
209
+ if (PinVaultNetworking.isInstalled) PinVaultNetworking.warnIfNotPinned("PinVault.fetch")
210
+ val request = try {
211
+ PinnedFetch.parse(requestJson)
212
+ } catch (e: IllegalArgumentException) {
213
+ promise.reject(E_INVALID_ARGUMENT, tokens.redact(e.message))
214
+ return@launch
215
+ }
216
+ val client = try {
217
+ if (request.settings != null) PinVault.getClient(request.settings) else PinVault.getClient()
218
+ } catch (e: IllegalStateException) {
219
+ promise.reject(E_NOT_STARTED, "PinVault has not started: call start() first")
220
+ return@launch
221
+ }
222
+ try {
223
+ promise.resolve(toBridge(PinnedFetch.execute(client, request)))
224
+ } catch (e: Exception) {
225
+ val name = e.javaClass.simpleName
226
+ promise.reject(E_FETCH, tokens.redact("$name: ${e.message}"), exceptionInfo(e))
227
+ }
228
+ }
229
+ }
230
+
231
+ // ── Enrollment ───────────────────────────────────────────────────────────
232
+
233
+ override fun deviceId(promise: Promise) = run(promise) {
234
+ @Suppress("HardwareIds")
235
+ Settings.Secure.getString(app.contentResolver, Settings.Secure.ANDROID_ID)
236
+ }
237
+
238
+ override fun enrollForResult(token: String, label: String?, promise: Promise) = run(promise) {
239
+ if (token.isEmpty() || token.length > 4096) throw BridgeInputException("token: must be 1 to 4096 characters")
240
+ tokens.withTransientSecretSuspend(token) { mapper.enrollment(PinVault.enrollForResult(app, token, label)) }
241
+ }
242
+
243
+ override fun autoEnrollForResult(promise: Promise) = run(promise) { mapper.enrollment(PinVault.autoEnrollForResult(app)) }
244
+
245
+ override fun checkPendingEnrollment(promise: Promise) = run(promise) { mapper.enrollment(PinVault.checkPendingEnrollment(app)) }
246
+
247
+ override fun isEnrolled(label: String?, promise: Promise) = run(promise) { PinVault.isEnrolled(app, label) }
248
+
249
+ override fun isEnrollmentPending(label: String?, promise: Promise) = run(promise) { PinVault.isEnrollmentPending(app, label) }
250
+
251
+ override fun enrollmentVerificationCode(label: String?, promise: Promise) = run(promise) {
252
+ PinVault.enrollmentVerificationCode(app, label)
253
+ }
254
+
255
+ override fun enrolledClientCN(label: String?, promise: Promise) = run(promise) { PinVault.enrolledClientCN(app, label) }
256
+
257
+ override fun enrolledClientNotAfter(label: String?, promise: Promise) = run(promise) {
258
+ PinVault.enrolledClientNotAfter(app, label)
259
+ }
260
+
261
+ override fun unenroll(label: String?, wipeVaultFiles: Boolean, promise: Promise) = run(promise) {
262
+ if (wipeVaultFiles) PinVault.unenroll(app, label, true) else PinVault.unenroll(app, label)
263
+ null
264
+ }
265
+
266
+ override fun identityKeySecurityLevel(label: String?, promise: Promise) = run(promise) {
267
+ PinVault.identityKeySecurityLevel(label)?.name
268
+ }
269
+
270
+ // ── Vault files ──────────────────────────────────────────────────────────
271
+
272
+ override fun setVaultToken(key: String, token: String?, promise: Promise) = run(promise) {
273
+ checkKey(key)
274
+ if (token != null && token.length > 4096) throw BridgeInputException("token: longer than 4096 characters")
275
+ tokens.put(key, token)
276
+ null
277
+ }
278
+
279
+ override fun clearVaultTokens(promise: Promise) = run(promise) { tokens.clear() }
280
+
281
+ override fun fetchFile(key: String, token: String?, promise: Promise) = run(promise) {
282
+ checkKey(key)
283
+ if (token != null) {
284
+ if (token.length > 4096) throw BridgeInputException("token: longer than 4096 characters")
285
+ tokens.put(key, token)
286
+ }
287
+ mapper.vaultFile(PinVault.fetchFile(key))
288
+ }
289
+
290
+ override fun loadFile(key: String, encoding: String, promise: Promise) = run(promise) {
291
+ checkKey(key)
292
+ val enc = ResultMapper.checkEncoding(encoding)
293
+ started(null) { PinVault.loadFile(key) }?.let { ResultMapper.encode(it, enc) }
294
+ }
295
+
296
+ override fun fileStatus(key: String, promise: Promise) = run(promise) {
297
+ checkKey(key)
298
+ started(VaultFileStatus.NOT_STORED) { PinVault.fileStatus(key) }.name
299
+ }
300
+
301
+ override fun unlockFile(key: String, promptJson: String, promise: Promise) = run(promise) {
302
+ checkKey(key)
303
+ val (prompt, encoding) = ConfigParser.unlockPrompt(promptJson)
304
+ val activity = reactApplicationContext.currentActivity as? FragmentActivity
305
+ ?: return@run rejectNow(promise, E_NO_ACTIVITY, "unlockFile needs a FragmentActivity in the foreground")
306
+ // The library shows BiometricPrompt on the main thread itself.
307
+ mapper.unlock(PinVault.unlockFile(activity, key, prompt), encoding)
308
+ }
309
+
310
+ override fun isFileLocked(key: String, promise: Promise) = run(promise) { checkKey(key); started(false) { PinVault.isFileLocked(key) } }
311
+
312
+ override fun hasFile(key: String, promise: Promise) = run(promise) { checkKey(key); started(false) { PinVault.hasFile(key) } }
313
+
314
+ override fun fileVersion(key: String, promise: Promise) = run(promise) { checkKey(key); started(0) { PinVault.fileVersion(key) } }
315
+
316
+ override fun clearFile(key: String, promise: Promise) = run(promise) {
317
+ checkKey(key)
318
+ started(Unit) { PinVault.clearFile(key) }
319
+ null
320
+ }
321
+
322
+ override fun syncAllFiles(promise: Promise) = run(promise) {
323
+ PinVault.syncAllFiles().mapValues { (_, r) -> mapper.vaultFile(r) }
324
+ }
325
+
326
+ // ── Attestation ──────────────────────────────────────────────────────────
327
+
328
+ override fun attestNow(configApiId: String?, promise: Promise) = run(promise) { mapper.attestation(PinVault.attestNow(configApiId)) }
329
+
330
+ override fun fetchAttestationToken(host: String?, promise: Promise) = run(promise) {
331
+ mapper.attestationToken(PinVault.fetchAttestationToken(host))
332
+ }
333
+
334
+ override fun attestationStatus(configApiId: String?, promise: Promise) = run(promise) {
335
+ mapper.attestation(PinVault.attestationStatus(configApiId))
336
+ }
337
+
338
+ // ── environmentGuard ─────────────────────────────────────────────────────
339
+
340
+ override fun answerGuard(requestId: String, allowed: Boolean) {
341
+ guard?.answer(requestId, allowed)
342
+ }
343
+
344
+ /** A vault file key: the library's rule (it names the stored copy's file). */
345
+ private fun checkKey(key: String) {
346
+ if (!VAULT_KEY.matches(key) || key.all { it == '.' }) {
347
+ throw BridgeInputException("key: must match [A-Za-z0-9._-]{1,64} and not be only dots")
348
+ }
349
+ }
350
+
351
+ private fun rejectNow(promise: Promise, code: String, message: String): Nothing {
352
+ promise.reject(code, message)
353
+ throw AlreadyAnswered()
354
+ }
355
+
356
+ /** Thrown after the promise was already rejected; [run] must not answer again. */
357
+ private class AlreadyAnswered : RuntimeException()
358
+
359
+ companion object {
360
+ const val NAME = NativePinVaultSpec.NAME
361
+
362
+ const val E_INVALID_CONFIG = "E_INVALID_CONFIG"
363
+ const val E_INVALID_ARGUMENT = "E_INVALID_ARGUMENT"
364
+ const val E_NOT_STARTED = "E_NOT_STARTED"
365
+ const val E_FETCH = "E_FETCH"
366
+ const val E_NO_ACTIVITY = "E_NO_ACTIVITY"
367
+ const val E_NATIVE = "E_NATIVE"
368
+ const val E_NETWORKING_NOT_PINNED = "E_NETWORKING_NOT_PINNED"
369
+
370
+ /** Process-wide: the library is a process singleton, a JS reload makes a new module. */
371
+ @Volatile private var current: PinVaultModule? = null
372
+ @Volatile private var startedInProcess = false
373
+ @Volatile private var guard: JsEnvironmentGuard? = null
374
+ private val startLock = Mutex()
375
+ private val tokens = VaultTokenStore()
376
+
377
+ /** Plain values → bridge values (maps, lists, numbers as doubles). */
378
+ internal fun toBridge(value: Any?): Any? = when (value) {
379
+ null, is Unit -> null
380
+ is String, is Boolean -> value
381
+ is Int -> value
382
+ is Long -> value.toDouble()
383
+ is Number -> value.toDouble()
384
+ is Map<*, *> -> Arguments.createMap().also { map ->
385
+ value.forEach { (k, v) -> putInto(map, k as String, toBridge(v)) }
386
+ }
387
+ is List<*> -> Arguments.createArray().also { array -> value.forEach { pushInto(array, toBridge(it)) } }
388
+ else -> value.toString()
389
+ }
390
+
391
+ private fun putInto(map: WritableMap, key: String, v: Any?) {
392
+ when (v) {
393
+ null -> map.putNull(key)
394
+ is String -> map.putString(key, v)
395
+ is Boolean -> map.putBoolean(key, v)
396
+ is Int -> map.putInt(key, v)
397
+ is Double -> map.putDouble(key, v)
398
+ is WritableMap -> map.putMap(key, v)
399
+ is WritableArray -> map.putArray(key, v)
400
+ else -> map.putString(key, v.toString())
401
+ }
402
+ }
403
+
404
+ private fun pushInto(array: WritableArray, v: Any?) {
405
+ when (v) {
406
+ null -> array.pushNull()
407
+ is String -> array.pushString(v)
408
+ is Boolean -> array.pushBoolean(v)
409
+ is Int -> array.pushInt(v)
410
+ is Double -> array.pushDouble(v)
411
+ is WritableMap -> array.pushMap(v)
412
+ is WritableArray -> array.pushArray(v)
413
+ else -> array.pushString(v.toString())
414
+ }
415
+ }
416
+ }
417
+ }
418
+
419
+ private val VAULT_KEY = Regex("^[A-Za-z0-9._-]{1,64}$")