@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,354 @@
1
+ package io.github.umutcansu.pinvault.reactnative
2
+
3
+ import android.content.Context
4
+ import android.content.pm.ApplicationInfo
5
+ import io.github.umutcansu.pinvault.model.CertificateConfig
6
+ import io.github.umutcansu.pinvault.model.HostPin
7
+ import io.github.umutcansu.pinvault.model.UserAuth
8
+ import io.github.umutcansu.pinvault.model.VaultFileEncryption
9
+ import java.io.FileNotFoundException
10
+ import java.io.IOException
11
+
12
+ /**
13
+ * The app's native security file: the trust anchors and relaxations of
14
+ * `start(config)` declared outside the JS bundle, which an OTA update
15
+ * (CodePush, Expo Updates) or an edited bundle can change. The file ships in
16
+ * the APK (`src/main/assets/pinvault_security.json`, or a build type's own
17
+ * `src/release/assets/`), which the APK signature seals.
18
+ *
19
+ * ```json
20
+ * {
21
+ * "configApis": [{
22
+ * "id": "default-tls",
23
+ * "bootstrapPins": [{ "hostname": "api.example.com", "sha256": ["…", "…"] }],
24
+ * "signaturePublicKeys": ["…", "…"], "requiredSignatures": 2,
25
+ * "recoveryPublicKeys": ["…"], "requiredRecoverySignatures": 1,
26
+ * "serverScope": "default-tls", "clientCaPins": ["…"],
27
+ * "url": "https://config.example.com/", "attestation": true,
28
+ * "tokenHosts": ["api.example.com"], "proofOfPossession": true, "clientCertHosts": ["api.example.com:443"],
29
+ * "enrollmentUrl": "https://enroll.example.com/", "renewalUrl": "https://renew.example.com/",
30
+ * "allowUnsigned": false, "allowUnpinnedConfigApi": false, "allowServerGeneratedKey": false
31
+ * }],
32
+ * "staticPins": { "pins": [ … ], "version": 1 },
33
+ * "require": {
34
+ * "requireUnlockedDevice": true, "requireHardwareBackedKeys": true, "managedTrustRoots": true,
35
+ * "wipeVaultFilesOnRevocation": true, "requireCaTrust": ["api.example.com"],
36
+ * "expectedSignerSha256": ["…"], "expiredConfigGraceSeconds": 0, "vaultFileMaxOfflineAgeSeconds": 604800
37
+ * },
38
+ * "vaultFiles": [{ "key": "statement", "signaturePublicKey": "…", "encryption": "USER_AUTH", "userAuth": "REQUIRED", "maxOfflineAgeSeconds": 86400 }]
39
+ * }
40
+ * ```
41
+ *
42
+ * When the file is there ([apply]):
43
+ * - every Config API of the JS config must be declared in it (an OTA update
44
+ * cannot add a block with keys of its own), and JS `staticPins` must be
45
+ * declared too;
46
+ * - a field the file declares is the value: JS may leave it out or repeat it,
47
+ * a different value is refused (`E_INVALID_CONFIG`);
48
+ * - `allowUnsigned`, `allowUnpinnedConfigApi`, `allowServerGeneratedKey` from
49
+ * JS are refused unless the file allows them for that block;
50
+ * - `url`, `tokenHosts` and `clientCertHosts` of a block are fixed where the
51
+ * file gives them, and `attestation: true` / `proofOfPossession: true` there
52
+ * cannot be switched off;
53
+ * - `require` only tightens: a protection it turns on stays on whatever JS
54
+ * says, `requireCaTrust` hosts are added to JS's, `expectedSignerSha256` is
55
+ * fixed, and `expiredConfigGraceSeconds` is the most JS may ask for;
56
+ * - `vaultFiles` fixes those files' signature key, encryption and screen lock;
57
+ * with the section present, a JS vault file it does not name is refused.
58
+ *
59
+ * Without the file, release builds (the app is not debuggable) refuse to start
60
+ * a config with Config APIs or static pins: the trust anchors would come from
61
+ * JS alone. An app that accepts that says so natively, in its manifest
62
+ * (`<meta-data android:name="io.github.umutcansu.pinvault.ALLOW_NO_NATIVE_SECURITY_FILE" android:value="true"/>`);
63
+ * the three relaxations stay refused from JS then. Debug builds take
64
+ * everything as before. Whatever the file says, a release build caps
65
+ * `expiredConfigGrace` at [MAX_RELEASE_GRACE_MS].
66
+ */
67
+ internal class NativeSecurity(
68
+ /** Where the file came from, for messages. */
69
+ val source: String,
70
+ val blocks: Map<String, Block>,
71
+ val staticPins: CertificateConfig?,
72
+ val require: Require = Require(),
73
+ /** Null when the file has no `vaultFiles` section. */
74
+ val vaultFiles: Map<String, VaultFilePolicy>? = null,
75
+ ) {
76
+
77
+ /** The `require` section: protections JS cannot switch off. */
78
+ class Require(
79
+ val requireUnlockedDevice: Boolean = false,
80
+ val requireHardwareBackedKeys: Boolean = false,
81
+ val managedTrustRoots: Boolean = false,
82
+ val wipeVaultFilesOnRevocation: Boolean = false,
83
+ val requireCaTrust: List<String>? = null,
84
+ val expectedSignerSha256: List<String>? = null,
85
+ /** The most `expiredConfigGrace` JS may set; null = only the release cap. */
86
+ val expiredConfigGraceMs: Long? = null,
87
+ /** The most `vaultFileMaxOfflineAge` JS may set (and the value when JS sets none); null = no rule. */
88
+ val vaultFileMaxOfflineAgeMs: Long? = null,
89
+ )
90
+
91
+ /** One `vaultFiles` entry: what JS cannot change about that file. */
92
+ class VaultFilePolicy(
93
+ val signaturePublicKey: String?,
94
+ val encryption: VaultFileEncryption?,
95
+ val userAuth: UserAuth?,
96
+ /** The most `maxOfflineAge` JS may give this file (and the value when JS gives none). */
97
+ val maxOfflineAgeMs: Long? = null,
98
+ )
99
+
100
+ class Block(
101
+ val bootstrapPins: List<HostPin>?,
102
+ val signaturePublicKeys: List<String>?,
103
+ val requiredSignatures: Int?,
104
+ val recoveryPublicKeys: List<String>?,
105
+ val requiredRecoverySignatures: Int?,
106
+ val serverScope: String?,
107
+ val clientCaPins: List<String>?,
108
+ val allowUnsigned: Boolean,
109
+ val allowUnpinnedConfigApi: Boolean,
110
+ val allowServerGeneratedKey: Boolean,
111
+ val url: String? = null,
112
+ val attestation: Boolean = false,
113
+ val tokenHosts: List<String>? = null,
114
+ val proofOfPossession: Boolean = false,
115
+ val clientCertHosts: List<String>? = null,
116
+ val enrollmentUrl: String? = null,
117
+ val renewalUrl: String? = null,
118
+ )
119
+
120
+ companion object {
121
+ const val ASSET_NAME = "pinvault_security.json"
122
+ const val NO_FILE_META_DATA = "io.github.umutcansu.pinvault.ALLOW_NO_NATIVE_SECURITY_FILE"
123
+ /** The longest `expiredConfigGrace` a release build takes: an expired pin set must not live on for months. */
124
+ const val MAX_RELEASE_GRACE_MS = 7L * 24 * 3600 * 1000
125
+ private const val MAX_OFFLINE_AGE_S = 10L * 365 * 24 * 3600
126
+
127
+ private fun httpsOnly(b: Fields, key: String): String? = b.string(key, 2048)?.also {
128
+ if (!it.startsWith("https://")) throw BridgeInputException("${b.path}.$key: must be an https:// URL")
129
+ }
130
+ private const val MAX_FILE_CHARS = 256 * 1024
131
+
132
+ /** The file from the app's assets; null when the app ships none. A broken file throws [BridgeInputException]. */
133
+ fun load(context: Context): NativeSecurity? {
134
+ val text = try {
135
+ context.assets.open(ASSET_NAME).bufferedReader(Charsets.UTF_8).use { it.readText() }
136
+ } catch (_: FileNotFoundException) {
137
+ return null
138
+ } catch (e: IOException) {
139
+ // There, but unreadable: fail closed.
140
+ throw BridgeInputException("native security file assets/$ASSET_NAME: cannot be read (${e.javaClass.simpleName})")
141
+ }
142
+ return parse(text, "assets/$ASSET_NAME")
143
+ }
144
+
145
+ /** True when the app's manifest says it starts without a native security file in release. */
146
+ fun noFileAllowed(context: Context): Boolean = try {
147
+ @Suppress("DEPRECATION")
148
+ context.packageManager.getApplicationInfo(context.packageName, android.content.pm.PackageManager.GET_META_DATA)
149
+ .metaData?.getBoolean(NO_FILE_META_DATA, false) == true
150
+ } catch (_: Exception) {
151
+ false
152
+ }
153
+
154
+ /** True for a release build of the app (not `android:debuggable`). */
155
+ fun isReleaseBuild(context: Context): Boolean =
156
+ (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0
157
+
158
+ fun parse(text: String, source: String): NativeSecurity {
159
+ val root = try {
160
+ StrictJson.parseObject(text, source, maxChars = MAX_FILE_CHARS)
161
+ } catch (e: BridgeInputException) {
162
+ throw BridgeInputException("native security file ${e.message}")
163
+ }
164
+ val blocks = LinkedHashMap<String, Block>()
165
+ try {
166
+ root.objList("configApis", ConfigParser.MAX_CONFIG_APIS)?.forEach { b ->
167
+ val id = b.requireString("id", 128)
168
+ if (id in blocks) throw BridgeInputException("${b.path}.id: '$id' is declared twice")
169
+ blocks[id] = Block(
170
+ bootstrapPins = b.objList("bootstrapPins", ConfigParser.MAX_PINS)?.map(ConfigParser::hostPin),
171
+ signaturePublicKeys = b.stringList("signaturePublicKeys", 16, ConfigParser.KEY_LENGTH, multiline = true),
172
+ requiredSignatures = b.int("requiredSignatures", 1, 16),
173
+ recoveryPublicKeys = b.stringList("recoveryPublicKeys", 16, ConfigParser.KEY_LENGTH, multiline = true),
174
+ requiredRecoverySignatures = b.int("requiredRecoverySignatures", 1, 16),
175
+ serverScope = b.string("serverScope", 128),
176
+ clientCaPins = b.stringList("clientCaPins", 16, 128),
177
+ allowUnsigned = b.bool("allowUnsigned") == true,
178
+ allowUnpinnedConfigApi = b.bool("allowUnpinnedConfigApi") == true,
179
+ allowServerGeneratedKey = b.bool("allowServerGeneratedKey") == true,
180
+ url = httpsOnly(b, "url"),
181
+ attestation = b.bool("attestation") == true,
182
+ tokenHosts = b.stringList("tokenHosts", 64, 255),
183
+ proofOfPossession = b.bool("proofOfPossession") == true,
184
+ clientCertHosts = b.stringList("clientCertHosts", 64, 2048),
185
+ enrollmentUrl = httpsOnly(b, "enrollmentUrl"),
186
+ renewalUrl = httpsOnly(b, "renewalUrl"),
187
+ )
188
+ b.finish()
189
+ }
190
+ val staticPins = root.obj("staticPins")?.let(ConfigParser::staticPins)
191
+ val require = root.obj("require")?.let { r ->
192
+ Require(
193
+ requireUnlockedDevice = r.bool("requireUnlockedDevice") == true,
194
+ requireHardwareBackedKeys = r.bool("requireHardwareBackedKeys") == true,
195
+ managedTrustRoots = r.bool("managedTrustRoots") == true,
196
+ wipeVaultFilesOnRevocation = r.bool("wipeVaultFilesOnRevocation") == true,
197
+ requireCaTrust = r.stringList("requireCaTrust", 64, 255),
198
+ expectedSignerSha256 = r.stringList("expectedSignerSha256", 16, 128),
199
+ expiredConfigGraceMs = r.long("expiredConfigGraceSeconds", 0, MAX_RELEASE_GRACE_MS / 1000)?.let { it * 1000 },
200
+ vaultFileMaxOfflineAgeMs = r.long("vaultFileMaxOfflineAgeSeconds", 1, MAX_OFFLINE_AGE_S)?.let { it * 1000 },
201
+ ).also {
202
+ // iOS-only (screen-lock strength): read for the shape, used by the iOS side.
203
+ r.string("userAuthStrength", 32)
204
+ // iOS-only (bundle and team ids): read for the shape, used by the iOS side.
205
+ r.stringList("expectedBundleIds", 16, 255)
206
+ r.stringList("expectedTeamIds", 16, 32)
207
+ r.finish()
208
+ }
209
+ } ?: Require()
210
+ val vaultFiles = root.objList("vaultFiles", ConfigParser.MAX_VAULT_FILES)?.let { list ->
211
+ val map = LinkedHashMap<String, VaultFilePolicy>()
212
+ list.forEach { f ->
213
+ val key = f.requireString("key", 128)
214
+ if (key in map) throw BridgeInputException("${f.path}.key: '$key' is declared twice")
215
+ map[key] = VaultFilePolicy(
216
+ signaturePublicKey = f.string("signaturePublicKey", ConfigParser.KEY_LENGTH, multiline = true),
217
+ encryption = f.enum("encryption", VaultFileEncryption.values()),
218
+ userAuth = f.enum("userAuth", UserAuth.values()),
219
+ maxOfflineAgeMs = f.long("maxOfflineAgeSeconds", 1, MAX_OFFLINE_AGE_S)?.let { it * 1000 },
220
+ )
221
+ f.finish()
222
+ }
223
+ map
224
+ }
225
+ root.finish()
226
+ if (blocks.isEmpty() && staticPins == null) {
227
+ throw BridgeInputException("$source: declares neither configApis nor staticPins")
228
+ }
229
+ return NativeSecurity(source, blocks, staticPins, require, vaultFiles)
230
+ } catch (e: BridgeInputException) {
231
+ val message = e.message ?: ""
232
+ throw BridgeInputException(if (message.startsWith("native security file")) message else "native security file $message")
233
+ } catch (e: IllegalArgumentException) {
234
+ // A builder / constructor rule (two pins per host, …).
235
+ throw BridgeInputException("native security file $source: ${e.message}")
236
+ }
237
+ }
238
+
239
+ // ── Comparison (order of lists does not matter) ─────────────────────
240
+
241
+ fun sameKeys(a: List<String>, b: List<String>): Boolean = a.map(String::trim).toSet() == b.map(String::trim).toSet()
242
+
243
+ private fun pinKey(p: HostPin) =
244
+ listOf(p.hostname.lowercase(), p.sha256.sorted().joinToString(","), p.version, p.forceUpdate, p.mtls, p.clientCertVersion)
245
+ .joinToString("|")
246
+
247
+ fun samePins(a: List<HostPin>, b: List<HostPin>): Boolean = a.map(::pinKey).sorted() == b.map(::pinKey).sorted()
248
+
249
+ fun sameStaticPins(a: CertificateConfig, b: CertificateConfig): Boolean =
250
+ a.version == b.version && a.forceUpdate == b.forceUpdate && samePins(a.pins, b.pins)
251
+ }
252
+ }
253
+
254
+ /** What JS asked for in one `configApis[i]` entry, before the native policy is applied. */
255
+ internal class SecurityFields(
256
+ val bootstrapPins: List<HostPin>?,
257
+ /** `signaturePublicKey` (one) or `signaturePublicKeys`; never both. */
258
+ val oneKey: String?,
259
+ val keys: List<String>?,
260
+ val requiredSignatures: Int?,
261
+ val recoveryKeys: List<String>?,
262
+ val requiredRecovery: Int?,
263
+ val serverScope: String?,
264
+ val clientCaPins: List<String>?,
265
+ val allowUnsigned: Boolean,
266
+ val allowUnpinned: Boolean,
267
+ val allowServerKey: Boolean,
268
+ )
269
+
270
+ /** The native policy applied to one block: the values that go to the builder, or a refusal. */
271
+ internal object SecurityPolicy {
272
+
273
+ private const val RELAXATIONS_HINT =
274
+ "a release build takes it only from the app's native security file (assets/${NativeSecurity.ASSET_NAME}, README \"Native security file\")"
275
+
276
+ fun apply(path: String, id: String, js: SecurityFields, native: NativeSecurity?, release: Boolean): SecurityFields {
277
+ if (native == null) {
278
+ if (release) {
279
+ if (js.allowUnsigned) throw BridgeInputException("$path.allowUnsigned: refused from JS; $RELAXATIONS_HINT")
280
+ if (js.allowUnpinned) throw BridgeInputException("$path.allowUnpinnedConfigApi: refused from JS; $RELAXATIONS_HINT")
281
+ if (js.allowServerKey) throw BridgeInputException("$path.allowServerGeneratedKey: refused from JS; $RELAXATIONS_HINT")
282
+ }
283
+ return js
284
+ }
285
+ val block = native.blocks[id]
286
+ ?: throw BridgeInputException("$path: Config API '$id' is not declared in the app's native security file (${native.source})")
287
+
288
+ fun fixed(key: String): Nothing =
289
+ throw BridgeInputException("$path.$key: differs from the app's native security file (${native.source}); the native value is fixed")
290
+
291
+ fun relaxation(key: String, js: Boolean, allowed: Boolean) {
292
+ if (js && !allowed) throw BridgeInputException("$path.$key: refused; the app's native security file (${native.source}) does not allow it")
293
+ }
294
+ relaxation("allowUnsigned", js.allowUnsigned, block.allowUnsigned)
295
+ relaxation("allowUnpinnedConfigApi", js.allowUnpinned, block.allowUnpinnedConfigApi)
296
+ relaxation("allowServerGeneratedKey", js.allowServerKey, block.allowServerGeneratedKey)
297
+
298
+ val pins = block.bootstrapPins?.also { n ->
299
+ if (js.bootstrapPins != null && !NativeSecurity.samePins(js.bootstrapPins, n)) fixed("bootstrapPins")
300
+ } ?: js.bootstrapPins
301
+
302
+ val oneKey = js.oneKey
303
+ var keys = js.keys
304
+ block.signaturePublicKeys?.let { n ->
305
+ val jsKeys = js.oneKey?.let { listOf(it) } ?: js.keys
306
+ if (jsKeys == null) {
307
+ keys = n
308
+ } else if (!NativeSecurity.sameKeys(jsKeys, n)) {
309
+ fixed(if (js.oneKey != null) "signaturePublicKey" else "signaturePublicKeys")
310
+ }
311
+ }
312
+
313
+ fun <T> one(key: String, nativeValue: T?, jsValue: T?): T? {
314
+ if (nativeValue == null) return jsValue
315
+ if (jsValue != null && jsValue != nativeValue) fixed(key)
316
+ return nativeValue
317
+ }
318
+
319
+ fun list(key: String, nativeValue: List<String>?, jsValue: List<String>?): List<String>? {
320
+ if (nativeValue == null) return jsValue
321
+ if (jsValue != null && !NativeSecurity.sameKeys(jsValue, nativeValue)) fixed(key)
322
+ return nativeValue
323
+ }
324
+
325
+ return SecurityFields(
326
+ bootstrapPins = pins,
327
+ oneKey = oneKey,
328
+ keys = keys,
329
+ requiredSignatures = one("requiredSignatures", block.requiredSignatures, js.requiredSignatures),
330
+ recoveryKeys = list("recoveryPublicKeys", block.recoveryPublicKeys, js.recoveryKeys),
331
+ requiredRecovery = one("requiredRecoverySignatures", block.requiredRecoverySignatures, js.requiredRecovery),
332
+ serverScope = one("serverScope", block.serverScope, js.serverScope),
333
+ clientCaPins = list("clientCaPins", block.clientCaPins, js.clientCaPins),
334
+ // The file is the source of truth for the relaxations too.
335
+ allowUnsigned = block.allowUnsigned,
336
+ allowUnpinned = block.allowUnpinnedConfigApi,
337
+ allowServerKey = block.allowServerGeneratedKey,
338
+ )
339
+ }
340
+
341
+ /** JS `staticPins` against the file: declared there or refused; the file's apply when JS has none. */
342
+ fun staticPins(path: String, js: CertificateConfig?, native: NativeSecurity?): CertificateConfig? {
343
+ if (native == null) return js
344
+ val n = native.staticPins
345
+ if (n == null) {
346
+ if (js != null) throw BridgeInputException("$path: not declared in the app's native security file (${native.source})")
347
+ return null
348
+ }
349
+ if (js != null && !NativeSecurity.sameStaticPins(js, n)) {
350
+ throw BridgeInputException("$path: differs from the app's native security file (${native.source}); the native value is fixed")
351
+ }
352
+ return n
353
+ }
354
+ }