Auditoria
O chaveiro
Kit/Sources/EscaliburKeys/SecretStore.swift
Onde os itens da carteira moram. Um protocolo para os testes poderem trocar o chaveiro por memória; em produção existe uma implementação só, `KeychainStore`.
| 1 | import EscaliburCore |
| 2 | import Foundation |
| 3 | import LocalAuthentication |
| 4 | import Security |
| 5 | |
| 6 | /// Onde os itens da carteira moram. Um protocolo para os testes poderem trocar o |
| 7 | /// chaveiro por memoria; em producao existe uma implementacao so, `KeychainStore`. |
| 8 | public protocol SecretStore: Sendable { |
| 9 | func read(_ account: String, context: LAContext?) throws -> Data? |
| 10 | func add(_ data: Data, account: String, protection: ItemProtection, context: LAContext?) throws |
| 11 | func update(_ data: Data, account: String) throws |
| 12 | func delete(_ account: String) throws |
| 13 | func deleteAll() throws |
| 14 | func exists(_ account: String) -> Bool |
| 15 | /// Existe, nao existe, ou o chaveiro nao conseguiu dizer. "Nao sei" nunca pode ser |
| 16 | /// tratado como "nao existe": isso levaria a um cadastro que apaga a chave antiga. |
| 17 | func probe(_ account: String) -> ItemState |
| 18 | /// Um contexto que carrega a senha de aplicativo para a proxima operacao, sem |
| 19 | /// nenhuma interface (`interactionNotAllowed`). |
| 20 | func applicationPasswordContext(_ password: SecureBytes) throws -> LAContext? |
| 21 | } |
| 22 | |
| 23 | public enum ItemState: Sendable, Equatable { case present, absent, unknown } |
| 24 | |
| 25 | /// A protecao de cada item. So existem estas duas, e as duas sao |
| 26 | /// `WhenPasscodeSetThisDeviceOnly`: o item some se o dono tirar o codigo do iPhone, |
| 27 | /// nunca sincroniza, nunca vai para backup, e nunca abre em outro aparelho. |
| 28 | /// |
| 29 | /// Proibidos no projeto inteiro, por verificar.sh: `.userPresence`, `.devicePasscode`, |
| 30 | /// `.biometryAny`, `AfterFirstUnlock` e `Always`. Quem viu o codigo do iPhone passa |
| 31 | /// por `.userPresence`; com `.biometryAny` o ladrao cadastra o proprio rosto. |
| 32 | public enum ItemProtection: Sendable { |
| 33 | /// Item comum, legivel com o aparelho desbloqueado. |
| 34 | case standard |
| 35 | /// Item que o proprio chaveiro so devolve com a senha de aplicativo certa |
| 36 | /// (`kSecAccessControlApplicationPassword`). E o que prende o PIN ao aparelho: |
| 37 | /// nao existe verificador de PIN gravado em lugar nenhum; o chaveiro recusa. |
| 38 | case applicationPassword |
| 39 | } |
| 40 | |
| 41 | public enum StoreError: Error, Equatable, Sendable { |
| 42 | /// O chaveiro recusou a senha de aplicativo: PIN errado. |
| 43 | case authenticationFailed |
| 44 | /// O aparelho nao tem codigo configurado. |
| 45 | case passcodeNotSet |
| 46 | case unexpected(OSStatus) |
| 47 | } |
| 48 | |
| 49 | /// O chaveiro do iOS, com os atributos exatos de docs/seguranca.md §2.3. |
| 50 | public final class KeychainStore: SecretStore, @unchecked Sendable { |
| 51 | public static let service = "app.escalibur.wallet" |
| 52 | |
| 53 | public init() {} |
| 54 | |
| 55 | private func baseQuery(_ account: String) -> [String: Any] { |
| 56 | [ |
| 57 | kSecClass as String: kSecClassGenericPassword, |
| 58 | kSecAttrService as String: Self.service, |
| 59 | kSecAttrAccount as String: account, |
| 60 | kSecAttrSynchronizable as String: kCFBooleanFalse!, |
| 61 | kSecUseDataProtectionKeychain as String: kCFBooleanTrue!, |
| 62 | ] |
| 63 | } |
| 64 | |
| 65 | public func read(_ account: String, context: LAContext?) throws -> Data? { |
| 66 | var query = baseQuery(account) |
| 67 | query[kSecReturnData as String] = kCFBooleanTrue |
| 68 | query[kSecMatchLimit as String] = kSecMatchLimitOne |
| 69 | if let context { query[kSecUseAuthenticationContext as String] = context } |
| 70 | var result: AnyObject? |
| 71 | let status = SecItemCopyMatching(query as CFDictionary, &result) |
| 72 | release(context) |
| 73 | switch status { |
| 74 | case errSecSuccess: return result as? Data |
| 75 | case errSecItemNotFound: return nil |
| 76 | case errSecAuthFailed, errSecUserCanceled, errSecInteractionNotAllowed: throw StoreError.authenticationFailed |
| 77 | default: throw StoreError.unexpected(status) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | public func add(_ data: Data, account: String, protection: ItemProtection, context: LAContext?) throws { |
| 82 | var query = baseQuery(account) |
| 83 | query[kSecValueData as String] = data |
| 84 | switch protection { |
| 85 | case .standard: |
| 86 | query[kSecAttrAccessible as String] = Self.accessibility |
| 87 | case .applicationPassword: |
| 88 | var error: Unmanaged<CFError>? |
| 89 | guard let access = SecAccessControlCreateWithFlags(nil, Self.accessibility, [.applicationPassword], &error) else { |
| 90 | throw StoreError.unexpected(errSecParam) |
| 91 | } |
| 92 | query[kSecAttrAccessControl as String] = access |
| 93 | } |
| 94 | if let context { query[kSecUseAuthenticationContext as String] = context } |
| 95 | let status = SecItemAdd(query as CFDictionary, nil) |
| 96 | release(context) |
| 97 | switch status { |
| 98 | case errSecSuccess: return |
| 99 | case errSecDecode, errSecNotAvailable: throw StoreError.passcodeNotSet |
| 100 | default: throw StoreError.unexpected(status) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /// Altera no lugar. O contador de tentativas usa isto e nunca apagar e recriar: |
| 105 | /// um `SecItemAdd` que falha depois de um `SecItemDelete` (disco cheio) zerava o |
| 106 | /// atraso no Escalibur. Aqui, se a gravacao falha, a tentativa nao e avaliada. |
| 107 | public func update(_ data: Data, account: String) throws { |
| 108 | let attributes = [kSecValueData as String: data] |
| 109 | let status = SecItemUpdate(baseQuery(account) as CFDictionary, attributes as CFDictionary) |
| 110 | guard status == errSecSuccess else { throw StoreError.unexpected(status) } |
| 111 | } |
| 112 | |
| 113 | public func delete(_ account: String) throws { |
| 114 | let status = SecItemDelete(baseQuery(account) as CFDictionary) |
| 115 | guard status == errSecSuccess || status == errSecItemNotFound else { throw StoreError.unexpected(status) } |
| 116 | } |
| 117 | |
| 118 | public func deleteAll() throws { |
| 119 | let query: [String: Any] = [ |
| 120 | kSecClass as String: kSecClassGenericPassword, |
| 121 | kSecAttrService as String: Self.service, |
| 122 | kSecUseDataProtectionKeychain as String: kCFBooleanTrue!, |
| 123 | ] |
| 124 | let status = SecItemDelete(query as CFDictionary) |
| 125 | guard status == errSecSuccess || status == errSecItemNotFound else { throw StoreError.unexpected(status) } |
| 126 | } |
| 127 | |
| 128 | public func exists(_ account: String) -> Bool { probe(account) == .present } |
| 129 | |
| 130 | public func probe(_ account: String) -> ItemState { |
| 131 | var query = baseQuery(account) |
| 132 | query[kSecReturnAttributes as String] = kCFBooleanTrue |
| 133 | let context = LAContext() |
| 134 | context.interactionNotAllowed = true |
| 135 | query[kSecUseAuthenticationContext as String] = context |
| 136 | let status = SecItemCopyMatching(query as CFDictionary, nil) |
| 137 | context.invalidate() |
| 138 | switch status { |
| 139 | case errSecSuccess, errSecInteractionNotAllowed, errSecAuthFailed: return .present |
| 140 | case errSecItemNotFound: return .absent |
| 141 | default: return .unknown |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | /// O contexto e usado numa chamada so e invalidado logo depois (ver `read` e |
| 146 | /// `add`), para a senha de aplicativo nao ficar retida num objeto vivo. |
| 147 | public func applicationPasswordContext(_ password: SecureBytes) throws -> LAContext? { |
| 148 | let context = LAContext() |
| 149 | context.interactionNotAllowed = true |
| 150 | let ok = password.withUnsafeData { context.setCredential($0, type: .applicationPassword) } |
| 151 | guard ok else { |
| 152 | #if targetEnvironment(simulator) |
| 153 | // O simulador recusa a credencial e grava o item sem senha de aplicativo. |
| 154 | // La, quem recusa o PIN errado e so a camada interna do RootKeyVault. |
| 155 | context.invalidate() |
| 156 | return nil |
| 157 | #else |
| 158 | context.invalidate() |
| 159 | throw StoreError.unexpected(errSecParam) |
| 160 | #endif |
| 161 | } |
| 162 | return context |
| 163 | } |
| 164 | |
| 165 | private func release(_ context: LAContext?) { |
| 166 | guard let context else { return } |
| 167 | context.setCredential(nil, type: .applicationPassword) |
| 168 | context.invalidate() |
| 169 | } |
| 170 | |
| 171 | /// No aparelho, `WhenPasscodeSetThisDeviceOnly`. O simulador nao tem codigo de |
| 172 | /// aparelho e recusa essa classe; so nele, e so em compilacao para simulador, |
| 173 | /// a classe cai para `WhenUnlockedThisDeviceOnly`. verificar.sh confere que esta |
| 174 | /// e a unica excecao e que ela esta atras de `targetEnvironment(simulator)`. |
| 175 | static var accessibility: CFString { |
| 176 | #if targetEnvironment(simulator) |
| 177 | return kSecAttrAccessibleWhenUnlockedThisDeviceOnly |
| 178 | #else |
| 179 | return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly |
| 180 | #endif |
| 181 | } |
| 182 | } |