|
| 1 | +import BitkitCore |
| 2 | +import Foundation |
| 3 | + |
| 4 | +/// Dev/E2E-only transport for talking to the Trezor Bridge exposed by bitkit-docker. |
| 5 | +final class TrezorBridgeTransport { |
| 6 | + static let shared = TrezorBridgeTransport() |
| 7 | + |
| 8 | + private static let pathPrefix = "bridge:" |
| 9 | + private static let deviceName = "Trezor Bridge Emulator" |
| 10 | + private static let vendorId = UInt16(0x1209) |
| 11 | + private static let productId = UInt16(0x53C1) |
| 12 | + private static let headerSize = 6 |
| 13 | + private static let connectTimeout: TimeInterval = 5 |
| 14 | + private static let readTimeout: TimeInterval = 30 |
| 15 | + |
| 16 | + private let decoder = JSONDecoder() |
| 17 | + private let sessionLock = NSLock() |
| 18 | + private var openSessions: [String: String] = [:] |
| 19 | + private var enumeratedSessions: [String: String] = [:] |
| 20 | + |
| 21 | + private init() {} |
| 22 | + |
| 23 | + var isEnabled: Bool { |
| 24 | + Env.trezorBridgeEnabled |
| 25 | + } |
| 26 | + |
| 27 | + func isBridgeDevice(path: String) -> Bool { |
| 28 | + isEnabled && path.hasPrefix(Self.pathPrefix) |
| 29 | + } |
| 30 | + |
| 31 | + func enumerateDevices() -> [NativeDeviceInfo] { |
| 32 | + guard isEnabled else { return [] } |
| 33 | + |
| 34 | + do { |
| 35 | + let response = try post(path: "/enumerate") |
| 36 | + let bridgeDevices = try decoder.decode([BridgeDevice].self, from: Data(response.utf8)) |
| 37 | + |
| 38 | + let devices = bridgeDevices.map { device in |
| 39 | + let bridgePath = Self.toBridgePath(device.path) |
| 40 | + sessionLock.lock() |
| 41 | + if let session = device.session { |
| 42 | + enumeratedSessions[bridgePath] = session |
| 43 | + } else { |
| 44 | + enumeratedSessions.removeValue(forKey: bridgePath) |
| 45 | + } |
| 46 | + sessionLock.unlock() |
| 47 | + |
| 48 | + return NativeDeviceInfo( |
| 49 | + path: bridgePath, |
| 50 | + transportType: "usb", |
| 51 | + name: Self.deviceName, |
| 52 | + vendorId: Self.vendorId, |
| 53 | + productId: Self.productId |
| 54 | + ) |
| 55 | + } |
| 56 | + |
| 57 | + debugLog("enumerateDevices: \(devices.count) Bridge device(s)") |
| 58 | + return devices |
| 59 | + } catch { |
| 60 | + debugLog("enumerateDevices FAILED: \(error.localizedDescription)") |
| 61 | + return [] |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + func openDevice(path: String) -> TrezorTransportWriteResult { |
| 66 | + let rawPath = Self.rawBridgePath(path) |
| 67 | + |
| 68 | + sessionLock.lock() |
| 69 | + let previousSession = openSessions.removeValue(forKey: path) ?? enumeratedSessions[path] ?? "null" |
| 70 | + sessionLock.unlock() |
| 71 | + |
| 72 | + do { |
| 73 | + let response = try post(path: "/acquire/\(Self.encode(rawPath))/\(Self.encode(previousSession))") |
| 74 | + let bridgeSession = try decoder.decode(BridgeSession.self, from: Data(response.utf8)) |
| 75 | + |
| 76 | + sessionLock.lock() |
| 77 | + openSessions[path] = bridgeSession.session |
| 78 | + sessionLock.unlock() |
| 79 | + |
| 80 | + debugLog("openDevice: \(path)") |
| 81 | + return TrezorTransportWriteResult(success: true, error: "") |
| 82 | + } catch { |
| 83 | + debugLog("openDevice FAILED: \(error.localizedDescription)") |
| 84 | + return TrezorTransportWriteResult(success: false, error: error.localizedDescription) |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + func closeDevice(path: String) -> TrezorTransportWriteResult { |
| 89 | + sessionLock.lock() |
| 90 | + let session = openSessions.removeValue(forKey: path) |
| 91 | + sessionLock.unlock() |
| 92 | + |
| 93 | + guard let session else { |
| 94 | + return TrezorTransportWriteResult(success: true, error: "") |
| 95 | + } |
| 96 | + |
| 97 | + do { |
| 98 | + _ = try post(path: "/release/\(Self.encode(session))") |
| 99 | + debugLog("closeDevice: \(path)") |
| 100 | + return TrezorTransportWriteResult(success: true, error: "") |
| 101 | + } catch { |
| 102 | + debugLog("closeDevice FAILED: \(error.localizedDescription)") |
| 103 | + return TrezorTransportWriteResult(success: false, error: error.localizedDescription) |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + func readChunk(path: String) -> TrezorTransportReadResult { |
| 108 | + TrezorTransportReadResult(success: false, data: Data(), error: "Trezor Bridge uses callMessage for \(path)") |
| 109 | + } |
| 110 | + |
| 111 | + func writeChunk(path: String, data: Data) -> TrezorTransportWriteResult { |
| 112 | + TrezorTransportWriteResult(success: false, error: "Trezor Bridge uses callMessage for \(path) and ignored \(data.count) bytes") |
| 113 | + } |
| 114 | + |
| 115 | + func callMessage(path: String, messageType: UInt16, data: Data) -> TrezorCallMessageResult { |
| 116 | + sessionLock.lock() |
| 117 | + let session = openSessions[path] |
| 118 | + sessionLock.unlock() |
| 119 | + |
| 120 | + guard let session else { |
| 121 | + return TrezorCallMessageResult(success: false, messageType: 0, data: Data(), error: "Trezor Bridge device not open: \(path)") |
| 122 | + } |
| 123 | + |
| 124 | + do { |
| 125 | + let request = Self.encodeFrame(messageType: messageType, data: data) |
| 126 | + let response = try post(path: "/call/\(Self.encode(session))", body: request) |
| 127 | + return try Self.decodeFrame(response) |
| 128 | + } catch { |
| 129 | + debugLog("callMessage FAILED: \(error.localizedDescription)") |
| 130 | + return TrezorCallMessageResult(success: false, messageType: 0, data: Data(), error: error.localizedDescription) |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + private func post(path: String, body: String? = nil) throws -> String { |
| 135 | + guard let url = URL(string: "\(Env.trezorBridgeUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")))\(path)") else { |
| 136 | + throw TrezorBridgeTransportError.invalidUrl |
| 137 | + } |
| 138 | + |
| 139 | + var request = URLRequest(url: url) |
| 140 | + request.httpMethod = "POST" |
| 141 | + request.timeoutInterval = body == nil ? Self.connectTimeout : Self.readTimeout |
| 142 | + request.cachePolicy = .reloadIgnoringLocalCacheData |
| 143 | + if let body { |
| 144 | + request.httpBody = Data(body.utf8) |
| 145 | + request.setValue("text/plain", forHTTPHeaderField: "Content-Type") |
| 146 | + } |
| 147 | + |
| 148 | + let semaphore = DispatchSemaphore(value: 0) |
| 149 | + var result: Result<(Data, HTTPURLResponse), Error>? |
| 150 | + |
| 151 | + URLSession.shared.dataTask(with: request) { data, response, error in |
| 152 | + defer { semaphore.signal() } |
| 153 | + if let error { |
| 154 | + result = .failure(error) |
| 155 | + return |
| 156 | + } |
| 157 | + guard let httpResponse = response as? HTTPURLResponse else { |
| 158 | + result = .failure(TrezorBridgeTransportError.invalidResponse) |
| 159 | + return |
| 160 | + } |
| 161 | + result = .success((data ?? Data(), httpResponse)) |
| 162 | + }.resume() |
| 163 | + |
| 164 | + semaphore.wait() |
| 165 | + |
| 166 | + let (data, response) = try result?.get() ?? (Data(), HTTPURLResponse()) |
| 167 | + let responseText = String(data: data, encoding: .utf8) ?? "" |
| 168 | + guard 200 ..< 300 ~= response.statusCode else { |
| 169 | + throw TrezorBridgeTransportError.httpError(statusCode: response.statusCode, body: responseText) |
| 170 | + } |
| 171 | + return responseText |
| 172 | + } |
| 173 | + |
| 174 | + private static func encodeFrame(messageType: UInt16, data: Data) -> String { |
| 175 | + var frame = Data() |
| 176 | + frame.append(UInt8((messageType >> 8) & 0xFF)) |
| 177 | + frame.append(UInt8(messageType & 0xFF)) |
| 178 | + |
| 179 | + let length = UInt32(data.count) |
| 180 | + frame.append(UInt8((length >> 24) & 0xFF)) |
| 181 | + frame.append(UInt8((length >> 16) & 0xFF)) |
| 182 | + frame.append(UInt8((length >> 8) & 0xFF)) |
| 183 | + frame.append(UInt8(length & 0xFF)) |
| 184 | + frame.append(data) |
| 185 | + |
| 186 | + return frame.map { String(format: "%02x", $0) }.joined() |
| 187 | + } |
| 188 | + |
| 189 | + private static func decodeFrame(_ hex: String) throws -> TrezorCallMessageResult { |
| 190 | + let bytes = try Data(hexEncoded: hex.trimmingCharacters(in: .whitespacesAndNewlines)) |
| 191 | + guard bytes.count >= headerSize else { |
| 192 | + throw TrezorBridgeTransportError.shortResponse |
| 193 | + } |
| 194 | + |
| 195 | + let messageType = (UInt16(bytes[0]) << 8) | UInt16(bytes[1]) |
| 196 | + let length = (UInt32(bytes[2]) << 24) | (UInt32(bytes[3]) << 16) | (UInt32(bytes[4]) << 8) | UInt32(bytes[5]) |
| 197 | + guard bytes.count >= headerSize + Int(length) else { |
| 198 | + throw TrezorBridgeTransportError.invalidPayloadLength |
| 199 | + } |
| 200 | + |
| 201 | + let payload = bytes.subdata(in: headerSize ..< headerSize + Int(length)) |
| 202 | + return TrezorCallMessageResult(success: true, messageType: messageType, data: payload, error: "") |
| 203 | + } |
| 204 | + |
| 205 | + private static func toBridgePath(_ path: String) -> String { |
| 206 | + "\(pathPrefix)\(path)" |
| 207 | + } |
| 208 | + |
| 209 | + private static func rawBridgePath(_ path: String) -> String { |
| 210 | + String(path.dropFirst(pathPrefix.count)) |
| 211 | + } |
| 212 | + |
| 213 | + private static func encode(_ value: String) -> String { |
| 214 | + var allowed = CharacterSet.urlPathAllowed |
| 215 | + allowed.remove(charactersIn: "/") |
| 216 | + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value |
| 217 | + } |
| 218 | + |
| 219 | + private func debugLog(_ message: String) { |
| 220 | + Logger.debug(message, context: "TrezorBridgeTransport") |
| 221 | + TrezorDebugLog.shared.log("[Bridge] \(message)") |
| 222 | + } |
| 223 | +} |
| 224 | + |
| 225 | +private struct BridgeDevice: Decodable { |
| 226 | + let path: String |
| 227 | + let session: String? |
| 228 | +} |
| 229 | + |
| 230 | +private struct BridgeSession: Decodable { |
| 231 | + let session: String |
| 232 | +} |
| 233 | + |
| 234 | +private enum TrezorBridgeTransportError: LocalizedError { |
| 235 | + case invalidUrl |
| 236 | + case invalidResponse |
| 237 | + case httpError(statusCode: Int, body: String) |
| 238 | + case invalidHex |
| 239 | + case shortResponse |
| 240 | + case invalidPayloadLength |
| 241 | + |
| 242 | + var errorDescription: String? { |
| 243 | + switch self { |
| 244 | + case .invalidUrl: |
| 245 | + return "Invalid Trezor Bridge URL" |
| 246 | + case .invalidResponse: |
| 247 | + return "Invalid Trezor Bridge response" |
| 248 | + case let .httpError(statusCode, body): |
| 249 | + return "Bridge request failed with HTTP \(statusCode): \(body)" |
| 250 | + case .invalidHex: |
| 251 | + return "Bridge returned invalid hex" |
| 252 | + case .shortResponse: |
| 253 | + return "Bridge response is shorter than the message header" |
| 254 | + case .invalidPayloadLength: |
| 255 | + return "Bridge response payload length exceeds available data" |
| 256 | + } |
| 257 | + } |
| 258 | +} |
| 259 | + |
| 260 | +private extension Data { |
| 261 | + init(hexEncoded hex: String) throws { |
| 262 | + guard hex.count.isMultiple(of: 2) else { |
| 263 | + throw TrezorBridgeTransportError.invalidHex |
| 264 | + } |
| 265 | + |
| 266 | + var data = Data(capacity: hex.count / 2) |
| 267 | + var index = hex.startIndex |
| 268 | + while index < hex.endIndex { |
| 269 | + let nextIndex = hex.index(index, offsetBy: 2) |
| 270 | + guard let byte = UInt8(hex[index ..< nextIndex], radix: 16) else { |
| 271 | + throw TrezorBridgeTransportError.invalidHex |
| 272 | + } |
| 273 | + data.append(byte) |
| 274 | + index = nextIndex |
| 275 | + } |
| 276 | + self = data |
| 277 | + } |
| 278 | +} |
0 commit comments