Pular para o conteúdo
Escalibur Wallet
Auditoria

O formato .esclbr

Kit/Sources/EscaliburCore/Envelope/VaultFormat.swift

O formato do arquivo `.esclbr`, campo a campo.

302 linhasSHA-256 acd0ba57145b0784…Abrir no GitHub, no commit 015f5f5
1import CryptoKit
2import 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.
34public 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
277extension 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
292extension 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}