@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,163 @@
1
+ // Native results → plain dictionaries for JS (the Swift twin of ResultMapper.kt;
2
+ // same keys). `type` is the Kotlin sealed subclass in lowerCamel, enum values are
3
+ // the Kotlin constant names (= Swift raw values). Every free-text field passes
4
+ // through VaultTokenStore.redact; vault content appears only in `unlocked`
5
+ // (after the native prompt) and in `loadFile`, never in a download result or an event.
6
+ import Foundation
7
+ import PinVault
8
+
9
+ public final class ResultMapper {
10
+ public static let utf8 = "utf8"
11
+ public static let base64 = "base64"
12
+
13
+ private let tokens: VaultTokenStore
14
+
15
+ public init(tokens: VaultTokenStore) { self.tokens = tokens }
16
+
17
+ private func text(_ s: String?) -> Any { tokens.redact(s) ?? NSNull() }
18
+ private func opt(_ v: Any?) -> Any { v ?? NSNull() }
19
+
20
+ public static func checkEncoding(_ encoding: String) throws -> String {
21
+ guard encoding == utf8 || encoding == base64 else { throw BridgeInputError("encoding: must be 'utf8' or 'base64'") }
22
+ return encoding
23
+ }
24
+
25
+ public static func encode(_ data: Data, _ encoding: String) -> String {
26
+ encoding == base64 ? data.base64EncodedString() : String(decoding: data, as: UTF8.self)
27
+ }
28
+
29
+ /// The Kotlin exception class name and message (`PinVaultError.exceptionName`).
30
+ public func exception(_ error: (any Error)?) -> Any {
31
+ guard let error else { return NSNull() }
32
+ if let e = error as? PinVaultError {
33
+ return ["name": e.exceptionName, "message": text(e.message)]
34
+ }
35
+ if let e = error as? BridgeInputError {
36
+ return ["name": "IllegalArgumentException", "message": text(e.message)]
37
+ }
38
+ let ns = error as NSError
39
+ let name = ns.domain == NSURLErrorDomain ? "URLError(\(ns.code))" : String(describing: type(of: error))
40
+ return ["name": name, "message": text(ns.localizedDescription)]
41
+ }
42
+
43
+ public func initResult(_ r: InitResult) -> [String: Any] {
44
+ switch r {
45
+ case .ready(let version): return ["type": "ready", "version": version]
46
+ case .failed(let reason, let e): return ["type": "failed", "reason": text(reason), "exception": exception(e)]
47
+ }
48
+ }
49
+
50
+ public func update(_ r: UpdateResult) -> [String: Any] {
51
+ switch r {
52
+ case .updated(let v): return ["type": "updated", "newVersion": v]
53
+ case .alreadyCurrent: return ["type": "alreadyCurrent"]
54
+ case .failed(let reason, let e): return ["type": "failed", "reason": text(reason), "exception": exception(e)]
55
+ }
56
+ }
57
+
58
+ public func enrollment(_ r: ClientCertEnrollmentResult) -> [String: Any] {
59
+ switch r {
60
+ case .enrolled(let already, let level):
61
+ return ["type": "enrolled", "alreadyEnrolled": already, "keySecurityLevel": opt(level?.rawValue)]
62
+ case .refused(let reason, let status, let serverError, let message):
63
+ return [
64
+ "type": "refused", "reason": reason.rawValue, "httpStatus": status,
65
+ "serverError": text(serverError), "message": text(message),
66
+ ]
67
+ case .pending(let requestId, let clientId, let message, let retryAfter, let code):
68
+ return [
69
+ "type": "pending", "requestId": requestId, "clientId": opt(clientId), "message": text(message),
70
+ "retryAfterSeconds": opt(retryAfter), "verificationCode": opt(code),
71
+ ]
72
+ case .failed(let message, let cause):
73
+ return ["type": "failed", "message": text(message), "cause": exception(cause)]
74
+ }
75
+ }
76
+
77
+ public func vaultFile(_ r: VaultFileResult) -> [String: Any] {
78
+ switch r {
79
+ // No bytes: the content is read with loadFile / unlockFile, on purpose.
80
+ case .updated(let key, let version, _): return ["type": "updated", "key": key, "version": version]
81
+ case .alreadyCurrent(let key, let version): return ["type": "alreadyCurrent", "key": key, "version": version]
82
+ case .failed(let key, let reason, let e, let code):
83
+ return ["type": "failed", "key": key, "reason": text(reason), "code": code, "exception": exception(e)]
84
+ }
85
+ }
86
+
87
+ public func unlock(_ r: VaultFileUnlockResult, encoding: String) -> [String: Any] {
88
+ switch r {
89
+ case .unlocked(let key, let version, let bytes):
90
+ return ["type": "unlocked", "key": key, "version": version, "content": Self.encode(bytes, encoding), "encoding": encoding]
91
+ case .notFound(let key): return ["type": "notFound", "key": key]
92
+ case .cancelled(let key): return ["type": "cancelled", "key": key]
93
+ case .invalidated(let key): return ["type": "invalidated", "key": key]
94
+ case .stale(let key): return ["type": "stale", "key": key]
95
+ case .failed(let key, let reason, let e):
96
+ return ["type": "failed", "key": key, "reason": text(reason), "exception": exception(e)]
97
+ }
98
+ }
99
+
100
+ public func attestation(_ s: AttestationStatus) -> [String: Any] {
101
+ [
102
+ "configApiId": s.configApiId,
103
+ "result": s.result.rawValue,
104
+ "arc": opt(s.arc),
105
+ "rejectionReasons": s.rejectionReasons,
106
+ "warnings": s.warnings,
107
+ "tokenExpiresAt": opt(s.tokenExpiresAt),
108
+ "lastAttestedAt": opt(s.lastAttestedAt),
109
+ "nextAttestAt": opt(s.nextAttestAt),
110
+ "clockSkewMs": opt(s.clockSkewMs),
111
+ "lastError": text(s.lastError),
112
+ "policyVersion": opt(s.policyVersion),
113
+ ]
114
+ }
115
+
116
+ public func attestationToken(_ r: AttestationTokenResult) -> [String: Any] {
117
+ switch r {
118
+ case .token(let value, let expiresAt): return ["type": "token", "value": value, "expiresAt": expiresAt]
119
+ case .rejected(let status): return ["type": "rejected", "status": attestation(status)]
120
+ case .failed(let message): return ["type": "failed", "message": text(message)]
121
+ case .unsupported: return ["type": "unsupported"]
122
+ }
123
+ }
124
+
125
+ public func signing(_ s: SigningStatus) -> [String: Any] {
126
+ [
127
+ "configApiId": s.configApiId,
128
+ "trustedKeyIds": s.trustedKeyIds,
129
+ "requiredSignatures": s.requiredSignatures,
130
+ "keySetVersion": s.keySetVersion,
131
+ "recoveryKeyIds": s.recoveryKeyIds,
132
+ "lastConfigSignedBy": s.lastConfigSignedBy,
133
+ ]
134
+ }
135
+
136
+ /// Connection telemetry. The library puts no token in events; free text is redacted anyway.
137
+ public func event(_ e: PinVaultConnectionEvent) -> [String: Any] {
138
+ switch e {
139
+ case let .connection(hostname, success, pinVersion, manufacturer, model, actualPin, expectedPins):
140
+ return [
141
+ "type": "connection", "hostname": hostname, "success": success, "pinVersion": pinVersion,
142
+ "deviceManufacturer": manufacturer, "deviceModel": model, "actualPin": actualPin, "expectedPins": expectedPins,
143
+ ]
144
+ case let .configUpdate(status, newVersion, manufacturer, model, failureReason):
145
+ return [
146
+ "type": "configUpdate", "status": status.rawValue, "newVersion": newVersion,
147
+ "deviceManufacturer": manufacturer, "deviceModel": model, "failureReason": text(failureReason),
148
+ ]
149
+ case let .clientCertRenewal(status, notAfter, via, configApiId, manufacturer, model, failureReason):
150
+ return [
151
+ "type": "clientCertRenewal", "status": status.rawValue, "notAfterEpochMs": notAfter, "via": opt(via?.rawValue),
152
+ "configApiId": configApiId, "deviceManufacturer": manufacturer, "deviceModel": model,
153
+ "failureReason": text(failureReason),
154
+ ]
155
+ case let .attestation(configApiId, status, arc, reasons, warnings, tokenExpiresAt, manufacturer, model, failureReason):
156
+ return [
157
+ "type": "attestation", "configApiId": configApiId, "status": status.rawValue, "arc": opt(arc),
158
+ "rejectionReasons": reasons, "warnings": warnings, "tokenExpiresAt": opt(tokenExpiresAt),
159
+ "deviceManufacturer": manufacturer, "deviceModel": model, "failureReason": text(failureReason),
160
+ ]
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,214 @@
1
+ // Strict JSON for bridge input (the Swift twin of StrictJson.kt).
2
+ //
3
+ // JSON from JS → Foundation values; `Fields` reads an object key by key and
4
+ // `finish()` refuses every key nobody read. Nothing is coerced: a "1" is not a
5
+ // number, a 1 is not a boolean (JSONSerialization hands both out as NSNumber;
6
+ // the CFBoolean type id tells them apart).
7
+ import Foundation
8
+
9
+ /// A refusal of bridge input; the message names the path (`config.configApis[0].url`).
10
+ public struct BridgeInputError: Error, CustomStringConvertible, Equatable {
11
+ public let message: String
12
+ public init(_ message: String) { self.message = message }
13
+ public var description: String { message }
14
+ }
15
+
16
+ public enum StrictJSON {
17
+ public static let maxInputChars = 256 * 1024
18
+ static let maxDepth = 8
19
+
20
+ public static func parseObject(_ json: String, path: String, maxChars: Int = maxInputChars) throws -> Fields {
21
+ if json.utf16.count > maxChars { throw BridgeInputError("\(path): larger than \(maxChars) characters") }
22
+ // Before the parser, as on Android: refuse deep nesting without letting a parser recurse into it.
23
+ if nestingDepth(json) > maxDepth + 1 { throw BridgeInputError("\(path): nested too deeply") }
24
+ let value: Any
25
+ do {
26
+ value = try JSONSerialization.jsonObject(with: Data(json.utf8), options: [])
27
+ } catch {
28
+ throw BridgeInputError("\(path): not valid JSON")
29
+ }
30
+ guard let object = value as? [String: Any] else { throw BridgeInputError("\(path): must be an object") }
31
+ try checkDepth(object, path: path, depth: 0)
32
+ return Fields(path: path, map: object)
33
+ }
34
+
35
+ /// The deepest `[` / `{` nesting of `json`, strings skipped; stops counting past the limit.
36
+ static func nestingDepth(_ json: String, stopAt: Int = maxDepth + 2) -> Int {
37
+ var depth = 0, deepest = 0
38
+ var inString = false, escaped = false
39
+ for c in json.utf8 {
40
+ if inString {
41
+ if escaped { escaped = false } else if c == UInt8(ascii: "\\") { escaped = true } else if c == UInt8(ascii: "\"") { inString = false }
42
+ continue
43
+ }
44
+ switch c {
45
+ case UInt8(ascii: "\""): inString = true
46
+ case UInt8(ascii: "["), UInt8(ascii: "{"):
47
+ depth += 1
48
+ if depth > deepest {
49
+ deepest = depth
50
+ if deepest >= stopAt { return deepest }
51
+ }
52
+ case UInt8(ascii: "]"), UInt8(ascii: "}"):
53
+ if depth > 0 { depth -= 1 }
54
+ default: break
55
+ }
56
+ }
57
+ return deepest
58
+ }
59
+
60
+ private static func checkDepth(_ value: Any, path: String, depth: Int) throws {
61
+ if depth > maxDepth { throw BridgeInputError("\(path): nested too deeply") }
62
+ if let object = value as? [String: Any] {
63
+ for (k, v) in object { try checkDepth(v, path: "\(path).\(k)", depth: depth + 1) }
64
+ } else if let array = value as? [Any] {
65
+ for (i, v) in array.enumerated() { try checkDepth(v, path: "\(path)[\(i)]", depth: depth + 1) }
66
+ }
67
+ }
68
+
69
+ static func isBool(_ value: Any) -> Bool {
70
+ guard let n = value as? NSNumber else { return false }
71
+ return CFGetTypeID(n) == CFBooleanGetTypeID()
72
+ }
73
+ }
74
+
75
+ /// One JSON object; every accessor marks its key as known. JSON `null` counts as absent.
76
+ public final class Fields {
77
+ public let path: String
78
+ private let map: [String: Any]
79
+ private var known = Set<String>()
80
+
81
+ init(path: String, map: [String: Any]) {
82
+ self.path = path
83
+ self.map = map
84
+ }
85
+
86
+ private func raw(_ key: String) -> Any? {
87
+ known.insert(key)
88
+ guard let v = map[key], !(v is NSNull) else { return nil }
89
+ return v
90
+ }
91
+
92
+ /// Refuses every key no accessor asked for.
93
+ public func finish() throws {
94
+ let unknown = map.keys.filter { !known.contains($0) }.sorted()
95
+ if !unknown.isEmpty {
96
+ throw BridgeInputError(
97
+ "\(path): unknown key\(unknown.count > 1 ? "s" : "") \(unknown.map { "'\($0)'" }.joined(separator: ", "))"
98
+ )
99
+ }
100
+ }
101
+
102
+ private static func badChar(_ c: Unicode.Scalar, multiline: Bool) -> Bool {
103
+ (c.value < 0x20 || c.value == 0x7f) && !(multiline && (c == "\n" || c == "\r"))
104
+ }
105
+
106
+ public func string(_ key: String, maxLength: Int = 2048, allowEmpty: Bool = false, multiline: Bool = false) throws -> String? {
107
+ guard let v = raw(key) else { return nil }
108
+ guard let s = v as? String else { throw BridgeInputError("\(path).\(key): must be a string") }
109
+ if s.count > maxLength { throw BridgeInputError("\(path).\(key): longer than \(maxLength) characters") }
110
+ if !allowEmpty && s.isEmpty { throw BridgeInputError("\(path).\(key): must not be empty") }
111
+ if s.unicodeScalars.contains(where: { Fields.badChar($0, multiline: multiline) }) {
112
+ throw BridgeInputError("\(path).\(key): control characters are not allowed")
113
+ }
114
+ return s
115
+ }
116
+
117
+ public func requireString(_ key: String, maxLength: Int = 2048) throws -> String {
118
+ guard let s = try string(key, maxLength: maxLength) else { throw BridgeInputError("\(path).\(key): required") }
119
+ return s
120
+ }
121
+
122
+ /// Free text (a request body): any characters, bounded length.
123
+ public func text(_ key: String, maxLength: Int) throws -> String? {
124
+ guard let v = raw(key) else { return nil }
125
+ guard let s = v as? String else { throw BridgeInputError("\(path).\(key): must be a string") }
126
+ if s.utf16.count > maxLength { throw BridgeInputError("\(path).\(key): longer than \(maxLength) characters") }
127
+ return s
128
+ }
129
+
130
+ public func bool(_ key: String) throws -> Bool? {
131
+ guard let v = raw(key) else { return nil }
132
+ guard StrictJSON.isBool(v), let n = v as? NSNumber else { throw BridgeInputError("\(path).\(key): must be a boolean") }
133
+ return n.boolValue
134
+ }
135
+
136
+ public func int64(_ key: String, min: Int64, max: Int64) throws -> Int64? {
137
+ guard let v = raw(key) else { return nil }
138
+ guard !StrictJSON.isBool(v), let n = v as? NSNumber else { throw BridgeInputError("\(path).\(key): must be a number") }
139
+ let d = n.doubleValue
140
+ if d != d.rounded() { throw BridgeInputError("\(path).\(key): must be a whole number") }
141
+ if d < Double(min) || d > Double(max) { throw BridgeInputError("\(path).\(key): must be between \(min) and \(max)") }
142
+ return Int64(d)
143
+ }
144
+
145
+ public func int(_ key: String, min: Int, max: Int) throws -> Int? {
146
+ try int64(key, min: Int64(min), max: Int64(max)).map { Int($0) }
147
+ }
148
+
149
+ public func double(_ key: String, min: Double, max: Double) throws -> Double? {
150
+ guard let v = raw(key) else { return nil }
151
+ guard !StrictJSON.isBool(v), let n = v as? NSNumber else { throw BridgeInputError("\(path).\(key): must be a number") }
152
+ let d = n.doubleValue
153
+ if d < min || d > max { throw BridgeInputError("\(path).\(key): must be between \(min) and \(max)") }
154
+ return d
155
+ }
156
+
157
+ public func stringList(_ key: String, maxItems: Int = 64, maxLength: Int = 2048, multiline: Bool = false) throws -> [String]? {
158
+ guard let v = raw(key) else { return nil }
159
+ guard let list = v as? [Any] else { throw BridgeInputError("\(path).\(key): must be a list of strings") }
160
+ if list.count > maxItems { throw BridgeInputError("\(path).\(key): more than \(maxItems) items") }
161
+ return try list.enumerated().map { i, item in
162
+ guard let s = item as? String else { throw BridgeInputError("\(path).\(key)[\(i)]: must be a string") }
163
+ if s.isEmpty || s.count > maxLength {
164
+ throw BridgeInputError("\(path).\(key)[\(i)]: must be 1 to \(maxLength) characters")
165
+ }
166
+ if s.unicodeScalars.contains(where: { Fields.badChar($0, multiline: multiline) }) {
167
+ throw BridgeInputError("\(path).\(key)[\(i)]: control characters are not allowed")
168
+ }
169
+ return s
170
+ }
171
+ }
172
+
173
+ public func object(_ key: String) throws -> Fields? {
174
+ guard let v = raw(key) else { return nil }
175
+ guard let o = v as? [String: Any] else { throw BridgeInputError("\(path).\(key): must be an object") }
176
+ return Fields(path: "\(path).\(key)", map: o)
177
+ }
178
+
179
+ public func objectList(_ key: String, maxItems: Int) throws -> [Fields]? {
180
+ guard let v = raw(key) else { return nil }
181
+ guard let list = v as? [Any] else { throw BridgeInputError("\(path).\(key): must be a list of objects") }
182
+ if list.count > maxItems { throw BridgeInputError("\(path).\(key): more than \(maxItems) items") }
183
+ return try list.enumerated().map { i, item in
184
+ guard let o = item as? [String: Any] else { throw BridgeInputError("\(path).\(key)[\(i)]: must be an object") }
185
+ return Fields(path: "\(path).\(key)[\(i)]", map: o)
186
+ }
187
+ }
188
+
189
+ /// A string → string map (`ios.resolve`, request headers).
190
+ public func stringMap(_ key: String, maxItems: Int, maxKeyLength: Int, maxValueLength: Int) throws -> [String: String]? {
191
+ guard let v = raw(key) else { return nil }
192
+ guard let o = v as? [String: Any] else { throw BridgeInputError("\(path).\(key): must be an object") }
193
+ if o.count > maxItems { throw BridgeInputError("\(path).\(key): more than \(maxItems) entries") }
194
+ var out: [String: String] = [:]
195
+ for (name, value) in o {
196
+ if name.isEmpty || name.count > maxKeyLength {
197
+ throw BridgeInputError("\(path).\(key): key '\(name.prefix(40))' has a bad length")
198
+ }
199
+ guard let s = value as? String else { throw BridgeInputError("\(path).\(key).\(name): must be a string") }
200
+ if s.count > maxValueLength { throw BridgeInputError("\(path).\(key).\(name): longer than \(maxValueLength) characters") }
201
+ out[name] = s
202
+ }
203
+ return out
204
+ }
205
+
206
+ /// An enum by its raw value (= the Kotlin constant name).
207
+ public func enumValue<E: RawRepresentable & CaseIterable>(_ key: String, _ type: E.Type) throws -> E? where E.RawValue == String {
208
+ guard let s = try string(key, maxLength: 64) else { return nil }
209
+ guard let e = E(rawValue: s) else {
210
+ throw BridgeInputError("\(path).\(key): '\(s)' is not one of \(E.allCases.map(\.rawValue).joined(separator: ", "))")
211
+ }
212
+ return e
213
+ }
214
+ }
@@ -0,0 +1,66 @@
1
+ // Vault access tokens (and the enrollment token of a running call), in memory
2
+ // only: never written to disk or the Keychain, gone with the process. The
3
+ // vault files' `accessToken { … }` providers read from here on every download.
4
+ // Every string that goes back to JS passes through `redact`.
5
+ import Foundation
6
+
7
+ public final class VaultTokenStore: @unchecked Sendable {
8
+ public static let minRedactLength = 6
9
+ public static let maxMessage = 1000
10
+
11
+ private let lock = NSLock()
12
+ private var tokens: [String: String] = [:]
13
+ private var transient: [String: Int] = [:]
14
+
15
+ public init() {}
16
+
17
+ public func put(_ key: String, _ token: String?) {
18
+ lock.lock(); defer { lock.unlock() }
19
+ if let token, !token.isEmpty { tokens[key] = token } else { tokens.removeValue(forKey: key) }
20
+ }
21
+
22
+ /// Empty when there is none: the server refuses that as an invalid token.
23
+ public func get(_ key: String) -> String {
24
+ lock.lock(); defer { lock.unlock() }
25
+ return tokens[key] ?? ""
26
+ }
27
+
28
+ @discardableResult
29
+ public func clear() -> Int {
30
+ lock.lock(); defer { lock.unlock() }
31
+ let n = tokens.count
32
+ tokens.removeAll()
33
+ return n
34
+ }
35
+
36
+ /// A secret that is not stored but must still be redacted while a call runs.
37
+ public func withTransientSecret<T>(_ secret: String, _ body: () async throws -> T) async rethrows -> T {
38
+ addTransient(secret)
39
+ defer { removeTransient(secret) }
40
+ return try await body()
41
+ }
42
+
43
+ private func addTransient(_ secret: String) {
44
+ guard secret.count >= Self.minRedactLength else { return }
45
+ lock.lock(); defer { lock.unlock() }
46
+ transient[secret, default: 0] += 1
47
+ }
48
+
49
+ private func removeTransient(_ secret: String) {
50
+ lock.lock(); defer { lock.unlock() }
51
+ if let n = transient[secret] {
52
+ if n <= 1 { transient.removeValue(forKey: secret) } else { transient[secret] = n - 1 }
53
+ }
54
+ }
55
+
56
+ public func redact(_ text: String?) -> String? {
57
+ guard var out = text else { return nil }
58
+ lock.lock()
59
+ let secrets = Array(tokens.values) + Array(transient.keys)
60
+ lock.unlock()
61
+ for secret in secrets where secret.count >= Self.minRedactLength {
62
+ out = out.replacingOccurrences(of: secret, with: "***")
63
+ }
64
+ return out.count > Self.maxMessage ? String(out.prefix(Self.maxMessage)) + "…" : out
65
+ }
66
+ }