@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.
- package/CHANGELOG.md +55 -0
- package/LICENSE +21 -0
- package/README.md +386 -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 +300 -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 +239 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultModule.kt +410 -0
- package/android/src/main/java/io/github/umutcansu/pinvault/reactnative/PinVaultNetworking.kt +296 -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 +276 -0
- package/ios/Core/JSEnvironmentGuard.swift +129 -0
- package/ios/Core/NativeSecurity.swift +197 -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 +412 -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 +312 -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 +346 -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 +385 -0
- 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
|
+
}
|