Skip to content

Commit eaa1ef5

Browse files
authored
Merge pull request #563 from synonymdev/ovi/trezor-bridge-transport
feat: add Trezor emulator bridge
2 parents e7a88a4 + 0d757ef commit eaa1ef5

5 files changed

Lines changed: 365 additions & 18 deletions

File tree

Bitkit/Constants/Env.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ enum Env {
3030
return trimmed?.isEmpty == false ? trimmed : nil
3131
}
3232

33+
private static func configValue(_ key: String) -> String? {
34+
let envValue = ProcessInfo.processInfo.environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines)
35+
if envValue?.isEmpty == false {
36+
return envValue
37+
}
38+
return infoPlistValue(key)
39+
}
40+
41+
private static func boolConfigValue(_ key: String) -> Bool {
42+
guard let value = configValue(key)?.lowercased() else { return false }
43+
return ["1", "true", "yes", "y"].contains(value)
44+
}
45+
3346
private static var e2eBackend: String {
3447
(infoPlistValue("E2E_BACKEND") ?? "local").lowercased()
3548
}
@@ -140,6 +153,19 @@ enum Env {
140153
}
141154
}
142155

156+
static var trezorBridgeEnabled: Bool {
157+
(isDebug || isE2E) && boolConfigValue("TREZOR_BRIDGE")
158+
}
159+
160+
static var trezorBridgeUrl: String {
161+
configValue("TREZOR_BRIDGE_URL") ?? "http://127.0.0.1:21325"
162+
}
163+
164+
static var trezorElectrumUrl: String? {
165+
guard isDebug || isE2E else { return nil }
166+
return configValue("TREZOR_ELECTRUM_URL")
167+
}
168+
143169
static var appStorageUrl: URL {
144170
// App group so files can be shared with extensions
145171
guard let documentsDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bitkit") else {
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
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

Comments
 (0)