Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Bitkit/Constants/Env.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ enum Env {
return trimmed?.isEmpty == false ? trimmed : nil
}

private static func configValue(_ key: String) -> String? {
let envValue = ProcessInfo.processInfo.environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines)
if envValue?.isEmpty == false {
return envValue
}
return infoPlistValue(key)
}

private static func boolConfigValue(_ key: String) -> Bool {
guard let value = configValue(key)?.lowercased() else { return false }
return ["1", "true", "yes", "y"].contains(value)
}

private static var e2eBackend: String {
(infoPlistValue("E2E_BACKEND") ?? "local").lowercased()
}
Expand Down Expand Up @@ -140,6 +153,19 @@ enum Env {
}
}

static var trezorBridgeEnabled: Bool {
(isDebug || isE2E) && boolConfigValue("TREZOR_BRIDGE")
}

static var trezorBridgeUrl: String {
configValue("TREZOR_BRIDGE_URL") ?? "http://127.0.0.1:21325"
}

static var trezorElectrumUrl: String? {
guard isDebug || isE2E else { return nil }
return configValue("TREZOR_ELECTRUM_URL")
}

static var appStorageUrl: URL {
// App group so files can be shared with extensions
guard let documentsDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.bitkit") else {
Expand Down
278 changes: 278 additions & 0 deletions Bitkit/Services/Trezor/TrezorBridgeTransport.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import BitkitCore
import Foundation

/// Dev/E2E-only transport for talking to the Trezor Bridge exposed by bitkit-docker.
final class TrezorBridgeTransport {
static let shared = TrezorBridgeTransport()

private static let pathPrefix = "bridge:"
private static let deviceName = "Trezor Bridge Emulator"
private static let vendorId = UInt16(0x1209)
private static let productId = UInt16(0x53C1)
private static let headerSize = 6
private static let connectTimeout: TimeInterval = 5
private static let readTimeout: TimeInterval = 30

private let decoder = JSONDecoder()
private let sessionLock = NSLock()
private var openSessions: [String: String] = [:]
private var enumeratedSessions: [String: String] = [:]

private init() {}

var isEnabled: Bool {
Env.trezorBridgeEnabled
}

func isBridgeDevice(path: String) -> Bool {
isEnabled && path.hasPrefix(Self.pathPrefix)
}

func enumerateDevices() -> [NativeDeviceInfo] {
guard isEnabled else { return [] }

do {
let response = try post(path: "/enumerate")
let bridgeDevices = try decoder.decode([BridgeDevice].self, from: Data(response.utf8))

let devices = bridgeDevices.map { device in
let bridgePath = Self.toBridgePath(device.path)
sessionLock.lock()
if let session = device.session {
enumeratedSessions[bridgePath] = session
} else {
enumeratedSessions.removeValue(forKey: bridgePath)
}
sessionLock.unlock()

return NativeDeviceInfo(
path: bridgePath,
transportType: "usb",
name: Self.deviceName,
vendorId: Self.vendorId,
productId: Self.productId
)
}

debugLog("enumerateDevices: \(devices.count) Bridge device(s)")
return devices
} catch {
debugLog("enumerateDevices FAILED: \(error.localizedDescription)")
return []
}
}

func openDevice(path: String) -> TrezorTransportWriteResult {
let rawPath = Self.rawBridgePath(path)

sessionLock.lock()
let previousSession = openSessions.removeValue(forKey: path) ?? enumeratedSessions[path] ?? "null"
sessionLock.unlock()

do {
let response = try post(path: "/acquire/\(Self.encode(rawPath))/\(Self.encode(previousSession))")
let bridgeSession = try decoder.decode(BridgeSession.self, from: Data(response.utf8))

sessionLock.lock()
openSessions[path] = bridgeSession.session
sessionLock.unlock()

debugLog("openDevice: \(path)")
return TrezorTransportWriteResult(success: true, error: "")
} catch {
debugLog("openDevice FAILED: \(error.localizedDescription)")
return TrezorTransportWriteResult(success: false, error: error.localizedDescription)
}
}

func closeDevice(path: String) -> TrezorTransportWriteResult {
sessionLock.lock()
let session = openSessions.removeValue(forKey: path)
sessionLock.unlock()

guard let session else {
return TrezorTransportWriteResult(success: true, error: "")
}

do {
_ = try post(path: "/release/\(Self.encode(session))")
debugLog("closeDevice: \(path)")
return TrezorTransportWriteResult(success: true, error: "")
} catch {
debugLog("closeDevice FAILED: \(error.localizedDescription)")
return TrezorTransportWriteResult(success: false, error: error.localizedDescription)
}
}

func readChunk(path: String) -> TrezorTransportReadResult {
TrezorTransportReadResult(success: false, data: Data(), error: "Trezor Bridge uses callMessage for \(path)")
}

func writeChunk(path: String, data: Data) -> TrezorTransportWriteResult {
TrezorTransportWriteResult(success: false, error: "Trezor Bridge uses callMessage for \(path) and ignored \(data.count) bytes")
}

func callMessage(path: String, messageType: UInt16, data: Data) -> TrezorCallMessageResult {
sessionLock.lock()
let session = openSessions[path]
sessionLock.unlock()

guard let session else {
return TrezorCallMessageResult(success: false, messageType: 0, data: Data(), error: "Trezor Bridge device not open: \(path)")
}

do {
let request = Self.encodeFrame(messageType: messageType, data: data)
let response = try post(path: "/call/\(Self.encode(session))", body: request)
return try Self.decodeFrame(response)
} catch {
debugLog("callMessage FAILED: \(error.localizedDescription)")
return TrezorCallMessageResult(success: false, messageType: 0, data: Data(), error: error.localizedDescription)
}
}

private func post(path: String, body: String? = nil) throws -> String {
guard let url = URL(string: "\(Env.trezorBridgeUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")))\(path)") else {
throw TrezorBridgeTransportError.invalidUrl
}

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = body == nil ? Self.connectTimeout : Self.readTimeout
request.cachePolicy = .reloadIgnoringLocalCacheData
if let body {
request.httpBody = Data(body.utf8)
request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
}

