Auditoria
As chaves do Secure Enclave
Kit/Sources/EscaliburKeys/Enclave.swift
Chaves de hardware que embrulham a chave raiz (RK).
| 1 | import CryptoKit |
| 2 | import EscaliburCore |
| 3 | import Foundation |
| 4 | import LocalAuthentication |
| 5 | import Security |
| 6 | |
| 7 | /// Chaves de hardware que embrulham a chave raiz (RK). |
| 8 | /// |
| 9 | /// Chaves P-256 no Secure Enclave: |
| 10 | /// - `K_pin` (e `K_pin` da troca de PIN em andamento): `.privateKeyUsage` + |
| 11 | /// `.applicationPassword`, com a senha derivada do PIN. O SE so desembrulha com ela: |
| 12 | /// cada chute de PIN passa por este aparelho, um de cada vez (auditoria 3, A1; |
| 13 | /// conferido num iPhone 13 Pro Max em 10/10/2026: a senha errada e recusada pelo |
| 14 | /// proprio SE, 22 ms por tentativa). |
| 15 | /// - `K_dev`: so `.privateKeyUsage`. A chave do PIN das versoes anteriores; e trocada |
| 16 | /// pela `K_pin` no primeiro PIN certo e apagada. |
| 17 | /// - `K_bio`: `.privateKeyUsage` + `.biometryCurrentSet`. Embrulha a RK para o |
| 18 | /// caminho do Face ID. A decisao de liberar e do SEP, nao de um booleano no app, e |
| 19 | /// cadastrar um rosto novo destroi a chave em vez de abri-la. |
| 20 | /// - Modo reforcado: `kand.bio` (`.biometryCurrentSet`), `kand.qualquer` |
| 21 | /// (`.biometryAny`, a unica chave com essa flag, que nunca embrulha a RK, so o miolo |
| 22 | /// que ainda pede o PIN) e `kand.pin.a`/`kand.pin.b` (`.applicationPassword`). As |
| 23 | /// flags de cada uma saem de `SecureEnclaveWrapper.flags(for:)`. |
| 24 | /// |
| 25 | /// Nenhum embrulho abre fora deste aparelho: a parte privada nunca sai do SE. |
| 26 | public protocol KeyWrapper: Sendable { |
| 27 | func createKey(_ slot: WrapSlot) throws |
| 28 | func hasKey(_ slot: WrapSlot) -> Bool |
| 29 | func deleteKey(_ slot: WrapSlot) |
| 30 | /// Embrulha com a chave de um slot sem senha (aparelho e rosto). Nos slots do PIN, use o |
| 31 | /// de baixo: buscar a chave sem a senha fazia o iOS pedir a senha na tela. |
| 32 | func wrap(_ secret: SecureBytes, slot: WrapSlot) throws -> Data |
| 33 | /// Embrulha com a chave de um slot do PIN, buscada com a senha de aplicativo e sem |
| 34 | /// interface nenhuma (visto no iPhone, 18/10/2026: ao ligar e desligar o Super Security, |
| 35 | /// o iOS abria "Digite a senha para Escalibur"). |
| 36 | func wrap(_ secret: SecureBytes, slot: WrapSlot, password: Data) throws -> Data |
| 37 | /// Abre um embrulho. No slot biometrico, o sistema mostra o Face ID com |
| 38 | /// `reason`; a chamada bloqueia a thread ate a decisao, entao nunca no MainActor. |
| 39 | func unwrap(_ blob: Data, slot: WrapSlot, reason: String?) throws -> SecureBytes |
| 40 | /// Cria a chave do slot do PIN com senha de aplicativo. Substitui a que houver no |
| 41 | /// mesmo slot: o slot do PIN so e gravado quando nao existe. |
| 42 | func createKey(_ slot: WrapSlot, password: Data) throws |
| 43 | /// Desembrulha com a senha de aplicativo. Senha errada: `EnclaveError.wrongPassword`. |
| 44 | func unwrap(_ blob: Data, slot: WrapSlot, password: Data) throws -> SecureBytes |
| 45 | /// Existe, nao existe, ou o SE e o chaveiro nao conseguiram dizer. "Nao sei" nunca vira |
| 46 | /// "nao existe": e o que separa "o Face ID mudou" de "o iPhone esta bloqueado". |
| 47 | func probeKey(_ slot: WrapSlot) -> ItemState |
| 48 | /// Abre `blob` pelo rosto, com um Face ID so, e na mesma sessao, sem interface nenhuma, |
| 49 | /// tenta abrir `watch` com a chave `watchSlot` (a vigia do modo reforcado). A recusa do |
| 50 | /// rosto lanca como `unwrap(_:slot:reason:)`; a vigia nunca lanca: diz o que viu. |
| 51 | func unwrap(_ blob: Data, slot: WrapSlot, reason: String, watching watch: Data, with watchSlot: WrapSlot) throws -> (SecureBytes, WatchResult) |
| 52 | } |
| 53 | |
| 54 | /// O que a vigia viu na sessao do rosto que acabou de abrir a outra camada. |
| 55 | public enum WatchResult: Sendable { |
| 56 | /// Abriu, sem pedir nada: o conteudo, que quem recebe zera. |
| 57 | case opened(SecureBytes) |
| 58 | /// A chave da vigia nao existe mais: o SE a destruiu quando o cadastro mudou. |
| 59 | case gone |
| 60 | /// Precisaria de interface, ou um erro que nao se classifica. Quem decide e o hash do |
| 61 | /// cadastro, e na duvida conta como troca. |
| 62 | case unknown |
| 63 | } |
| 64 | |
| 65 | public enum WrapSlot: String, Sendable, CaseIterable { |
| 66 | case device = "kdev" |
| 67 | case biometry = "kbio" |
| 68 | /// A chave do slot principal do PIN, com senha de aplicativo. |
| 69 | case pin = "kpin" |
| 70 | /// A do slot da troca de PIN em andamento. |
| 71 | case pinPending = "kpin.novo" |
| 72 | /// Modo reforcado: a camada de fora do rk.and que acompanha o cadastro de rostos, |
| 73 | /// `.biometryCurrentSet`. A unica de fora em "Bloquear as carteiras"; a vigia em |
| 74 | /// "Esperar 24 horas". |
| 75 | case hardenedBiometry = "kand.bio" |
| 76 | /// Modo reforcado: a camada de fora que sobrevive a um rosto novo, `.biometryAny`. Em |
| 77 | /// "Continuar normal" e "Esperar 24 horas". Nunca embrulha a RK: so o miolo, que ainda |
| 78 | /// pede o PIN pela `kand.pin`. |
| 79 | case hardenedBiometryAny = "kand.qualquer" |
| 80 | /// Modo reforcado: a camada de dentro, `.applicationPassword` derivada do PIN. Duas |
| 81 | /// geracoes que se alternam na troca de PIN; o cabecalho do rk.and diz qual vale. |
| 82 | case hardenedPINA = "kand.pin.a" |
| 83 | case hardenedPINB = "kand.pin.b" |
| 84 | |
| 85 | public var usesBiometry: Bool { [.biometry, .hardenedBiometry, .hardenedBiometryAny].contains(self) } |
| 86 | /// A chave morre quando o cadastro de rostos muda (`.biometryCurrentSet`). |
| 87 | public var followsEnrollment: Bool { self == .biometry || self == .hardenedBiometry } |
| 88 | public var usesPassword: Bool { [.pin, .pinPending, .hardenedPINA, .hardenedPINB].contains(self) } |
| 89 | } |
| 90 | |
| 91 | public enum EnclaveError: Error, Equatable, Sendable { |
| 92 | case unavailable |
| 93 | case keyMissing |
| 94 | /// A autenticacao nao liberou a chave desta vez. Se o cadastro de rostos mudou, e |
| 95 | /// o RootKeyVault que decide, comparando o estado gravado. |
| 96 | case cancelled |
| 97 | case failed(String) |
| 98 | /// O SE recusou a senha de aplicativo: o PIN nao e este. |
| 99 | case wrongPassword |
| 100 | } |
| 101 | |
| 102 | /// O Secure Enclave de verdade. |
| 103 | public final class SecureEnclaveWrapper: KeyWrapper, @unchecked Sendable { |
| 104 | private static let tagPrefix = "app.escalibur.wallet." |
| 105 | private static let algorithm = SecKeyAlgorithm.eciesEncryptionCofactorVariableIVX963SHA256AESGCM |
| 106 | |
| 107 | public init() {} |
| 108 | |
| 109 | public static var isAvailable: Bool { SecureEnclave.isAvailable } |
| 110 | |
| 111 | private func tag(_ slot: WrapSlot) -> Data { Data((Self.tagPrefix + slot.rawValue).utf8) } |
| 112 | |
| 113 | public func createKey(_ slot: WrapSlot) throws { |
| 114 | // A chave do aparelho nunca e substituida em silencio: ela e o que abre a RK |
| 115 | // de todas as carteiras. So a da biometria e recriada ao religar. |
| 116 | if slot == .device, hasKey(.device) { throw EnclaveError.failed("chave do aparelho ja existe") } |
| 117 | guard slot == .device || slot.usesBiometry else { throw EnclaveError.failed("o slot do PIN pede senha") } |
| 118 | deleteKey(slot) |
| 119 | var error: Unmanaged<CFError>? |
| 120 | guard let access = SecAccessControlCreateWithFlags(nil, KeychainStore.accessibility, Self.flags(for: slot), &error) else { |
| 121 | throw EnclaveError.failed("controle de acesso") |
| 122 | } |
| 123 | let attributes: [String: Any] = [ |
| 124 | kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, |
| 125 | kSecAttrKeySizeInBits as String: 256, |
| 126 | kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, |
| 127 | kSecPrivateKeyAttrs as String: [ |
| 128 | kSecAttrIsPermanent as String: true, |
| 129 | kSecAttrApplicationTag as String: tag(slot), |
| 130 | kSecAttrAccessControl as String: access, |
| 131 | ], |
| 132 | ] |
| 133 | guard SecKeyCreateRandomKey(attributes as CFDictionary, &error) != nil else { |
| 134 | throw EnclaveError.unavailable |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | public func hasKey(_ slot: WrapSlot) -> Bool { |
| 139 | let context = LAContext() |
| 140 | context.interactionNotAllowed = true |
| 141 | return (try? privateKey(slot, context: context)) != nil |
| 142 | } |
| 143 | |
| 144 | public func probeKey(_ slot: WrapSlot) -> ItemState { |
| 145 | let context = LAContext() |
| 146 | context.interactionNotAllowed = true |
| 147 | defer { context.invalidate() } |
| 148 | let query: [String: Any] = [ |
| 149 | kSecClass as String: kSecClassKey, |
| 150 | kSecAttrApplicationTag as String: tag(slot), |
| 151 | kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, |
| 152 | kSecReturnAttributes as String: true, |
| 153 | kSecUseAuthenticationContext as String: context, |
| 154 | ] |
| 155 | switch SecItemCopyMatching(query as CFDictionary, nil) { |
| 156 | case errSecSuccess, errSecInteractionNotAllowed, errSecAuthFailed: return .present |
| 157 | case errSecItemNotFound: return .absent |
| 158 | default: return .unknown |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | public func deleteKey(_ slot: WrapSlot) { |
| 163 | let query: [String: Any] = [ |
| 164 | kSecClass as String: kSecClassKey, |
| 165 | kSecAttrApplicationTag as String: tag(slot), |
| 166 | kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, |
| 167 | ] |
| 168 | SecItemDelete(query as CFDictionary) |
| 169 | } |
| 170 | |
| 171 | private func privateKey(_ slot: WrapSlot, context: LAContext?) throws -> SecKey { |
| 172 | var query: [String: Any] = [ |
| 173 | kSecClass as String: kSecClassKey, |
| 174 | kSecAttrApplicationTag as String: tag(slot), |
| 175 | kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, |
| 176 | kSecReturnRef as String: true, |
| 177 | ] |
| 178 | if let context { query[kSecUseAuthenticationContext as String] = context } |
| 179 | var item: CFTypeRef? |
| 180 | let status = SecItemCopyMatching(query as CFDictionary, &item) |
| 181 | guard status == errSecSuccess, let item else { throw EnclaveError.keyMissing } |
| 182 | return item as! SecKey // swiftlint:disable:this force_cast |
| 183 | } |
| 184 | |
| 185 | public func wrap(_ secret: SecureBytes, slot: WrapSlot) throws -> Data { |
| 186 | // A kand.qualquer so embrulha o miolo, o ECIES da kand.pin, bem maior que a RK: um |
| 187 | // segredo do tamanho dela nunca passa por aqui. |
| 188 | if slot == .hardenedBiometryAny, secret.count <= 32 { throw EnclaveError.failed("kand.qualquer so embrulha o miolo") } |
| 189 | // Nos slots do PIN, so com a senha (o de baixo). Aqui, nenhuma interface do sistema. |
| 190 | guard !slot.usesPassword else { throw EnclaveError.failed("o slot do PIN embrulha com a senha") } |
| 191 | let context = LAContext() |
| 192 | context.interactionNotAllowed = true |
| 193 | defer { context.invalidate() } |
| 194 | return try encrypt(secret, with: privateKey(slot, context: context)) |
| 195 | } |
| 196 | |
| 197 | public func wrap(_ secret: SecureBytes, slot: WrapSlot, password: Data) throws -> Data { |
| 198 | guard slot.usesPassword else { throw EnclaveError.failed("senha so nos slots do PIN") } |
| 199 | let context = LAContext() |
| 200 | defer { Self.release(context) } |
| 201 | context.interactionNotAllowed = true |
| 202 | guard context.setCredential(password, type: .applicationPassword) else { throw EnclaveError.failed("credencial") } |
| 203 | return try encrypt(secret, with: privateKey(slot, context: context)) |
| 204 | } |
| 205 | |
| 206 | private func encrypt(_ secret: SecureBytes, with key: SecKey) throws -> Data { |
| 207 | guard let publicKey = SecKeyCopyPublicKey(key) else { throw EnclaveError.keyMissing } |
| 208 | var error: Unmanaged<CFError>? |
| 209 | let blob = secret.withUnsafeData { plain in |
| 210 | SecKeyCreateEncryptedData(publicKey, Self.algorithm, plain as CFData, &error) |
| 211 | } |
| 212 | guard let blob else { throw EnclaveError.failed("embrulho") } |
| 213 | return blob as Data |
| 214 | } |
| 215 | |
| 216 | public func unwrap(_ blob: Data, slot: WrapSlot, reason: String?) throws -> SecureBytes { |
| 217 | let context = Self.faceContext(reason: reason) |
| 218 | defer { context.invalidate() } |
| 219 | return try decrypt(blob, slot: slot, context: context) |
| 220 | } |
| 221 | |
| 222 | /// Um Face ID, e a vigia no mesmo contexto ja avaliado, agora sem interface: nenhum |
| 223 | /// segundo rosto. Se o contexto nao servir para a outra chave, ou ela recusar sem dizer |
| 224 | /// por que, a vigia volta "nao se sabe" e quem decide e o hash do cadastro. So a chave |
| 225 | /// que o chaveiro diz que nao existe e troca certa (conferir no aparelho, §2.6). |
| 226 | public func unwrap(_ blob: Data, slot: WrapSlot, reason: String, watching watch: Data, with watchSlot: WrapSlot) throws -> (SecureBytes, WatchResult) { |
| 227 | let context = Self.faceContext(reason: reason) |
| 228 | defer { context.invalidate() } |
| 229 | let opened = try decrypt(blob, slot: slot, context: context) |
| 230 | context.interactionNotAllowed = true |
| 231 | let query: [String: Any] = [ |
| 232 | kSecClass as String: kSecClassKey, |
| 233 | kSecAttrApplicationTag as String: tag(watchSlot), |
| 234 | kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, |
| 235 | kSecReturnRef as String: true, |
| 236 | kSecUseAuthenticationContext as String: context, |
| 237 | ] |
| 238 | var item: CFTypeRef? |
| 239 | let status = SecItemCopyMatching(query as CFDictionary, &item) |
| 240 | if status == errSecItemNotFound { return (opened, .gone) } |
| 241 | guard status == errSecSuccess, let item else { return (opened, .unknown) } |
| 242 | let key = item as! SecKey // swiftlint:disable:this force_cast |
| 243 | var error: Unmanaged<CFError>? |
| 244 | guard let plain = SecKeyCreateDecryptedData(key, Self.algorithm, watch as CFData, &error) else { |
| 245 | _ = error?.takeRetainedValue() |
| 246 | return (opened, .unknown) |
| 247 | } |
| 248 | return (opened, .opened(SecureBytes.consuming(plain))) |
| 249 | } |
| 250 | |
| 251 | /// O contexto de um Face ID. Nenhum reuso de autenticacao: cada assinatura pede presenca |
| 252 | /// nova. Sem botao de alternativa do sistema: a alternativa e o PIN do app. Sem motivo, |
| 253 | /// nenhuma interface. |
| 254 | private static func faceContext(reason: String?) -> LAContext { |
| 255 | let context = LAContext() |
| 256 | context.touchIDAuthenticationAllowableReuseDuration = 0 |
| 257 | context.localizedFallbackTitle = "" |
| 258 | if let reason { context.localizedReason = reason } else { context.interactionNotAllowed = true } |
| 259 | return context |
| 260 | } |
| 261 | |
| 262 | private func decrypt(_ blob: Data, slot: WrapSlot, context: LAContext) throws -> SecureBytes { |
| 263 | let key: SecKey |
| 264 | do { |
| 265 | key = try privateKey(slot, context: context) |
| 266 | } catch { |
| 267 | throw EnclaveError.keyMissing |
| 268 | } |
| 269 | var error: Unmanaged<CFError>? |
| 270 | guard let plain = SecKeyCreateDecryptedData(key, Self.algorithm, blob as CFData, &error) else { |
| 271 | let nsError = (error?.takeRetainedValue() as Error?).map { $0 as NSError } |
| 272 | // Qualquer recusa da autenticacao (cancelar, ligacao chegando, bloqueio por |
| 273 | // tentativas, rosto nao reconhecido) e "nao desta vez", nao "a biometria |
| 274 | // mudou". Quem decide se o cadastro mudou e o RootKeyVault, pelo estado. |
| 275 | if nsError?.domain == LAErrorDomain || nsError?.code == Int(errSecUserCanceled) || nsError?.code == Int(errSecAuthFailed) { |
| 276 | throw EnclaveError.cancelled |
| 277 | } |
| 278 | throw EnclaveError.failed("desembrulho") |
| 279 | } |
| 280 | return SecureBytes.consuming(plain) |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | extension SecureEnclaveWrapper { |
| 285 | /// As flags do controle de acesso de cada chave. A unica com `.biometryAny` e a |
| 286 | /// `kand.qualquer`, a camada de fora do modo reforcado que sobrevive a um rosto novo: |
| 287 | /// sozinha ela nao abre nada (por baixo, a `kand.pin` pede o PIN), e o `verificar.sh` |
| 288 | /// confere que a excecao nao sai desta linha. |
| 289 | static func flags(for slot: WrapSlot) -> SecAccessControlCreateFlags { |
| 290 | switch slot { |
| 291 | case .device: return [.privateKeyUsage] |
| 292 | case .biometry, .hardenedBiometry: return [.privateKeyUsage, .biometryCurrentSet] |
| 293 | case .hardenedBiometryAny: return [.privateKeyUsage, .biometryAny] |
| 294 | case .pin, .pinPending, .hardenedPINA, .hardenedPINB: return [.privateKeyUsage, .applicationPassword] |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | /// O contexto com a senha de aplicativo vale para uma chamada so: na volta, a |
| 299 | /// credencial e solta e o contexto invalidado, para a senha derivada do PIN nao ficar |
| 300 | /// retida num objeto vivo (auditoria 4, C7-21). O mesmo que `KeychainStore` faz. |
| 301 | private static func release(_ context: LAContext) { |
| 302 | context.setCredential(nil, type: .applicationPassword) |
| 303 | context.invalidate() |
| 304 | } |
| 305 | |
| 306 | public func createKey(_ slot: WrapSlot, password: Data) throws { |
| 307 | guard slot.usesPassword else { throw EnclaveError.failed("senha so nos slots do PIN") } |
| 308 | deleteKey(slot) |
| 309 | let context = LAContext() |
| 310 | defer { Self.release(context) } |
| 311 | guard context.setCredential(password, type: .applicationPassword) else { throw EnclaveError.failed("credencial") } |
| 312 | var error: Unmanaged<CFError>? |
| 313 | guard let access = SecAccessControlCreateWithFlags(nil, KeychainStore.accessibility, Self.flags(for: slot), &error) else { |
| 314 | throw EnclaveError.failed("controle de acesso") |
| 315 | } |
| 316 | let attributes: [String: Any] = [ |
| 317 | kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, |
| 318 | kSecAttrKeySizeInBits as String: 256, |
| 319 | kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, |
| 320 | kSecUseAuthenticationContext as String: context, |
| 321 | kSecPrivateKeyAttrs as String: [ |
| 322 | kSecAttrIsPermanent as String: true, |
| 323 | kSecAttrApplicationTag as String: Data(("app.escalibur.wallet." + slot.rawValue).utf8), |
| 324 | kSecAttrAccessControl as String: access, |
| 325 | ], |
| 326 | ] |
| 327 | guard SecKeyCreateRandomKey(attributes as CFDictionary, &error) != nil else { throw EnclaveError.unavailable } |
| 328 | } |
| 329 | |
| 330 | public func unwrap(_ blob: Data, slot: WrapSlot, password: Data) throws -> SecureBytes { |
| 331 | let context = LAContext() |
| 332 | defer { Self.release(context) } |
| 333 | context.interactionNotAllowed = true |
| 334 | guard context.setCredential(password, type: .applicationPassword) else { throw EnclaveError.failed("credencial") } |
| 335 | let query: [String: Any] = [ |
| 336 | kSecClass as String: kSecClassKey, |
| 337 | kSecAttrApplicationTag as String: Data(("app.escalibur.wallet." + slot.rawValue).utf8), |
| 338 | kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, |
| 339 | kSecReturnRef as String: true, |
| 340 | kSecUseAuthenticationContext as String: context, |
| 341 | ] |
| 342 | var item: CFTypeRef? |
| 343 | guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let item else { throw EnclaveError.keyMissing } |
| 344 | let key = item as! SecKey // swiftlint:disable:this force_cast |
| 345 | var error: Unmanaged<CFError>? |
| 346 | guard let plain = SecKeyCreateDecryptedData(key, .eciesEncryptionCofactorVariableIVX963SHA256AESGCM, blob as CFData, &error) else { |
| 347 | let nsError = (error?.takeRetainedValue() as Error?).map { $0 as NSError } |
| 348 | // A senha errada volta do proprio SE como erro do CryptoTokenKit (no iPhone, |
| 349 | // "dado corrompido": a senha entra na chave) ou como falha de autenticacao. |
| 350 | if nsError?.domain == "CryptoTokenKit" || nsError?.code == Int(errSecAuthFailed) || nsError?.domain == LAErrorDomain { |
| 351 | throw EnclaveError.wrongPassword |
| 352 | } |
| 353 | throw EnclaveError.failed("desembrulho") |
| 354 | } |
| 355 | return SecureBytes.consuming(plain) |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | extension SecureBytes { |
| 360 | /// Copia de um `CFData` que acabou de sair de uma API do sistema para o buffer |
| 361 | /// seguro, e apaga a origem. O `CFData` e imutavel pela API, mas a memoria e do |
| 362 | /// processo: zerar e o que encurta a janela do segredo em claro. |
| 363 | static func consuming(_ data: CFData) -> SecureBytes { |
| 364 | let length = CFDataGetLength(data) |
| 365 | let out = SecureBytes(capacity: max(length, 1)) |
| 366 | if let pointer = CFDataGetBytePtr(data), length > 0 { |
| 367 | out.append(contentsOf: UnsafeBufferPointer(start: pointer, count: length)) |
| 368 | memset_s(UnsafeMutableRawPointer(mutating: pointer), length, 0, length) |
| 369 | } |
| 370 | return out |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | #if DEBUG |
| 375 | /// So no build de teste: o experimento do MUST 1 (auditoria 1) e do A1 (auditoria 3). |
| 376 | /// Uma chave descartavel no Secure Enclave com `.privateKeyUsage` e |
| 377 | /// `.applicationPassword`, que nunca toca as chaves da carteira (tag propria, apagada no |
| 378 | /// fim). Confere no aparelho de verdade se o SE abre com a senha certa e recusa a |
| 379 | /// errada e a falta dela, sem pedir nada na tela, e quanto custa cada tentativa. |
| 380 | public enum EnclaveSpike { |
| 381 | static let tag = Data("app.escalibur.wallet.experimento".utf8) |
| 382 | static let algorithm = SecKeyAlgorithm.eciesEncryptionCofactorVariableIVX963SHA256AESGCM |
| 383 | |
| 384 | public static func run() -> [String] { |
| 385 | var log: [String] = ["Secure Enclave disponivel: \(SecureEnclave.isAvailable)"] |
| 386 | delete() |
| 387 | defer { delete() } |
| 388 | let password = Data("senha-certa-do-experimento".utf8) |
| 389 | let creation = LAContext() |
| 390 | creation.setCredential(password, type: .applicationPassword) |
| 391 | var error: Unmanaged<CFError>? |
| 392 | guard let access = SecAccessControlCreateWithFlags(nil, KeychainStore.accessibility, [.privateKeyUsage, .applicationPassword], &error) else { |
| 393 | log.append("controle de acesso: recusado (\(describe(error)))") |
| 394 | return log |
| 395 | } |
| 396 | let attributes: [String: Any] = [ |
| 397 | kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, |
| 398 | kSecAttrKeySizeInBits as String: 256, |
| 399 | kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, |
| 400 | kSecUseAuthenticationContext as String: creation, |
| 401 | kSecPrivateKeyAttrs as String: [ |
| 402 | kSecAttrIsPermanent as String: true, |
| 403 | kSecAttrApplicationTag as String: tag, |
| 404 | kSecAttrAccessControl as String: access, |
| 405 | ], |
| 406 | ] |
| 407 | guard let created = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { |
| 408 | log.append("criar a chave: recusado (\(describe(error)))") |
| 409 | return log |
| 410 | } |
| 411 | log.append("criar a chave: ok") |
| 412 | guard let publicKey = SecKeyCopyPublicKey(created), |
| 413 | let blob = SecKeyCreateEncryptedData(publicKey, algorithm, Data("segredo do experimento".utf8) as CFData, &error) as Data? |
| 414 | else { |
| 415 | log.append("embrulhar: falhou (\(describe(error)))") |
| 416 | return log |
| 417 | } |
| 418 | log.append(attempt("senha certa", credential: password)) |
| 419 | log.append(attempt("senha errada", credential: Data("senha-errada".utf8))) |
| 420 | log.append(attempt("sem senha", credential: nil)) |
| 421 | let start = Date() |
| 422 | var refused = 0 |
| 423 | for index in 0..<20 where !attempt("errada \(index)", credential: Data("errada-\(index)".utf8)).hasSuffix("abriu") { refused += 1 } |
| 424 | log.append("20 erradas seguidas: \(refused) recusadas, \(String(format: "%.0f", Date().timeIntervalSince(start) * 1000 / 20)) ms cada") |
| 425 | log.append(attempt("senha certa depois das 20 erradas", credential: password)) |
| 426 | _ = blob |
| 427 | return log |
| 428 | |
| 429 | func attempt(_ label: String, credential: Data?) -> String { |
| 430 | let context = LAContext() |
| 431 | context.interactionNotAllowed = true |
| 432 | if let credential { context.setCredential(credential, type: .applicationPassword) } |
| 433 | let query: [String: Any] = [ |
| 434 | kSecClass as String: kSecClassKey, |
| 435 | kSecAttrApplicationTag as String: tag, |
| 436 | kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, |
| 437 | kSecReturnRef as String: true, |
| 438 | kSecUseAuthenticationContext as String: context, |
| 439 | ] |
| 440 | var item: CFTypeRef? |
| 441 | let status = SecItemCopyMatching(query as CFDictionary, &item) |
| 442 | guard status == errSecSuccess, let item else { return "\(label): chave nao encontrada (\(status))" } |
| 443 | let key = item as! SecKey // swiftlint:disable:this force_cast |
| 444 | var failure: Unmanaged<CFError>? |
| 445 | if let plain = SecKeyCreateDecryptedData(key, algorithm, blob as CFData, &failure) as Data? { |
| 446 | return "\(label): \(plain == Data("segredo do experimento".utf8) ? "abriu" : "abriu com conteudo errado")" |
| 447 | } |
| 448 | return "\(label): recusou (\(describe(failure)))" |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | static func delete() { |
| 453 | let query: [String: Any] = [ |
| 454 | kSecClass as String: kSecClassKey, |
| 455 | kSecAttrApplicationTag as String: tag, |
| 456 | kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, |
| 457 | ] |
| 458 | SecItemDelete(query as CFDictionary) |
| 459 | } |
| 460 | |
| 461 | static func describe(_ error: Unmanaged<CFError>?) -> String { |
| 462 | guard let error = error?.takeRetainedValue() else { return "sem erro" } |
| 463 | let ns = error as Error as NSError |
| 464 | return "\(ns.domain) \(ns.code)" |
| 465 | } |
| 466 | } |
| 467 | #endif |