SyncCredentialsStore.swift 130 lignes · 4490 octets
import Foundation
import Security

/// Backing storage abstraction (Android `EncryptedSharedPreferences`); Keychain in
/// production, in-memory in tests. `nil` value removes the entry.
protocol SyncCredentialsStorage {
    func get(_ key: String) -> String?
    func set(_ key: String, _ value: String?)
}

/// Keychain-backed storage: one generic-password item per key under a dedicated service.
final class KeychainCredentialsStorage: SyncCredentialsStorage {
    static let service = "fr.ebii.card2vcf.sync"

    private let service: String

    init(service: String = KeychainCredentialsStorage.service) {
        self.service = service
    }

    func get(_ key: String) -> String? {
        var query = baseQuery(key)
        query[kSecReturnData as String] = true
        query[kSecMatchLimit as String] = kSecMatchLimitOne
        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        guard status == errSecSuccess, let data = item as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }

    func set(_ key: String, _ value: String?) {
        let query = baseQuery(key)
        guard let value else {
            SecItemDelete(query as CFDictionary)
            return
        }
        let data = Data(value.utf8)
        let update: [String: Any] = [kSecValueData as String: data]
        let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
        if status == errSecItemNotFound {
            var add = query
            add[kSecValueData as String] = data
            add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
            SecItemAdd(add as CFDictionary, nil)
        }
    }

    private func baseQuery(_ key: String) -> [String: Any] {
        [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
        ]
    }
}

/// In-memory storage for tests / previews.
final class InMemoryCredentialsStorage: SyncCredentialsStorage {
    private var values: [String: String] = [:]

    func get(_ key: String) -> String? { values[key] }

    func set(_ key: String, _ value: String?) {
        if let value {
            values[key] = value
        } else {
            values.removeValue(forKey: key)
        }
    }
}

/// Server URL + login + API key store (Kotlin `SyncCredentialsStore` over
/// EncryptedSharedPreferences). The password itself is never persisted.
final class SyncCredentialsStore {
    static let KEY_BASE_URL = "base_url"
    static let KEY_API_KEY = "api_key"
    static let KEY_USER_NAME = "user_name"
    static let KEY_ALLOW_CLEARTEXT = "allow_cleartext_http"

    private let storage: SyncCredentialsStorage

    init(storage: SyncCredentialsStorage = KeychainCredentialsStorage()) {
        self.storage = storage
    }

    var baseUrl: String? {
        get { nonBlank(storage.get(Self.KEY_BASE_URL)) }
        set { storage.set(Self.KEY_BASE_URL, nonBlank(newValue)) }
    }

    var apiKey: String? {
        get { nonBlank(storage.get(Self.KEY_API_KEY)) }
        set { storage.set(Self.KEY_API_KEY, nonBlank(newValue)) }
    }

    var userName: String? {
        get { nonBlank(storage.get(Self.KEY_USER_NAME)) }
        set { storage.set(Self.KEY_USER_NAME, nonBlank(newValue)) }
    }

    /// Allows `http://` URLs (internal network / demo / dev).
    /// Kept after `clear()`: not a secret, just a local preference.
    /// Default: `false` (HTTPS required).
    var allowCleartextHttp: Bool {
        get { storage.get(Self.KEY_ALLOW_CLEARTEXT) == "true" }
        set { storage.set(Self.KEY_ALLOW_CLEARTEXT, newValue ? "true" : "false") }
    }

    func save(baseUrl: String, apiKey: String, userName: String) {
        storage.set(Self.KEY_BASE_URL, baseUrl)
        storage.set(Self.KEY_API_KEY, apiKey)
        storage.set(Self.KEY_USER_NAME, userName)
    }

    func clear() {
        // Do not clear `allowCleartextHttp`: convenient in demo/internal setups between logins.
        storage.set(Self.KEY_BASE_URL, nil)
        storage.set(Self.KEY_API_KEY, nil)
        storage.set(Self.KEY_USER_NAME, nil)
    }

    func isConfigured() -> Bool {
        baseUrl != nil && apiKey != nil && userName != nil
    }

    private func nonBlank(_ value: String?) -> String? {
        guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
        return value
    }
}