Pular para o conteúdo
Escalibur Wallet
Auditoria

O envelope

Kit/Sources/EscaliburCore/Envelope/Envelope.swift

A porta de entrada da carteira para o envelope Escalibur (`.esclbr`).

214 linhasSHA-256 176b8868f680c528…Abrir no GitHub, no commit 015f5f5
1import Foundation
2
3/// A porta de entrada da carteira para o envelope Escalibur (`.esclbr`).
4///
5/// O formato e o do app Escalibur, sem extensao nenhuma: um envelope lacrado aqui
6/// abre no cofre e no decifrador de referencia em Python, e um envelope do cofre
7/// abre aqui. Os testes de interoperabilidade conferem os dois sentidos.
8///
9/// Diferenca deliberada em relacao ao Escalibur: a abertura decodifica o bloco
10/// direto para `SecureBytes` (frase e 25a palavra), sem passar por `String`. No
11/// cofre a frase e exibida; na carteira ela e importada, e importar nao pode deixar
12/// a frase no heap pela vida inteira do processo.
13public enum Envelope {
14 public static let fileLength = VaultFormat.fileLength
15
16 public enum Failure: Error, Equatable, Sendable {
17 /// Tamanho, assinatura ou cabecalho fora do formato. Nada foi derivado.
18 case notAnEnvelope
19 /// O cabecalho pede mais memoria do que este aparelho tem agora.
20 case tooExpensiveForThisDevice(memoryMiB: Int)
21 /// Envelope vinculado a outro aparelho (formato previsto, nao implementado).
22 case boundToDevice
23 /// Senha errada ou arquivo adulterado. Uma mensagem so, de proposito.
24 case cannotOpen
25 /// Abriu, mas guarda uma parte SLIP-39, que sozinha nao e uma carteira.
26 case slip39Share
27 /// Abriu, mas o idioma gravado nao e nenhum dos dez conhecidos.
28 case unknownLanguage
29 /// Abriu, mas a frase dentro nao fecha o checksum BIP-39.
30 case invalidPhrase
31 /// Lacrar: o aparelho nao tem memoria livre agora para o piso de 256 MiB.
32 case notEnoughMemory
33 /// Lacrar: senha abaixo do piso de `PasswordStrength`.
34 case weakPassword
35 }
36
37 /// O que um envelope aberto entrega a carteira.
38 public struct Contents: Sendable {
39 /// A frase, na forma canonica, em buffer seguro.
40 public let phrase: SecureBytes
41 /// A 25a palavra, em buffer seguro. Vazia quase sempre, e **nunca ignorada**:
42 /// sem ela, a carteira importada seria outra, valida e vazia.
43 public let passphrase: SecureBytes
44 /// Nome e anotacoes sao texto nao confiavel, ja higienizados.
45 public let label: String
46 public let notes: String
47 public let language: BIP39Language
48
49 public func wipe() {
50 phrase.wipe()
51 passphrase.wipe()
52 }
53 }
54
55 /// Informacao publica do cabecalho, para a interface decidir antes de derivar.
56 public struct Inspection: Sendable {
57 public let kdf: KDFParameters
58 /// Segundos estimados para abrir, pela taxa medida neste aparelho.
59 public let estimatedSeconds: Double
60 }
61
62 // MARK: Lacrar
63
64 /// Lacra uma frase num envelope novo, com Argon2id calibrado para este aparelho e
65 /// nunca abaixo do piso de lacre. Reabre o resultado antes de devolver.
66 public static func seal(
67 phrase: SecureBytes,
68 passphrase: SecureBytes? = nil,
69 label: String,
70 notes: String = "",
71 password: SecureBytes,
72 parameters: KDFParameters? = nil,
73 generatedBits: Double? = nil,
74 acceptedRisk: Bool = false
75 ) throws -> Data {
76 // A senha e do dono, com no minimo 4 caracteres (pedido de 14/10/2026). Abaixo do
77 // piso de forca, so com o "sim" explicito que a tela de confirmacao entrega:
78 // nenhum caminho de codigo lacra senha fraca por acidente. A senha sorteada pelo
79 // app vale a entropia do sorteio (`generatedBits`), nao a estimativa.
80 guard PasswordStrength.scalarCount(password) >= PasswordStrength.minimumLength else { throw Failure.weakPassword }
81 let strength = generatedBits ?? PasswordStrength.bits(password)
82 guard strength >= PasswordStrength.minimumBits || acceptedRisk else { throw Failure.weakPassword }
83 guard KDFCalibration.canSeal() else { throw Failure.notEnoughMemory }
84 let chosen = parameters ?? KDFCalibration.calibrate()
85 let language: BIP39Language
86 if case .valid(let detected) = BIP39.validate(phrase) { language = detected } else { throw Failure.invalidPhrase }
87 let emptyPassphrase = SecureBytes(capacity: 1)
88 let sealing = SealingContents(
89 mnemonic: phrase, passphrase: passphrase ?? emptyPassphrase, label: label, notes: notes,
90 language: language, kind: .bip39
91 )
92 let data = try VaultFile.create(sealing: sealing, password: password, parameters: chosen)
93
94 // Condicao posterior: um envelope que nao abre com a senha que o dono acabou
95 // de digitar, ou que abre com outra frase, nao sai daqui.
96 let check = try open(data, password: password)
97 defer { check.wipe() }
98 let samePhrase = Hash.constantTimeEqual(phrase, check.phrase)
99 let samePassphrase = Hash.constantTimeEqual(passphrase ?? emptyPassphrase, check.passphrase)
100 guard samePhrase, samePassphrase else { throw CryptoError.malformedVault("o envelope gravado não confere com a frase") }
101 return data
102 }
103
104 // MARK: Abrir
105
106 /// Confere forma e custo antes de qualquer derivacao. O arquivo veio de fora
107 /// (AirDrop, Arquivos) e e tratado como hostil: um cabecalho com m = 1 GiB
108 /// derrubaria o app pelo jetsam no meio do Argon2.
109 public static func inspect(_ data: Data) throws -> Inspection {
110 guard looksLikeEnvelope(data) else { throw Failure.notAnEnvelope }
111 let header: VaultFormat.Header_
112 do { header = try VaultFormat.Header_.decode([UInt8](data)) } catch { throw Failure.notAnEnvelope }
113 guard header.binding == .passwordOnly else { throw Failure.boundToDevice }
114 let available = KDFCalibration.availableMemoryBytes()
115 let needed = UInt64(header.kdf.memoryKiB) * 1024
116 guard needed + 300 * 1024 * 1024 <= available else {
117 throw Failure.tooExpensiveForThisDevice(memoryMiB: Int(header.kdf.memoryKiB / 1024))
118 }
119 let seconds = KDFCalibration.estimatedSeconds(for: header.kdf)
120 return Inspection(kdf: header.kdf, estimatedSeconds: seconds)
121 }
122
123 /// Abre um envelope. Chamar fora do MainActor: o Argon2 leva segundos.
124 public static func open(_ data: Data, password: SecureBytes) throws -> Contents {
125 _ = try inspect(data)
126 let raw: (block: SecureBytes, slotIndex: Int, header: VaultFormat.Header_)
127 do {
128 raw = try VaultFile.openBlock(data, password: password)
129 } catch CryptoError.cannotOpen {
130 throw Failure.cannotOpen
131 }
132 defer { raw.block.wipe() }
133 return try decode(raw.block)
134 }
135
136 /// Um arquivo parece um envelope? Tamanho e assinatura, sem senha.
137 public static func looksLikeEnvelope(_ data: Data) -> Bool {
138 data.count == VaultFormat.fileLength && data.prefix(6) == Data(VaultFormat.magic)
139 }
140
141 // MARK: Conteudo
142
143 /// Decodifica o bloco de 3936 bytes direto para buffers seguros.
144 ///
145 /// Os erros daqui so existem depois que as duas tags AEAD fecharam, entao nao
146 /// servem de oraculo de senha.
147 static func decode(_ block: SecureBytes) throws -> Contents {
148 try block.withUnsafeBytes { raw -> Contents in
149 let bytes = raw.bindMemory(to: UInt8.self)
150 guard bytes.count == VaultFormat.plaintextLength, bytes[0] == 0x01 else { throw Failure.cannotOpen }
151 guard let kind = VaultContentKind(rawValue: bytes[1]) else { throw Failure.cannotOpen }
152 guard kind == .bip39 else { throw Failure.slip39Share }
153 guard let language = BIP39Language(storageCode: bytes[2]) else { throw Failure.unknownLanguage }
154
155 var lengths: [Int] = []
156 for field in 0..<4 {
157 let at = 4 + field * 2
158 lengths.append(Int(bytes[at]) << 8 | Int(bytes[at + 1]))
159 }
160 var cursor = 12
161 func take(_ length: Int) throws -> UnsafeBufferPointer<UInt8> {
162 guard cursor + length <= bytes.count else { throw Failure.cannotOpen }
163 defer { cursor += length }
164 return UnsafeBufferPointer(rebasing: bytes[cursor..<(cursor + length)])
165 }
166
167 let phraseSlice = try take(lengths[0])
168 let phrase = SecureBytes(capacity: max(phraseSlice.count, 1))
169 phrase.append(contentsOf: phraseSlice)
170
171 let passphraseSlice = try take(lengths[1])
172 let passphrase = SecureBytes(capacity: max(passphraseSlice.count, 1))
173 passphrase.append(contentsOf: passphraseSlice)
174
175 let label = sanitize(String(decoding: try take(lengths[2]), as: UTF8.self), limit: 64)
176 let notes = sanitize(String(decoding: try take(lengths[3]), as: UTF8.self), limit: 500)
177
178 guard case .valid = BIP39.validate(phrase) else {
179 phrase.wipe()
180 passphrase.wipe()
181 throw Failure.invalidPhrase
182 }
183 return Contents(phrase: phrase, passphrase: passphrase, label: label, notes: notes, language: language)
184 }
185 }
186
187 /// Texto vindo de arquivo alheio: sem controles bidirecionais (que reordenam o
188 /// que a tela mostra), sem largura zero, sem controle, com tamanho limitado.
189 public static func sanitize(_ text: String, limit: Int) -> String {
190 var scalars = String.UnicodeScalarView()
191 for scalar in text.unicodeScalars {
192 switch scalar.properties.generalCategory {
193 // Formato (bidirecional, largura zero, hifen suave, tags), controle,
194 // separador de linha e de paragrafo, uso privado e nao atribuido: nada
195 // disso tem lugar no nome de uma carteira, e tudo isso serve para um
196 // arquivo alheio mostrar uma coisa e ser outra.
197 case .format, .control, .lineSeparator, .paragraphSeparator, .privateUse, .surrogate, .unassigned:
198 continue
199 case .spaceSeparator:
200 // Qualquer espaco vira espaco comum, e dois seguidos viram um.
201 if scalars.last != " ", !scalars.isEmpty { scalars.append(" ") }
202 default:
203 // Marcas combinantes empilhadas (texto "zalgo") ficam limitadas a duas.
204 if scalar.properties.generalCategory == .nonspacingMark {
205 let trailing = scalars.reversed().prefix { $0.properties.generalCategory == .nonspacingMark }.count
206 if trailing >= 2 { continue }
207 }
208 scalars.append(scalar)
209 }
210 }
211 let clean = String(scalars).trimmingCharacters(in: .whitespaces)
212 return String(clean.prefix(limit))
213 }
214}