let semaphore = DispatchSemaphore(value: 0)
var result: Result<(Data, HTTPURLResponse), Error>?

URLSession.shared.dataTask(with: request) { data, response, error in
defer { semaphore.signal() }
if let error {
result = .failure(error)
return
}
guard let httpResponse = response as? HTTPURLResponse else {
result = .failure(TrezorBridgeTransportError.invalidResponse)
return
}
result = .success((data ?? Data(), httpResponse))
}.resume()

semaphore.wait()

let (data, response) = try result?.get() ?? (Data(), HTTPURLResponse())
let responseText = String(data: data, encoding: .utf8) ?? ""
guard 200 ..< 300 ~= response.statusCode else {
throw TrezorBridgeTransportError.httpError(statusCode: response.statusCode, body: responseText)
}
return responseText
}

private static func encodeFrame(messageType: UInt16, data: Data) -> String {
var frame = Data()
frame.append(UInt8((messageType >> 8) & 0xFF))
frame.append(UInt8(messageType & 0xFF))

let length = UInt32(data.count)
frame.append(UInt8((length >> 24) & 0xFF))
frame.append(UInt8((length >> 16) & 0xFF))
frame.append(UInt8((length >> 8) & 0xFF))
frame.append(UInt8(length & 0xFF))
frame.append(data)

return frame.map { String(format: "%02x", $0) }.joined()
}

private static func decodeFrame(_ hex: String) throws -> TrezorCallMessageResult {
let bytes = try Data(hexEncoded: hex.trimmingCharacters(in: .whitespacesAndNewlines))
guard bytes.count >= headerSize else {
throw TrezorBridgeTransportError.shortResponse
}

let messageType = (UInt16(bytes[0]) << 8) | UInt16(bytes[1])
let length = (UInt32(bytes[2]) << 24) | (UInt32(bytes[3]) << 16) | (UInt32(bytes[4]) << 8) | UInt32(bytes[5])
guard bytes.count >= headerSize + Int(length) else {
throw TrezorBridgeTransportError.invalidPayloadLength
}

let payload = bytes.subdata(in: headerSize ..< headerSize + Int(length))
return TrezorCallMessageResult(success: true, messageType: messageType, data: payload, error: "")
}

private static func toBridgePath(_ path: String) -> String {
"\(pathPrefix)\(path)"
}

private static func rawBridgePath(_ path: String) -> String {
String(path.dropFirst(pathPrefix.count))
}

private static func encode(_ value: String) -> String {
var allowed = CharacterSet.urlPathAllowed
allowed.remove(charactersIn: "/")
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
}

private func debugLog(_ message: String) {
Logger.debug(message, context: "TrezorBridgeTransport")
TrezorDebugLog.shared.log("[Bridge] \(message)")
}
}

private struct BridgeDevice: Decodable {
let path: String
let session: String?
}

private struct BridgeSession: Decodable {
let session: String
}

private enum TrezorBridgeTransportError: LocalizedError {
case invalidUrl
case invalidResponse
case httpError(statusCode: Int, body: String)
case invalidHex
case shortResponse
case invalidPayloadLength

var errorDescription: String? {
switch self {
case .invalidUrl:
return "Invalid Trezor Bridge URL"
case .invalidResponse:
return "Invalid Trezor Bridge response"
case let .httpError(statusCode, body):
return "Bridge request failed with HTTP \(statusCode): \(body)"
case .invalidHex:
return "Bridge returned invalid hex"
case .shortResponse:
return "Bridge response is shorter than the message header"
case .invalidPayloadLength:
return "Bridge response payload length exceeds available data"
}
}
}

private extension Data {
init(hexEncoded hex: String) throws {
guard hex.count.isMultiple(of: 2) else {
throw TrezorBridgeTransportError.invalidHex
}

var data = Data(capacity: hex.count / 2)
var index = hex.startIndex
while index < hex.endIndex {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(hex[index ..< nextIndex], radix: 16) else {
throw TrezorBridgeTransportError.invalidHex
}
data.append(byte)
index = nextIndex
}
self = data
}
}
Loading
Loading