@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,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
+ }