Pular para o conteúdo
Escalibur Wallet
Auditoria

Endereço parecido

Kit/Sources/EscaliburChains/AddressPoisoning.swift

Detecção de envenenamento de endereço.

379 linhasSHA-256 d2c3315c6eea49e8…Abrir no GitHub, no commit 015f5f5
1import Foundation
2
3/// Deteccao de envenenamento de endereco.
4///
5/// O golpe: o atacante gera um endereco com o comeco e o fim iguais aos de um que o
6/// dono ja usou e manda um valor minusculo, para o endereco falso aparecer no
7/// historico. Quem copia de la, conferindo so as pontas, paga o atacante.
8///
9/// A comparacao ignora o prefixo fixo de cada rede (o atacante nao precisa gerar
10/// "0x" nem "bc1q", entao eles nao contam como coincidencia) e olha as pontas do
11/// resto. Duas formas da mesma conta (caixa do EIP-55, amigavel e crua da TON) nao
12/// sao parecidas: sao a mesma.
13public enum AddressPoisoning {
14 /// Coincidencias de ponta que um atacante consegue gerar em minutos. Acima disso
15 /// o custo cresce 16 a 58 vezes por caractere.
16 static let minimumPrefix = 3
17 static let minimumSuffix = 3
18 static let minimumTotal = 7
19 /// Uma ponta so, longa: quem confere so o comeco ou so o fim cai em 2+6, 2+8 ou 0+10,
20 /// que as duas pontas juntas deixavam passar (auditoria 4, C7-07). Ao acaso, cinco
21 /// caracteres iguais numa ponta saem uma vez em um milhao de pares, em hexadecimal
22 /// (16^5 = 2^20).
23 static let singleEnd = 5
24 /// A mesma ponta so, com 32 simbolos ou mais (bech32, base58, base32 da Stellar,
25 /// base64 da TON): quatro caracteres ja sao os mesmos 20 bits (32^4 = 2^20). O sosia que
26 /// casa so os 4 do fim, os que a tela mostra, custa o mesmo milhao de tentativas que
27 /// os 5 do hexadecimal, e passava sem aviso (revisao dos enderecos parecidos, B-5).
28 static let singleEndWide = 4
29 /// Na Cardano, o comeco do corpo e a credencial de pagamento inteira (28 bytes, 44
30 /// caracteres de bech32): com ela igual, os dois enderecos sao da mesma chave de
31 /// pagamento, com outra parte de stake, e nao um sosia gerado.
32 static let cardanoPaymentCharacters = 44
33 /// Na Cardano, a credencial de stake de um endereco e publica: o atacante poe a de um
34 /// endereco que o dono usou no seu, com o pagamento dele, e a segunda metade dos dois
35 /// fica igual de graca. Com o stake igual e o pagamento diferente, tres caracteres
36 /// iguais no fim (o checksum) ja marcam (B-5).
37 static let cardanoSameStakeSuffix = 3
38
39 /// Menos que isso depois do prefixo da rede nao e endereco gerado (nome curto da NEAR).
40 /// O tamanho conta o registro do nome da NEAR: tirar o ".near" do corpo nao deixa de
41 /// comparar um nome que ja era comparado.
42 public static let minimumLength = 16
43
44 /// O primeiro endereco de `known` parecido com `address`, se houver.
45 public static func lookalike(_ address: String, among known: [String], chain: Chain) -> String? {
46 guard let target = Compared(address, chain: chain) else { return nil }
47 let targetAccount = Address.canonicalRecipient(address, chain: chain)
48 return known.first { other in
49 guard let compared = Compared(other, chain: chain), compared.text != target.text else { return false }
50 if let targetAccount, Address.canonicalRecipient(other, chain: chain) == targetAccount { return false }
51 // Na Stellar, o endereco M e a conta base G dele (ou dois M da mesma base) sao
52 // a mesma conta com ids diferentes, nao um sosia. O ID trocado entre dois M e
53 // outra conferencia (`sameAccountOtherID`).
54 if chain.family == .stellar, let a = StellarMuxedAccount(address: other.trimmingCharacters(in: .whitespacesAndNewlines))?.account,
55 let b = StellarMuxedAccount(address: address.trimmingCharacters(in: .whitespacesAndNewlines))?.account, a == b {
56 return false
57 }
58 let candidate = compared.body
59 let head = commonPrefix(candidate, target.body)
60 let tail = commonPrefix(String(candidate.reversed()), String(target.body.reversed()))
61 if chain.family == .cardano {
62 if head >= cardanoPaymentCharacters { return false }
63 if tail >= cardanoSameStakeSuffix, cardanoSameStakeOtherPayment(address, other) { return true }
64 }
65 if head >= minimumPrefix && tail >= minimumSuffix && head + tail >= minimumTotal { return true }
66 if chain.family == .near, !isHex64(candidate), !isHex64(target.body), similarNames(candidate, target.body) { return true }
67 guard let single = singleEndLength(chain, candidate, target.body) else { return false }
68 return max(head, tail) >= single
69 }
70 }
71
72 /// Dois nomes da NEAR parecidos entre si: ate 2 edicoes (letra trocada, posta ou
73 /// tirada) com corpo de 8 ou mais, ou um comeco comum que cobre o nome menor menos 2.
74 /// Registrar um nome sob ".near" custa centavos e nao pede nenhum caractere gerado:
75 /// "coinbase-hot-wa11et", "coinbase-hot-wallets" e "binance-deposit-02" imitam
76 /// "coinbase-hot-wallet" e "binance-deposit-01" so com o fim mudado, e o fim do corpo,
77 /// sem o ".near", ja nao contava como ponta (revisao do B-9).
78 ///
79 /// As duas regras valem tambem sobre o nome como o olho le (`visualFold`): com tres
80 /// trocas ou mais, "coinbase-h0t-wa11et" e "binanse-dep0sit-07" passavam.
81 static func similarNames(_ a: String, _ b: String) -> Bool {
82 similarSpelling(a, b) || similarSpelling(visualFold(a), visualFold(b))
83 }
84
85 static func similarSpelling(_ a: String, _ b: String) -> Bool {
86 let shorter = min(a.count, b.count)
87 if commonPrefix(a, b) >= max(nameSharedPrefix, shorter - 2) { return true }
88 return shorter >= nameMinimumForEdits && editDistance(a, b, limit: nameEdits) <= nameEdits
89 }
90
91 static let nameSharedPrefix = 6
92 static let nameMinimumForEdits = 8
93 static let nameEdits = 2
94
95 /// Pares de letras que se leem como uma so, e os caracteres que se passam por outros
96 /// num nome de conta (so minusculas, algarismos, "-", "_" e "."): "rn" por "m", "vv" por
97 /// "w", "cl" por "d", "0" por "o", "5" por "s", e "1", "i" e "l" juntos. Ficam de fora o
98 /// "_" no lugar do "-" (ja e uma edicao, e dobrado somava nomes de numeros diferentes,
99 /// "camila-5948" e "camila_9998") e os algarismos que so lembram letras (2, 6, 8, 9),
100 /// que transformavam numeros em palavras ("andrew-9016" e "andrew-gomes") sem ganhar
101 /// sosia nenhum nas amostras (teste `nearVisualMimics`).
102 static let visualPairs: [(String, String)] = [("rn", "m"), ("vv", "w"), ("cl", "d")]
103 static let visualSingles: [Character: Character] = ["0": "o", "1": "l", "i": "l", "5": "s"]
104
105 /// O nome como o olho le: dois nomes que so diferem por um sosia visual dobram no mesmo.
106 static func visualFold(_ name: String) -> String {
107 var text = name
108 for (pair, single) in visualPairs { text = text.replacingOccurrences(of: pair, with: single) }
109 return String(text.map { visualSingles[$0] ?? $0 })
110 }
111
112 /// Distancia de edicao (Levenshtein) entre `a` e `b`, parando em `limit + 1`.
113 static func editDistance(_ a: String, _ b: String, limit: Int) -> Int {
114 let x = Array(a.utf8), y = Array(b.utf8)
115 guard abs(x.count - y.count) <= limit else { return limit + 1 }
116 guard !x.isEmpty, !y.isEmpty else { return max(x.count, y.count) }
117 var previous = Array(0...y.count)
118 for i in 1...x.count {
119 var current = [i] + [Int](repeating: 0, count: y.count)
120 for j in 1...y.count {
121 current[j] = min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (x[i - 1] == y[j - 1] ? 0 : 1))
122 }
123 if current.min()! > limit { return limit + 1 }
124 previous = current
125 }
126 return min(previous[y.count], limit + 1)
127 }
128
129 /// Um endereco como a comparacao le, em minusculas: `text`, tudo depois do prefixo da
130 /// rede, diz se dois sao o mesmo; `body`, tambem sem o sufixo de nome da NEAR, e de
131 /// onde saem as pontas. O mesmo nome sob ".near" e sob ".tg" tem o corpo igual e o
132 /// texto diferente: sao contas diferentes, e as duas pontas iguais o marcam.
133 private struct Compared {
134 let text: String
135 let body: String
136
137 init?(_ address: String, chain: Chain) {
138 text = AddressPoisoning.afterPrefix(address, chain: chain)
139 guard text.count >= AddressPoisoning.minimumLength else { return nil }
140 body = AddressPoisoning.body(address, chain: chain)
141 }
142 }
143
144 /// Quantos caracteres iguais numa ponta so ja fazem o par parecido, ou `nil` quando a
145 /// regra da ponta so nao vale. Quatro com 32 simbolos ou mais, cinco em hexadecimal:
146 /// os dois sao 20 bits, e ao acaso saem perto de duas vezes em um milhao de pares
147 /// (as taxas medidas por rede estao no teste).
148 static func singleEndLength(_ chain: Chain, _ a: String, _ b: String) -> Int? {
149 switch chain.family {
150 case .evm, .sui, .aptos:
151 return singleEnd
152 case .near:
153 // Conta com nome e escolhida letra a letra, e nomes com o mesmo comeco ou o
154 // mesmo fim sao comuns ("alex...", "...-wallet"): entre dois nomes a ponta so nao
155 // vale, e a regra e a de `similarNames`. Com uma conta implicita (64 caracteres
156 // hexadecimais) de um lado, vale: um nome feito dos 59 primeiros hex de uma
157 // implicita, mais ".near", e um sosia dela. Pelo tamanho so, um nome de 64
158 // letras tambem entrava (revisao dos enderecos parecidos, B-9).
159 return isHex64(a) || isHex64(b) ? singleEnd : nil
160 case .ton:
161 // A grafia crua ("0:" e 64 hex) e hexadecimal; a amigavel, base64.
162 return isHex64(a) && isHex64(b) ? singleEnd : singleEndWide
163 case .utxo, .tron, .xrpl, .stellar, .solana, .cardano, .polkadot:
164 return singleEndWide
165 }
166 }
167
168 /// 64 caracteres hexadecimais (o corpo ja vem em minusculas).
169 static func isHex64(_ text: String) -> Bool {
170 text.utf8.count == 64 && text.utf8.allSatisfy { (0x30...0x39).contains($0) || (0x61...0x66).contains($0) }
171 }
172
173 /// Dois enderecos base da Cardano com a mesma credencial de stake e pagamentos
174 /// diferentes.
175 static func cardanoSameStakeOtherPayment(_ a: String, _ b: String) -> Bool {
176 guard case .success(let first) = CardanoAddress.parse(a), case .success(let second) = CardanoAddress.parse(b),
177 let stake = first.stakeHash, stake == second.stakeHash
178 else { return false }
179 return first.paymentHash != second.paymentHash
180 }
181
182 // MARK: Mesma conta, outro ID (Stellar)
183
184 /// O conhecido com a mesma conta base de `address` e outro ID, quando nenhum conhecido
185 /// tem a mesma conta com o mesmo ID (revisao dos enderecos parecidos, B-6).
186 ///
187 /// `lookalike` deixa de fora, de proposito, dois M da mesma base: sao a mesma conta, e
188 /// nao um sosia gerado. So que numa corretora o ID e o que diz de qual cliente e o
189 /// deposito. Um programa que troca o endereco copiado pode decodificar o M, trocar so
190 /// o ID pelo de uma conta do atacante na mesma corretora e codificar de novo: o
191 /// endereco continua valido, da mesma conta, e passava por todas as conferencias.
192 ///
193 /// O que se compara e o par (conta base, ID): o M traz os dois, e um G traz o ID no memo
194 /// que vai junto. Por isso `tag` e o memo de um destino G, e `tagged` sao os contatos G
195 /// salvos com o memo (revisao do B-6). Um G sem memo (o historico da rede so da a conta
196 /// base) nao diz qual ID o dono usou, e nao tem o que comparar.
197 public static func sameAccountOtherID(_ address: String, tag: String? = nil, among known: [String],
198 tagged: [(address: String, tag: String)] = [], chain: Chain) -> SameAccount? {
199 guard chain.family == .stellar, let target = muxedPair(address, tag: tag) else { return nil }
200 let pairs = known.compactMap { text in muxedPair(text, tag: nil).map { (text: text, pair: $0) } }
201 + tagged.compactMap { entry in muxedPair(entry.address, tag: entry.tag).map { (text: entry.address, pair: $0) } }
202 // O mesmo par ja usado: e o destino de sempre.
203 if pairs.contains(where: { $0.pair == target }) { return nil }
204 return pairs.first { $0.pair.account == target.account }.map { SameAccount(known: $0.text, knownID: $0.pair.id) }
205 }
206
207 /// Um conhecido com a mesma conta base e outro ID.
208 public struct SameAccount: Equatable, Sendable {
209 /// O endereco como o dono o tem: um M, ou o G de um contato salvo com o ID no memo.
210 public let known: String
211 /// O ID desse conhecido.
212 public let knownID: UInt64
213 }
214
215 /// Conta base e ID: do M, ou do G com um memo que e numero.
216 static func muxedPair(_ address: String, tag: String?) -> (account: StellarAccountID, id: UInt64)? {
217 guard let account = muxedAccount(address) else { return nil }
218 if let id = account.id { return (account.account, id) }
219 guard let tag, let id = UInt64(tag.trimmingCharacters(in: .whitespacesAndNewlines)) else { return nil }
220 return (account.account, id)
221 }
222
223 /// O dono confirmou o ID de um M com a mesma conta de um conhecido: digitou o numero
224 /// do ID que quem vai receber mostra, ou os 6 ultimos caracteres do endereco, lidos onde
225 /// ele foi copiado (revisao do B-6).
226 ///
227 /// Os 6 ultimos so valem como resposta porque nenhum dos dois esta na tela enquanto o
228 /// desafio esta: o trecho do ID do colado (que inclui os 6 ultimos) vem escondido, e o
229 /// campo do endereco colado some ate o dono apertar "Editar", que desfaz o desafio.
230 public static func muxedIDConfirmed(_ typed: String, address: String) -> Bool {
231 let text = address.trimmingCharacters(in: .whitespacesAndNewlines)
232 guard let id = muxedAccount(text)?.id else { return false }
233 if idConfirmed(typed, id: id) { return true }
234 let answer = typed.filter { !$0.isWhitespace }
235 return answer.count == muxedTailLength && answer.uppercased() == String(text.suffix(muxedTailLength)).uppercased()
236 }
237
238 static let muxedTailLength = 6
239
240 /// O numero digitado e `id`. Vale com espacos e com os algarismos do teclado do idioma
241 /// (arabicos orientais, persas, devanagari), que viram 0 a 9.
242 public static func idConfirmed(_ typed: String, id: UInt64) -> Bool {
243 guard let digits = asciiDigits(typed), let number = UInt64(digits) else { return false }
244 return number == id
245 }
246
247 /// Os algarismos de `text` em 0 a 9, sem os espacos; `nil` se houver qualquer outra coisa.
248 static func asciiDigits(_ text: String) -> String? {
249 var out = ""
250 for scalar in text.unicodeScalars where !scalar.properties.isWhitespace {
251 guard scalar.properties.numericType == .decimal, let value = scalar.properties.numericValue,
252 let digit = Int(exactly: value), (0...9).contains(digit) else { return nil }
253 out += String(digit)
254 }
255 return out.isEmpty ? nil : out
256 }
257
258 /// O trecho que o dono digita lendo na fonte, quando nao ha com o que comparar: os 6 do
259 /// meio do endereco. Num M da Stellar o meio ainda e a chave, igual em todo M da mesma
260 /// conta, e a troca so do ID passava; ali o trecho e o fim, o ID e o checksum (revisao
261 /// do B-6).
262 public static func blindSegment(_ address: String, chain: Chain, length: Int = 6) -> Segment? {
263 let text = address.trimmingCharacters(in: .whitespacesAndNewlines)
264 if chain.family == .stellar, muxedAccount(text)?.isMuxed == true {
265 return Segment(start: text.count - length, length: length)
266 }
267 return middleSegment(address, length: length)
268 }
269
270 /// O trecho do endereco M depois da chave: o ID e o checksum, o que muda quando so o
271 /// ID muda. A versao e a chave ocupam 33 bytes, 264 bits; o caractere 52 ainda leva os
272 /// 4 ultimos bits da chave (e o bit mais alto do ID), e do 53 em diante e so ID e
273 /// checksum.
274 public static func muxedIDSegment(_ address: String) -> Segment? {
275 let text = address.trimmingCharacters(in: .whitespacesAndNewlines)
276 guard let account = muxedAccount(text), account.isMuxed, text.count > muxedIDStart else { return nil }
277 return Segment(start: muxedIDStart, length: text.count - muxedIDStart)
278 }
279
280 static let muxedIDStart = (1 + 32) * 8 / 5 + 1
281
282 static func muxedAccount(_ address: String) -> StellarMuxedAccount? {
283 StellarMuxedAccount(address: address.trimmingCharacters(in: .whitespacesAndNewlines))
284 }
285
286 /// Um trecho do endereco, em posicoes de caractere do texto como ele e mostrado.
287 public struct Segment: Equatable, Sendable {
288 public let start: Int
289 public let length: Int
290
291 /// O trecho dentro de `address`.
292 public func text(in address: String) -> String {
293 String(address.dropFirst(start).prefix(length))
294 }
295 }
296
297 /// Os `length` caracteres do meio do endereco: o que o dono le e digita quando nao ha
298 /// com o que comparar (historico da rede ilegivel, auditoria 3, B1).
299 public static func middleSegment(_ address: String, length: Int = 6) -> Segment? {
300 let count = address.count
301 guard count >= length + 4 else { return nil }
302 return Segment(start: (count - length) / 2, length: length)
303 }
304
305 /// Onde `address` comeca a diferir de `other`: os `length` caracteres a partir da
306 /// primeira diferenca depois do prefixo da rede. E o trecho que o desafio pede:
307 /// as pontas o atacante copia, o meio ele nao consegue (auditoria 2, M2).
308 public static func differingSegment(_ address: String, from other: String, chain: Chain, length: Int = 6) -> Segment? {
309 let shown = address.trimmingCharacters(in: .whitespacesAndNewlines)
310 let target = afterPrefix(shown, chain: chain)
311 let candidate = afterPrefix(other, chain: chain)
312 guard target.count >= length, target != candidate else { return nil }
313 let fixed = shown.count - target.count
314 let first = fixed + commonPrefix(target, candidate)
315 let start = min(first, shown.count - length)
316 return Segment(start: max(start, fixed), length: length)
317 }
318
319 /// O endereco sem o prefixo que a rede impoe (e, na NEAR, sem o registro do nome), em
320 /// minusculas.
321 static func body(_ address: String, chain: Chain) -> String {
322 let text = address.trimmingCharacters(in: .whitespacesAndNewlines)
323 return bodySegment(text, chain: chain).text(in: text).lowercased()
324 }
325
326 /// Tudo depois do prefixo que a rede impoe, em minusculas, com o registro do nome da
327 /// NEAR: e por ele que se sabe se dois enderecos sao o mesmo texto.
328 static func afterPrefix(_ address: String, chain: Chain) -> String {
329 let text = address.trimmingCharacters(in: .whitespacesAndNewlines)
330 return String(text.dropFirst(bodySegment(text, chain: chain).start)).lowercased()
331 }
332
333 /// Onde fica o corpo do endereco, no texto como ele e mostrado (sem espaco nas pontas):
334 /// tudo depois do prefixo que a rede impoe e, na NEAR, antes do registro do nome. A
335 /// tela abrevia por aqui (`Fmt.address`), para os 6 do comeco serem do corpo, e nao o
336 /// "addr1q" que todo endereco tem.
337 public static func bodySegment(_ address: String, chain: Chain) -> Segment {
338 let text = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
339 let count = text.count
340 let start: Int
341 switch chain.family {
342 case .evm, .sui, .aptos:
343 start = text.hasPrefix("0x") ? 2 : 0
344 case .utxo:
345 if let hrp = UTXOParams.for(chain).bech32HRP, text.hasPrefix(hrp + "1") {
346 start = min(hrp.count + 2, count) // hrp, separador e versao (q, p)
347 } else {
348 start = min(1, count) // caractere de versao do base58
349 }
350 case .tron, .xrpl, .stellar:
351 start = min(1, count)
352 case .ton:
353 start = text.hasPrefix("0:") || count > 2 ? 2 : 0
354 case .solana:
355 start = 0
356 case .cardano:
357 // "addr1" e o caractere do cabecalho (tipo de endereco), que o atacante copia
358 // de graca, como o hrp e a versao do bech32 no Bitcoin.
359 start = text.hasPrefix("addr1") && count > 6 ? 6 : 0
360 case .polkadot:
361 // O prefixo de rede 0 faz todo endereco da Polkadot comecar com 1.
362 start = min(1, count)
363 case .near:
364 // Conta implicita sem prefixo; nome inteiro, que o dono escolhe letra a letra,
365 // menos o registro do fim (".near", ".tg"): ele e igual por desenho, e contado
366 // como ponta fazia "alexis-exchange.near" parecido com "alexander-wallet.near"
367 // (B-9).
368 if let suffix = NEARAccountID.registrySuffixes.first(where: { text.hasSuffix($0) && count > $0.count }) {
369 return Segment(start: 0, length: count - suffix.count)
370 }
371 start = 0
372 }
373 return Segment(start: start, length: count - start)
374 }
375
376 static func commonPrefix(_ a: String, _ b: String) -> Int {
377 zip(a, b).prefix { $0 == $1 }.count
378 }
379}