@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.
- package/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +437 -2
- package/RNPinVault.podspec +43 -0
- package/android/build.gradle +68 -0
- package/android/consumer-rules.pro +25 -0
- package/android/src/main/AndroidManifest.xml +16 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ConfigParser.kt +396 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/JsEnvironmentGuard.kt +53 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/NativeSecurity.kt +354 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +419 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +379 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultPackage.kt +25 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinnedFetch.kt +131 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/ResultMapper.kt +188 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/StrictJson.kt +225 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/VaultTokenStore.kt +63 -0
- package/ios/Core/ConfigParser.swift +437 -0
- package/ios/Core/JSEnvironmentGuard.swift +129 -0
- package/ios/Core/NativeSecurity.swift +303 -0
- package/ios/Core/PinnedFetch.swift +119 -0
- package/ios/Core/ReactNetworking.swift +172 -0
- package/ios/Core/ResultMapper.swift +163 -0
- package/ios/Core/StrictJSON.swift +214 -0
- package/ios/Core/VaultTokenStore.swift +66 -0
- package/ios/PinVaultBridge.swift +424 -0
- package/ios/PinVaultReactNetworking.swift +47 -0
- package/ios/RNPinVault.h +7 -0
- package/ios/RNPinVault.mm +293 -0
- package/ios/RNPinVaultURLRequestHandler.mm +92 -0
- package/lib/module/NativePinVault.js +13 -0
- package/lib/module/NativePinVault.js.map +1 -0
- package/lib/module/index.js +331 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativePinVault.d.ts +50 -0
- package/lib/typescript/src/NativePinVault.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +150 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +359 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +128 -4
- package/src/NativePinVault.ts +72 -0
- package/src/index.ts +398 -0
- package/src/types.ts +362 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
package io.github.umutcansu.pinvault.reactnative
|
|
2
|
+
|
|
3
|
+
import io.github.umutcansu.pinvault.api.PinVaultConnectionEvent
|
|
4
|
+
import io.github.umutcansu.pinvault.model.AttestationStatus
|
|
5
|
+
import io.github.umutcansu.pinvault.model.AttestationTokenResult
|
|
6
|
+
import io.github.umutcansu.pinvault.model.ClientCertEnrollmentResult
|
|
7
|
+
import io.github.umutcansu.pinvault.model.InitResult
|
|
8
|
+
import io.github.umutcansu.pinvault.model.SigningStatus
|
|
9
|
+
import io.github.umutcansu.pinvault.model.UpdateResult
|
|
10
|
+
import io.github.umutcansu.pinvault.model.VaultFileResult
|
|
11
|
+
import io.github.umutcansu.pinvault.model.VaultFileUnlockResult
|
|
12
|
+
import okio.ByteString.Companion.toByteString
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Native results → plain maps for JS. `type` is the sealed subclass in
|
|
16
|
+
* lowerCamel (`Ready` → `ready`), every other key is the Kotlin property name.
|
|
17
|
+
* Every free-text field goes through [VaultTokenStore.redact]; vault file
|
|
18
|
+
* content appears only in `unlocked` (after the native prompt) and in
|
|
19
|
+
* `loadFile`, never in a download result or an event.
|
|
20
|
+
*/
|
|
21
|
+
internal class ResultMapper(private val tokens: VaultTokenStore) {
|
|
22
|
+
|
|
23
|
+
private fun text(s: String?): String? = tokens.redact(s)
|
|
24
|
+
|
|
25
|
+
fun exception(e: Throwable?): Map<String, Any?>? = e?.let {
|
|
26
|
+
mapOf("name" to it.javaClass.simpleName.ifEmpty { "Exception" }, "message" to text(it.message))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fun init(r: InitResult): Map<String, Any?> = when (r) {
|
|
30
|
+
is InitResult.Ready -> mapOf("type" to "ready", "version" to r.version)
|
|
31
|
+
is InitResult.Failed -> mapOf("type" to "failed", "reason" to text(r.reason), "exception" to exception(r.exception))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fun update(r: UpdateResult): Map<String, Any?> = when (r) {
|
|
35
|
+
is UpdateResult.Updated -> mapOf("type" to "updated", "newVersion" to r.newVersion)
|
|
36
|
+
UpdateResult.AlreadyCurrent -> mapOf("type" to "alreadyCurrent")
|
|
37
|
+
is UpdateResult.Failed -> mapOf("type" to "failed", "reason" to text(r.reason), "exception" to exception(r.exception))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fun enrollment(r: ClientCertEnrollmentResult): Map<String, Any?> = when (r) {
|
|
41
|
+
is ClientCertEnrollmentResult.Enrolled -> mapOf(
|
|
42
|
+
"type" to "enrolled",
|
|
43
|
+
"alreadyEnrolled" to r.alreadyEnrolled,
|
|
44
|
+
"keySecurityLevel" to r.keySecurityLevel?.name,
|
|
45
|
+
)
|
|
46
|
+
is ClientCertEnrollmentResult.Refused -> mapOf(
|
|
47
|
+
"type" to "refused",
|
|
48
|
+
"reason" to r.reason.name,
|
|
49
|
+
"httpStatus" to r.httpStatus,
|
|
50
|
+
"serverError" to text(r.serverError),
|
|
51
|
+
"message" to text(r.message),
|
|
52
|
+
)
|
|
53
|
+
is ClientCertEnrollmentResult.Pending -> mapOf(
|
|
54
|
+
"type" to "pending",
|
|
55
|
+
"requestId" to r.requestId,
|
|
56
|
+
"clientId" to r.clientId,
|
|
57
|
+
"message" to text(r.message),
|
|
58
|
+
"retryAfterSeconds" to r.retryAfterSeconds,
|
|
59
|
+
"verificationCode" to r.verificationCode,
|
|
60
|
+
)
|
|
61
|
+
is ClientCertEnrollmentResult.Failed -> mapOf(
|
|
62
|
+
"type" to "failed",
|
|
63
|
+
"message" to text(r.message),
|
|
64
|
+
"cause" to exception(r.cause),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fun vaultFile(r: VaultFileResult): Map<String, Any?> = when (r) {
|
|
69
|
+
// No bytes: the content is read with loadFile / unlockFile, on purpose.
|
|
70
|
+
is VaultFileResult.Updated -> mapOf("type" to "updated", "key" to r.key, "version" to r.version)
|
|
71
|
+
is VaultFileResult.AlreadyCurrent -> mapOf("type" to "alreadyCurrent", "key" to r.key, "version" to r.version)
|
|
72
|
+
is VaultFileResult.Failed -> mapOf(
|
|
73
|
+
"type" to "failed",
|
|
74
|
+
"key" to r.key,
|
|
75
|
+
"reason" to text(r.reason),
|
|
76
|
+
"code" to r.code,
|
|
77
|
+
"exception" to exception(r.exception),
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fun unlock(r: VaultFileUnlockResult, encoding: String): Map<String, Any?> = when (r) {
|
|
82
|
+
is VaultFileUnlockResult.Unlocked -> mapOf(
|
|
83
|
+
"type" to "unlocked",
|
|
84
|
+
"key" to r.key,
|
|
85
|
+
"version" to r.version,
|
|
86
|
+
"content" to encode(r.bytes, encoding),
|
|
87
|
+
"encoding" to encoding,
|
|
88
|
+
)
|
|
89
|
+
is VaultFileUnlockResult.NotFound -> mapOf("type" to "notFound", "key" to r.key)
|
|
90
|
+
is VaultFileUnlockResult.Cancelled -> mapOf("type" to "cancelled", "key" to r.key)
|
|
91
|
+
is VaultFileUnlockResult.Invalidated -> mapOf("type" to "invalidated", "key" to r.key)
|
|
92
|
+
is VaultFileUnlockResult.Stale -> mapOf("type" to "stale", "key" to r.key)
|
|
93
|
+
is VaultFileUnlockResult.Failed -> mapOf(
|
|
94
|
+
"type" to "failed",
|
|
95
|
+
"key" to r.key,
|
|
96
|
+
"reason" to text(r.reason),
|
|
97
|
+
"exception" to exception(r.exception),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fun attestation(s: AttestationStatus): Map<String, Any?> = mapOf(
|
|
102
|
+
"configApiId" to s.configApiId,
|
|
103
|
+
"result" to s.result.name,
|
|
104
|
+
"arc" to s.arc,
|
|
105
|
+
"rejectionReasons" to s.rejectionReasons,
|
|
106
|
+
"warnings" to s.warnings,
|
|
107
|
+
"tokenExpiresAt" to s.tokenExpiresAt,
|
|
108
|
+
"lastAttestedAt" to s.lastAttestedAt,
|
|
109
|
+
"nextAttestAt" to s.nextAttestAt,
|
|
110
|
+
"clockSkewMs" to s.clockSkewMs,
|
|
111
|
+
"lastError" to text(s.lastError),
|
|
112
|
+
"policyVersion" to s.policyVersion,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
fun attestationToken(r: AttestationTokenResult): Map<String, Any?> = when (r) {
|
|
116
|
+
is AttestationTokenResult.Token -> mapOf("type" to "token", "value" to r.value, "expiresAt" to r.expiresAt)
|
|
117
|
+
is AttestationTokenResult.Rejected -> mapOf("type" to "rejected", "status" to attestation(r.status))
|
|
118
|
+
is AttestationTokenResult.Failed -> mapOf("type" to "failed", "message" to text(r.message))
|
|
119
|
+
AttestationTokenResult.Unsupported -> mapOf("type" to "unsupported")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fun signing(s: SigningStatus): Map<String, Any?> = mapOf(
|
|
123
|
+
"configApiId" to s.configApiId,
|
|
124
|
+
"trustedKeyIds" to s.trustedKeyIds,
|
|
125
|
+
"requiredSignatures" to s.requiredSignatures,
|
|
126
|
+
"keySetVersion" to s.keySetVersion,
|
|
127
|
+
"recoveryKeyIds" to s.recoveryKeyIds,
|
|
128
|
+
"lastConfigSignedBy" to s.lastConfigSignedBy,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
/** Connection telemetry. The library puts no token in events; free text is redacted anyway. */
|
|
132
|
+
fun event(e: PinVaultConnectionEvent): Map<String, Any?> = when (e) {
|
|
133
|
+
is PinVaultConnectionEvent.Connection -> mapOf(
|
|
134
|
+
"type" to "connection",
|
|
135
|
+
"hostname" to e.hostname,
|
|
136
|
+
"success" to e.success,
|
|
137
|
+
"pinVersion" to e.pinVersion,
|
|
138
|
+
"deviceManufacturer" to e.deviceManufacturer,
|
|
139
|
+
"deviceModel" to e.deviceModel,
|
|
140
|
+
"actualPin" to e.actualPin,
|
|
141
|
+
"expectedPins" to e.expectedPins,
|
|
142
|
+
)
|
|
143
|
+
is PinVaultConnectionEvent.ConfigUpdate -> mapOf(
|
|
144
|
+
"type" to "configUpdate",
|
|
145
|
+
"status" to e.status.name,
|
|
146
|
+
"newVersion" to e.newVersion,
|
|
147
|
+
"deviceManufacturer" to e.deviceManufacturer,
|
|
148
|
+
"deviceModel" to e.deviceModel,
|
|
149
|
+
"failureReason" to text(e.failureReason),
|
|
150
|
+
)
|
|
151
|
+
is PinVaultConnectionEvent.ClientCertRenewal -> mapOf(
|
|
152
|
+
"type" to "clientCertRenewal",
|
|
153
|
+
"status" to e.status.name,
|
|
154
|
+
"notAfterEpochMs" to e.notAfterEpochMs,
|
|
155
|
+
"via" to e.via?.name,
|
|
156
|
+
"configApiId" to e.configApiId,
|
|
157
|
+
"deviceManufacturer" to e.deviceManufacturer,
|
|
158
|
+
"deviceModel" to e.deviceModel,
|
|
159
|
+
"failureReason" to text(e.failureReason),
|
|
160
|
+
)
|
|
161
|
+
is PinVaultConnectionEvent.Attestation -> mapOf(
|
|
162
|
+
"type" to "attestation",
|
|
163
|
+
"configApiId" to e.configApiId,
|
|
164
|
+
"status" to e.status.name,
|
|
165
|
+
"arc" to e.arc,
|
|
166
|
+
"rejectionReasons" to e.rejectionReasons,
|
|
167
|
+
"warnings" to e.warnings,
|
|
168
|
+
"tokenExpiresAt" to e.tokenExpiresAt,
|
|
169
|
+
"deviceManufacturer" to e.deviceManufacturer,
|
|
170
|
+
"deviceModel" to e.deviceModel,
|
|
171
|
+
"failureReason" to text(e.failureReason),
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
companion object {
|
|
176
|
+
const val UTF8 = "utf8"
|
|
177
|
+
const val BASE64 = "base64"
|
|
178
|
+
|
|
179
|
+
fun checkEncoding(encoding: String): String {
|
|
180
|
+
if (encoding != UTF8 && encoding != BASE64) throw BridgeInputException("encoding: must be 'utf8' or 'base64'")
|
|
181
|
+
return encoding
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
fun encode(bytes: ByteArray, encoding: String): String =
|
|
185
|
+
// Okio, not android.util.Base64: it also runs in JVM unit tests (and java.util.Base64 needs API 26).
|
|
186
|
+
if (encoding == BASE64) bytes.toByteString().base64() else bytes.toString(Charsets.UTF_8)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
package io.github.umutcansu.pinvault.reactnative
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONException
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
import org.json.JSONTokener
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A refusal of bridge input: unknown key, wrong type, value out of range,
|
|
10
|
+
* oversized input. Its message names the path (`config.configApis[0].url`).
|
|
11
|
+
*/
|
|
12
|
+
class BridgeInputException(message: String) : IllegalArgumentException(message)
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Strict JSON for bridge input. JSON from JS → plain Kotlin values (Map, List,
|
|
16
|
+
* String, Boolean, Number, null); then [Fields] reads an object key by key and
|
|
17
|
+
* refuses every key it did not read ([Fields.finish]). Nothing is silently
|
|
18
|
+
* ignored and nothing is coerced (a "1" is not a number, a 1 is not a boolean).
|
|
19
|
+
*/
|
|
20
|
+
internal object StrictJson {
|
|
21
|
+
|
|
22
|
+
const val MAX_INPUT_CHARS = 256 * 1024
|
|
23
|
+
const val MAX_DEPTH = 8
|
|
24
|
+
|
|
25
|
+
fun parseObject(json: String, path: String, maxChars: Int = MAX_INPUT_CHARS): Fields {
|
|
26
|
+
if (json.length > maxChars) throw BridgeInputException("$path: larger than $maxChars characters")
|
|
27
|
+
// Before the parser: org.json's tokener recurses once per level, and a
|
|
28
|
+
// StackOverflowError is no Exception the bridge would catch.
|
|
29
|
+
if (nestingDepth(json) > MAX_DEPTH + 1) throw BridgeInputException("$path: nested too deeply")
|
|
30
|
+
val value = try {
|
|
31
|
+
JSONTokener(json).nextValue()
|
|
32
|
+
} catch (e: JSONException) {
|
|
33
|
+
throw BridgeInputException("$path: not valid JSON")
|
|
34
|
+
} catch (e: StackOverflowError) {
|
|
35
|
+
throw BridgeInputException("$path: nested too deeply")
|
|
36
|
+
}
|
|
37
|
+
if (value !is JSONObject) throw BridgeInputException("$path: must be an object")
|
|
38
|
+
@Suppress("UNCHECKED_CAST")
|
|
39
|
+
return Fields(path, toKotlin(value, path, 0) as Map<String, Any?>)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The deepest `[` / `{` nesting of [json], strings skipped; stops counting
|
|
44
|
+
* past the limit. Malformed text is left to the parser.
|
|
45
|
+
*/
|
|
46
|
+
fun nestingDepth(json: String, stopAt: Int = MAX_DEPTH + 2): Int {
|
|
47
|
+
var depth = 0
|
|
48
|
+
var deepest = 0
|
|
49
|
+
var inString = false
|
|
50
|
+
var escaped = false
|
|
51
|
+
for (c in json) {
|
|
52
|
+
if (inString) {
|
|
53
|
+
when {
|
|
54
|
+
escaped -> escaped = false
|
|
55
|
+
c == '\\' -> escaped = true
|
|
56
|
+
c == '"' -> inString = false
|
|
57
|
+
}
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
when (c) {
|
|
61
|
+
'"' -> inString = true
|
|
62
|
+
'[', '{' -> {
|
|
63
|
+
depth++
|
|
64
|
+
if (depth > deepest) {
|
|
65
|
+
deepest = depth
|
|
66
|
+
if (deepest >= stopAt) return deepest
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
']', '}' -> if (depth > 0) depth--
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return deepest
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private fun toKotlin(value: Any?, path: String, depth: Int): Any? {
|
|
76
|
+
if (depth > MAX_DEPTH) throw BridgeInputException("$path: nested too deeply")
|
|
77
|
+
return when (value) {
|
|
78
|
+
null, JSONObject.NULL -> null
|
|
79
|
+
is JSONObject -> {
|
|
80
|
+
val out = LinkedHashMap<String, Any?>()
|
|
81
|
+
val keys = value.keys()
|
|
82
|
+
while (keys.hasNext()) {
|
|
83
|
+
val k = keys.next()
|
|
84
|
+
out[k] = toKotlin(value.opt(k), "$path.$k", depth + 1)
|
|
85
|
+
}
|
|
86
|
+
out
|
|
87
|
+
}
|
|
88
|
+
is JSONArray -> (0 until value.length()).map { toKotlin(value.opt(it), "$path[$it]", depth + 1) }
|
|
89
|
+
is String, is Boolean -> value
|
|
90
|
+
is Number -> {
|
|
91
|
+
val d = value.toDouble()
|
|
92
|
+
if (d.isNaN() || d.isInfinite()) throw BridgeInputException("$path: not a finite number")
|
|
93
|
+
value
|
|
94
|
+
}
|
|
95
|
+
else -> throw BridgeInputException("$path: unsupported value")
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One JSON object; every accessor marks its key as known. JSON `null` counts as absent. */
|
|
101
|
+
internal class Fields(val path: String, private val map: Map<String, Any?>) {
|
|
102
|
+
|
|
103
|
+
private val known = mutableSetOf<String>()
|
|
104
|
+
|
|
105
|
+
private fun raw(key: String): Any? {
|
|
106
|
+
known += key
|
|
107
|
+
return map[key]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fun has(key: String): Boolean {
|
|
111
|
+
known += key
|
|
112
|
+
return map[key] != null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Refuses every key no accessor asked for. */
|
|
116
|
+
fun finish() {
|
|
117
|
+
val unknown = map.keys.filter { it !in known }
|
|
118
|
+
if (unknown.isNotEmpty()) {
|
|
119
|
+
throw BridgeInputException(
|
|
120
|
+
"$path: unknown key${if (unknown.size > 1) "s" else ""} ${unknown.joinToString { "'$it'" }}"
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fun string(key: String, maxLength: Int = 2048, allowEmpty: Boolean = false, multiline: Boolean = false): String? {
|
|
126
|
+
val v = raw(key) ?: return null
|
|
127
|
+
if (v !is String) throw BridgeInputException("$path.$key: must be a string")
|
|
128
|
+
if (v.length > maxLength) throw BridgeInputException("$path.$key: longer than $maxLength characters")
|
|
129
|
+
if (!allowEmpty && v.isEmpty()) throw BridgeInputException("$path.$key: must not be empty")
|
|
130
|
+
if (v.any { badChar(it, multiline) }) throw BridgeInputException("$path.$key: control characters are not allowed")
|
|
131
|
+
return v
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Free text (a request body): any characters, bounded length. */
|
|
135
|
+
fun text(key: String, maxLength: Int): String? {
|
|
136
|
+
val v = raw(key) ?: return null
|
|
137
|
+
if (v !is String) throw BridgeInputException("$path.$key: must be a string")
|
|
138
|
+
if (v.length > maxLength) throw BridgeInputException("$path.$key: longer than $maxLength characters")
|
|
139
|
+
return v
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private fun badChar(c: Char, multiline: Boolean): Boolean =
|
|
143
|
+
(c.code < 0x20 || c.code == 0x7f) && !(multiline && (c == '\n' || c == '\r'))
|
|
144
|
+
|
|
145
|
+
fun requireString(key: String, maxLength: Int = 2048): String =
|
|
146
|
+
string(key, maxLength) ?: throw BridgeInputException("$path.$key: required")
|
|
147
|
+
|
|
148
|
+
fun bool(key: String): Boolean? {
|
|
149
|
+
val v = raw(key) ?: return null
|
|
150
|
+
if (v !is Boolean) throw BridgeInputException("$path.$key: must be a boolean")
|
|
151
|
+
return v
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
fun long(key: String, min: Long, max: Long): Long? {
|
|
155
|
+
val v = raw(key) ?: return null
|
|
156
|
+
if (v !is Number) throw BridgeInputException("$path.$key: must be a number")
|
|
157
|
+
val d = v.toDouble()
|
|
158
|
+
if (d != Math.floor(d)) throw BridgeInputException("$path.$key: must be a whole number")
|
|
159
|
+
if (d < min || d > max) throw BridgeInputException("$path.$key: must be between $min and $max")
|
|
160
|
+
return d.toLong()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fun int(key: String, min: Int, max: Int): Int? = long(key, min.toLong(), max.toLong())?.toInt()
|
|
164
|
+
|
|
165
|
+
fun double(key: String, min: Double, max: Double): Double? {
|
|
166
|
+
val v = raw(key) ?: return null
|
|
167
|
+
if (v !is Number) throw BridgeInputException("$path.$key: must be a number")
|
|
168
|
+
val d = v.toDouble()
|
|
169
|
+
if (d < min || d > max) throw BridgeInputException("$path.$key: must be between $min and $max")
|
|
170
|
+
return d
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fun stringList(key: String, maxItems: Int = 64, maxLength: Int = 2048, multiline: Boolean = false): List<String>? {
|
|
174
|
+
val v = raw(key) ?: return null
|
|
175
|
+
if (v !is List<*>) throw BridgeInputException("$path.$key: must be a list of strings")
|
|
176
|
+
if (v.size > maxItems) throw BridgeInputException("$path.$key: more than $maxItems items")
|
|
177
|
+
return v.mapIndexed { i, item ->
|
|
178
|
+
if (item !is String) throw BridgeInputException("$path.$key[$i]: must be a string")
|
|
179
|
+
if (item.isEmpty() || item.length > maxLength) {
|
|
180
|
+
throw BridgeInputException("$path.$key[$i]: must be 1 to $maxLength characters")
|
|
181
|
+
}
|
|
182
|
+
if (item.any { badChar(it, multiline) }) throw BridgeInputException("$path.$key[$i]: control characters are not allowed")
|
|
183
|
+
item
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
fun obj(key: String): Fields? {
|
|
188
|
+
val v = raw(key) ?: return null
|
|
189
|
+
if (v !is Map<*, *>) throw BridgeInputException("$path.$key: must be an object")
|
|
190
|
+
@Suppress("UNCHECKED_CAST")
|
|
191
|
+
return Fields("$path.$key", v as Map<String, Any?>)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
fun objList(key: String, maxItems: Int): List<Fields>? {
|
|
195
|
+
val v = raw(key) ?: return null
|
|
196
|
+
if (v !is List<*>) throw BridgeInputException("$path.$key: must be a list of objects")
|
|
197
|
+
if (v.size > maxItems) throw BridgeInputException("$path.$key: more than $maxItems items")
|
|
198
|
+
return v.mapIndexed { i, item ->
|
|
199
|
+
if (item !is Map<*, *>) throw BridgeInputException("$path.$key[$i]: must be an object")
|
|
200
|
+
@Suppress("UNCHECKED_CAST")
|
|
201
|
+
Fields("$path.$key[$i]", item as Map<String, Any?>)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A string → string map (`ios.resolve`, request headers). */
|
|
206
|
+
fun stringMap(key: String, maxItems: Int, maxKeyLength: Int, maxValueLength: Int): Map<String, String>? {
|
|
207
|
+
val v = raw(key) ?: return null
|
|
208
|
+
if (v !is Map<*, *>) throw BridgeInputException("$path.$key: must be an object")
|
|
209
|
+
if (v.size > maxItems) throw BridgeInputException("$path.$key: more than $maxItems entries")
|
|
210
|
+
return v.entries.associate { (k, value) ->
|
|
211
|
+
val name = k as String
|
|
212
|
+
if (name.isEmpty() || name.length > maxKeyLength) throw BridgeInputException("$path.$key: key '${name.take(40)}' has a bad length")
|
|
213
|
+
if (value !is String) throw BridgeInputException("$path.$key.$name: must be a string")
|
|
214
|
+
if (value.length > maxValueLength) throw BridgeInputException("$path.$key.$name: longer than $maxValueLength characters")
|
|
215
|
+
name to value
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** `enum` by its Kotlin constant name. */
|
|
220
|
+
fun <E : Enum<E>> enum(key: String, values: Array<E>): E? {
|
|
221
|
+
val v = string(key, 64) ?: return null
|
|
222
|
+
return values.firstOrNull { it.name == v }
|
|
223
|
+
?: throw BridgeInputException("$path.$key: '$v' is not one of ${values.joinToString { it.name }}")
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
package io.github.umutcansu.pinvault.reactnative
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Vault access tokens and the enrollment token of a running call, in memory
|
|
7
|
+
* only: never written to disk, gone with the process. The vault files'
|
|
8
|
+
* `accessToken { … }` providers read from here on every download. Every
|
|
9
|
+
* string that goes back to JS is passed through [redact], so a token that a
|
|
10
|
+
* native message happens to echo never reaches JS.
|
|
11
|
+
*/
|
|
12
|
+
internal class VaultTokenStore {
|
|
13
|
+
|
|
14
|
+
private val tokens = ConcurrentHashMap<String, String>()
|
|
15
|
+
private val transient = ConcurrentHashMap.newKeySet<String>()
|
|
16
|
+
|
|
17
|
+
fun put(key: String, token: String?) {
|
|
18
|
+
if (token.isNullOrEmpty()) tokens.remove(key) else tokens[key] = token
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Empty when there is none: the server refuses that as an invalid token. */
|
|
22
|
+
fun get(key: String): String = tokens[key] ?: ""
|
|
23
|
+
|
|
24
|
+
fun clear(): Int {
|
|
25
|
+
val n = tokens.size
|
|
26
|
+
tokens.clear()
|
|
27
|
+
return n
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A secret that is not stored but must still be redacted while a call runs (enrollment token). */
|
|
31
|
+
fun <T> withTransientSecret(secret: String, block: () -> T): T {
|
|
32
|
+
if (secret.length >= MIN_REDACT_LENGTH) transient += secret
|
|
33
|
+
try {
|
|
34
|
+
return block()
|
|
35
|
+
} finally {
|
|
36
|
+
transient -= secret
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
suspend fun <T> withTransientSecretSuspend(secret: String, block: suspend () -> T): T {
|
|
41
|
+
if (secret.length >= MIN_REDACT_LENGTH) transient += secret
|
|
42
|
+
try {
|
|
43
|
+
return block()
|
|
44
|
+
} finally {
|
|
45
|
+
transient -= secret
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fun redact(text: String?): String? {
|
|
50
|
+
if (text == null) return null
|
|
51
|
+
var out: String = text
|
|
52
|
+
for (secret in tokens.values + transient) {
|
|
53
|
+
if (secret.length >= MIN_REDACT_LENGTH) out = out.replace(secret, "***")
|
|
54
|
+
}
|
|
55
|
+
return if (out.length > MAX_MESSAGE) out.take(MAX_MESSAGE) + "…" else out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
companion object {
|
|
59
|
+
/** Shorter values would redact ordinary words. */
|
|
60
|
+
const val MIN_REDACT_LENGTH = 6
|
|
61
|
+
const val MAX_MESSAGE = 1000
|
|
62
|
+
}
|
|
63
|
+
}
|