Auditoria
O envelope
Kit/Sources/EscaliburCore/Envelope/Envelope.swift
A porta de entrada da carteira para o envelope Escalibur (`.esclbr`).
| 1 | import 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. |
| 13 | public 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 | } |