O primeiro jeito que salvei um token de autenticação nesse app foi o mais rápido que existe.
UserDefaults.standard.set(token, forKey: "authToken")
Compilava, funcionava, o login se mantinha entre aberturas do app. Só que UserDefaults grava num plist dentro do sandbox do app, sem criptografia. Pra um token de sessão isso é o tipo de coisa que passa despercebida até alguém perguntar, numa revisão de segurança, onde exatamente esse valor fica salvo no disco.
Trocando UserDefaults por chamadas direto no Security framework
A resposta certa é Keychain. Só que a API do Security framework é baixo nível, e cada chamada pede um dicionário [String: Any] montado na mão.
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "authToken",
kSecValueData as String: Data(token.utf8),
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
Comecei espalhando isso direto onde precisava: no login, no refresh de token, no logout. Cada lugar montava o dicionário de novo, e num desses lugares eu esqueci de incluir kSecAttrService. O item ficava salvo, só que numa consulta diferente da que eu esperava, e a tela de perfil lia nil onde a tela de login tinha acabado de gravar um valor. Levei um tempo pra perceber que o bug não era no fluxo de login, era num campo faltando numa query de outra tela.
Um wrapper pra não repetir o dicionário toda vez
Depois desse bug ficou claro que repetir a query em cada chamada ia continuar gerando esse tipo de divergência. Escrevi um KeychainStore pequeno, só com o que eu realmente usava: salvar, ler e apagar.
struct KeychainStore {
private let service: String
private let accessibility: CFString
init(service: String, accessibility: CFString = kSecAttrAccessibleWhenUnlockedThisDeviceOnly) {
self.service = service
self.accessibility = accessibility
}
func save(_ data: Data, account: String) throws {
SecItemDelete(baseQuery(account: account) as CFDictionary)
var attributes = baseQuery(account: account)
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] = accessibility
let status = SecItemAdd(attributes as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError(status: status) }
}
func load(account: String) throws -> Data? {
var query = baseQuery(account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess: return result as? Data
case errSecItemNotFound: return nil
default: throw KeychainError(status: status)
}
}
func delete(account: String) throws {
let status = SecItemDelete(baseQuery(account: account) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError(status: status)
}
}
private func baseQuery(account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
}
struct KeychainError: Error {
let status: OSStatus
}
service fica fixo por instância, então não tem mais como um chamador esquecer de passar. Cada tela recebe um KeychainStore já configurado, em vez de montar a query sozinha.
A acessibilidade que eu quase deixei no padrão
kSecAttrAccessibleWhenUnlockedThisDeviceOnly era o valor que eu tinha colocado como default do wrapper, meio sem pensar muito, só porque parecia a opção mais restritiva e mais segura. E pra maioria dos itens era a escolha certa mesmo.
O problema apareceu com o refresh token. Tem uma rotina em background, disparada por push silencioso, que renova a sessão antes do token expirar. Com WhenUnlockedThisDeviceOnly, se o processo rodasse com a tela bloqueada, o Keychain simplesmente recusava o acesso ao item. Nenhum crash, nenhum log óbvio. O request de refresh saía sem token, a API respondia 401, e a próxima vez que o usuário abria o app caía na tela de login sem motivo aparente.
A troca foi usar kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly só pra esse item:
let refreshTokenStore = KeychainStore(
service: "com.app.refreshToken",
accessibility: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
)
Esse nível libera o acesso depois do primeiro desbloqueio após o boot do aparelho, mesmo com a tela travada de novo depois. Ainda restrito ao dispositivo, sem ir pro backup de iCloud, mas acessível pra rotina em background. Os outros itens, que só são lidos com o app em primeiro plano, continuaram no WhenUnlockedThisDeviceOnly.
flowchart LR
ViewModel --> SecretStore
SecretStore -->|produção| KeychainStore
SecretStore -->|teste| FakeSecretStore
KeychainStore --> Security[Security.framework]
O -34018 que só aparecia no CI
Escrevi um teste pro KeychainStore: salva um valor, lê de volta, confere se bate. Rodando pelo Xcode, no simulador que eu já tinha aberto no dia a dia, passava sempre. Rodando via xcodebuild test, num simulador recém criado do zero pelo runner do CI, o save retornava status -34018.
SecCopyErrorMessageString traduz esse código pra “A required entitlement isn’t present”. errSecMissingEntitlement. No simulador aberto manualmente, com uma sessão já iniciada, o Keychain deixa passar. Num simulador novo, provisionado do zero só pra rodar o test target, faltava o que o sistema esperava pra liberar acesso ao Keychain daquele processo.
Não cheguei a resolver isso ajustando entitlement ou provisionamento do simulador de CI. O caminho que segui foi outro.
Tirando o Keychain de dentro do teste unitário
O KeychainStore virou uma implementação atrás de um protocolo:
protocol SecretStore {
func save(_ data: Data, account: String) throws
func load(account: String) throws -> Data?
func delete(account: String) throws
}
extension KeychainStore: SecretStore {}
final class InMemorySecretStore: SecretStore {
private var storage: [String: Data] = [:]
func save(_ data: Data, account: String) throws {
storage[account] = data
}
func load(account: String) throws -> Data? {
storage[account]
}
func delete(account: String) throws {
storage[account] = nil
}
}
O teste que checava salvar e ler token passou a usar InMemorySecretStore, sem tocar no Security framework nenhuma vez. O -34018 parou de aparecer no CI porque o teste parou de depender de Keychain de verdade.
Isso não prova que KeychainStore continua funcionando contra a API real depois de qualquer mudança. Fica um teste separado, que exercita o KeychainStore de verdade, marcado pra rodar só localmente, num simulador já provisionado. Não é automático, e depende de alguém lembrar de rodar aquele teste antes de mexer no wrapper. Não achei uma forma de ter as duas coisas, Keychain real testado e suite rápida sem depender de simulador provisionado, sem abrir mão de uma das duas.