Pular para o conteúdo
Escalibur Wallet
Auditoria

O cliente HTTP

Kit/Sources/EscaliburNetwork/HTTPClient.swift

O único ponto do app que fala com a internet.

514 linhasSHA-256 bddd91f4dabb4850…Abrir no GitHub, no commit 015f5f5
1import EscaliburChains
2import Foundation
3
4/// O unico ponto do app que fala com a internet.
5///
6/// Configuracao de docs/seguranca.md §5.1: sessao efemera sem cache, sem cookies e
7/// sem credenciais guardadas (nada de `Cache.db` revelando quais tokens o dono olha),
8/// sem seguir redirecionamento, resposta limitada a 4 MB, timeouts curtos, e um
9/// User-Agent constante que nao entrega versao do app nem do iOS.
10public final class HTTPClient: NSObject, @unchecked Sendable {
11 public enum Failure: Error, Equatable, Sendable {
12 case offline
13 case timeout
14 case status(Int)
15 case tooLarge
16 case redirectRefused
17 case invalidResponse
18 /// Host fora do hosts.lock, ou esquema diferente de https.
19 case hostNotAllowed
20 case decoding(String)
21 }
22
23 public static let shared = HTTPClient()
24
25 static let maxResponseBytes = 4 * 1024 * 1024
26 static let userAgent = "EscaliburWallet"
27
28 /// So para os testes: um `URLProtocol` no lugar da rede.
29 private let protocolClasses: [AnyClass]?
30 /// Os unicos hosts com que o cliente fala: gerados do hosts.lock
31 /// (`AllowedHosts.swift`, conferido pelo verificar.sh). Um endereco montado em tempo
32 /// de execucao, ou vindo de uma resposta, nao sai do aparelho.
33 private let allowedHosts: Set<String>
34
35 public override convenience init() { self.init(protocolClasses: nil, allowedHosts: AllowedHosts.all) }
36
37 init(protocolClasses: [AnyClass]?, allowedHosts: Set<String> = AllowedHosts.all) {
38 self.protocolClasses = protocolClasses
39 self.allowedHosts = allowedHosts
40 super.init()
41 }
42
43 /// Criada uma vez so, sob a trava: `lazy var` nao e seguro entre threads, e duas
44 /// sessoes repetiriam `taskIdentifier`, trocando a leitura de uma pela de outra.
45 private var session: URLSession {
46 lock.withLock {
47 if let existing = sessionStorage { return existing }
48 let made = makeSession()
49 sessionStorage = made
50 return made
51 }
52 }
53 private var sessionStorage: URLSession?
54
55 private func makeSession() -> URLSession {
56 let configuration = URLSessionConfiguration.ephemeral
57 if let protocolClasses { configuration.protocolClasses = protocolClasses }
58 configuration.urlCache = nil
59 configuration.httpCookieStorage = nil
60 configuration.urlCredentialStorage = nil
61 configuration.httpShouldSetCookies = false
62 configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
63 configuration.waitsForConnectivity = false
64 configuration.timeoutIntervalForRequest = 10
65 configuration.timeoutIntervalForResource = 30
66 configuration.tlsMinimumSupportedProtocolVersion = .TLSv12
67 configuration.httpAdditionalHeaders = ["User-Agent": Self.userAgent, "Accept-Language": "en"]
68 return URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
69 }
70
71 // MARK: Requisicoes
72
73 public func get(_ url: URL, headers: [String: String] = [:], timeout: TimeInterval = 10) async throws -> Data {
74 var request = URLRequest(url: url, timeoutInterval: timeout)
75 request.httpMethod = "GET"
76 headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
77 return try await perform(request)
78 }
79
80 public func post(_ url: URL, json body: Data, headers: [String: String] = [:], timeout: TimeInterval = 10) async throws -> Data {
81 var request = URLRequest(url: url, timeoutInterval: timeout)
82 request.httpMethod = "POST"
83 request.httpBody = body
84 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
85 headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
86 return try await perform(request)
87 }
88
89 /// PUT e DELETE com corpo JSON, pelas mesmas regras de `get`/`post`. A CoW so aceita
90 /// cancelamento em `DELETE /api/v1/orders` e registro de appData em `PUT`.
91 public func send(_ method: String, _ url: URL, json body: Data?, headers: [String: String] = [:], timeout: TimeInterval = 10) async throws -> Data {
92 guard ["PUT", "DELETE"].contains(method) else { throw Failure.invalidResponse }
93 var request = URLRequest(url: url, timeoutInterval: timeout)
94 request.httpMethod = method
95 if let body {
96 request.httpBody = body
97 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
98 }
99 headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
100 return try await perform(request)
101 }
102
103 public func getJSON<T: Decodable>(_ type: T.Type, from url: URL, headers: [String: String] = [:]) async throws -> T {
104 let data = try await get(url, headers: headers)
105 do {
106 return try JSONDecoder().decode(T.self, from: data)
107 } catch {
108 throw Failure.decoding(String(describing: type))
109 }
110 }
111
112 /// Hosts que servem muita coisa alem do que o app pede: so este caminho (auditoria 6). O
113 /// GitHub serve qualquer repositorio por `raw.githubusercontent.com`, e o app so le as
114 /// logos da Trust Wallet. O site da Escalibur (escalibur-wallet.github.io) e da org da
115 /// Escalibur inteiro e nao precisa de caminho.
116 static let pathPrefixes: [String: String] = [
117 "raw.githubusercontent.com": "/trustwallet/assets/",
118 ]
119
120 /// Porta alem da 443, por host. Os servidores publicos da Ripple atendem JSON-RPC na
121 /// 51234, a porta da documentacao deles: a trava da auditoria 6 sem esta excecao deixou
122 /// a XRP Ledger com um servidor so, e a leitura por dois parou (build 9, 19/10/2026).
123 static let extraPorts: [String: Int] = [
124 "s1.ripple.com": 51234,
125 "s2.ripple.com": 51234,
126 ]
127
128 /// A regra de todo endereco: https, host da lista, a porta 443 (ou a do host, acima) e,
129 /// quando o host tem, so o caminho dele.
130 static func permits(_ url: URL, allowedHosts: Set<String>) -> Bool {
131 guard url.scheme == "https", let host = url.host, allowedHosts.contains(host) else { return false }
132 guard url.port == nil || url.port == 443 || url.port == extraPorts[host] else { return false }
133 return pathPrefixes[host].map { url.path.hasPrefix($0) } ?? true
134 }
135
136 /// O corpo e lido em fluxo e cortado ao passar do limite. Resposta sem
137 /// Content-Length (chunked) nao chega inteira na memoria antes de ser medida.
138 ///
139 /// Uma tarefa de dados com o delegado da sessao, e nao `bytes(for:)`: em algumas
140 /// versoes do macOS (a do runner do CI) o fluxo de `bytes(for:)` parava de entregar
141 /// o corpo, e o delegado classico mede cada pedaco igual em todas.
142 private func perform(_ request: URLRequest, limit: Int = HTTPClient.maxResponseBytes) async throws -> Data {
143 guard let url = request.url, Self.permits(url, allowedHosts: allowedHosts) else { throw Failure.hostNotAllowed }
144 guard !isQuiet else { throw Failure.offline }
145 let task = session.dataTask(with: request)
146 do {
147 return try await withTaskCancellationHandler {
148 try await withCheckedThrowingContinuation { continuation in
149 // A conferencia do silencio e o registro sob a mesma trava: uma tela de
150 // segredo que abre agora cancela esta tarefa, ou ela nem comeca.
151 let paused = lock.withLock { () -> Bool in
152 guard quietHolds == 0 else { return true }
153 transfers[ObjectIdentifier(task)] = Transfer(continuation, task: task, limit: limit)
154 return false
155 }
156 if paused {
157 continuation.resume(throwing: Failure.offline)
158 return
159 }
160 task.resume()
161 }
162 } onCancel: {
163 task.cancel()
164 }
165 } catch {
166 throw error
167 }
168 }
169
170 /// Uma leitura em andamento: o corpo que ja chegou, a recusa (se houve) e quem espera.
171 private final class Transfer {
172 var data = Data()
173 var failure: Failure?
174 let continuation: CheckedContinuation<Data, Error>
175 let task: URLSessionTask
176 let limit: Int
177
178 init(_ continuation: CheckedContinuation<Data, Error>, task: URLSessionTask, limit: Int) {
179 self.continuation = continuation
180 self.task = task
181 self.limit = limit
182 }
183 }
184
185 // MARK: Silencio
186
187 /// Quantas telas de segredo estao abertas agora (`beginQuiet`). Com alguma aberta,
188 /// nenhuma requisicao sai do aparelho, e as que estavam no ar sao canceladas: a tela
189 /// de digitar as palavras nao divide o momento com conexao nenhuma (pedido do dono,
190 /// 16/10/2026). As palavras nunca vao para a rede de qualquer jeito; isto garante que
191 /// nem saldo, nem preco, nem logo saem enquanto elas estao na tela.
192 private var quietHolds = 0
193
194 public var isQuiet: Bool { lock.withLock { quietHolds > 0 } }
195
196 /// Abre o silencio e cancela o que estava no ar. Cada `beginQuiet` pede um `endQuiet`.
197 public func beginQuiet() {
198 let live = lock.withLock { () -> [URLSessionTask] in
199 quietHolds += 1
200 return transfers.values.map(\.task)
201 }
202 live.forEach { $0.cancel() }
203 }
204
205 public func endQuiet() {
206 lock.withLock { quietHolds = max(0, quietHolds - 1) }
207 }
208
209 private let lock = NSLock()
210 /// Pela identidade da tarefa, viva ate `didCompleteWithError` tirar a entrada daqui.
211 private var transfers: [ObjectIdentifier: Transfer] = [:]
212
213 private func transfer(_ task: URLSessionTask) -> Transfer? {
214 lock.withLock { transfers[ObjectIdentifier(task)] }
215 }
216
217 static func failure(for error: Error) -> Failure {
218 switch (error as? URLError)?.code {
219 case .notConnectedToInternet, .networkConnectionLost, .dataNotAllowed: return .offline
220 case .timedOut: return .timeout
221 default: return .invalidResponse
222 }
223 }
224}
225
226extension HTTPClient: URLSessionDataDelegate {
227 /// Redirecionamento nunca e seguido: um provedor comprometido nao manda o app
228 /// buscar dado em outro host.
229 public func urlSession(
230 _ session: URLSession, task: URLSessionTask,
231 willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
232 completionHandler: @escaping @Sendable (URLRequest?) -> Void
233 ) {
234 completionHandler(nil)
235 }
236
237 /// Status e tamanho anunciado sao conferidos antes de qualquer byte do corpo.
238 public func urlSession(
239 _ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
240 completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void
241 ) {
242 guard let transfer = transfer(dataTask) else { return completionHandler(.cancel) }
243 let refusal: Failure?
244 if let http = response as? HTTPURLResponse {
245 if (300..<400).contains(http.statusCode) { refusal = .redirectRefused }
246 else if !(200..<300).contains(http.statusCode) { refusal = .status(http.statusCode) }
247 else if http.expectedContentLength > Int64(transfer.limit) { refusal = .tooLarge }
248 else { refusal = nil }
249 } else {
250 refusal = .invalidResponse
251 }
252 lock.withLock {
253 transfer.failure = refusal
254 if refusal == nil, response.expectedContentLength > 0 {
255 transfer.data.reserveCapacity(Int(response.expectedContentLength))
256 }
257 }
258 completionHandler(refusal == nil ? .allow : .cancel)
259 }
260
261 public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
262 guard let transfer = transfer(dataTask) else { return }
263 let over = lock.withLock { () -> Bool in
264 guard transfer.failure == nil else { return false }
265 transfer.data.append(data)
266 guard transfer.data.count > transfer.limit else { return false }
267 transfer.failure = .tooLarge
268 transfer.data = Data()
269 return true
270 }
271 if over { dataTask.cancel() }
272 }
273
274 public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
275 guard let transfer = lock.withLock({ transfers.removeValue(forKey: ObjectIdentifier(task)) }) else { return }
276 #if DEBUG
277 NetworkDiagnostics.shared.record(host: task.originalRequest?.url?.host ?? "?", failure: transfer.failure, error: error)
278 #endif
279 if let failure = transfer.failure {
280 transfer.continuation.resume(throwing: failure)
281 } else if let error {
282 transfer.continuation.resume(throwing: Self.failure(for: error))
283 } else {
284 transfer.continuation.resume(returning: transfer.data)
285 }
286 }
287}
288
289/// Um conjunto de provedores equivalentes para o mesmo servico, com contingencia.
290///
291/// Circuit breaker simples: tres falhas seguidas tiram o provedor por 60 segundos.
292/// A ordem de preferencia e fixa no codigo; nenhuma resposta de rede acrescenta
293/// provedor.
294public actor ProviderPool {
295 public struct Provider: Sendable, Hashable {
296 public let name: String
297 public let baseURL: URL
298
299 public init(name: String, baseURL: URL) {
300 self.name = name
301 self.baseURL = baseURL
302 }
303 }
304
305 public let providers: [Provider]
306 private var failures: [String: Int] = [:]
307 private var benchedUntil: [String: Date] = [:]
308
309 public init(_ providers: [Provider]) {
310 self.providers = providers
311 }
312
313 /// Os provedores disponiveis agora, na ordem de preferencia.
314 public func available(now: Date = .now) -> [Provider] {
315 let live = providers.filter { (benchedUntil[$0.name] ?? .distantPast) <= now }
316 return live.isEmpty ? providers : live
317 }
318
319 public func reportSuccess(_ provider: Provider) {
320 failures[provider.name] = 0
321 }
322
323 public func reportFailure(_ provider: Provider, now: Date = .now) {
324 let count = (failures[provider.name] ?? 0) + 1
325 failures[provider.name] = count
326 if count >= 3 {
327 benchedUntil[provider.name] = now.addingTimeInterval(60)
328 failures[provider.name] = 0
329 }
330 }
331
332 /// A falha com o motivo. Recusa por limite vai direto para o banco, por mais tempo:
333 /// insistir num provedor que ja disse "chega" so prolonga o bloqueio do IP (a
334 /// Blockchair passa do 429 ao 430, "IP temporary blacklisted"). O resto segue a regra
335 /// das tres falhas seguidas.
336 public func reportFailure(_ provider: Provider, error: Error, now: Date = .now) {
337 guard let pause = Self.benchTime(for: error) else { return reportFailure(provider, now: now) }
338 let until = now.addingTimeInterval(pause)
339 benchedUntil[provider.name] = max(benchedUntil[provider.name] ?? until, until)
340 failures[provider.name] = 0
341 }
342
343 /// Quanto tempo cada recusa deixa o provedor de fora. 429 e o limite por IP; 430
344 /// (Blockchair) e 403 (Cloudflare) sao bloqueio do IP, mais demorado; um provedor
345 /// fora do ar custa o timeout inteiro a cada pergunta.
346 public static func benchTime(for error: Error) -> TimeInterval? {
347 switch error as? HTTPClient.Failure {
348 case .status(429): return 5 * 60
349 case .status(430), .status(403): return 15 * 60
350 case .timeout: return 2 * 60
351 default: return nil
352 }
353 }
354
355 /// So para os testes e para a tela: ate quando o provedor esta no banco.
356 public func benched(_ provider: Provider) -> Date? { benchedUntil[provider.name] }
357
358 /// Tenta cada provedor em ordem ate um responder.
359 public func first<T: Sendable>(_ operation: @Sendable (Provider) async throws -> T) async throws -> T {
360 var lastError: Error = HTTPClient.Failure.offline
361 for provider in available() {
362 do {
363 let value = try await operation(provider)
364 reportSuccess(provider)
365 return value
366 } catch {
367 reportFailure(provider)
368 lastError = error
369 }
370 }
371 throw lastError
372 }
373
374 /// Pergunta a provedores distintos e exige que dois concordem. Nonce, sequence e
375 /// saldo que vao para uma transacao passam por aqui (docs/seguranca.md §5.5).
376 ///
377 /// Nunca aceita uma resposta so: com menos de dois provedores respondendo, falha.
378 /// Os que estao no banco do circuit breaker entram no fim da fila, porque um
379 /// consenso que so tem um provedor vivo nao e consenso.
380 public func agreeing<T: Sendable & Equatable>(_ operation: @Sendable (Provider) async throws -> T) async throws -> T {
381 let live = available()
382 let candidates = live + providers.filter { !live.contains($0) }
383 var answers: [T] = []
384 for provider in candidates {
385 do {
386 let value = try await operation(provider)
387 reportSuccess(provider)
388 if answers.contains(value) { return value }
389 answers.append(value)
390 } catch {
391 reportFailure(provider)
392 }
393 }
394 throw ConsensusFailure(answers: answers.count)
395 }
396}
397
398public struct ConsensusFailure: Error, Sendable {
399 public let answers: Int
400}
401
402/// JSON-RPC 2.0 sobre HTTPS.
403public struct JSONRPC: Sendable {
404 public struct RPCError: Error, Sendable, Decodable {
405 public let code: Int
406 public let message: String
407 }
408
409 private struct Envelope<T: Decodable>: Decodable {
410 let result: T?
411 let error: RPCError?
412 }
413
414 public static func call<T: Decodable>(_ url: URL, method: String, params: [JSONValue], as type: T.Type, client: HTTPClient = .shared) async throws -> T {
415 let body: [String: JSONValue] = [
416 "jsonrpc": .string("2.0"), "id": .number(1), "method": .string(method), "params": .array(params),
417 ]
418 let data = try await client.post(url, json: try JSONEncoder().encode(body))
419 let envelope: Envelope<T>
420 do { envelope = try JSONDecoder().decode(Envelope<T>.self, from: data) } catch { throw HTTPClient.Failure.decoding(method) }
421 if let error = envelope.error { throw error }
422 guard let result = envelope.result else { throw HTTPClient.Failure.invalidResponse }
423 return result
424 }
425}
426
427/// Um valor JSON qualquer, para montar parametros sem `[String: Any]`.
428public enum JSONValue: Codable, Sendable, Equatable {
429 case string(String)
430 case number(Double)
431 case bool(Bool)
432 case array([JSONValue])
433 case object([String: JSONValue])
434 case null
435
436 public func encode(to encoder: Encoder) throws {
437 var container = encoder.singleValueContainer()
438 switch self {
439 case .string(let value): try container.encode(value)
440 case .number(let value):
441 if value == value.rounded(), abs(value) < 1e15 { try container.encode(Int64(value)) } else { try container.encode(value) }
442 case .bool(let value): try container.encode(value)
443 case .array(let value): try container.encode(value)
444 case .object(let value): try container.encode(value)
445 case .null: try container.encodeNil()
446 }
447 }
448
449 public init(from decoder: Decoder) throws {
450 let container = try decoder.singleValueContainer()
451 if container.decodeNil() { self = .null }
452 else if let value = try? container.decode(Bool.self) { self = .bool(value) }
453 else if let value = try? container.decode(Double.self) { self = .number(value) }
454 else if let value = try? container.decode(String.self) { self = .string(value) }
455 else if let value = try? container.decode([JSONValue].self) { self = .array(value) }
456 else { self = .object(try container.decode([String: JSONValue].self)) }
457 }
458
459 public var stringValue: String? { if case .string(let v) = self { return v }; return nil }
460 public var doubleValue: Double? { if case .number(let v) = self { return v }; return nil }
461 public var boolValue: Bool? { if case .bool(let v) = self { return v }; return nil }
462 public subscript(key: String) -> JSONValue? { if case .object(let o) = self { return o[key] }; return nil }
463 public subscript(index: Int) -> JSONValue? {
464 if case .array(let a) = self, a.indices.contains(index) { return a[index] }
465 return nil
466 }
467 public var arrayValue: [JSONValue]? { if case .array(let a) = self { return a }; return nil }
468}
469
470#if DEBUG
471/// So no build de teste: as ultimas falhas de rede (host e motivo), para a tela dizer
472/// por que uma leitura falhou no iPhone de quem testa. Fica so na memoria, sem endereco
473/// nem corpo de resposta, e o build de distribuicao nem compila isto.
474public final class NetworkDiagnostics: @unchecked Sendable {
475 public static let shared = NetworkDiagnostics()
476
477 private let lock = NSLock()
478 private var entries: [(host: String, reason: String, at: Date)] = []
479
480 /// A recusa do app (status, tamanho, redirecionamento) ou o codigo do URLError. Pedido
481 /// cancelado (a tela saiu, a leitura foi trocada) nao e falha de rede e nao entra.
482 func record(host: String, failure: HTTPClient.Failure?, error: Error?) {
483 let reason: String
484 if let failure {
485 reason = String(describing: failure)
486 } else if let error = error as? URLError {
487 guard error.code != .cancelled else { return }
488 reason = "URLError \(error.code.rawValue)"
489 } else if let error {
490 reason = String(describing: type(of: error))
491 } else {
492 return
493 }
494 lock.withLock {
495 entries.append((host, reason, .now))
496 if entries.count > 80 { entries.removeFirst(entries.count - 80) }
497 }
498 }
499
500 /// "host: motivo (n)" de cada falha dos ultimos `seconds` segundos.
501 public func recent(seconds: TimeInterval = 180) -> [String] {
502 let since = Date.now.addingTimeInterval(-seconds)
503 let recent = lock.withLock { entries.filter { $0.at >= since } }
504 var counts: [String: Int] = [:]
505 var order: [String] = []
506 for entry in recent {
507 let key = "\(entry.host): \(entry.reason)"
508 if counts[key] == nil { order.append(key) }
509 counts[key, default: 0] += 1
510 }
511 return order.map { "\($0) (\(counts[$0] ?? 0))" }
512 }
513}
514#endif