Pular para o conteúdo
Escalibur Wallet
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`.

182 linhasSHA-256 0615aad1e4445dad…Abrir no GitHub, no commit 015f5f5
1import EscaliburCore
2import Foundation
3import LocalAuthentication
4import 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`.
8public 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
23public 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.
32public 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
41public 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.
50public 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}