Guardando segredo no iOS: Keychain sem gambiarra

4 minswiftkeychainsegurançatestes

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.