Auditoria
A chave raiz, o PIN e o Face ID
Kit/Sources/EscaliburKeys/RootKeyVault.swift
A chave raiz (RK) e os dois caminhos que a destrancam: PIN e biometria.
| 1 | import CryptoKit |
| 2 | import EscaliburCore |
| 3 | import Foundation |
| 4 | import LocalAuthentication |
| 5 | |
| 6 | /// A chave raiz (RK) e os dois caminhos que a destrancam: PIN e biometria. |
| 7 | /// |
| 8 | /// ``` |
| 9 | /// PIN ─Argon2id─► AP ─┬─(senha de aplicativo do item)─► item rk.pin |
| 10 | /// ├─HKDF─► senha da chave do PIN no SE |
| 11 | /// └─HKDF─► K_pin |
| 12 | /// item rk.pin = embrulho(chave do PIN no SE, ChaChaPoly(K_pin, RK)) ─SE+senha─► ─K_pin─► RK |
| 13 | /// Face ID (decisao no SEP) ─► item rk.bio = embrulho(K_bio, RK) ─SE─► RK |
| 14 | /// ``` |
| 15 | /// |
| 16 | /// Formato 2 do slot (auditoria 3, A1): a chave do SE que embrulha o slot tem senha de |
| 17 | /// aplicativo derivada do PIN, entao o SE so desembrulha com o PIN certo. Quem tirar uma |
| 18 | /// copia do slot nao consegue mais testar PINs fora do aparelho depois de uma unica |
| 19 | /// chamada ao SE: cada chute passa pelo SE deste iPhone, mais um Argon2id. O formato 1 |
| 20 | /// (chave do aparelho sem senha) migra no primeiro PIN certo. |
| 21 | /// |
| 22 | /// **Nao existe verificador de PIN gravado em lugar nenhum.** O PIN errado e recusado |
| 23 | /// pelo chaveiro, que nao devolve o item sem a senha de aplicativo certa, e de novo |
| 24 | /// pela camada interna, que so abre com a chave derivada do PIN. A camada interna e o |
| 25 | /// que garante o desenho mesmo se a senha de aplicativo for so uma regra de acesso e |
| 26 | /// nao entrar na chave do item: quem copiar o item e conseguir usar o SE deste aparelho |
| 27 | /// ainda precisa do PIN, e cada chute custa um Argon2id de 256 MiB |
| 28 | /// (docs/seguranca.md §2). |
| 29 | public final class RootKeyVault: @unchecked Sendable { |
| 30 | public enum Failure: Error, Equatable, Sendable { |
| 31 | case notSetUp |
| 32 | case alreadySetUp |
| 33 | case wrongPIN(remainingBeforeWipe: UInt32?) |
| 34 | case throttled(seconds: TimeInterval) |
| 35 | /// Nao sao 4 nem 6 digitos de 0 a 9. |
| 36 | case malformedPIN |
| 37 | case biometryNotEnabled |
| 38 | /// O cadastro de rostos mudou desde que o Face ID foi ligado: o slot foi |
| 39 | /// apagado e religar exige o PIN. |
| 40 | case biometryChanged |
| 41 | /// O Face ID nao abriu desta vez (cancelou, nao reconheceu, bloqueio do |
| 42 | /// sistema). O slot continua; o caminho agora e o PIN. |
| 43 | case cancelled |
| 44 | /// Apagar apos erros disparou: tudo foi destruido. |
| 45 | case wiped |
| 46 | /// O iPhone reiniciou (ou a bateria acabou) desde o ultimo PIN: o Face ID so volta |
| 47 | /// a valer depois do PIN digitado, como no proprio iPhone. |
| 48 | case pinRequiredAfterRestart |
| 49 | /// O Face ID caiu ou foi religado ha pouco: ver as palavras, lacrar o envelope e |
| 50 | /// remover carteira esperam `seconds` (`PINPolicy.biometryLossHold`). No modo |
| 51 | /// reforcado com "Esperar 24 horas", o cadastro de rostos mudou: tudo menos abrir o |
| 52 | /// app espera. |
| 53 | case biometryHold(seconds: TimeInterval) |
| 54 | /// O Face ID caiu sem o dono pedir e ainda nao foi religado: o que pede PIN e rosto |
| 55 | /// fica recusado ate religar (e a espera conta a partir dai). Nunca volta sozinho a |
| 56 | /// pedir so o PIN. |
| 57 | case biometryRequired |
| 58 | /// O Face ID nao esta disponivel agora (tirado do app, Espelhamento do iPhone, falha |
| 59 | /// passageira do sistema). Nada e apagado; o que pede PIN e rosto recusa. |
| 60 | case biometryUnavailable |
| 61 | case storage |
| 62 | /// Modo reforcado ligado: o caminho e `unlockHardened`, nunca o PIN so nem o rosto so. |
| 63 | case hardenedModeActive |
| 64 | case notHardened |
| 65 | /// Modo reforcado: o rosto nao confirmou. Contou como tentativa. |
| 66 | case biometryNotConfirmed(remainingBeforeWipe: UInt32?) |
| 67 | /// O Face ID esta bloqueado pelo sistema (5 erros): nada foi tentado nem contado. |
| 68 | case biometryLockedOut |
| 69 | /// Modo reforcado sem a trava: nenhum rosto cadastrado no Face ID agora. Nada foi |
| 70 | /// tentado, contado nem apagado; volta a abrir com um rosto cadastrado. |
| 71 | case biometryNotEnrolled |
| 72 | /// Modo reforcado: a chave do rosto que a politica usa sumiu (com a trava, o cadastro |
| 73 | /// de rostos mudou) e o rk.and nao abre mais. Nada foi apagado; o caminho e restaurar |
| 74 | /// pelas palavras. |
| 75 | case hardenedSlotLost |
| 76 | /// Modo reforcado: a chave do rosto existe e o SE recusou abrir a camada de fora por um |
| 77 | /// erro que nao vem do Face ID (uma redefinicao do Face ID interrompida, por exemplo). |
| 78 | /// Nao conta tentativa e nao apaga nada. |
| 79 | case hardenedFaceKeyFailed |
| 80 | case hardenedBlocked(HardenedBlocker) |
| 81 | } |
| 82 | |
| 83 | /// O que impede ligar o modo reforcado agora. |
| 84 | public enum HardenedBlocker: Sendable, Equatable { case biometryOff, changePending, legacySlot, biometryUnavailable } |
| 85 | |
| 86 | enum Account { |
| 87 | static let pinKDF = "pin.kdf" |
| 88 | /// Quantos digitos tem o PIN do slot (1 byte), para a tela saber quando o PIN |
| 89 | /// acabou sem gastar tentativa. Sem o item, 6: o tamanho de antes da escolha. |
| 90 | static let pinLength = "pin.digitos" |
| 91 | static let rootByPIN = "rk.pin" |
| 92 | static let rootByBiometry = "rk.bio" |
| 93 | static let biometryState = "bio.estado" |
| 94 | /// O prazo da espera depois que o Face ID caiu ou foi religado (`HoldRecord`). |
| 95 | static let biometryHold = "bio.espera" |
| 96 | /// O Face ID caiu sem o dono pedir e ainda nao foi religado (1 byte). Sem prazo: so |
| 97 | /// um `enableBiometry` que confere o rosto tira a marca. |
| 98 | static let biometryLost = "bio.perdido" |
| 99 | static let attempts = "pin.tentativas" |
| 100 | static let wipeAfterErrors = "pin.apagar" |
| 101 | static let pinBoot = "pin.boot" |
| 102 | static let pendingSuffix = ".novo" |
| 103 | /// Modo reforcado: o slot unico, PIN e rosto em camadas (docs/seguranca.md §2.6). |
| 104 | static let rootHardened = "rk.and" |
| 105 | /// O hash do cadastro de rostos que o modo reforcado conhece: o de quando a `kand.bio` |
| 106 | /// nasceu (ou o de quando a politica passou a valer, na `.continue`). Na `.wait`, |
| 107 | /// decide quando a vigia nao responde; nas outras, so classifica o erro. |
| 108 | static let hardenedState = "and.estado" |
| 109 | /// A espera do `.wait` depois que a vigia viu outro cadastro (`HardenedHold`). |
| 110 | static let hardenedHold = "and.espera" |
| 111 | } |
| 112 | |
| 113 | /// O registro de custo do slot: sal (16), memoria (4), passadas (4), faixas (1) e, no |
| 114 | /// formato 2, a versao (1). |
| 115 | enum SlotFormat { |
| 116 | static let legacyLength = 25 |
| 117 | static let version: UInt8 = 2 |
| 118 | static let length = 26 |
| 119 | static let passwordInfo = Data("escalibur-wallet/v2/kpin.senha".utf8) |
| 120 | } |
| 121 | |
| 122 | /// A chave do SE de cada slot do PIN no formato 2. |
| 123 | static func wrapSlot(_ suffix: String) -> WrapSlot { suffix.isEmpty ? .pin : .pinPending } |
| 124 | |
| 125 | /// A senha de aplicativo da chave do PIN no SE: derivada do AP, separada da chave da |
| 126 | /// camada interna pelo `info`. |
| 127 | /// |
| 128 | /// Sai em buffer seguro, que quem chama zera; o `Data` que o SE pede so existe dentro |
| 129 | /// de `withUnsafeData`, zerado na volta. Antes era um `Data` que ninguem zerava, e a |
| 130 | /// senha do PIN no SE ficava no heap depois do uso (auditoria 4, C7-21). |
| 131 | static func devicePassword(ap: SecureBytes, salt: [UInt8]) -> SecureBytes { |
| 132 | let material = ap.withUnsafeBytes { SymmetricKey(data: $0) } |
| 133 | return secure(HKDF<SHA256>.deriveKey(inputKeyMaterial: material, salt: salt, info: SlotFormat.passwordInfo, outputByteCount: 32)) |
| 134 | } |
| 135 | |
| 136 | /// Os bytes de uma chave do CryptoKit num buffer seguro. |
| 137 | static func secure(_ key: SymmetricKey) -> SecureBytes { |
| 138 | let out = SecureBytes(capacity: key.bitCount / 8) |
| 139 | key.withUnsafeBytes { out.append(contentsOf: $0.bindMemory(to: UInt8.self)) } |
| 140 | return out |
| 141 | } |
| 142 | |
| 143 | /// Camada interna do slot do PIN: versao, nonce (12), RK cifrada (32), tag (16). |
| 144 | enum Inner { |
| 145 | static let version: UInt8 = 1 |
| 146 | static let length = 1 + 12 + 32 + 16 |
| 147 | static let info = Data("escalibur-wallet/v1/rk.pin".utf8) |
| 148 | } |
| 149 | |
| 150 | let store: any SecretStore |
| 151 | let wrapper: any KeyWrapper |
| 152 | /// O Face ID deste iPhone agora. Trocavel so nos testes, que rodam num Mac sem ele. |
| 153 | var biometryAvailability: @Sendable () -> BiometryAvailability = { RootKeyVault.systemBiometryAvailability() } |
| 154 | /// O Face ID para o modo reforcado e o hash do cadastro. Trocaveis so nos testes. |
| 155 | var hardenedBiometryStatus: @Sendable () -> BiometryStatus = { RootKeyVault.systemBiometryStatus() } |
| 156 | var hardenedDomainState: @Sendable () -> Data? = { RootKeyVault.biometryDomainState() } |
| 157 | /// Custo fixo do PIN, so para os testes nao pagarem 256 MiB por chamada. |
| 158 | private let fixedPINParameters: KDFParameters? |
| 159 | private let lock = NSLock() |
| 160 | |
| 161 | public convenience init(store: any SecretStore, wrapper: any KeyWrapper) { |
| 162 | self.init(store: store, wrapper: wrapper, pinParameters: nil) |
| 163 | } |
| 164 | |
| 165 | init(store: any SecretStore, wrapper: any KeyWrapper, pinParameters: KDFParameters?) { |
| 166 | self.store = store |
| 167 | self.wrapper = wrapper |
| 168 | self.fixedPINParameters = pinParameters |
| 169 | } |
| 170 | |
| 171 | private var pinParameters: KDFParameters { fixedPINParameters ?? Self.calibratedPINParameters() } |
| 172 | |
| 173 | /// Existe algum slot do PIN? Quando o chaveiro nao consegue responder, a resposta |
| 174 | /// e "sim": mandar o dono para o cadastro por causa de um erro de leitura e o |
| 175 | /// caminho que um dia apagaria a chave do aparelho. O cadastro, por sua vez, so |
| 176 | /// prossegue com um "nao existe" definitivo. |
| 177 | public var isSetUp: Bool { |
| 178 | let slots = [Account.rootByPIN, Account.rootByPIN + Account.pendingSuffix, Account.rootHardened].map(store.probe) |
| 179 | return slots.contains(.present) || slots.contains(.unknown) |
| 180 | } |
| 181 | |
| 182 | // MARK: Cadastro |
| 183 | |
| 184 | /// Primeiro PIN: cria K_dev, sorteia a RK, grava o slot do PIN e confere lendo de |
| 185 | /// volta. Devolve a RK aberta, para o chamador derivar a chave de indice e criar a |
| 186 | /// primeira carteira sem pedir o PIN de novo; quem recebe zera. |
| 187 | public func setUp(pin: SecureBytes) throws -> SecureBytes { |
| 188 | lock.lock(); defer { lock.unlock() } |
| 189 | let slots = [Account.rootByPIN, Account.rootByPIN + Account.pendingSuffix, Account.rootByBiometry, Account.rootHardened].map(store.probe) |
| 190 | guard !slots.contains(.unknown) else { throw Failure.storage } |
| 191 | guard !slots.contains(.present) else { throw Failure.alreadySetUp } |
| 192 | guard Self.isWellFormed(pin) else { throw Failure.malformedPIN } |
| 193 | |
| 194 | // Sem nenhum slot da RK, o que sobrou de um cadastro interrompido nao abre |
| 195 | // nada: a K_dev orfa, o contador e as carteiras cifradas sob uma RK perdida. |
| 196 | // Apagar aqui e explicito; `createKey` nunca substitui a K_dev sozinho. |
| 197 | try discardOrphans() |
| 198 | |
| 199 | let rk = try SecureBytes.random(count: 32) |
| 200 | do { |
| 201 | try store.add(AttemptRecord.zero.encoded, account: Account.attempts, protection: .standard, context: nil) |
| 202 | try writePINSlot(rk: rk, pin: pin, kdf: pinParameters, suffix: "") |
| 203 | try confirm(pin: pin, suffix: "", matches: rk) |
| 204 | recordPINThisBoot() |
| 205 | } catch { |
| 206 | rk.wipe() |
| 207 | try? discardOrphans() |
| 208 | throw error |
| 209 | } |
| 210 | return rk |
| 211 | } |
| 212 | |
| 213 | private func discardOrphans() throws { |
| 214 | WrapSlot.allCases.forEach(wrapper.deleteKey) |
| 215 | do { try store.deleteAll() } catch { throw Failure.storage } |
| 216 | } |
| 217 | |
| 218 | // MARK: Destrancar |
| 219 | |
| 220 | /// Destranca pelo PIN. O incremento de tentativa e gravado **antes** da avaliacao; |
| 221 | /// se a gravacao falhar, a tentativa nao e avaliada (falha fechada). |
| 222 | /// |
| 223 | /// Com uma troca de PIN interrompida existem dois slots, e os dois sao tentados na |
| 224 | /// mesma tentativa. O que abrir decide o desfecho: o antigo desfaz a troca, o novo |
| 225 | /// a conclui. |
| 226 | public func unlock(pin: SecureBytes) throws -> SecureBytes { |
| 227 | lock.lock(); defer { lock.unlock() } |
| 228 | return try unlockLocked(pin: pin) |
| 229 | } |
| 230 | |
| 231 | private func unlockLocked(pin: SecureBytes) throws -> SecureBytes { |
| 232 | switch securityMode { |
| 233 | case .hardened: throw Failure.hardenedModeActive |
| 234 | case .unknown: throw Failure.storage |
| 235 | case .normal: break |
| 236 | } |
| 237 | let pendingAccount = Account.rootByPIN + Account.pendingSuffix |
| 238 | let main = store.probe(Account.rootByPIN) |
| 239 | let pending = store.probe(pendingAccount) |
| 240 | guard main != .unknown, pending != .unknown else { throw Failure.storage } |
| 241 | guard main == .present || pending == .present else { throw Failure.notSetUp } |
| 242 | |
| 243 | // PIN sem forma (envio duplo com o buffer vazio) nunca gasta tentativa. |
| 244 | guard Self.isWellFormed(pin) else { throw Failure.malformedPIN } |
| 245 | let record = try rebasedAttemptsLocked() |
| 246 | let waiting = record.remaining() |
| 247 | guard waiting <= 0 else { throw Failure.throttled(seconds: waiting) } |
| 248 | |
| 249 | let next = AttemptRecord.after(failures: record.failures + 1) |
| 250 | do { try store.update(next.encoded, account: Account.attempts) } catch { throw Failure.storage } |
| 251 | |
| 252 | let candidates = [main == .present ? "" : nil, pending == .present ? Account.pendingSuffix : nil].compactMap { $0 } |
| 253 | var opened: (rk: SecureBytes, suffix: String)? |
| 254 | for suffix in candidates where opened == nil { |
| 255 | do { |
| 256 | opened = (try readPINSlot(pin: pin, suffix: suffix), suffix) |
| 257 | } catch Failure.wrongPIN { |
| 258 | continue |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | guard let opened else { |
| 263 | if let threshold = wipeThreshold, next.failures >= threshold { |
| 264 | wipeAllLocked() |
| 265 | throw Failure.wiped |
| 266 | } |
| 267 | let left = wipeThreshold.map { $0 - next.failures } |
| 268 | throw Failure.wrongPIN(remainingBeforeWipe: left) |
| 269 | } |
| 270 | |
| 271 | // Contador de volta a zero. Se falhar, o dono so espera mais na proxima vez. |
| 272 | try? store.update(AttemptRecord.zero.encoded, account: Account.attempts) |
| 273 | recordPINThisBoot() |
| 274 | // Um rk.and ao lado do rk.pin e uma transicao que nao chegou ao fim (ligar |
| 275 | // interrompido antes do commit, ou desligar interrompido depois do rk.pin gravado). |
| 276 | // O PIN acabou de provar o slot do modo normal: o rk.and sai. |
| 277 | if store.probe(Account.rootHardened) == .present { discardHardenedLocked() } |
| 278 | if pending == .present { |
| 279 | if opened.suffix.isEmpty { |
| 280 | deleteSlot(suffix: Account.pendingSuffix) |
| 281 | } else { |
| 282 | try? promotePendingLocked(rk: opened.rk, pin: pin) |
| 283 | } |
| 284 | } |
| 285 | // Slot no formato 1: passa para o 2 agora que o PIN certo esta na mao. Falha aqui |
| 286 | // nao tranca ninguem: antes do ponto sem volta o slot antigo continua o unico. |
| 287 | if slotVersion(suffix: "") == 1 { |
| 288 | try? migrateLocked(rk: opened.rk, pin: pin) |
| 289 | } else { |
| 290 | dropLegacyKeyIfDone() |
| 291 | } |
| 292 | return opened.rk |
| 293 | } |
| 294 | |
| 295 | /// Com o principal no formato 2 e nenhuma troca pendente, a chave do aparelho antiga |
| 296 | /// nao abre mais nada: sai. |
| 297 | private func dropLegacyKeyIfDone() { |
| 298 | guard slotVersion(suffix: "") == 2, store.probe(Account.rootByPIN + Account.pendingSuffix) == .absent, |
| 299 | wrapper.hasKey(.device) else { return } |
| 300 | wrapper.deleteKey(.device) |
| 301 | } |
| 302 | |
| 303 | /// O formato do slot principal do PIN (1 ou 2), para a tela e os testes. So le. |
| 304 | public var pinSlotFormat: Int? { slotVersion(suffix: "") } |
| 305 | |
| 306 | /// Quantos digitos pode ter o PIN que abre: o tamanho de cada slot que existe. Quase |
| 307 | /// sempre um so; dois quando uma troca entre tamanhos diferentes foi interrompida antes |
| 308 | /// do ponto sem volta (o PIN antigo desfaz, o novo conclui, e a tela aceita os dois). |
| 309 | /// Sem o registro, 6 (slot de antes da escolha). Chaveiro mudo ou registro estranho: |
| 310 | /// os dois tamanhos, nunca um chute, para a tela nao gastar tentativa fechando no |
| 311 | /// tamanho errado. Nao e segredo (o iPhone tambem mostra quantos pontos o codigo tem). |
| 312 | /// So le. |
| 313 | public var pinLengths: Set<Int> { |
| 314 | switch securityMode { |
| 315 | case .unknown: return Set(PINPolicy.lengths) |
| 316 | case .hardened: |
| 317 | guard let record = try? readHardened() else { return Set(PINPolicy.lengths) } |
| 318 | return [Int(record.header.pinLength)] |
| 319 | case .normal: break |
| 320 | } |
| 321 | var lengths = Set<Int>() |
| 322 | for suffix in ["", Account.pendingSuffix] { |
| 323 | switch store.probe(Account.rootByPIN + suffix) { |
| 324 | case .absent: continue |
| 325 | case .unknown: return Set(PINPolicy.lengths) |
| 326 | case .present: |
| 327 | guard let length = slotLength(suffix: suffix) else { return Set(PINPolicy.lengths) } |
| 328 | lengths.insert(length) |
| 329 | } |
| 330 | } |
| 331 | return lengths.isEmpty ? [PINPolicy.digits] : lengths |
| 332 | } |
| 333 | |
| 334 | private func slotLength(suffix: String) -> Int? { |
| 335 | switch store.probe(Account.pinLength + suffix) { |
| 336 | case .absent: return PINPolicy.digits |
| 337 | case .unknown: return nil |
| 338 | case .present: |
| 339 | guard let data = try? store.read(Account.pinLength + suffix, context: nil), data.count == 1, |
| 340 | PINPolicy.lengths.contains(Int(data[0])) else { return nil } |
| 341 | return Int(data[0]) |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | /// O formato do slot: 1 (chave do aparelho sem senha), 2 (chave do PIN com senha) ou |
| 346 | /// nil (sem slot, registro ilegivel). |
| 347 | func slotVersion(suffix: String) -> Int? { |
| 348 | guard let record = try? store.read(Account.pinKDF + suffix, context: nil) else { return nil } |
| 349 | if record.count == SlotFormat.legacyLength { return 1 } |
| 350 | if record.count == SlotFormat.length, record.last == SlotFormat.version { return 2 } |
| 351 | return nil |
| 352 | } |
| 353 | |
| 354 | /// Formato 1 para 2 com o mesmo PIN, pelo mesmo caminho atomico da troca de PIN: o |
| 355 | /// slot novo e gravado e conferido como pendente, o antigo so sai depois, e a chave |
| 356 | /// do aparelho antiga so e apagada com o principal novo conferido. |
| 357 | private func migrateLocked(rk: SecureBytes, pin: SecureBytes) throws { |
| 358 | try replacePINLocked(rk: rk, newPIN: pin) |
| 359 | dropLegacyKeyIfDone() |
| 360 | } |
| 361 | |
| 362 | /// Destranca pelo Face ID. Bloqueia a thread enquanto o sistema pergunta: chamar |
| 363 | /// fora do MainActor. O que autoriza e o SEP liberar a chave, nunca um booleano. |
| 364 | public func unlockWithBiometry(reason: String) throws -> SecureBytes { |
| 365 | lock.lock(); defer { lock.unlock() } |
| 366 | return try unlockWithBiometryLocked(reason: reason) |
| 367 | } |
| 368 | |
| 369 | /// Sob a trava: uma varredura do modo reforcado rodando ao lado de uma troca de PIN |
| 370 | /// apagaria a geracao nova da chave do PIN (revisao do modo reforcado, M2). No |
| 371 | /// reforcado so recusa; quem varre e `rebaseAttempts` e `unlockHardened`, sob a trava. |
| 372 | private func unlockWithBiometryLocked(reason: String) throws -> SecureBytes { |
| 373 | switch securityMode { |
| 374 | case .hardened: throw Failure.hardenedModeActive |
| 375 | case .unknown: throw Failure.storage |
| 376 | case .normal: break |
| 377 | } |
| 378 | guard pinEnteredThisBoot else { throw Failure.pinRequiredAfterRestart } |
| 379 | let blob: Data? |
| 380 | do { blob = try store.read(Account.rootByBiometry, context: nil) } catch { throw Failure.storage } |
| 381 | guard let blob else { throw Failure.biometryNotEnabled } |
| 382 | guard wrapper.hasKey(.biometry) else { |
| 383 | try biometryLost() |
| 384 | throw Failure.biometryChanged |
| 385 | } |
| 386 | let rk: SecureBytes |
| 387 | do { |
| 388 | rk = try wrapper.unwrap(blob, slot: .biometry, reason: reason) |
| 389 | } catch { |
| 390 | // So um cadastro de rostos diferente do gravado ao ligar, ou a chave que o |
| 391 | // SEP ja destruiu, apagam o slot. Cancelar, nao reconhecer ou o bloqueio |
| 392 | // temporario do sistema deixam tudo como esta. |
| 393 | if biometryEnrollmentChanged || !wrapper.hasKey(.biometry) { |
| 394 | try biometryLost() |
| 395 | throw Failure.biometryChanged |
| 396 | } |
| 397 | throw Failure.cancelled |
| 398 | } |
| 399 | // O contador de PIN nao zera aqui: quem apresenta o rosto do dono a forca nao |
| 400 | // ganha chutes de PIN de graca a cada Face ID (auditoria 2, B4). So o PIN certo zera. |
| 401 | // A ancora do boot acompanha a oscilacao do kern.boottime: ainda e o mesmo boot |
| 402 | // (conferido acima), so com o instante lido agora. |
| 403 | recordPINThisBoot() |
| 404 | return rk |
| 405 | } |
| 406 | |
| 407 | /// A1: o que entrega ou destroi a carteira inteira (ver as palavras, lacrar o |
| 408 | /// envelope, remover, desligar o Face ID) pede o PIN e, com o Face ID ligado, o rosto |
| 409 | /// tambem. Quem viu o PIN e pegou o iPhone nao leva a frase; quem apresenta o rosto do |
| 410 | /// dono a forca tambem nao. Sem o Face ID ligado, so o PIN. |
| 411 | /// |
| 412 | /// O PIN vem primeiro e conta tentativa como em `unlock`; o rosto e decidido no SEP, |
| 413 | /// pelo slot da biometria, e as duas chaves raiz tem de ser a mesma. Nenhum dos dois |
| 414 | /// caminhos cai para o codigo do iPhone. |
| 415 | /// |
| 416 | /// O Face ID que cai sem o dono pedir (cadastro de rostos mudou, Face ID tirado do app |
| 417 | /// ou do iPhone) apaga o slot e comeca a espera de `PINPolicy.biometryLossHold`: |
| 418 | /// durante ela, isto recusa antes de pedir qualquer coisa, mesmo com um Face ID |
| 419 | /// religado. Sem a espera, o ladrao que viu o codigo do iPhone cadastrava o proprio |
| 420 | /// rosto e passava com o PIN so. Chaveiro mudo recusa: nunca vira "Face ID desligado". |
| 421 | public func unlockWithPINAndBiometry(pin: SecureBytes, reason: String) throws -> SecureBytes { |
| 422 | lock.lock(); defer { lock.unlock() } |
| 423 | return try unlockWithPINAndBiometryLocked(pin: pin, reason: reason) |
| 424 | } |
| 425 | |
| 426 | private func unlockWithPINAndBiometryLocked(pin: SecureBytes, reason: String) throws -> SecureBytes { |
| 427 | try checkPINAndBiometryAllowed() |
| 428 | let slot = store.probe(Account.rootByBiometry) |
| 429 | guard slot != .unknown else { throw Failure.storage } |
| 430 | |
| 431 | let rk = try unlockLocked(pin: pin) |
| 432 | guard slot == .present else { return rk } |
| 433 | do { |
| 434 | // Face ID indisponivel agora nao e Face ID perdido: nada e apagado, e isto recusa |
| 435 | // (revisao do A1: uma falha passageira, ou o Espelhamento, derrubava o slot). |
| 436 | if biometryAvailability() == .gone { throw Failure.biometryUnavailable } |
| 437 | let check = try unlockWithBiometryLocked(reason: reason) |
| 438 | defer { check.wipe() } |
| 439 | guard Hash.constantTimeEqual(check, rk) else { throw Failure.storage } |
| 440 | return rk |
| 441 | } catch { |
| 442 | rk.wipe() |
| 443 | throw error |
| 444 | } |
| 445 | } |
| 446 | |
| 447 | /// Quanto falta da espera do A1, para a tela avisar antes de pedir o PIN. Chaveiro mudo |
| 448 | /// ou registro ilegivel: a espera cheia, nunca zero. So le. |
| 449 | public var biometryHoldRemaining: TimeInterval { |
| 450 | (try? biometryHoldRemainingChecked()) ?? PINPolicy.biometryLossHold |
| 451 | } |
| 452 | |
| 453 | /// O que pede PIN e rosto pode ser tentado agora? Recusa na espera, e com o Face ID |
| 454 | /// perdido e nao religado. Para a tela perguntar antes do PIN e da voz; o cofre confere |
| 455 | /// de novo em `unlockWithPINAndBiometry`. |
| 456 | public func checkPINAndBiometryAllowed() throws { |
| 457 | // No reforcado nao existe slot do rosto do modo normal: a espera e a marca do A1 |
| 458 | // nao valem (e a varredura as apaga). Vale a espera do `.wait`. |
| 459 | guard securityMode != .hardened else { |
| 460 | try checkHardenedAllowed() |
| 461 | return |
| 462 | } |
| 463 | let hold = try biometryHoldRemainingChecked() |
| 464 | guard hold <= 0 else { throw Failure.biometryHold(seconds: hold) } |
| 465 | switch store.probe(Account.biometryLost) { |
| 466 | case .unknown: throw Failure.storage |
| 467 | case .present: throw Failure.biometryRequired |
| 468 | case .absent: break |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | /// A espera que falta. Vencida, o registro sai. Ilegivel vale a espera cheia, e a |
| 473 | /// abertura do app o regrava a partir de agora (`rebaseHoldLocked`). |
| 474 | private func biometryHoldRemainingChecked() throws -> TimeInterval { |
| 475 | switch store.probe(Account.biometryHold) { |
| 476 | case .absent: return 0 |
| 477 | case .unknown: throw Failure.storage |
| 478 | case .present: |
| 479 | guard let data = try? store.read(Account.biometryHold, context: nil), let record = HoldRecord(data) else { |
| 480 | return PINPolicy.biometryLossHold |
| 481 | } |
| 482 | let remaining = record.remaining() |
| 483 | if remaining <= 0 { try? store.delete(Account.biometryHold) } |
| 484 | return remaining |
| 485 | } |
| 486 | } |
| 487 | |
| 488 | /// O Face ID caiu sem o dono pedir: a espera e a marca de perdido sao gravadas |
| 489 | /// primeiro, e so entao o slot sai. Se uma delas nao gravar, o slot fica: o rosto |
| 490 | /// continua sem abrir, e o que pede PIN e rosto continua travado, em vez de passar com |
| 491 | /// o PIN so. |
| 492 | private func biometryLost() throws { |
| 493 | try startHold() |
| 494 | if store.probe(Account.biometryLost) != .present { |
| 495 | try? store.add(Data([1]), account: Account.biometryLost, protection: .standard, context: nil) |
| 496 | } |
| 497 | guard store.probe(Account.biometryLost) == .present else { throw Failure.storage } |
| 498 | dropBiometrySlot() |
| 499 | } |
| 500 | |
| 501 | private func startHold() throws { |
| 502 | let record = HoldRecord.startingNow().encoded |
| 503 | if (try? store.update(record, account: Account.biometryHold)) == nil { |
| 504 | try? store.add(record, account: Account.biometryHold, protection: .standard, context: nil) |
| 505 | } |
| 506 | guard store.probe(Account.biometryHold) == .present else { throw Failure.storage } |
| 507 | } |
| 508 | |
| 509 | /// Depois de reiniciar, o prazo da espera gravado com o relogio monotonico antigo nao |
| 510 | /// vale mais: recomeca cheio a partir de agora, como o das tentativas. Registro |
| 511 | /// ilegivel tambem. |
| 512 | private func rebaseHoldLocked() { |
| 513 | guard store.probe(Account.biometryHold) == .present else { return } |
| 514 | let record = (try? store.read(Account.biometryHold, context: nil)).flatMap { HoldRecord($0) } |
| 515 | guard record == nil || record?.isFromAnotherBoot == true else { return } |
| 516 | try? store.update(HoldRecord.startingNow().encoded, account: Account.biometryHold) |
| 517 | } |
| 518 | |
| 519 | /// Quanto falta de espera, para a tela de bloqueio mostrar antes do toque. So le. |
| 520 | public func throttleRemaining() -> TimeInterval { |
| 521 | (try? readAttempts()?.remaining()) ?? 0 |
| 522 | } |
| 523 | |
| 524 | public func failureCount() -> UInt32 { |
| 525 | (try? readAttempts()?.failures) ?? 0 |
| 526 | } |
| 527 | |
| 528 | /// Depois de reiniciar, o prazo gravado com o relogio monotonico antigo nao vale |
| 529 | /// mais: a espera recomeca cheia a partir de agora. Chamado ao abrir e ao voltar |
| 530 | /// para o app, os unicos pontos que gravam fora de uma tentativa. |
| 531 | public func rebaseAttempts() { |
| 532 | lock.lock(); defer { lock.unlock() } |
| 533 | _ = try? rebasedAttemptsLocked() |
| 534 | rebaseHoldLocked() |
| 535 | rebaseHardenedHoldLocked() |
| 536 | sweepHardenedLocked() |
| 537 | // O slot do rosto sem a chave do SE (cadastro de rostos trocado): a queda conta a |
| 538 | // partir de quando o app a ve, nao de quando alguem resolve usar o Face ID. |
| 539 | if securityMode == .normal, store.probe(Account.rootByBiometry) == .present, !wrapper.hasKey(.biometry) { try? biometryLost() } |
| 540 | } |
| 541 | |
| 542 | // MARK: Biometria |
| 543 | |
| 544 | /// O PIN foi digitado neste boot? Depois de reiniciar, o Face ID espera o PIN. |
| 545 | public var pinEnteredThisBoot: Bool { |
| 546 | guard let saved = try? store.read(Account.pinBoot, context: nil) else { return false } |
| 547 | return PINPolicy.pinRecordValid([UInt8](saved), boot: PINPolicy.bootSession, uptime: PINPolicy.uptime) |
| 548 | } |
| 549 | |
| 550 | private func recordPINThisBoot() { |
| 551 | let current = Data(PINPolicy.pinRecord(boot: PINPolicy.bootSession, uptime: PINPolicy.uptime)) |
| 552 | if (try? store.update(current, account: Account.pinBoot)) == nil { |
| 553 | try? store.add(current, account: Account.pinBoot, protection: .standard, context: nil) |
| 554 | } |
| 555 | } |
| 556 | |
| 557 | /// O slot do rosto existe (ou o chaveiro nao sabe dizer). A chave do SE pode ter |
| 558 | /// sumido: e o primeiro uso do Face ID que descobre e comeca a espera. Antes era |
| 559 | /// "slot e chave", e com a chave invalidada por um rosto novo o app mostrava o Face ID |
| 560 | /// desligado, e religar sobrescrevia o slot do dono sem espera (revisao do A1). |
| 561 | public var isBiometryEnabled: Bool { securityMode != .hardened && store.probe(Account.rootByBiometry) != .absent } |
| 562 | |
| 563 | /// Liga o Face ID e confere na hora: o slot novo tem de abrir com o rosto e devolver a |
| 564 | /// mesma RK. Exige a RK aberta, ou seja, o PIN digitado agora. |
| 565 | /// |
| 566 | /// Religar por cima de um slot que existe conta como queda, e religar depois de uma |
| 567 | /// queda comeca a espera do A1 a partir de agora: quem cadastrou o proprio rosto no |
| 568 | /// iPhone nao ve a frase na hora so por ligar o Face ID com o PIN. O primeiro Face ID |
| 569 | /// de um cofre que nunca o teve nao espera: antes dele, o PIN ja abria tudo. |
| 570 | public func enableBiometry(rk: SecureBytes, reason: String = "") throws { |
| 571 | guard securityMode == .normal else { throw Failure.hardenedModeActive } |
| 572 | let slot = store.probe(Account.rootByBiometry) |
| 573 | let lost = store.probe(Account.biometryLost) |
| 574 | guard slot != .unknown, lost != .unknown else { throw Failure.storage } |
| 575 | if slot == .present { try biometryLost() } |
| 576 | let restarted = slot == .present || lost == .present |
| 577 | |
| 578 | try wrapper.createKey(.biometry) |
| 579 | let blob = try wrapper.wrap(rk, slot: .biometry) |
| 580 | do { |
| 581 | try store.delete(Account.rootByBiometry) |
| 582 | try store.add(blob, account: Account.rootByBiometry, protection: .standard, context: nil) |
| 583 | try store.delete(Account.biometryState) |
| 584 | if let state = Self.biometryDomainState() { |
| 585 | try store.add(state, account: Account.biometryState, protection: .standard, context: nil) |
| 586 | } |
| 587 | } catch { |
| 588 | dropBiometrySlot() |
| 589 | throw Failure.storage |
| 590 | } |
| 591 | // A conferencia: sem ela o dono descobriria que o atalho nao funciona no dia em que |
| 592 | // precisasse. Falhou, o slot novo sai, e a marca de perdido (se havia) fica. |
| 593 | do { |
| 594 | let check = try wrapper.unwrap(blob, slot: .biometry, reason: reason) |
| 595 | defer { check.wipe() } |
| 596 | guard Hash.constantTimeEqual(check, rk) else { throw Failure.storage } |
| 597 | } catch { |
| 598 | dropBiometrySlot() |
| 599 | throw error is Failure ? error : Failure.cancelled |
| 600 | } |
| 601 | // Religar depois de uma queda: a espera comeca e a marca sai, nessa ordem e |
| 602 | // conferidas. Se uma das duas falhar, o slot novo sai e a marca fica: o dono tenta |
| 603 | // de novo, em vez de ficar com o Face ID ligado e o que pede os dois recusado para |
| 604 | // sempre (segunda revisao do A1). |
| 605 | if restarted { |
| 606 | do { |
| 607 | try startHold() |
| 608 | try? store.delete(Account.biometryLost) |
| 609 | guard store.probe(Account.biometryLost) == .absent else { throw Failure.storage } |
| 610 | } catch { |
| 611 | dropBiometrySlot() |
| 612 | throw Failure.storage |
| 613 | } |
| 614 | } |
| 615 | } |
| 616 | |
| 617 | /// Desliga o Face ID pelo dono: pede o PIN e o rosto (A1), porque com ele desligado o |
| 618 | /// que pedia os dois passa a pedir so o PIN. Nunca toca o slot do PIN. |
| 619 | public func disableBiometry(pin: SecureBytes, reason: String) throws { |
| 620 | let rk = try unlockWithPINAndBiometry(pin: pin, reason: reason) |
| 621 | rk.wipe() |
| 622 | dropBiometrySlot() |
| 623 | } |
| 624 | |
| 625 | /// Tira o slot do rosto e a chave dele, sem pedir nada: so por dentro do cofre (queda, |
| 626 | /// ligar que nao conferiu, desligar depois do PIN e do rosto). |
| 627 | func dropBiometrySlot() { |
| 628 | try? store.delete(Account.rootByBiometry) |
| 629 | try? store.delete(Account.biometryState) |
| 630 | wrapper.deleteKey(.biometry) |
| 631 | } |
| 632 | |
| 633 | /// O cadastro de biometria mudou desde que o Face ID foi ligado aqui? Sem estado |
| 634 | /// gravado ou sem biometria disponivel agora (bloqueio do sistema), a resposta e |
| 635 | /// "nao se sabe", tratada como "nao mudou": nada e apagado por duvida. |
| 636 | public var biometryEnrollmentChanged: Bool { |
| 637 | guard let saved = try? store.read(Account.biometryState, context: nil), let now = Self.biometryDomainState() else { |
| 638 | return false |
| 639 | } |
| 640 | return saved != now |
| 641 | } |
| 642 | |
| 643 | static func biometryDomainState() -> Data? { |
| 644 | let context = LAContext() |
| 645 | defer { context.invalidate() } |
| 646 | var error: NSError? |
| 647 | guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { return nil } |
| 648 | if #available(iOS 18.0, macOS 15.0, *) { |
| 649 | return context.domainState.biometry.stateHash |
| 650 | } |
| 651 | return context.evaluatedPolicyDomainState |
| 652 | } |
| 653 | |
| 654 | /// O Face ID deste iPhone visto pelo app agora: sem rosto cadastrado ou tirado do app |
| 655 | /// (`gone`), ou presente, inclusive no bloqueio temporario do sistema e em qualquer |
| 656 | /// motivo que nao se sabe: nada e apagado por duvida. |
| 657 | public enum BiometryAvailability: Sendable { case available, gone } |
| 658 | |
| 659 | static func systemBiometryAvailability() -> BiometryAvailability { |
| 660 | let context = LAContext() |
| 661 | defer { context.invalidate() } |
| 662 | var error: NSError? |
| 663 | guard !context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error), let error, |
| 664 | error.domain == LAError.errorDomain else { return .available } |
| 665 | switch LAError.Code(rawValue: error.code) { |
| 666 | case .biometryNotEnrolled?, .biometryNotAvailable?: return .gone |
| 667 | default: return .available |
| 668 | } |
| 669 | } |
| 670 | |
| 671 | // MARK: Troca de PIN |
| 672 | |
| 673 | /// Troca atomica. O slot novo e gravado com sufixo e conferido; o ponto sem volta |
| 674 | /// e apagar o slot antigo. Antes dele, qualquer falha desfaz a troca e o PIN antigo |
| 675 | /// continua o unico. Depois dele, a troca esta feita: o slot pendente abre com o |
| 676 | /// PIN novo, e se a promocao a principal falhar aqui, o proximo desbloqueio a |
| 677 | /// conclui. Em nenhum instante o dono fica sem um slot que abre. |
| 678 | public func changePIN(rk: SecureBytes, newPIN: SecureBytes) throws { |
| 679 | lock.lock(); defer { lock.unlock() } |
| 680 | try replacePINLocked(rk: rk, newPIN: newPIN) |
| 681 | dropLegacyKeyIfDone() |
| 682 | } |
| 683 | |
| 684 | private func replacePINLocked(rk: SecureBytes, newPIN: SecureBytes) throws { |
| 685 | guard Self.isWellFormed(newPIN) else { throw Failure.malformedPIN } |
| 686 | let pendingAccount = Account.rootByPIN + Account.pendingSuffix |
| 687 | // Uma troca interrompida se resolve no proximo desbloqueio, nao aqui. |
| 688 | guard store.probe(Account.rootByPIN) == .present else { throw Failure.storage } |
| 689 | switch store.probe(pendingAccount) { |
| 690 | case .unknown: throw Failure.storage |
| 691 | case .present: deleteSlot(suffix: Account.pendingSuffix) |
| 692 | case .absent: break |
| 693 | } |
| 694 | |
| 695 | do { |
| 696 | try writePINSlot(rk: rk, pin: newPIN, kdf: pinParameters, suffix: Account.pendingSuffix) |
| 697 | try confirm(pin: newPIN, suffix: Account.pendingSuffix, matches: rk) |
| 698 | try store.delete(Account.rootByPIN) |
| 699 | } catch { |
| 700 | deleteSlot(suffix: Account.pendingSuffix) |
| 701 | throw error is Failure ? error : Failure.storage |
| 702 | } |
| 703 | // Ponto sem volta passado: o PIN antigo nao abre mais nada. |
| 704 | try? store.delete(Account.pinKDF) |
| 705 | try? store.delete(Account.pinLength) |
| 706 | try? promotePendingLocked(rk: rk, pin: newPIN) |
| 707 | } |
| 708 | |
| 709 | /// Regrava o slot principal com o PIN que abriu o pendente, confere, e so entao |
| 710 | /// apaga o pendente. |
| 711 | private func promotePendingLocked(rk: SecureBytes, pin: SecureBytes) throws { |
| 712 | if store.probe(Account.rootByPIN) == .present { |
| 713 | try store.delete(Account.rootByPIN) |
| 714 | } |
| 715 | try writePINSlot(rk: rk, pin: pin, kdf: pinParameters, suffix: "") |
| 716 | try confirm(pin: pin, suffix: "", matches: rk) |
| 717 | deleteSlot(suffix: Account.pendingSuffix) |
| 718 | } |
| 719 | |
| 720 | private func deleteSlot(suffix: String) { |
| 721 | try? store.delete(Account.rootByPIN + suffix) |
| 722 | try? store.delete(Account.pinKDF + suffix) |
| 723 | try? store.delete(Account.pinLength + suffix) |
| 724 | // A chave do SE do slot pendente vai junto; a do principal, nunca aqui: so a |
| 725 | // gravacao de um principal novo a substitui. |
| 726 | if !suffix.isEmpty { wrapper.deleteKey(.pinPending) } |
| 727 | } |
| 728 | |
| 729 | private func confirm(pin: SecureBytes, suffix: String, matches rk: SecureBytes) throws { |
| 730 | let check = try readPINSlot(pin: pin, suffix: suffix) |
| 731 | defer { check.wipe() } |
| 732 | guard Hash.constantTimeEqual(check, rk) else { throw Failure.storage } |
| 733 | } |
| 734 | |
| 735 | // MARK: Apagar |
| 736 | |
| 737 | /// Quantos PINs errados apagam tudo; nil e nunca. O registro antigo (um byte 1) |
| 738 | /// valia 10. |
| 739 | public var wipeThreshold: UInt32? { |
| 740 | guard let data = try? store.read(Account.wipeAfterErrors, context: nil), data.count == 1 else { return nil } |
| 741 | let value = UInt32(data[data.startIndex]) |
| 742 | if value == 1 { return 10 } |
| 743 | return PINPolicy.wipeOptions.contains(value) ? value : nil |
| 744 | } |
| 745 | |
| 746 | public var wipeAfterErrorsEnabled: Bool { wipeThreshold != nil } |
| 747 | |
| 748 | /// Liga com um dos limites de `PINPolicy.wipeOptions`, ou desliga com nil. |
| 749 | public func setWipeAfterErrors(_ threshold: UInt32?) throws { |
| 750 | if let threshold, !PINPolicy.wipeOptions.contains(threshold) { throw Failure.storage } |
| 751 | do { |
| 752 | try store.delete(Account.wipeAfterErrors) |
| 753 | if let threshold { |
| 754 | try store.add(Data([UInt8(threshold)]), account: Account.wipeAfterErrors, protection: .standard, context: nil) |
| 755 | } |
| 756 | } catch { |
| 757 | throw Failure.storage |
| 758 | } |
| 759 | } |
| 760 | |
| 761 | /// Destruicao criptografica: as chaves do SE e os slots da RK primeiro (em |
| 762 | /// milissegundos todo embrulho vira ruido), depois o resto. |
| 763 | public func wipeAll() { |
| 764 | lock.lock(); defer { lock.unlock() } |
| 765 | wipeAllLocked() |
| 766 | } |
| 767 | |
| 768 | private func wipeAllLocked() { |
| 769 | WrapSlot.allCases.forEach(wrapper.deleteKey) |
| 770 | try? store.delete(Account.rootHardened) |
| 771 | try? store.delete(Account.rootByPIN) |
| 772 | try? store.delete(Account.rootByPIN + Account.pendingSuffix) |
| 773 | try? store.delete(Account.rootByBiometry) |
| 774 | try? store.deleteAll() |
| 775 | } |
| 776 | |
| 777 | // MARK: Interno |
| 778 | |
| 779 | private func readAttempts() throws -> AttemptRecord? { |
| 780 | guard let data = try store.read(Account.attempts, context: nil) else { return nil } |
| 781 | guard let record = AttemptRecord(data) else { throw Failure.storage } |
| 782 | return record |
| 783 | } |
| 784 | |
| 785 | /// O registro atual, com o prazo regravado se ele veio de outro boot. So chamado |
| 786 | /// de caminhos que ja gravam. Registro ausente com slot presente e recriado: quem |
| 787 | /// consegue apagar item do chaveiro deste app ja executa codigo aqui dentro, e o |
| 788 | /// contador nunca foi barreira contra isso. |
| 789 | private func rebasedAttemptsLocked() throws -> AttemptRecord { |
| 790 | let current: AttemptRecord? |
| 791 | do { current = try readAttempts() } catch { throw Failure.storage } |
| 792 | guard var record = current else { |
| 793 | do { |
| 794 | try store.add(AttemptRecord.zero.encoded, account: Account.attempts, protection: .standard, context: nil) |
| 795 | } catch { |
| 796 | throw Failure.storage |
| 797 | } |
| 798 | return .zero |
| 799 | } |
| 800 | if record.isFromAnotherBoot { |
| 801 | record = AttemptRecord.after(failures: record.failures) |
| 802 | do { try store.update(record.encoded, account: Account.attempts) } catch { throw Failure.storage } |
| 803 | } |
| 804 | return record |
| 805 | } |
| 806 | |
| 807 | /// Grava um slot do PIN. Pre-condicao: o slot nao existe. O registro de custo e |
| 808 | /// gravado antes e desfeito se o slot falhar, para nunca sobrar um slot sem custo. |
| 809 | func writePINSlot(rk: SecureBytes, pin: SecureBytes, kdf: KDFParameters, suffix: String) throws { |
| 810 | guard store.probe(Account.rootByPIN + suffix) == .absent else { throw Failure.storage } |
| 811 | let saltBytes = try SecureBytes.random(count: VaultFormat.saltLength) |
| 812 | let salt = saltBytes.withUnsafeBytes { Array($0) } |
| 813 | let ap = try KeyDerivation.deriveMasterKey(password: pin, salt: salt, parameters: kdf) |
| 814 | defer { ap.wipe() } |
| 815 | |
| 816 | let inner = try Self.sealInner(rk, ap: ap, salt: salt) |
| 817 | defer { inner.wipe() } |
| 818 | // Formato 2: a chave do SE deste slot so desembrulha com a senha derivada do PIN. |
| 819 | let slot = Self.wrapSlot(suffix) |
| 820 | let password = Self.devicePassword(ap: ap, salt: salt) |
| 821 | defer { password.wipe() } |
| 822 | try password.withUnsafeData { try wrapper.createKey(slot, password: $0) } |
| 823 | let blob: Data |
| 824 | do { |
| 825 | blob = try password.withUnsafeData { try wrapper.wrap(inner, slot: slot, password: $0) } |
| 826 | } catch { |
| 827 | wrapper.deleteKey(slot) |
| 828 | throw error |
| 829 | } |
| 830 | |
| 831 | var kdfRecord = salt |
| 832 | kdfRecord += kdf.memoryKiB.bigEndianByteArray + kdf.passes.bigEndianByteArray + [kdf.lanes] |
| 833 | kdfRecord.append(SlotFormat.version) |
| 834 | try? store.delete(Account.pinKDF + suffix) |
| 835 | try? store.delete(Account.pinLength + suffix) |
| 836 | do { |
| 837 | // O tamanho vai antes do slot e sai junto se ele falhar: nunca existe um slot |
| 838 | // de 4 digitos que a tela trate como de 6. |
| 839 | try store.add(Data([UInt8(pin.count)]), account: Account.pinLength + suffix, protection: .standard, context: nil) |
| 840 | try store.add(Data(kdfRecord), account: Account.pinKDF + suffix, protection: .standard, context: nil) |
| 841 | let context = try store.applicationPasswordContext(ap) |
| 842 | try store.add(blob, account: Account.rootByPIN + suffix, protection: .applicationPassword, context: context) |
| 843 | } catch { |
| 844 | try? store.delete(Account.pinKDF + suffix) |
| 845 | try? store.delete(Account.pinLength + suffix) |
| 846 | wrapper.deleteKey(slot) |
| 847 | throw error |
| 848 | } |
| 849 | } |
| 850 | |
| 851 | /// So para os testes da migracao: grava um slot no formato 1 (chave do aparelho sem |
| 852 | /// senha), como as versoes anteriores gravavam. |
| 853 | func writeLegacyPINSlot(rk: SecureBytes, pin: SecureBytes, kdf: KDFParameters) throws { |
| 854 | if !wrapper.hasKey(.device) { try wrapper.createKey(.device) } |
| 855 | let saltBytes = try SecureBytes.random(count: VaultFormat.saltLength) |
| 856 | let salt = saltBytes.withUnsafeBytes { Array($0) } |
| 857 | let ap = try KeyDerivation.deriveMasterKey(password: pin, salt: salt, parameters: kdf) |
| 858 | defer { ap.wipe() } |
| 859 | let inner = try Self.sealInner(rk, ap: ap, salt: salt) |
| 860 | defer { inner.wipe() } |
| 861 | let blob = try wrapper.wrap(inner, slot: .device) |
| 862 | var kdfRecord = salt |
| 863 | kdfRecord += kdf.memoryKiB.bigEndianByteArray + kdf.passes.bigEndianByteArray + [kdf.lanes] |
| 864 | try store.add(Data(kdfRecord), account: Account.pinKDF, protection: .standard, context: nil) |
| 865 | try store.add(blob, account: Account.rootByPIN, protection: .applicationPassword, context: store.applicationPasswordContext(ap)) |
| 866 | if store.probe(Account.attempts) == .absent { |
| 867 | try store.add(AttemptRecord.zero.encoded, account: Account.attempts, protection: .standard, context: nil) |
| 868 | } |
| 869 | } |
| 870 | |
| 871 | private func readPINSlot(pin: SecureBytes, suffix: String) throws -> SecureBytes { |
| 872 | guard let record = try store.read(Account.pinKDF + suffix, context: nil), |
| 873 | record.count == SlotFormat.legacyLength || (record.count == SlotFormat.length && record.last == SlotFormat.version) |
| 874 | else { |
| 875 | throw Failure.storage |
| 876 | } |
| 877 | let version = record.count == SlotFormat.length ? 2 : 1 |
| 878 | let bytes = [UInt8](record) |
| 879 | let salt = Array(bytes[0..<16]) |
| 880 | let memory = bytes[16..<20].reduce(UInt32(0)) { $0 << 8 | UInt32($1) } |
| 881 | let passes = bytes[20..<24].reduce(UInt32(0)) { $0 << 8 | UInt32($1) } |
| 882 | let ap = try KeyDerivation.deriveMasterKey( |
| 883 | password: pin, salt: salt, |
| 884 | parameters: KDFParameters(memoryKiB: memory, passes: passes, lanes: bytes[24]) |
| 885 | ) |
| 886 | defer { ap.wipe() } |
| 887 | |
| 888 | let blob: Data? |
| 889 | do { |
| 890 | blob = try store.read(Account.rootByPIN + suffix, context: store.applicationPasswordContext(ap)) |
| 891 | } catch StoreError.authenticationFailed { |
| 892 | throw Failure.wrongPIN(remainingBeforeWipe: nil) |
| 893 | } |
| 894 | guard let blob else { throw Failure.notSetUp } |
| 895 | let inner: SecureBytes |
| 896 | if version == 2 { |
| 897 | let password = Self.devicePassword(ap: ap, salt: salt) |
| 898 | defer { password.wipe() } |
| 899 | do { |
| 900 | inner = try password.withUnsafeData { try wrapper.unwrap(blob, slot: Self.wrapSlot(suffix), password: $0) } |
| 901 | } catch EnclaveError.wrongPassword { |
| 902 | throw Failure.wrongPIN(remainingBeforeWipe: nil) |
| 903 | } |
| 904 | } else { |
| 905 | inner = try wrapper.unwrap(blob, slot: .device, reason: nil) |
| 906 | } |
| 907 | defer { inner.wipe() } |
| 908 | return try Self.openInner(inner, ap: ap, salt: salt) |
| 909 | } |
| 910 | |
| 911 | static func innerKey(ap: SecureBytes, salt: [UInt8]) -> SymmetricKey { |
| 912 | let material = ap.withUnsafeBytes { SymmetricKey(data: $0) } |
| 913 | return HKDF<SHA256>.deriveKey(inputKeyMaterial: material, salt: salt, info: Inner.info, outputByteCount: 32) |
| 914 | } |
| 915 | |
| 916 | static func sealInner(_ rk: SecureBytes, ap: SecureBytes, salt: [UInt8]) throws -> SecureBytes { |
| 917 | let key = innerKey(ap: ap, salt: salt) |
| 918 | let box = try rk.withUnsafeData { try ChaChaPoly.seal($0, using: key, authenticating: Inner.info) } |
| 919 | let out = SecureBytes(capacity: Inner.length) |
| 920 | out.append(Inner.version) |
| 921 | box.combined.withUnsafeBytes { out.append(contentsOf: $0.bindMemory(to: UInt8.self)) } |
| 922 | return out |
| 923 | } |
| 924 | |
| 925 | /// Falha de autenticacao na camada interna e PIN errado: o chaveiro deixou passar |
| 926 | /// (senha de aplicativo so como regra de acesso), mas a chave do PIN nao confere. |
| 927 | static func openInner(_ inner: SecureBytes, ap: SecureBytes, salt: [UInt8]) throws -> SecureBytes { |
| 928 | guard inner.count == Inner.length else { throw Failure.storage } |
| 929 | let key = innerKey(ap: ap, salt: salt) |
| 930 | let parsed: (version: UInt8, box: ChaChaPoly.SealedBox)? = inner.withUnsafeBytes { raw in |
| 931 | guard let box = try? ChaChaPoly.SealedBox(combined: Data(raw[1...])) else { return nil } |
| 932 | return (raw[0], box) |
| 933 | } |
| 934 | guard let parsed, parsed.version == Inner.version else { throw Failure.storage } |
| 935 | var plain: Data |
| 936 | do { |
| 937 | plain = try ChaChaPoly.open(parsed.box, using: key, authenticating: Inner.info) |
| 938 | } catch { |
| 939 | throw Failure.wrongPIN(remainingBeforeWipe: nil) |
| 940 | } |
| 941 | let rk = SecureBytes(capacity: 32) |
| 942 | plain.withUnsafeMutableBytes { raw in |
| 943 | rk.append(contentsOf: raw.bindMemory(to: UInt8.self).withMemoryRebound(to: UInt8.self) { UnsafeBufferPointer($0) }) |
| 944 | if let base = raw.baseAddress { memset_s(base, raw.count, 0, raw.count) } |
| 945 | } |
| 946 | return rk |
| 947 | } |
| 948 | |
| 949 | /// A forma do PIN (4 ou 6 digitos), sem deixar copia deles para tras. |
| 950 | public static func isWellFormed(_ pin: SecureBytes) -> Bool { |
| 951 | var digits = pin.withUnsafeBytes { raw in raw.map { $0 &- 0x30 } } |
| 952 | defer { digits.withUnsafeMutableBytes { if let base = $0.baseAddress { memset_s(base, $0.count, 0, $0.count) } } } |
| 953 | return PINPolicy.isWellFormed(digits) |
| 954 | } |
| 955 | |
| 956 | /// AP = Argon2id(PIN, 256 MiB, p=2, t ate cerca de 0,8 s). E o custo que o |
| 957 | /// atacante com execucao de codigo no aparelho paga a cada chute: sem ele, 10^6 |
| 958 | /// PINs sairiam em horas. |
| 959 | public static func calibratedPINParameters() -> KDFParameters { |
| 960 | let memory: UInt32 = 256 * 1024 |
| 961 | let lanes: UInt8 = 2 |
| 962 | for passes: UInt32 in [4, 3, 2] { |
| 963 | let estimate = KDFCalibration.estimatedSeconds(for: KDFParameters(memoryKiB: memory, passes: passes, lanes: lanes)) |
| 964 | if estimate <= 0.8 { return KDFParameters(memoryKiB: memory, passes: passes, lanes: lanes) } |
| 965 | } |
| 966 | return KDFParameters(memoryKiB: memory, passes: 2, lanes: lanes) |
| 967 | } |
| 968 | } |
| 969 | |
| 970 | // MARK: - Modo reforcado |
| 971 | |
| 972 | /// Cabecalho do rk.and, em claro e autenticado como AAD da camada interna (docs/seguranca.md |
| 973 | /// §2.6). |
| 974 | /// |
| 975 | /// Formato 4: versao (1) | digitos (1) | chave do PIN (1: 0 = a, 1 = b) | camadas (1) | sal |
| 976 | /// (16) | memoria (4) | passadas (4) | faixas (1) = 29 bytes. As camadas dizem a politica: 1 so |
| 977 | /// a `kand.bio` (`.lock`), 2 so a `kand.qualquer` (`.continue`), 3 as duas (`.wait`). |
| 978 | /// |
| 979 | /// Formato 3 (28 bytes, sem o byte das camadas): o de antes das politicas, so a `kand.bio`. |
| 980 | /// Lido como `.lock`, com o AAD dele, e nunca mais gravado. |
| 981 | struct HardenedHeader: Equatable { |
| 982 | static let version: UInt8 = 4 |
| 983 | static let length = 29 |
| 984 | static let aadInfo = Array("escalibur-wallet/v4/rk.and".utf8) |
| 985 | static let legacyVersion: UInt8 = 3 |
| 986 | static let legacyLength = 28 |
| 987 | static let legacyAADInfo = Array("escalibur-wallet/v3/rk.and".utf8) |
| 988 | static let passwordInfo = Data("escalibur-wallet/v3/kand.senha".utf8) |
| 989 | static let innerInfo = Data("escalibur-wallet/v3/rk.and.interna".utf8) |
| 990 | |
| 991 | var version: UInt8 |
| 992 | var pinLength: UInt8 |
| 993 | var pinKey: WrapSlot |
| 994 | var faceChange: RootKeyVault.HardenedFaceChange |
| 995 | var salt: [UInt8] |
| 996 | var kdf: KDFParameters |
| 997 | |
| 998 | var byteCount: Int { version == Self.legacyVersion ? Self.legacyLength : Self.length } |
| 999 | |
| 1000 | var encoded: [UInt8] { |
| 1001 | let key: UInt8 = pinKey == .hardenedPINA ? 0 : 1 |
| 1002 | let cost = kdf.memoryKiB.bigEndianByteArray + kdf.passes.bigEndianByteArray + [kdf.lanes] |
| 1003 | if version == Self.legacyVersion { return [version, pinLength, key] + salt + cost } |
| 1004 | return [version, pinLength, key, faceChange.layerCode] + salt + cost |
| 1005 | } |
| 1006 | |
| 1007 | var aad: Data { Data((version == Self.legacyVersion ? Self.legacyAADInfo : Self.aadInfo) + encoded) } |
| 1008 | |
| 1009 | /// Um cabecalho novo: sempre no formato 4. |
| 1010 | init(pinLength: UInt8, pinKey: WrapSlot, faceChange: RootKeyVault.HardenedFaceChange, salt: [UInt8], kdf: KDFParameters) { |
| 1011 | self.version = Self.version |
| 1012 | self.pinLength = pinLength |
| 1013 | self.pinKey = pinKey |
| 1014 | self.faceChange = faceChange |
| 1015 | self.salt = salt |
| 1016 | self.kdf = kdf |
| 1017 | } |
| 1018 | |
| 1019 | /// Le o formato 4 ou o 3. Camadas fora de {1, 2, 3}: nil. |
| 1020 | init?(_ bytes: [UInt8]) { |
| 1021 | guard let first = bytes.first, first == Self.version || first == Self.legacyVersion else { return nil } |
| 1022 | let legacy = first == Self.legacyVersion |
| 1023 | guard bytes.count >= (legacy ? Self.legacyLength : Self.length), |
| 1024 | PINPolicy.lengths.contains(Int(bytes[1])), bytes[2] <= 1 else { return nil } |
| 1025 | let policy: RootKeyVault.HardenedFaceChange |
| 1026 | if legacy { |
| 1027 | policy = .lock |
| 1028 | } else { |
| 1029 | guard let parsed = RootKeyVault.HardenedFaceChange(layerCode: bytes[3]) else { return nil } |
| 1030 | policy = parsed |
| 1031 | } |
| 1032 | let start = legacy ? 3 : 4 |
| 1033 | version = first |
| 1034 | pinLength = bytes[1] |
| 1035 | pinKey = bytes[2] == 0 ? .hardenedPINA : .hardenedPINB |
| 1036 | faceChange = policy |
| 1037 | salt = Array(bytes[start..<start + 16]) |
| 1038 | let memory = bytes[start + 16..<start + 20].reduce(UInt32(0)) { $0 << 8 | UInt32($1) } |
| 1039 | let passes = bytes[start + 20..<start + 24].reduce(UInt32(0)) { $0 << 8 | UInt32($1) } |
| 1040 | kdf = KDFParameters(memoryKiB: memory, passes: passes, lanes: bytes[start + 24]) |
| 1041 | } |
| 1042 | } |
| 1043 | |
| 1044 | /// O rk.and inteiro: o cabecalho e, para cada camada de fora da politica, o mesmo miolo |
| 1045 | /// (`ECIES_{kand.pin}(0x01 ‖ ChaChaPoly(K_int, RK))`) embrulhado pela chave dela. |
| 1046 | /// |
| 1047 | /// Formato 4: cada camada presente, na ordem `kand.bio`, `kand.qualquer`, como tamanho (2, |
| 1048 | /// big-endian) ‖ ECIES. Camadas que nao batem com o corpo (sobra, falta, tamanho zero): o |
| 1049 | /// registro nao e este. Formato 3: so o ECIES da `kand.bio`, sem tamanho. |
| 1050 | struct HardenedRecord: Equatable { |
| 1051 | var header: HardenedHeader |
| 1052 | var layers: [WrapSlot: Data] |
| 1053 | |
| 1054 | var encoded: Data { |
| 1055 | var out = Data(header.encoded) |
| 1056 | if header.version == HardenedHeader.legacyVersion { |
| 1057 | out.append(layers[.hardenedBiometry] ?? Data()) |
| 1058 | return out |
| 1059 | } |
| 1060 | for slot in header.faceChange.layers { |
| 1061 | let blob = layers[slot] ?? Data() |
| 1062 | out.append(contentsOf: UInt16(clamping: blob.count).bigEndianByteArray) |
| 1063 | out.append(blob) |
| 1064 | } |
| 1065 | return out |
| 1066 | } |
| 1067 | |
| 1068 | static func parse(_ data: Data) throws -> HardenedRecord { |
| 1069 | let bytes = [UInt8](data) |
| 1070 | guard let header = HardenedHeader(bytes) else { throw RootKeyVault.Failure.storage } |
| 1071 | var rest = bytes[header.byteCount...] |
| 1072 | if header.version == HardenedHeader.legacyVersion { |
| 1073 | guard !rest.isEmpty else { throw RootKeyVault.Failure.storage } |
| 1074 | return HardenedRecord(header: header, layers: [.hardenedBiometry: Data(rest)]) |
| 1075 | } |
| 1076 | var layers: [WrapSlot: Data] = [:] |
| 1077 | for slot in header.faceChange.layers { |
| 1078 | guard rest.count >= 2 else { throw RootKeyVault.Failure.storage } |
| 1079 | let size = Int(rest[rest.startIndex]) << 8 | Int(rest[rest.startIndex + 1]) |
| 1080 | rest = rest.dropFirst(2) |
| 1081 | guard size > 0, rest.count >= size else { throw RootKeyVault.Failure.storage } |
| 1082 | layers[slot] = Data(rest.prefix(size)) |
| 1083 | rest = rest.dropFirst(size) |
| 1084 | } |
| 1085 | guard rest.isEmpty else { throw RootKeyVault.Failure.storage } |
| 1086 | return HardenedRecord(header: header, layers: layers) |
| 1087 | } |
| 1088 | } |
| 1089 | |
| 1090 | /// A espera do `.wait` (`and.espera`): o prazo (`HoldRecord`, 25 bytes) e o hash do cadastro |
| 1091 | /// de rostos que a comecou. Vencida, so realinha a vigia para esse mesmo cadastro: um rosto |
| 1092 | /// cadastrado depois do prazo, sem o app aberto no meio, comeca outra espera em vez de passar |
| 1093 | /// pela que ja venceu. |
| 1094 | struct HardenedHold: Equatable { |
| 1095 | var hold: HoldRecord |
| 1096 | var state: Data |
| 1097 | |
| 1098 | var encoded: Data { hold.encoded + state } |
| 1099 | var remaining: TimeInterval { hold.remaining() } |
| 1100 | |
| 1101 | init(hold: HoldRecord, state: Data) { |
| 1102 | self.hold = hold |
| 1103 | self.state = state |
| 1104 | } |
| 1105 | |
| 1106 | init?(_ data: Data) { |
| 1107 | guard data.count >= 25, let hold = HoldRecord(Data(data.prefix(25))) else { return nil } |
| 1108 | self.hold = hold |
| 1109 | self.state = Data(data.dropFirst(25)) |
| 1110 | } |
| 1111 | } |
| 1112 | |
| 1113 | extension RootKeyVault { |
| 1114 | public enum SecurityMode: Sendable, Equatable { case normal, hardened, unknown } |
| 1115 | |
| 1116 | /// O Face ID visto pelo modo reforcado. So `notEnrolled` e terminal, e so na `.lock`; o |
| 1117 | /// resto volta. |
| 1118 | public enum BiometryStatus: Sendable, Equatable { case ready, lockedOut, notEnrolled, unavailable } |
| 1119 | |
| 1120 | /// O que o modo reforcado faz quando o cadastro de rostos muda, escolhido pelo dono em |
| 1121 | /// "Quando o Face ID mudar" (docs/seguranca.md §2.6). Contra quem nao sabe o codigo do |
| 1122 | /// iPhone as tres valem igual: cadastrar um rosto pede o codigo. |
| 1123 | public enum HardenedFaceChange: Sendable, Equatable, CaseIterable { |
| 1124 | /// Continuar normal, o padrao: a camada de fora e so a `kand.qualquer` |
| 1125 | /// (`.biometryAny`), e um rosto novo abre tudo com o PIN, na hora. Quem sabe o PIN e |
| 1126 | /// o codigo do iPhone cadastra o proprio rosto e leva tudo. |
| 1127 | case `continue` |
| 1128 | /// Esperar 24 horas: a `kand.qualquer` abre, e a vigia `kand.bio` |
| 1129 | /// (`.biometryCurrentSet`), testada na mesma sessao do rosto, diz se o cadastro |
| 1130 | /// mudou. Mudou: o app abre e mostra o saldo, e o resto espera 24 horas. |
| 1131 | case wait |
| 1132 | /// Bloquear as carteiras: so a `kand.bio`, o comportamento de antes das politicas. Um |
| 1133 | /// rosto novo fecha o cofre de vez, e so as palavras trazem de volta. |
| 1134 | case lock |
| 1135 | |
| 1136 | /// As camadas de fora, na ordem do corpo do rk.and. |
| 1137 | var layers: [WrapSlot] { |
| 1138 | switch self { |
| 1139 | case .continue: return [.hardenedBiometryAny] |
| 1140 | case .wait: return [.hardenedBiometry, .hardenedBiometryAny] |
| 1141 | case .lock: return [.hardenedBiometry] |
| 1142 | } |
| 1143 | } |
| 1144 | |
| 1145 | /// A chave que o Face ID da chamada abre. |
| 1146 | var faceKey: WrapSlot { self == .lock ? .hardenedBiometry : .hardenedBiometryAny } |
| 1147 | |
| 1148 | var layerCode: UInt8 { |
| 1149 | switch self { |
| 1150 | case .lock: return 1 |
| 1151 | case .continue: return 2 |
| 1152 | case .wait: return 3 |
| 1153 | } |
| 1154 | } |
| 1155 | |
| 1156 | init?(layerCode: UInt8) { |
| 1157 | switch layerCode { |
| 1158 | case 1: self = .lock |
| 1159 | case 2: self = .continue |
| 1160 | case 3: self = .wait |
| 1161 | default: return nil |
| 1162 | } |
| 1163 | } |
| 1164 | } |
| 1165 | |
| 1166 | /// Para que o cofre reforcado e aberto. Na espera do `.wait`, so abrir o app passa: o |
| 1167 | /// saldo aparece, e o resto espera. |
| 1168 | public enum HardenedUse: Sendable { case openApp, operation } |
| 1169 | |
| 1170 | /// Todas as chaves do modo reforcado. |
| 1171 | static let hardenedKeys: [WrapSlot] = [.hardenedBiometry, .hardenedBiometryAny, .hardenedPINA, .hardenedPINB] |
| 1172 | |
| 1173 | static func systemBiometryStatus() -> BiometryStatus { |
| 1174 | let context = LAContext() |
| 1175 | defer { context.invalidate() } |
| 1176 | var error: NSError? |
| 1177 | if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { return .ready } |
| 1178 | guard let error, error.domain == LAError.errorDomain else { return .ready } |
| 1179 | switch LAError.Code(rawValue: error.code) { |
| 1180 | case .biometryLockout?: return .lockedOut |
| 1181 | case .biometryNotEnrolled?: return .notEnrolled |
| 1182 | case .biometryNotAvailable?: return .unavailable |
| 1183 | default: return .ready // o SEP decide; nada e recusado nem apagado por duvida |
| 1184 | } |
| 1185 | } |
| 1186 | |
| 1187 | /// Reforcado so com o rk.and presente e nenhum slot do PIN: o commit de ligar e |
| 1188 | /// apagar o rk.pin. Com os dois, a transicao nao terminou e o modo normal manda. |
| 1189 | public var securityMode: SecurityMode { |
| 1190 | let probes = [Account.rootHardened, Account.rootByPIN, Account.rootByPIN + Account.pendingSuffix].map(store.probe) |
| 1191 | if probes.contains(.unknown) { return .unknown } |
| 1192 | return probes[0] == .present && probes[1] == .absent && probes[2] == .absent ? .hardened : .normal |
| 1193 | } |
| 1194 | |
| 1195 | /// Sobrou alguma coisa de uma instalacao (slot, chave do SE)? Para a limpeza de |
| 1196 | /// reinstalacao, sem o app conhecer nomes de conta. |
| 1197 | public var hasResidue: Bool { |
| 1198 | let accounts = [Account.rootByPIN, Account.rootByPIN + Account.pendingSuffix, Account.rootByBiometry, Account.rootHardened] |
| 1199 | return accounts.map(store.probe).contains { $0 != .absent } || WrapSlot.allCases.contains { wrapper.probeKey($0) != .absent } |
| 1200 | } |
| 1201 | |
| 1202 | /// A politica do modo reforcado, do cabecalho do rk.and. Nil fora do reforcado ou com o |
| 1203 | /// cabecalho ilegivel. So le. |
| 1204 | public var hardenedFaceChange: HardenedFaceChange? { |
| 1205 | guard securityMode == .hardened, let record = try? readHardened() else { return nil } |
| 1206 | return record.header.faceChange |
| 1207 | } |
| 1208 | |
| 1209 | /// Quanto falta da espera do `.wait`, para a tela avisar antes do teclado. Zero sem espera |
| 1210 | /// e fora do reforcado; chaveiro mudo ou registro ilegivel, a espera cheia, nunca zero. So |
| 1211 | /// le. |
| 1212 | public var hardenedHoldRemaining: TimeInterval { |
| 1213 | switch securityMode { |
| 1214 | case .normal: return 0 |
| 1215 | case .unknown: return PINPolicy.biometryLossHold |
| 1216 | case .hardened: break |
| 1217 | } |
| 1218 | if let policy = hardenedFaceChange, policy != .wait { return 0 } |
| 1219 | switch store.probe(Account.hardenedHold) { |
| 1220 | case .absent: return 0 |
| 1221 | case .unknown: return PINPolicy.biometryLossHold |
| 1222 | case .present: |
| 1223 | guard let data = try? store.read(Account.hardenedHold, context: nil), let hold = HardenedHold(data) else { |
| 1224 | return PINPolicy.biometryLossHold |
| 1225 | } |
| 1226 | return hold.remaining |
| 1227 | } |
| 1228 | } |
| 1229 | |
| 1230 | /// O que abre o cofre pode comecar agora? Na espera do `.wait`, recusa antes do teclado. |
| 1231 | /// O cofre confere de novo em `unlockHardened`. |
| 1232 | public func checkHardenedAllowed() throws { |
| 1233 | let hold = hardenedHoldRemaining |
| 1234 | guard hold <= 0 else { throw Failure.biometryHold(seconds: hold) } |
| 1235 | } |
| 1236 | |
| 1237 | /// A senha de aplicativo da chave do PIN do modo reforcado. Como `devicePassword`, sai |
| 1238 | /// em buffer seguro, que quem chama zera. |
| 1239 | static func hardenedPassword(ap: SecureBytes, salt: [UInt8]) -> SecureBytes { |
| 1240 | let material = ap.withUnsafeBytes { SymmetricKey(data: $0) } |
| 1241 | return secure(HKDF<SHA256>.deriveKey(inputKeyMaterial: material, salt: salt, info: HardenedHeader.passwordInfo, outputByteCount: 32)) |
| 1242 | } |
| 1243 | |
| 1244 | static func sealHardenedInner(_ rk: SecureBytes, ap: SecureBytes, header: HardenedHeader) throws -> SecureBytes { |
| 1245 | let material = ap.withUnsafeBytes { SymmetricKey(data: $0) } |
| 1246 | let key = HKDF<SHA256>.deriveKey(inputKeyMaterial: material, salt: header.salt, info: HardenedHeader.innerInfo, outputByteCount: 32) |
| 1247 | let box = try rk.withUnsafeData { try ChaChaPoly.seal($0, using: key, authenticating: header.aad) } |
| 1248 | let out = SecureBytes(capacity: Inner.length) |
| 1249 | out.append(Inner.version) |
| 1250 | box.combined.withUnsafeBytes { out.append(contentsOf: $0.bindMemory(to: UInt8.self)) } |
| 1251 | return out |
| 1252 | } |
| 1253 | |
| 1254 | static func openHardenedInner(_ inner: SecureBytes, ap: SecureBytes, header: HardenedHeader) throws -> SecureBytes { |
| 1255 | guard inner.count == Inner.length else { throw Failure.storage } |
| 1256 | let material = ap.withUnsafeBytes { SymmetricKey(data: $0) } |
| 1257 | let key = HKDF<SHA256>.deriveKey(inputKeyMaterial: material, salt: header.salt, info: HardenedHeader.innerInfo, outputByteCount: 32) |
| 1258 | let parsed: (UInt8, ChaChaPoly.SealedBox)? = inner.withUnsafeBytes { raw in |
| 1259 | guard let box = try? ChaChaPoly.SealedBox(combined: Data(raw[1...])) else { return nil } |
| 1260 | return (raw[0], box) |
| 1261 | } |
| 1262 | guard let parsed, parsed.0 == Inner.version else { throw Failure.storage } |
| 1263 | var plain: Data |
| 1264 | do { plain = try ChaChaPoly.open(parsed.1, using: key, authenticating: header.aad) } catch { throw Failure.wrongPIN(remainingBeforeWipe: nil) } |
| 1265 | let rk = SecureBytes(capacity: 32) |
| 1266 | plain.withUnsafeMutableBytes { raw in |
| 1267 | rk.append(contentsOf: UnsafeBufferPointer(raw.bindMemory(to: UInt8.self))) |
| 1268 | if let base = raw.baseAddress { memset_s(base, raw.count, 0, raw.count) } |
| 1269 | } |
| 1270 | return rk |
| 1271 | } |
| 1272 | |
| 1273 | func readHardened() throws -> HardenedRecord { |
| 1274 | let data: Data? |
| 1275 | do { data = try store.read(Account.rootHardened, context: nil) } catch { throw Failure.storage } |
| 1276 | guard let data else { throw Failure.notHardened } |
| 1277 | return try HardenedRecord.parse(data) |
| 1278 | } |
| 1279 | |
| 1280 | // MARK: Abrir |
| 1281 | |
| 1282 | /// PIN e rosto, os dois pela criptografia. Uma chamada = uma tentativa = um Face ID = um |
| 1283 | /// PIN avaliado. O contador sobe antes do rosto e so zera com a RK aberta. |
| 1284 | /// |
| 1285 | /// `use` diz para que: so `AppSession.unlock` passa `.openApp`, o unico uso que a espera |
| 1286 | /// do `.wait` deixa passar. Na espera, o resto recusa antes do rosto e sem contar. |
| 1287 | public func unlockHardened(pin: SecureBytes, reason: String, use: HardenedUse = .operation) throws -> SecureBytes { |
| 1288 | lock.lock(); defer { lock.unlock() } |
| 1289 | return try openHardenedLocked(pin: pin, reason: reason, use: use).rk |
| 1290 | } |
| 1291 | |
| 1292 | /// A RK aberta e se o cadastro de rostos e o que o reforcado conhece: na `.lock`, a |
| 1293 | /// `kand.bio` abriu; na `.wait`, a vigia disse "o mesmo" (ou a espera foi cumprida e a |
| 1294 | /// vigia realinhada); na `.continue`, o hash e o gravado quando a politica passou a valer. |
| 1295 | private func openHardenedLocked(pin: SecureBytes, reason: String, use: HardenedUse) throws -> (rk: SecureBytes, sameEnrollment: Bool) { |
| 1296 | switch securityMode { |
| 1297 | case .unknown: throw Failure.storage |
| 1298 | case .normal: throw Failure.notHardened |
| 1299 | case .hardened: break |
| 1300 | } |
| 1301 | sweepHardenedLocked() |
| 1302 | let record = try readHardened() |
| 1303 | let policy = record.header.faceChange |
| 1304 | // Tamanho nao e segredo; PIN fora de forma nunca gasta tentativa. |
| 1305 | guard Self.isWellFormed(pin), pin.count == Int(record.header.pinLength) else { throw Failure.malformedPIN } |
| 1306 | var hold: HardenedHold? |
| 1307 | if policy == .wait { hold = try hardenedHoldLocked() } |
| 1308 | if let hold, hold.remaining > 0, use == .operation { throw Failure.biometryHold(seconds: hold.remaining) } |
| 1309 | try hardenedPreflight(record) |
| 1310 | |
| 1311 | let attempts = try rebasedAttemptsLocked() |
| 1312 | let waiting = attempts.remaining() |
| 1313 | guard waiting <= 0 else { throw Failure.throttled(seconds: waiting) } |
| 1314 | let next = AttemptRecord.after(failures: attempts.failures + 1) |
| 1315 | do { try store.update(next.encoded, account: Account.attempts) } catch { throw Failure.storage } |
| 1316 | |
| 1317 | let opened: HardenedOpening |
| 1318 | do { |
| 1319 | opened = try openHardenedRecord(record, pin: pin, reason: reason) |
| 1320 | } catch let failure as Failure { |
| 1321 | switch failure { |
| 1322 | case .wrongPIN, .biometryNotConfirmed: |
| 1323 | if let threshold = wipeThreshold, next.failures >= threshold { |
| 1324 | wipeAllLocked() |
| 1325 | throw Failure.wiped |
| 1326 | } |
| 1327 | let left = wipeThreshold.map { $0 - next.failures } |
| 1328 | if case .wrongPIN = failure { throw Failure.wrongPIN(remainingBeforeWipe: left) } |
| 1329 | throw Failure.biometryNotConfirmed(remainingBeforeWipe: left) |
| 1330 | case .hardenedFaceKeyFailed: |
| 1331 | // Nenhum rosto foi recusado e o PIN nem foi avaliado: o contador volta ao de |
| 1332 | // antes. Se nao gravar, a tentativa fica contada (falha para o lado seguro). |
| 1333 | try? store.update(attempts.encoded, account: Account.attempts) |
| 1334 | throw failure |
| 1335 | default: |
| 1336 | throw failure |
| 1337 | } |
| 1338 | } |
| 1339 | defer { opened.middle.wipe() } |
| 1340 | try? store.update(AttemptRecord.zero.encoded, account: Account.attempts) |
| 1341 | recordPINThisBoot() |
| 1342 | // O AAD provou o cabecalho: so agora a varredura pode apagar o que ele diz que sobra. |
| 1343 | sweepHardenedKeysLocked(record.header) |
| 1344 | switch policy { |
| 1345 | case .lock: |
| 1346 | refreshHardenedState() |
| 1347 | return (opened.rk, true) |
| 1348 | case .continue: |
| 1349 | return (opened.rk, hardenedStateMatches) |
| 1350 | case .wait: |
| 1351 | do { |
| 1352 | return (opened.rk, try settleWatchLocked(record, opened: opened, hold: hold, use: use)) |
| 1353 | } catch { |
| 1354 | opened.rk.wipe() |
| 1355 | throw error |
| 1356 | } |
| 1357 | } |
| 1358 | } |
| 1359 | |
| 1360 | /// O que nao depende do PIN nem gasta tentativa: o Face ID utilizavel agora e as chaves |
| 1361 | /// do SE que a chamada usa presentes. A vigia do `.wait` nao entra: ausente, e o que ela |
| 1362 | /// tem a dizer. |
| 1363 | private func hardenedPreflight(_ record: HardenedRecord) throws { |
| 1364 | let policy = record.header.faceChange |
| 1365 | switch hardenedBiometryStatus() { |
| 1366 | case .lockedOut: throw Failure.biometryLockedOut |
| 1367 | case .unavailable: throw Failure.biometryUnavailable |
| 1368 | // Sem rosto cadastrado: com a trava, a kand.bio ja morreu; nas outras, a |
| 1369 | // kand.qualquer volta a abrir com um rosto cadastrado de novo. |
| 1370 | case .notEnrolled: throw policy == .lock ? Failure.hardenedSlotLost : Failure.biometryNotEnrolled |
| 1371 | case .ready: break |
| 1372 | } |
| 1373 | for slot in [policy.faceKey, record.header.pinKey] { |
| 1374 | switch wrapper.probeKey(slot) { |
| 1375 | case .present: continue |
| 1376 | case .absent: throw Failure.hardenedSlotLost |
| 1377 | case .unknown: throw Failure.storage |
| 1378 | } |
| 1379 | } |
| 1380 | } |
| 1381 | |
| 1382 | /// O que sai de uma abertura: a RK e o miolo, os dois zerados por quem chama, e o que a |
| 1383 | /// vigia do `.wait` viu. |
| 1384 | struct HardenedOpening { |
| 1385 | let rk: SecureBytes |
| 1386 | let middle: SecureBytes |
| 1387 | let watch: WatchVerdict? |
| 1388 | } |
| 1389 | |
| 1390 | /// A vigia, depois de conferida contra o miolo: abriu o mesmo, a chave nao existe mais, ou |
| 1391 | /// nao se sabe. |
| 1392 | enum WatchVerdict: Equatable { case same, gone, unknown } |
| 1393 | |
| 1394 | /// rosto (SEP) -> Argon2id -> SE com a senha do PIN -> ChaChaPoly com AAD. Na `.wait`, a |
| 1395 | /// vigia na mesma sessao do rosto, sem segundo Face ID; um miolo diferente nela e um |
| 1396 | /// registro que nao e o gravado. Nao conta nem grava: quem chama decide. Privado: nada |
| 1397 | /// fora do cofre abre "o rosto ja passou". |
| 1398 | private func openHardenedRecord(_ record: HardenedRecord, pin: SecureBytes, reason: String) throws -> HardenedOpening { |
| 1399 | let header = record.header |
| 1400 | let face = header.faceChange.faceKey |
| 1401 | guard let outer = record.layers[face] else { throw Failure.storage } |
| 1402 | let middle: SecureBytes |
| 1403 | var seen: WatchResult? |
| 1404 | do { |
| 1405 | if header.faceChange == .wait, let watch = record.layers[.hardenedBiometry] { |
| 1406 | let pair = try wrapper.unwrap(outer, slot: face, reason: reason, watching: watch, with: .hardenedBiometry) |
| 1407 | middle = pair.0 |
| 1408 | seen = pair.1 |
| 1409 | } else { |
| 1410 | middle = try wrapper.unwrap(outer, slot: face, reason: reason) |
| 1411 | } |
| 1412 | } catch EnclaveError.failed { |
| 1413 | // O SE recusou por um erro que nao vem do Face ID (nenhum rosto foi recusado): nao |
| 1414 | // conta. A chave que nao existe mais e o cofre perdido. |
| 1415 | if wrapper.probeKey(face) == .absent { throw Failure.hardenedSlotLost } |
| 1416 | throw Failure.hardenedFaceKeyFailed |
| 1417 | } catch { |
| 1418 | // Perdido so com prova: a chave do rosto que o SE ja nao tem. O hash do cadastro |
| 1419 | // diferente com a chave presente pode ser so deriva do sistema e conta como o |
| 1420 | // rosto que nao confirmou (revisao do modo reforcado, M6). |
| 1421 | if wrapper.probeKey(face) == .absent { throw Failure.hardenedSlotLost } |
| 1422 | throw Failure.biometryNotConfirmed(remainingBeforeWipe: nil) |
| 1423 | } |
| 1424 | let watch: WatchVerdict? |
| 1425 | switch seen { |
| 1426 | case .opened(let other)?: |
| 1427 | let equal = Hash.constantTimeEqual(other, middle) |
| 1428 | other.wipe() |
| 1429 | guard equal else { |
| 1430 | middle.wipe() |
| 1431 | throw Failure.storage |
| 1432 | } |
| 1433 | watch = .same |
| 1434 | case .gone?: watch = .gone |
| 1435 | case .unknown?: watch = .unknown |
| 1436 | case nil: watch = nil |
| 1437 | } |
| 1438 | do { |
| 1439 | return HardenedOpening(rk: try openHardenedMiddle(middle, header: header, pin: pin), middle: middle, watch: watch) |
| 1440 | } catch { |
| 1441 | middle.wipe() |
| 1442 | throw error |
| 1443 | } |
| 1444 | } |
| 1445 | |
| 1446 | private func openHardenedMiddle(_ middle: SecureBytes, header: HardenedHeader, pin: SecureBytes) throws -> SecureBytes { |
| 1447 | let ap: SecureBytes |
| 1448 | do { ap = try KeyDerivation.deriveMasterKey(password: pin, salt: header.salt, parameters: header.kdf) } catch { throw Failure.storage } |
| 1449 | defer { ap.wipe() } |
| 1450 | let password = Self.hardenedPassword(ap: ap, salt: header.salt) |
| 1451 | defer { password.wipe() } |
| 1452 | let inner: SecureBytes |
| 1453 | do { |
| 1454 | inner = try middle.withUnsafeData { body in |
| 1455 | try password.withUnsafeData { try wrapper.unwrap(body, slot: header.pinKey, password: $0) } |
| 1456 | } |
| 1457 | } catch EnclaveError.wrongPassword { |
| 1458 | throw Failure.wrongPIN(remainingBeforeWipe: nil) |
| 1459 | } catch EnclaveError.keyMissing { |
| 1460 | // O wrapper diz "sem chave" para qualquer falha da busca: perdido so se o SE |
| 1461 | // confirmar que ela nao existe. |
| 1462 | throw wrapper.probeKey(header.pinKey) == .absent ? Failure.hardenedSlotLost : Failure.storage |
| 1463 | } catch { |
| 1464 | throw Failure.storage |
| 1465 | } |
| 1466 | defer { inner.wipe() } |
| 1467 | return try Self.openHardenedInner(inner, ap: ap, header: header) |
| 1468 | } |
| 1469 | |
| 1470 | /// O que a vigia do `.wait` viu, ja com a RK aberta (PIN e rosto certos). |
| 1471 | /// |
| 1472 | /// - Mesmo cadastro: a vigia abriu o mesmo miolo, ou nao respondeu e o hash e o gravado. |
| 1473 | /// O hash e regravado, e uma espera vencida termina (um realinhamento que caiu antes de |
| 1474 | /// apagar a espera). |
| 1475 | /// - Troca: a chave da vigia nao existe mais, ou nao respondeu e o hash e outro (ou falta): |
| 1476 | /// sem espera, ela comeca, conferida; com a espera correndo, nada muda (so abrir o app |
| 1477 | /// chega aqui); vencida para o cadastro que a comecou, a vigia realinha; vencida para |
| 1478 | /// outro, outra espera comeca. Fora de abrir o app, a espera comecada agora recusa. |
| 1479 | /// |
| 1480 | /// Devolve se o cadastro e o conhecido. |
| 1481 | private func settleWatchLocked(_ record: HardenedRecord, opened: HardenedOpening, hold: HardenedHold?, use: HardenedUse) throws -> Bool { |
| 1482 | let now = hardenedDomainState() |
| 1483 | let changed: Bool |
| 1484 | switch opened.watch { |
| 1485 | case .same?: |
| 1486 | changed = false |
| 1487 | case .unknown?: |
| 1488 | let saved = try? store.read(Account.hardenedState, context: nil) |
| 1489 | changed = saved == nil || now == nil || saved != now |
| 1490 | case .gone?, nil: |
| 1491 | changed = true |
| 1492 | } |
| 1493 | if !changed { |
| 1494 | // So a prova do SEP (a vigia abriu o mesmo miolo) regrava o hash e termina uma |
| 1495 | // espera vencida. O hash sozinho nunca encerra espera. |
| 1496 | if opened.watch == .same { |
| 1497 | refreshHardenedState() |
| 1498 | if let hold, hold.remaining <= 0 { try? store.delete(Account.hardenedHold) } |
| 1499 | } |
| 1500 | return true |
| 1501 | } |
| 1502 | if let hold { |
| 1503 | // Sem o hash de agora, nao se sabe para que cadastro a espera vale: correndo, ela |
| 1504 | // fica como esta; vencida, nada abre ate o hash voltar (nem recomeca uma espera de |
| 1505 | // cadastro nenhum). |
| 1506 | guard let now else { |
| 1507 | if hold.remaining > 0 { return false } |
| 1508 | throw Failure.biometryUnavailable |
| 1509 | } |
| 1510 | if hold.remaining > 0 { |
| 1511 | // Outro cadastro no meio da espera (o dono redefiniu o Face ID, ou alguem |
| 1512 | // cadastrou mais um rosto): ela recomeca cheia, para o cadastro de agora, e a |
| 1513 | // faixa diz o prazo certo. So abrir o app chega aqui. |
| 1514 | if hold.state != now { try startHardenedHold(state: now) } |
| 1515 | return false |
| 1516 | } |
| 1517 | if !hold.state.isEmpty, hold.state == now { |
| 1518 | // Uma queda em qualquer passo termina na proxima abertura: a espera vencida |
| 1519 | // continua la, e a vigia continua vendo a troca. |
| 1520 | try? realignWatchLocked(record, middle: opened.middle, state: now) |
| 1521 | return true |
| 1522 | } |
| 1523 | } |
| 1524 | try startHardenedHold(state: now) |
| 1525 | if use == .operation { throw Failure.biometryHold(seconds: PINPolicy.biometryLossHold) } |
| 1526 | return false |
| 1527 | } |
| 1528 | |
| 1529 | /// A espera venceu para o cadastro que a comecou: a vigia passa a acompanhar o de agora. |
| 1530 | /// Uma `kand.bio` nova, o mesmo miolo embrulhado por ela, o `update` do rk.and conferido |
| 1531 | /// lendo de volta, o hash regravado e a espera apagada, nessa ordem. A `kand.qualquer` e a |
| 1532 | /// camada dela nao sao tocadas: em nenhum passo o registro deixa de abrir. |
| 1533 | private func realignWatchLocked(_ record: HardenedRecord, middle: SecureBytes, state: Data) throws { |
| 1534 | try wrapper.createKey(.hardenedBiometry) |
| 1535 | var updated = record |
| 1536 | updated.layers[.hardenedBiometry] = try wrapper.wrap(middle, slot: .hardenedBiometry) |
| 1537 | let data = updated.encoded |
| 1538 | try? store.update(data, account: Account.rootHardened) |
| 1539 | guard (try? store.read(Account.rootHardened, context: nil)) == data else { throw Failure.storage } |
| 1540 | writeHardenedState(state) |
| 1541 | try? store.delete(Account.hardenedHold) |
| 1542 | } |
| 1543 | |
| 1544 | /// A espera do `.wait`, lida depois de rebaseada. Nil sem espera; chaveiro mudo recusa. |
| 1545 | private func hardenedHoldLocked() throws -> HardenedHold? { |
| 1546 | switch store.probe(Account.hardenedHold) { |
| 1547 | case .absent: return nil |
| 1548 | case .unknown: throw Failure.storage |
| 1549 | case .present: break |
| 1550 | } |
| 1551 | rebaseHardenedHoldLocked() |
| 1552 | guard let data = try? store.read(Account.hardenedHold, context: nil), let hold = HardenedHold(data) else { |
| 1553 | return HardenedHold(hold: .startingNow(), state: Data()) |
| 1554 | } |
| 1555 | return hold |
| 1556 | } |
| 1557 | |
| 1558 | /// A troca vista agora: a espera comeca cheia, com o hash do cadastro que a comecou, e so |
| 1559 | /// vale se ficou gravada como foi escrita. |
| 1560 | private func startHardenedHold(state: Data?) throws { |
| 1561 | let record = HardenedHold(hold: .startingNow(), state: state ?? Data()).encoded |
| 1562 | if (try? store.update(record, account: Account.hardenedHold)) == nil { |
| 1563 | try? store.add(record, account: Account.hardenedHold, protection: .standard, context: nil) |
| 1564 | } |
| 1565 | guard (try? store.read(Account.hardenedHold, context: nil)) == record else { throw Failure.storage } |
| 1566 | } |
| 1567 | |
| 1568 | /// Depois de reiniciar, o prazo gravado com o relogio monotonico antigo nao vale mais: a |
| 1569 | /// espera do `.wait` recomeca cheia a partir de agora, para o mesmo cadastro. Ilegivel, |
| 1570 | /// recomeca de cadastro nenhum (vencida, comeca outra). |
| 1571 | func rebaseHardenedHoldLocked() { |
| 1572 | guard store.probe(Account.hardenedHold) == .present else { return } |
| 1573 | let hold = (try? store.read(Account.hardenedHold, context: nil)).flatMap { HardenedHold($0) } |
| 1574 | guard hold == nil || hold?.hold.isFromAnotherBoot == true else { return } |
| 1575 | try? store.update(HardenedHold(hold: .startingNow(), state: hold?.state ?? Data()).encoded, account: Account.hardenedHold) |
| 1576 | } |
| 1577 | |
| 1578 | /// O hash do cadastro de rostos diferente do gravado. Nunca decide nada sozinho: so a |
| 1579 | /// mensagem do rosto que nao confirmou diz que so as palavras trazem de volta (na trava, se |
| 1580 | /// o cadastro mudou; nas outras, se o Face ID foi redefinido). |
| 1581 | public var hardenedEnrollmentChanged: Bool { |
| 1582 | guard let saved = try? store.read(Account.hardenedState, context: nil), let now = hardenedDomainState() else { return false } |
| 1583 | return saved != now |
| 1584 | } |
| 1585 | |
| 1586 | /// O hash de agora e o gravado quando a politica passou a valer. Sem um dos dois, nao: na |
| 1587 | /// duvida, conta como troca. |
| 1588 | private var hardenedStateMatches: Bool { |
| 1589 | guard let saved = try? store.read(Account.hardenedState, context: nil), let now = hardenedDomainState() else { return false } |
| 1590 | return saved == now |
| 1591 | } |
| 1592 | |
| 1593 | /// A `kand.bio` abriu: o cadastro e o de quando a chave nasceu. Um hash diferente era so |
| 1594 | /// deriva do sistema. |
| 1595 | private func refreshHardenedState() { |
| 1596 | guard let now = hardenedDomainState() else { return } |
| 1597 | writeHardenedState(now) |
| 1598 | } |
| 1599 | |
| 1600 | private func writeHardenedState(_ state: Data) { |
| 1601 | if (try? store.read(Account.hardenedState, context: nil)) == state { return } |
| 1602 | try? store.delete(Account.hardenedState) |
| 1603 | try? store.add(state, account: Account.hardenedState, protection: .standard, context: nil) |
| 1604 | } |
| 1605 | |
| 1606 | // MARK: Gravar e conferir |
| 1607 | |
| 1608 | /// Monta o registro com o PIN e a geracao `pinKey` da chave do PIN (criada aqui, no lugar |
| 1609 | /// da que houver nela), e embrulha o mesmo miolo por cada camada de fora de `faceChange`. |
| 1610 | /// As chaves de fora ja existem: quem chama decide quais nascem agora e quais o registro |
| 1611 | /// em vigor ja usa (essas nunca sao recriadas). A `kand.qualquer` embrulha o miolo aqui e |
| 1612 | /// em nenhum outro lugar, e nunca a RK (verificar.sh). |
| 1613 | private func buildHardenedRecord(rk: SecureBytes, pin: SecureBytes, pinKey: WrapSlot, faceChange: HardenedFaceChange) throws -> Data { |
| 1614 | let saltBytes = try SecureBytes.random(count: VaultFormat.saltLength) |
| 1615 | let salt = saltBytes.withUnsafeBytes { Array($0) } |
| 1616 | let kdf = pinParameters |
| 1617 | let ap = try KeyDerivation.deriveMasterKey(password: pin, salt: salt, parameters: kdf) |
| 1618 | defer { ap.wipe() } |
| 1619 | let header = HardenedHeader(pinLength: UInt8(pin.count), pinKey: pinKey, faceChange: faceChange, salt: salt, kdf: kdf) |
| 1620 | let password = Self.hardenedPassword(ap: ap, salt: salt) |
| 1621 | defer { password.wipe() } |
| 1622 | try password.withUnsafeData { try wrapper.createKey(pinKey, password: $0) } |
| 1623 | let inner = try Self.sealHardenedInner(rk, ap: ap, header: header) |
| 1624 | defer { inner.wipe() } |
| 1625 | var middleData = try password.withUnsafeData { try wrapper.wrap(inner, slot: pinKey, password: $0) } |
| 1626 | let middle = SecureBytes(capacity: middleData.count) |
| 1627 | middleData.withUnsafeMutableBytes { raw in |
| 1628 | middle.append(contentsOf: raw.bindMemory(to: UInt8.self).withMemoryRebound(to: UInt8.self) { UnsafeBufferPointer($0) }) |
| 1629 | // O miolo e o "rosto ja passou": nao fica num Data solto. |
| 1630 | if let base = raw.baseAddress { memset_s(base, raw.count, 0, raw.count) } |
| 1631 | } |
| 1632 | defer { middle.wipe() } |
| 1633 | var layers: [WrapSlot: Data] = [:] |
| 1634 | if faceChange.layers.contains(.hardenedBiometry) { |
| 1635 | layers[.hardenedBiometry] = try wrapper.wrap(middle, slot: .hardenedBiometry) |
| 1636 | } |
| 1637 | if faceChange.layers.contains(.hardenedBiometryAny) { |
| 1638 | layers[.hardenedBiometryAny] = try wrapper.wrap(middle, slot: .hardenedBiometryAny) |
| 1639 | } |
| 1640 | return HardenedRecord(header: header, layers: layers).encoded |
| 1641 | } |
| 1642 | |
| 1643 | /// A prova de que o registro abre com este PIN e este rosto e devolve esta RK; na `.wait`, |
| 1644 | /// tambem que a vigia abre na mesma sessao (ou, sem resposta dela, que o cadastro de agora |
| 1645 | /// e `state`). Nao conta: so roda com a RK ja aberta. |
| 1646 | private func verifyHardened(_ data: Data, pin: SecureBytes, reason: String, matches rk: SecureBytes, state: Data?) throws { |
| 1647 | let record = try HardenedRecord.parse(data) |
| 1648 | let opened = try openHardenedRecord(record, pin: pin, reason: reason) |
| 1649 | defer { |
| 1650 | opened.rk.wipe() |
| 1651 | opened.middle.wipe() |
| 1652 | } |
| 1653 | guard Hash.constantTimeEqual(opened.rk, rk) else { throw Failure.storage } |
| 1654 | switch opened.watch { |
| 1655 | case nil, .same?: |
| 1656 | return |
| 1657 | case .unknown?: |
| 1658 | guard let state, hardenedDomainState() == state else { throw Failure.storage } |
| 1659 | case .gone?: |
| 1660 | throw Failure.storage |
| 1661 | } |
| 1662 | } |
| 1663 | |
| 1664 | private func discardHardenedLocked() { |
| 1665 | try? store.delete(Account.rootHardened) |
| 1666 | try? store.delete(Account.hardenedState) |
| 1667 | try? store.delete(Account.hardenedHold) |
| 1668 | Self.hardenedKeys.forEach(wrapper.deleteKey) |
| 1669 | } |
| 1670 | |
| 1671 | /// Leva o que sobrou de uma transicao para o estado que o modo diz. So com respostas |
| 1672 | /// definitivas; com os dois slots, nada (o PIN certo resolve). |
| 1673 | /// |
| 1674 | /// No reforcado, so as sobras do modo normal: as chaves do reforcado dependem do |
| 1675 | /// cabecalho, e o cabecalho em claro so vale depois de autenticado |
| 1676 | /// (`sweepHardenedKeysLocked`). Antes, isto lia o cabecalho sem autenticar, e trocar o |
| 1677 | /// byte das camadas entre 1 e 2 fazia a volta ao app apagar a chave de fora em vigor |
| 1678 | /// (revisao das tres politicas, B3). |
| 1679 | func sweepHardenedLocked() { |
| 1680 | switch securityMode { |
| 1681 | case .hardened: |
| 1682 | for account in [Account.rootByBiometry, Account.biometryState, Account.biometryHold, Account.biometryLost, Account.pinKDF, Account.pinLength, |
| 1683 | Account.pinKDF + Account.pendingSuffix, Account.pinLength + Account.pendingSuffix] { |
| 1684 | try? store.delete(account) |
| 1685 | } |
| 1686 | [WrapSlot.device, .pin, .pinPending, .biometry].forEach(wrapper.deleteKey) |
| 1687 | case .normal: |
| 1688 | guard store.probe(Account.rootHardened) == .absent else { return } |
| 1689 | Self.hardenedKeys.forEach(wrapper.deleteKey) |
| 1690 | try? store.delete(Account.hardenedState) |
| 1691 | try? store.delete(Account.hardenedHold) |
| 1692 | case .unknown: |
| 1693 | return |
| 1694 | } |
| 1695 | } |
| 1696 | |
| 1697 | /// O que um cabecalho autenticado diz que sobra: a geracao da chave do PIN que ele nao usa, |
| 1698 | /// as chaves de fora que a politica dele nao usa (`.lock`: a `kand.qualquer`; `.continue`: |
| 1699 | /// a `kand.bio`; `.wait`: nenhuma, nem a `kand.bio` morta, que o realinhamento recria), e a |
| 1700 | /// espera fora da `.wait`. So com `header` provado: depois de uma abertura (o AAD da camada |
| 1701 | /// interna o autentica) ou de um commit que o cofre acabou de gravar e conferir. Antes do |
| 1702 | /// commit de uma troca o cabecalho antigo manda, depois o novo: o registro em vigor nunca |
| 1703 | /// aponta para uma chave que isto apaga. |
| 1704 | private func sweepHardenedKeysLocked(_ header: HardenedHeader) { |
| 1705 | let policy = header.faceChange |
| 1706 | wrapper.deleteKey(header.pinKey == .hardenedPINA ? .hardenedPINB : .hardenedPINA) |
| 1707 | for slot in [WrapSlot.hardenedBiometry, .hardenedBiometryAny] where !policy.layers.contains(slot) { |
| 1708 | wrapper.deleteKey(slot) |
| 1709 | } |
| 1710 | if policy != .wait { try? store.delete(Account.hardenedHold) } |
| 1711 | } |
| 1712 | |
| 1713 | // MARK: Ligar, desligar, trocar o PIN, trocar a politica |
| 1714 | |
| 1715 | /// Liga. Uma chamada, sincrona, fora do MainActor: PIN e rosto pelo caminho de hoje, |
| 1716 | /// `check` com a RK (a conferencia das palavras), as chaves de fora de `faceChange` |
| 1717 | /// criadas, o rk.and gravado e aberto de novo com PIN e rosto, e so entao o commit |
| 1718 | /// (apagar o rk.pin). |
| 1719 | /// |
| 1720 | /// `check` roda sob a trava do cofre: nunca pode chamar o `RootKeyVault` (a trava nao e |
| 1721 | /// recursiva). Abrir carteiras pelo `WalletVault` pode. |
| 1722 | public func enableHardened(pin: SecureBytes, reason: String, confirmReason: String, faceChange: HardenedFaceChange = .continue, |
| 1723 | check: (SecureBytes) throws -> Void) throws { |
| 1724 | lock.lock(); defer { lock.unlock() } |
| 1725 | switch securityMode { |
| 1726 | case .hardened: throw Failure.hardenedModeActive |
| 1727 | case .unknown: throw Failure.storage |
| 1728 | case .normal: break |
| 1729 | } |
| 1730 | guard slotVersion(suffix: "") == 2 else { throw Failure.hardenedBlocked(.legacySlot) } |
| 1731 | switch store.probe(Account.rootByPIN + Account.pendingSuffix) { |
| 1732 | case .absent: break |
| 1733 | case .present: throw Failure.hardenedBlocked(.changePending) |
| 1734 | case .unknown: throw Failure.storage |
| 1735 | } |
| 1736 | guard store.probe(Account.rootByBiometry) == .present, wrapper.probeKey(.biometry) == .present else { |
| 1737 | throw Failure.hardenedBlocked(.biometryOff) |
| 1738 | } |
| 1739 | let hold = try biometryHoldRemainingChecked() |
| 1740 | guard hold <= 0 else { throw Failure.biometryHold(seconds: hold) } |
| 1741 | guard hardenedBiometryStatus() == .ready else { throw Failure.hardenedBlocked(.biometryUnavailable) } |
| 1742 | let stateBefore = hardenedDomainState() |
| 1743 | |
| 1744 | // 1. PIN e o rosto do slot de hoje (A1): prova que o cadastro e o de quando o Face |
| 1745 | // ID foi ligado, e conta tentativa como sempre. |
| 1746 | let rk = try unlockWithPINAndBiometryLocked(pin: pin, reason: reason) |
| 1747 | defer { rk.wipe() } |
| 1748 | try check(rk) |
| 1749 | // 2. Sobras de uma ativacao interrompida: so agora, com o slot do PIN provado vivo. |
| 1750 | discardHardenedLocked() |
| 1751 | // 3. Chaves novas e o registro. |
| 1752 | let committed: HardenedHeader |
| 1753 | do { |
| 1754 | for slot in faceChange.layers { try wrapper.createKey(slot) } |
| 1755 | let record = try buildHardenedRecord(rk: rk, pin: pin, pinKey: .hardenedPINA, faceChange: faceChange) |
| 1756 | try store.add(record, account: Account.rootHardened, protection: .standard, context: nil) |
| 1757 | if let state = stateBefore { try store.add(state, account: Account.hardenedState, protection: .standard, context: nil) } |
| 1758 | // 4. Conferir lendo do chaveiro e abrindo de verdade: segundo Face ID. |
| 1759 | guard let stored = try store.read(Account.rootHardened, context: nil) else { throw Failure.storage } |
| 1760 | try verifyHardened(stored, pin: pin, reason: confirmReason, matches: rk, state: stateBefore) |
| 1761 | guard hardenedDomainState() == stateBefore else { throw Failure.biometryChanged } |
| 1762 | committed = try HardenedRecord.parse(stored).header |
| 1763 | } catch { |
| 1764 | discardHardenedLocked() |
| 1765 | throw error is Failure ? error : Failure.storage |
| 1766 | } |
| 1767 | // 5. Commit. Daqui em diante nada apaga o rk.and por falha. |
| 1768 | try? store.delete(Account.rootByPIN) |
| 1769 | guard store.probe(Account.rootByPIN) == .absent else { throw Failure.storage } |
| 1770 | // 6. Limpeza; se parar no meio, a varredura e a proxima abertura terminam. |
| 1771 | sweepHardenedLocked() |
| 1772 | sweepHardenedKeysLocked(committed) |
| 1773 | } |
| 1774 | |
| 1775 | /// Desliga: PIN e rosto no rk.and (fora da espera do `.wait`), o slot do PIN gravado e |
| 1776 | /// conferido (a partir dai o modo normal manda), o Face ID religado e conferido (segundo |
| 1777 | /// rosto; se falhar, fica desligado), e o rk.and sai. Devolve se o Face ID voltou. |
| 1778 | /// |
| 1779 | /// O Face ID volta sem a espera do A1 so quando o reforcado reconhece o cadastro: a |
| 1780 | /// `kand.bio` abriu (`.lock`), a vigia disse "o mesmo" (`.wait`), ou o hash e o gravado |
| 1781 | /// (`.continue`). Senao, a marca de perdido e gravada antes do slot do PIN: o |
| 1782 | /// `enableBiometry` comeca a espera ao religar, e sem o Face ID religado o que pede PIN e |
| 1783 | /// rosto recusa ate o dono religar, como numa queda do A1. |
| 1784 | @discardableResult |
| 1785 | public func disableHardened(pin: SecureBytes, reason: String, biometryReason: String) throws -> Bool { |
| 1786 | lock.lock(); defer { lock.unlock() } |
| 1787 | let (rk, same) = try openHardenedLocked(pin: pin, reason: reason, use: .operation) |
| 1788 | defer { rk.wipe() } |
| 1789 | if !same { |
| 1790 | if store.probe(Account.biometryLost) != .present { |
| 1791 | try? store.add(Data([1]), account: Account.biometryLost, protection: .standard, context: nil) |
| 1792 | } |
| 1793 | guard store.probe(Account.biometryLost) == .present else { throw Failure.storage } |
| 1794 | } |
| 1795 | do { |
| 1796 | try writePINSlot(rk: rk, pin: pin, kdf: pinParameters, suffix: "") |
| 1797 | try confirm(pin: pin, suffix: "", matches: rk) |
| 1798 | } catch { |
| 1799 | // O rk.pin sai e e conferido antes da chave dele: um rk.pin sem chave tiraria o |
| 1800 | // cofre do reforcado com um slot que nao abre (revisao do modo reforcado). |
| 1801 | if !same { try? store.delete(Account.biometryLost) } |
| 1802 | try? store.delete(Account.rootByPIN) |
| 1803 | guard store.probe(Account.rootByPIN) == .absent else { throw Failure.storage } |
| 1804 | deleteSlot(suffix: "") |
| 1805 | wrapper.deleteKey(.pin) |
| 1806 | throw error is Failure ? error : Failure.storage |
| 1807 | } |
| 1808 | // A conferencia do rosto e a do proprio `enableBiometry`; falhou, fica desligado. |
| 1809 | let restored = (try? enableBiometry(rk: rk, reason: biometryReason)) != nil |
| 1810 | discardHardenedLocked() |
| 1811 | return restored |
| 1812 | } |
| 1813 | |
| 1814 | /// Troca o PIN no modo reforcado, mantendo a politica e as chaves de fora: a chave do PIN |
| 1815 | /// alterna (a/b), o registro novo e aberto com PIN novo e rosto antes, e o commit e a |
| 1816 | /// troca no lugar do rk.and (atomica). Na espera do `.wait`, recusa. |
| 1817 | public func changeHardenedPIN(currentPIN: SecureBytes, newPIN: SecureBytes, reason: String, confirmReason: String) throws { |
| 1818 | lock.lock(); defer { lock.unlock() } |
| 1819 | guard Self.isWellFormed(newPIN) else { throw Failure.malformedPIN } |
| 1820 | let rk = try openHardenedLocked(pin: currentPIN, reason: reason, use: .operation).rk |
| 1821 | defer { rk.wipe() } |
| 1822 | try replaceHardenedLocked(rk: rk, pin: newPIN, target: nil, confirmReason: confirmReason) |
| 1823 | } |
| 1824 | |
| 1825 | /// Muda o que acontece quando o Face ID muda. PIN e rosto (uma tentativa contada; na |
| 1826 | /// espera do `.wait`, recusa antes), `check` com a RK so na ida para `.lock` (com a |
| 1827 | /// trava, o papel e o unico caminho de volta: as palavras de cada carteira), o registro |
| 1828 | /// novo montado com a outra geracao da chave do PIN e aberto com o segundo rosto, e o |
| 1829 | /// commit pelo `update` do rk.and. Sair da `.lock` abre pela `kand.bio`: o rosto de agora |
| 1830 | /// prova o mesmo cadastro. A mesma politica nao pede nada. |
| 1831 | /// |
| 1832 | /// `check` roda sob a trava do cofre: nunca pode chamar o `RootKeyVault`. |
| 1833 | public func setHardenedFaceChange(_ target: HardenedFaceChange, pin: SecureBytes, reason: String, confirmReason: String, |
| 1834 | check: (SecureBytes) throws -> Void) throws { |
| 1835 | lock.lock(); defer { lock.unlock() } |
| 1836 | switch securityMode { |
| 1837 | case .unknown: throw Failure.storage |
| 1838 | case .normal: throw Failure.notHardened |
| 1839 | case .hardened: break |
| 1840 | } |
| 1841 | guard try readHardened().header.faceChange != target else { return } |
| 1842 | let rk = try openHardenedLocked(pin: pin, reason: reason, use: .operation).rk |
| 1843 | defer { rk.wipe() } |
| 1844 | if target == .lock { try check(rk) } |
| 1845 | try replaceHardenedLocked(rk: rk, pin: pin, target: target, confirmReason: confirmReason) |
| 1846 | } |
| 1847 | |
| 1848 | /// Troca o rk.and no lugar, com a outra geracao da chave do PIN: a maquina da troca de PIN |
| 1849 | /// (`target` nil, a mesma politica) e da troca de politica. |
| 1850 | /// |
| 1851 | /// As chaves de fora que o registro em vigor ja usa sao reaproveitadas, nunca recriadas; |
| 1852 | /// as que faltam para a politica nova nascem agora. O registro novo e aberto com o PIN e o |
| 1853 | /// rosto (o segundo Face ID, que nao conta tentativa: recusado, volta como cancelado) antes |
| 1854 | /// do commit, que e o `update` do rk.and, e o que ficou gravado decide, nao o retorno do |
| 1855 | /// update. Antes do commit o cabecalho antigo manda, e a varredura so apaga o que nasceu |
| 1856 | /// aqui; depois, o novo, e a varredura apaga a geracao antiga e as chaves de fora que a |
| 1857 | /// politica nova nao usa. |
| 1858 | private func replaceHardenedLocked(rk: SecureBytes, pin: SecureBytes, target: HardenedFaceChange?, confirmReason: String) throws { |
| 1859 | guard let old = try? store.read(Account.rootHardened, context: nil) else { throw Failure.storage } |
| 1860 | let current = try HardenedRecord.parse(old) |
| 1861 | let from = current.header.faceChange |
| 1862 | let to = target ?? from |
| 1863 | let nextKey: WrapSlot = current.header.pinKey == .hardenedPINA ? .hardenedPINB : .hardenedPINA |
| 1864 | let born = to.layers.filter { !from.layers.contains($0) } |
| 1865 | let fresh = [nextKey] + born |
| 1866 | let stateBefore = hardenedDomainState() |
| 1867 | // A vigia que nasce agora acompanha o cadastro de agora; a reaproveitada, o gravado. |
| 1868 | let state: Data? = born.contains(.hardenedBiometry) ? stateBefore : (try? store.read(Account.hardenedState, context: nil)) ?? nil |
| 1869 | wrapper.deleteKey(nextKey) |
| 1870 | let record: Data |
| 1871 | do { |
| 1872 | for slot in born { try wrapper.createKey(slot) } |
| 1873 | record = try buildHardenedRecord(rk: rk, pin: pin, pinKey: nextKey, faceChange: to) |
| 1874 | try verifyHardened(record, pin: pin, reason: confirmReason, matches: rk, state: state) |
| 1875 | if born.contains(.hardenedBiometry) { |
| 1876 | guard hardenedDomainState() == stateBefore else { throw Failure.biometryChanged } |
| 1877 | } |
| 1878 | } catch Failure.biometryNotConfirmed { |
| 1879 | fresh.forEach(wrapper.deleteKey) |
| 1880 | throw Failure.cancelled |
| 1881 | } catch { |
| 1882 | fresh.forEach(wrapper.deleteKey) |
| 1883 | throw error is Failure ? error : Failure.storage |
| 1884 | } |
| 1885 | try? store.update(record, account: Account.rootHardened) |
| 1886 | guard let stored = try? store.read(Account.rootHardened, context: nil) else { throw Failure.storage } |
| 1887 | if stored == record { |
| 1888 | // Uma chave nova sumiu depois do commit: volta o registro antigo, conferido, antes |
| 1889 | // de recusar. Sem isso, o rk.and gravado apontaria para uma chave que nao existe e |
| 1890 | // a varredura apagaria as antigas (segunda revisao do modo reforcado, N2). |
| 1891 | guard fresh.allSatisfy({ wrapper.probeKey($0) == .present }) else { |
| 1892 | try? store.update(old, account: Account.rootHardened) |
| 1893 | guard (try? store.read(Account.rootHardened, context: nil)) == old else { throw Failure.storage } |
| 1894 | fresh.forEach(wrapper.deleteKey) |
| 1895 | throw Failure.storage |
| 1896 | } |
| 1897 | if born.contains(.hardenedBiometry), let stateBefore { writeHardenedState(stateBefore) } |
| 1898 | sweepHardenedLocked() |
| 1899 | if let header = try? HardenedRecord.parse(record).header { sweepHardenedKeysLocked(header) } |
| 1900 | } else if stored == old { |
| 1901 | fresh.forEach(wrapper.deleteKey) |
| 1902 | throw Failure.storage |
| 1903 | } else { |
| 1904 | throw Failure.storage |
| 1905 | } |
| 1906 | } |
| 1907 | } |