Auditoria
O formato .esclbr
Kit/Sources/EscaliburCore/Envelope/VaultFormat.swift
O formato do arquivo `.esclbr`, campo a campo.
| 1 | import CryptoKit |
| 2 | import Foundation |
| 3 | |
| 4 | /// O formato do arquivo `.esclbr`, campo a campo. |
| 5 | /// |
| 6 | /// Duas exigencias se cruzam aqui, e a segunda molda a primeira: |
| 7 | /// |
| 8 | /// - Do desenho criptografico: envelope explicito. A chave que cifra a frase (`DEK`) |
| 9 | /// nasce do gerador do sistema e nunca da senha; ela e guardada embrulhada por uma |
| 10 | /// chave derivada da senha (`KEK`). Trocar a senha re-embrulha 32 bytes em vez de |
| 11 | /// re-cifrar o cofre inteiro. |
| 12 | /// |
| 13 | /// - Do desenho de custodia: negacao plausivel de verdade. Um cofre isca guardado |
| 14 | /// como "mais um arquivo" nao nega nada, porque a existencia do arquivo ja e a |
| 15 | /// prova. Entao **todo arquivo tem sempre quatro compartimentos do mesmo tamanho**, |
| 16 | /// e os compartimentos sem uso ficam cheios de bytes aleatorios. Cifra autenticada |
| 17 | /// e ruido sao indistinguiveis, entao quem obriga o dono a abrir um compartimento |
| 18 | /// nao consegue provar que existe outro. |
| 19 | /// |
| 20 | /// Consequencias que valem por si: |
| 21 | /// |
| 22 | /// - **Todo cofre tem exatamente 16.504 bytes.** O tamanho do arquivo nao conta |
| 23 | /// quantas palavras a frase tem, se ha anotacao, nem quantos compartimentos estao |
| 24 | /// em uso. Metadado de tamanho e de graca para o atacante escolher qual arquivo |
| 25 | /// atacar primeiro, e aqui ele nao existe. |
| 26 | /// - **O cabecalho em claro nao tem nada com significado.** Sem nome, sem data, sem |
| 27 | /// dica de senha, sem contador. So o que a mecanica de abertura exige. O nome do |
| 28 | /// cofre mora dentro do compartimento cifrado, porque "Ledger principal" escrito em |
| 29 | /// claro ja diz a quem pega o arquivo que vale a pena insistir. |
| 30 | /// |
| 31 | /// O formato e publico de proposito. Um cofre que so este app abre e um cofre com |
| 32 | /// prazo de validade, e a especificacao em docs/formato.md mais o decifrador de |
| 33 | /// referencia existem para que o dono nunca dependa da loja de aplicativos. |
| 34 | public enum VaultFormat { |
| 35 | |
| 36 | // MARK: Constantes |
| 37 | |
| 38 | public static let magic: [UInt8] = Array("ESCLBR".utf8) |
| 39 | public static let formatVersion: UInt8 = 0x01 |
| 40 | |
| 41 | /// Argon2id v1.3 + HKDF-SHA256 + ChaCha20-Poly1305 com subchave por mensagem. |
| 42 | public static let suiteID: UInt8 = 0x01 |
| 43 | public static let kdfID: UInt8 = 0x01 |
| 44 | |
| 45 | public static let headerLength = 120 |
| 46 | public static let saltLength = 16 |
| 47 | public static let keyLength = 32 |
| 48 | public static let subkeySaltLength = 32 |
| 49 | public static let tagLength = 16 |
| 50 | |
| 51 | /// Quatro, sempre. Nao e um contador gravado no arquivo: e uma constante desta |
| 52 | /// versao do formato, entao ler o arquivo nao diz quantos estao em uso. |
| 53 | public static let slotCount = 4 |
| 54 | public static let slotLength = 4096 |
| 55 | |
| 56 | public static let fileLength = headerLength + slotCount * slotLength |
| 57 | |
| 58 | // MARK: Deslocamentos do cabecalho |
| 59 | |
| 60 | private enum Header { |
| 61 | static let magic = 0 // 6 |
| 62 | static let formatVersion = 6 // 1 |
| 63 | static let suiteID = 7 // 1 |
| 64 | static let vaultUUID = 8 // 16 |
| 65 | static let kdfID = 24 // 1 |
| 66 | static let kdfMemoryKiB = 25 // 4 |
| 67 | static let kdfPasses = 29 // 4 |
| 68 | static let kdfLanes = 33 // 1 |
| 69 | static let kdfSalt = 34 // 16 |
| 70 | static let bindingID = 50 // 1 |
| 71 | static let reserved = 51 // 1 |
| 72 | static let bindingPublicKey = 52 // 65 |
| 73 | static let cloudFlag = 117 // 1 |
| 74 | static let reserved2 = 118 // 2 |
| 75 | } |
| 76 | |
| 77 | /// Deslocamentos dentro de um compartimento. |
| 78 | public enum Slot { |
| 79 | static let salt = 0 // 32 |
| 80 | static let wrapSalt = 32 // 32 |
| 81 | static let wrappedDEK = 64 // 48 = 32 de cifra + 16 de tag |
| 82 | static let payloadSalt = 112 // 32 |
| 83 | static let payload = 144 // 3952 = 3936 de texto + 16 de tag |
| 84 | static let payloadLength = 3952 |
| 85 | static let plaintextLength = 3952 - VaultFormat.tagLength |
| 86 | } |
| 87 | |
| 88 | public static let plaintextLength = Slot.plaintextLength |
| 89 | |
| 90 | // MARK: Vinculo com o aparelho |
| 91 | |
| 92 | public enum Binding: UInt8, Sendable { |
| 93 | /// O arquivo abre com a senha, em qualquer aparelho. E o que permite backup. |
| 94 | case passwordOnly = 0x00 |
| 95 | |
| 96 | /// A chave tambem depende de um segredo que so este aparelho consegue |
| 97 | /// reproduzir, no Secure Enclave. Elimina o ataque offline por completo, e |
| 98 | /// cobra o preco correspondente: perdeu o aparelho, perdeu o cofre. |
| 99 | case secureEnclave = 0x01 |
| 100 | } |
| 101 | |
| 102 | // MARK: Cabecalho |
| 103 | |
| 104 | public struct Header_ : Equatable, Sendable { |
| 105 | var vaultID: UUID |
| 106 | var kdf: KDFParameters |
| 107 | var kdfSalt: [UInt8] |
| 108 | var binding: Binding |
| 109 | var bindingPublicKey: [UInt8] // 65 bytes, zeros quando nao ha vinculo |
| 110 | var cloudEnabled: Bool |
| 111 | |
| 112 | func encoded() -> [UInt8] { |
| 113 | var bytes = [UInt8](repeating: 0, count: VaultFormat.headerLength) |
| 114 | bytes.replaceSubrange(Header.magic..<(Header.magic + 6), with: VaultFormat.magic) |
| 115 | bytes[Header.formatVersion] = VaultFormat.formatVersion |
| 116 | bytes[Header.suiteID] = VaultFormat.suiteID |
| 117 | |
| 118 | let uuid = withUnsafeBytes(of: vaultID.uuid) { Array($0) } |
| 119 | bytes.replaceSubrange(Header.vaultUUID..<(Header.vaultUUID + 16), with: uuid) |
| 120 | |
| 121 | bytes[Header.kdfID] = VaultFormat.kdfID |
| 122 | bytes.replaceSubrange( |
| 123 | Header.kdfMemoryKiB..<(Header.kdfMemoryKiB + 4), |
| 124 | with: kdf.memoryKiB.bigEndianBytes |
| 125 | ) |
| 126 | bytes.replaceSubrange( |
| 127 | Header.kdfPasses..<(Header.kdfPasses + 4), |
| 128 | with: kdf.passes.bigEndianBytes |
| 129 | ) |
| 130 | bytes[Header.kdfLanes] = kdf.lanes |
| 131 | bytes.replaceSubrange( |
| 132 | Header.kdfSalt..<(Header.kdfSalt + VaultFormat.saltLength), |
| 133 | with: kdfSalt |
| 134 | ) |
| 135 | bytes[Header.bindingID] = binding.rawValue |
| 136 | bytes.replaceSubrange( |
| 137 | Header.bindingPublicKey..<(Header.bindingPublicKey + 65), |
| 138 | with: bindingPublicKey |
| 139 | ) |
| 140 | bytes[Header.cloudFlag] = cloudEnabled ? 0x01 : 0x00 |
| 141 | return bytes |
| 142 | } |
| 143 | |
| 144 | static func decode(_ bytes: [UInt8]) throws -> Header_ { |
| 145 | guard bytes.count >= VaultFormat.headerLength else { |
| 146 | throw CryptoError.malformedVault("arquivo curto demais para ser um cofre") |
| 147 | } |
| 148 | guard Array(bytes[Header.magic..<(Header.magic + 6)]) == VaultFormat.magic else { |
| 149 | throw CryptoError.malformedVault("este arquivo não é um cofre do Escalibur") |
| 150 | } |
| 151 | // Versao exata, e nao "ate a atual": aceitar 0x00 e trata-lo como v1 |
| 152 | // daria significado retroativo a um valor que a especificacao reservou |
| 153 | // como invalido. Nao e vetor de downgrade, porque o cabecalho inteiro |
| 154 | // entra no AAD, mas um arquivo escrito a mao com 0x00 nao pode passar. |
| 155 | guard bytes[Header.formatVersion] == VaultFormat.formatVersion else { |
| 156 | throw CryptoError.malformedVault( |
| 157 | bytes[Header.formatVersion] > VaultFormat.formatVersion |
| 158 | ? "cofre de uma versão mais nova do formato; atualize o app" |
| 159 | : "versão de formato inválida" |
| 160 | ) |
| 161 | } |
| 162 | guard bytes[Header.suiteID] == VaultFormat.suiteID, |
| 163 | bytes[Header.kdfID] == VaultFormat.kdfID |
| 164 | else { |
| 165 | throw CryptoError.malformedVault("conjunto de algoritmos desconhecido") |
| 166 | } |
| 167 | |
| 168 | let uuidBytes = Array(bytes[Header.vaultUUID..<(Header.vaultUUID + 16)]) |
| 169 | let vaultID = uuidBytes.withUnsafeBytes { raw in |
| 170 | UUID(uuid: raw.load(as: uuid_t.self)) |
| 171 | } |
| 172 | |
| 173 | let kdf = KDFParameters( |
| 174 | memoryKiB: UInt32( |
| 175 | bigEndian: Array(bytes[Header.kdfMemoryKiB..<(Header.kdfMemoryKiB + 4)]) |
| 176 | ), |
| 177 | passes: UInt32( |
| 178 | bigEndian: Array(bytes[Header.kdfPasses..<(Header.kdfPasses + 4)]) |
| 179 | ), |
| 180 | lanes: bytes[Header.kdfLanes] |
| 181 | ) |
| 182 | // A faixa e conferida aqui, antes de qualquer alocacao. Um arquivo com |
| 183 | // `m = 4 GiB` escrito a mao mataria o app por falta de memoria, e isso |
| 184 | // seria negacao de servico de graca. |
| 185 | guard kdf.isWithinAcceptedRange else { |
| 186 | throw CryptoError.malformedVault("parâmetros de derivação fora da faixa aceita") |
| 187 | } |
| 188 | |
| 189 | guard let binding = Binding(rawValue: bytes[Header.bindingID]) else { |
| 190 | throw CryptoError.malformedVault("tipo de vínculo desconhecido") |
| 191 | } |
| 192 | |
| 193 | let cloudEnabled = bytes[Header.cloudFlag] == 0x01 |
| 194 | // Um cofre preso a este aparelho e ao mesmo tempo marcado para a nuvem |
| 195 | // seria lixo irrecuperavel do outro lado. So um adversario produz essa |
| 196 | // combinacao, entao ela e recusada. |
| 197 | guard !(binding == .secureEnclave && cloudEnabled) else { |
| 198 | throw CryptoError.malformedVault("cofre com vínculo de aparelho não vai para a nuvem") |
| 199 | } |
| 200 | |
| 201 | return Header_( |
| 202 | vaultID: vaultID, |
| 203 | kdf: kdf, |
| 204 | kdfSalt: Array(bytes[Header.kdfSalt..<(Header.kdfSalt + VaultFormat.saltLength)]), |
| 205 | binding: binding, |
| 206 | bindingPublicKey: Array( |
| 207 | bytes[Header.bindingPublicKey..<(Header.bindingPublicKey + 65)] |
| 208 | ), |
| 209 | cloudEnabled: cloudEnabled |
| 210 | ) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | // MARK: Material de chave |
| 215 | |
| 216 | /// `KEK` do compartimento `index`. |
| 217 | /// |
| 218 | /// O salt do Argon2 e do arquivo, nao do compartimento, e isso e deliberado: com |
| 219 | /// um salt por compartimento, abrir um cofre exigiria rodar o Argon2 quatro |
| 220 | /// vezes, o que a 1,5 s cada tornaria a abertura insuportavel. Com um salt so, a |
| 221 | /// derivacao cara acontece uma vez e os quatro compartimentos sao testados com |
| 222 | /// HKDF, que e barato. |
| 223 | public static func encryptionKey( |
| 224 | masterKey: SecureBytes, |
| 225 | bindingSecret: SecureBytes?, |
| 226 | slotSalt: [UInt8], |
| 227 | slotIndex: Int, |
| 228 | vaultID: UUID |
| 229 | ) -> SymmetricKey { |
| 230 | var ikm = [UInt8]() |
| 231 | defer { ikm.resetBytes() } |
| 232 | |
| 233 | masterKey.withUnsafeBytes { ikm.append(contentsOf: $0) } |
| 234 | // O segredo do aparelho entra na **mesma** chave, e nao num segundo |
| 235 | // compartimento. Num segundo compartimento, o compartimento da senha |
| 236 | // continuaria atacavel offline e o vinculo nao valeria nada. |
| 237 | bindingSecret?.withUnsafeBytes { ikm.append(contentsOf: $0) } |
| 238 | |
| 239 | var info = Array("escalibur/v1/kek".utf8) |
| 240 | info.append(UInt8(slotIndex)) |
| 241 | info.append(contentsOf: withUnsafeBytes(of: vaultID.uuid) { Array($0) }) |
| 242 | |
| 243 | return HKDF<SHA256>.deriveKey( |
| 244 | inputKeyMaterial: SymmetricKey(data: ikm), |
| 245 | salt: slotSalt, |
| 246 | info: info, |
| 247 | outputByteCount: keyLength |
| 248 | ) |
| 249 | } |
| 250 | |
| 251 | /// Subchave de uma operacao de cifra. |
| 252 | /// |
| 253 | /// O nonce e sempre zero, e isso e seguro porque a **chave** nunca se repete: ela |
| 254 | /// nasce de um salt de 32 bytes aleatorios gerado a cada gravacao. E o padrao do |
| 255 | /// `secretstream` do libsodium, e resolve de forma definitiva o problema que |
| 256 | /// derrubaria um contador de nonce: restaurar um backup antigo do iPhone faz o |
| 257 | /// contador voltar no tempo e reemitir um nonce ja usado, o que em cifra |
| 258 | /// autenticada nao vaza so a mensagem, vaza a chave de autenticacao. |
| 259 | public static func messageKey(from key: SymmetricKey, salt: [UInt8], purpose: String) -> SymmetricKey { |
| 260 | HKDF<SHA256>.deriveKey( |
| 261 | inputKeyMaterial: key, |
| 262 | salt: salt, |
| 263 | info: Array(purpose.utf8), |
| 264 | outputByteCount: keyLength |
| 265 | ) |
| 266 | } |
| 267 | |
| 268 | /// Doze zeros, sempre. E seguro porque a **chave** nunca se repete: cada |
| 269 | /// operacao deriva a sua a partir de um salt sorteado na hora. |
| 270 | public static let zeroNonce = try! ChaChaPoly.Nonce( |
| 271 | data: Data(repeating: 0, count: 12) |
| 272 | ) |
| 273 | } |
| 274 | |
| 275 | // MARK: - Conversao de inteiros |
| 276 | |
| 277 | extension UInt32 { |
| 278 | public var bigEndianBytes: [UInt8] { |
| 279 | [UInt8(truncatingIfNeeded: self >> 24), |
| 280 | UInt8(truncatingIfNeeded: self >> 16), |
| 281 | UInt8(truncatingIfNeeded: self >> 8), |
| 282 | UInt8(truncatingIfNeeded: self)] |
| 283 | } |
| 284 | |
| 285 | public init(bigEndian bytes: [UInt8]) { |
| 286 | precondition(bytes.count == 4) |
| 287 | self = (UInt32(bytes[0]) << 24) | (UInt32(bytes[1]) << 16) |
| 288 | | (UInt32(bytes[2]) << 8) | UInt32(bytes[3]) |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | extension UInt16 { |
| 293 | public var bigEndianBytes: [UInt8] { |
| 294 | [UInt8(truncatingIfNeeded: self >> 8), UInt8(truncatingIfNeeded: self)] |
| 295 | } |
| 296 | |
| 297 | public init(bigEndian bytes: ArraySlice<UInt8>) { |
| 298 | precondition(bytes.count == 2) |
| 299 | let start = bytes.startIndex |
| 300 | self = (UInt16(bytes[start]) << 8) | UInt16(bytes[start + 1]) |
| 301 | } |
| 302 | } |