mirror of
https://github.com/xweiba/location-spoofer.git
synced 2026-09-21 22:30:46 +08:00
179 lines
6.9 KiB
Swift
179 lines
6.9 KiB
Swift
import Foundation
|
||
import Security
|
||
|
||
protocol CertificateAuthorityKeychain {
|
||
func load() throws -> CertificateAuthority?
|
||
func save(_ authority: CertificateAuthority) throws
|
||
func remove() throws
|
||
}
|
||
|
||
enum CertificateAuthorityStoreError: LocalizedError {
|
||
case invalidAuthority
|
||
case keychain(OSStatus)
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .invalidAuthority: return "本地 CA 证书或私钥无效"
|
||
case let .keychain(status): return "无法写入设备钥匙串(\(status))"
|
||
}
|
||
}
|
||
}
|
||
|
||
final class DeviceCertificateAuthorityKeychain: CertificateAuthorityKeychain {
|
||
private enum Item {
|
||
static let service = "com.paopaolabs.location-spoofer.certificate-authority"
|
||
static let certificateAccount = "root-ca-certificate"
|
||
static let keyAccount = "root-ca-private-key"
|
||
}
|
||
|
||
func load() throws -> CertificateAuthority? {
|
||
guard let certPEM = try load(account: Item.certificateAccount),
|
||
let keyPEM = try load(account: Item.keyAccount) else {
|
||
return nil
|
||
}
|
||
return CertificateAuthority(certPEM: certPEM, keyPEM: keyPEM)
|
||
}
|
||
|
||
func save(_ authority: CertificateAuthority) throws {
|
||
try remove()
|
||
do {
|
||
try save(authority.certPEM, account: Item.certificateAccount)
|
||
try save(authority.keyPEM, account: Item.keyAccount)
|
||
} catch {
|
||
try? remove()
|
||
throw error
|
||
}
|
||
}
|
||
|
||
func remove() throws {
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: Item.service,
|
||
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
|
||
]
|
||
let status = SecItemDelete(query as CFDictionary)
|
||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||
throw CertificateAuthorityStoreError.keychain(status)
|
||
}
|
||
}
|
||
|
||
private func load(account: String) throws -> String? {
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: Item.service,
|
||
kSecAttrAccount as String: account,
|
||
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
|
||
kSecReturnData as String: true,
|
||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||
]
|
||
var result: CFTypeRef?
|
||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||
if status == errSecItemNotFound { return nil }
|
||
guard status == errSecSuccess, let data = result as? Data,
|
||
let value = String(data: data, encoding: .utf8) else {
|
||
throw CertificateAuthorityStoreError.keychain(status)
|
||
}
|
||
return value
|
||
}
|
||
|
||
private func save(_ value: String, account: String) throws {
|
||
guard let data = value.data(using: .utf8) else {
|
||
throw CocoaError(.fileWriteInapplicableStringEncoding)
|
||
}
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: Item.service,
|
||
kSecAttrAccount as String: account,
|
||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||
kSecAttrSynchronizable as String: kCFBooleanFalse as Any,
|
||
kSecValueData as String: data,
|
||
]
|
||
let status = SecItemAdd(query as CFDictionary, nil)
|
||
guard status == errSecSuccess else { throw CertificateAuthorityStoreError.keychain(status) }
|
||
}
|
||
}
|
||
|
||
final class CertificateAuthorityStore {
|
||
private let directory: URL
|
||
private let generator: () throws -> CertificateAuthority
|
||
private let validator: (CertificateAuthority) -> Bool
|
||
private let keychain: CertificateAuthorityKeychain
|
||
private let certificateURL: URL
|
||
private let keyURL: URL
|
||
|
||
init(
|
||
directory: URL = AppGroup.containerURL.appendingPathComponent("CertificateAuthority", isDirectory: true),
|
||
keychain: CertificateAuthorityKeychain = DeviceCertificateAuthorityKeychain(),
|
||
generator: @escaping () throws -> CertificateAuthority = CoreBridge.generateCertificateAuthority,
|
||
validator: @escaping (CertificateAuthority) -> Bool = CoreBridge.isValidCertificateAuthority
|
||
) {
|
||
self.directory = directory
|
||
self.keychain = keychain
|
||
self.generator = generator
|
||
self.validator = validator
|
||
self.certificateURL = directory.appendingPathComponent("ca-cert.pem")
|
||
self.keyURL = directory.appendingPathComponent("ca-key.pem")
|
||
}
|
||
|
||
func ensure() throws -> CertificateAuthority {
|
||
if let authority = try loadValidKeychainAuthority() {
|
||
removeLegacyFilesBestEffort()
|
||
RuntimeLogger.debug("SHARED", "Certificate.store", "复用设备钥匙串中的 CA")
|
||
return authority
|
||
}
|
||
|
||
if let legacy = try loadLegacyAuthority(), validator(legacy) {
|
||
try keychain.save(legacy)
|
||
removeLegacyFilesBestEffort()
|
||
RuntimeLogger.info("SHARED", "Certificate.store", "旧 CA 已迁移到设备钥匙串")
|
||
return legacy
|
||
}
|
||
|
||
let authority = try generator()
|
||
guard validator(authority) else { throw CertificateAuthorityStoreError.invalidAuthority }
|
||
try keychain.save(authority)
|
||
removeLegacyFilesBestEffort()
|
||
RuntimeLogger.info("SHARED", "Certificate.store", "已生成并保存设备专属 CA")
|
||
return authority
|
||
}
|
||
|
||
func load() throws -> CertificateAuthority? {
|
||
try loadValidKeychainAuthority()
|
||
}
|
||
|
||
private func loadValidKeychainAuthority() throws -> CertificateAuthority? {
|
||
guard let authority = try keychain.load() else { return nil }
|
||
guard validator(authority) else {
|
||
RuntimeLogger.warning("SHARED", "Certificate.store", "钥匙串中的 CA 无效,准备回退")
|
||
try? keychain.remove()
|
||
return nil
|
||
}
|
||
return authority
|
||
}
|
||
|
||
private func loadLegacyAuthority() throws -> CertificateAuthority? {
|
||
guard FileManager.default.fileExists(atPath: certificateURL.path),
|
||
FileManager.default.fileExists(atPath: keyURL.path) else {
|
||
return nil
|
||
}
|
||
return CertificateAuthority(
|
||
certPEM: try String(contentsOf: certificateURL, encoding: .utf8),
|
||
keyPEM: try String(contentsOf: keyURL, encoding: .utf8)
|
||
)
|
||
}
|
||
|
||
private func removeLegacyFilesBestEffort() {
|
||
let fileManager = FileManager.default
|
||
// Remove private material first. Each item is retried on later launches
|
||
// when a valid Keychain authority is available.
|
||
for url in [keyURL, certificateURL] where fileManager.fileExists(atPath: url.path) {
|
||
do {
|
||
try fileManager.removeItem(at: url)
|
||
} catch {
|
||
RuntimeLogger.error("SHARED", "Certificate.store", "删除旧 CA 文件失败,将在下次启动重试", error: error)
|
||
}
|
||
}
|
||
try? fileManager.removeItem(at: directory)
|
||
}
|
||
}
|