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 } }
GitRust