diff --git a/Sources/XuqmSDK/License/DeviceInfoProvider.swift b/Sources/XuqmSDK/License/DeviceInfoProvider.swift new file mode 100644 index 0000000..bdc87cb --- /dev/null +++ b/Sources/XuqmSDK/License/DeviceInfoProvider.swift @@ -0,0 +1,45 @@ +#if canImport(UIKit) +import UIKit +#endif +import Foundation + +enum DeviceInfoProvider { + + static func getDeviceId(store: LicenseStore) -> String { + if let existing = store.deviceId { return existing } + #if canImport(UIKit) + let id = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + #else + let id = UUID().uuidString + #endif + store.deviceId = id + return id + } + + static func getDeviceName() -> String { + #if canImport(UIKit) + return UIDevice.current.name + #else + return Host.current().localizedName ?? "Mac" + #endif + } + + static func getDeviceModel() -> String { + #if canImport(UIKit) + return UIDevice.current.model + #else + return "Mac" + #endif + } + + static func getDeviceVendor() -> String { "Apple" } + + static func getOsVersion() -> String { + #if canImport(UIKit) + return "iOS \(UIDevice.current.systemVersion)" + #else + let v = ProcessInfo.processInfo.operatingSystemVersion + return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + #endif + } +} diff --git a/Sources/XuqmSDK/License/LicenseFileCrypto.swift b/Sources/XuqmSDK/License/LicenseFileCrypto.swift new file mode 100644 index 0000000..1482bbd --- /dev/null +++ b/Sources/XuqmSDK/License/LicenseFileCrypto.swift @@ -0,0 +1,66 @@ +import Foundation +import CryptoKit +import CommonCrypto + +enum LicenseFileCryptoError: Error { + case invalidFormat + case keyDerivationFailed + case decryptionFailed +} + +enum LicenseFileCrypto { + private static let magic = "XUQM-LICENSE-V1" + private static let passphrase = "xuqm-license-file-v1.2026.internal" + private static let keyByteCount = 32 + private static let pbkdf2Iterations: UInt32 = 120_000 + + // Format: MAGIC.base64UrlSalt.base64UrlIV.base64UrlCiphertext + // Base64: URL-safe, no padding, no wrap + // Algorithm: AES/GCM/NoPadding, PBKDF2WithHmacSHA256, 256-bit key, 128-bit tag + static func decrypt(_ content: String) throws -> String { + let parts = content.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: ".") + guard parts.count == 4, parts[0] == magic else { + throw LicenseFileCryptoError.invalidFormat + } + guard let salt = base64UrlDecode(parts[1]), + let iv = base64UrlDecode(parts[2]), + let ciphertext = base64UrlDecode(parts[3]) else { + throw LicenseFileCryptoError.invalidFormat + } + let key = try deriveKey(salt: salt) + // CryptoKit SealedBox(combined:) expects nonce(12) || ciphertext || tag(16) + // Android JCE appends the 16-byte GCM tag at end of ciphertext — matches this format exactly + let combined = iv + ciphertext + let sealedBox = try AES.GCM.SealedBox(combined: combined) + let decrypted = try AES.GCM.open(sealedBox, using: key) + guard let plaintext = String(data: decrypted, encoding: .utf8) else { + throw LicenseFileCryptoError.decryptionFailed + } + return plaintext + } + + private static func deriveKey(salt: Data) throws -> SymmetricKey { + let passphraseBytes = Array(passphrase.utf8) + let saltBytes = Array(salt) + var derivedKey = [UInt8](repeating: 0, count: keyByteCount) + let status = CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passphrase, passphraseBytes.count, + saltBytes, saltBytes.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), + pbkdf2Iterations, + &derivedKey, keyByteCount + ) + guard status == kCCSuccess else { + throw LicenseFileCryptoError.keyDerivationFailed + } + return SymmetricKey(data: Data(derivedKey)) + } + + private static func base64UrlDecode(_ value: String) -> Data? { + var s = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + let rem = s.count % 4 + if rem > 0 { s += String(repeating: "=", count: 4 - rem) } + return Data(base64Encoded: s) + } +} diff --git a/Sources/XuqmSDK/License/LicenseFileReader.swift b/Sources/XuqmSDK/License/LicenseFileReader.swift new file mode 100644 index 0000000..ecdc51d --- /dev/null +++ b/Sources/XuqmSDK/License/LicenseFileReader.swift @@ -0,0 +1,21 @@ +import Foundation + +enum LicenseFileReader { + + static func read() -> LicenseFile? { + guard let url = Bundle.main.url(forResource: "license", withExtension: "xuqm", subdirectory: "xuqm"), + let encrypted = try? String(contentsOf: url, encoding: .utf8) else { + return nil + } + return parse(encrypted) + } + + static func parse(_ encrypted: String) -> LicenseFile? { + guard let json = try? LicenseFileCrypto.decrypt(encrypted), + let data = json.data(using: .utf8), + let file = try? JSONDecoder().decode(LicenseFile.self, from: data) else { + return nil + } + return file + } +} diff --git a/Sources/XuqmSDK/License/LicenseModels.swift b/Sources/XuqmSDK/License/LicenseModels.swift new file mode 100644 index 0000000..0db7507 --- /dev/null +++ b/Sources/XuqmSDK/License/LicenseModels.swift @@ -0,0 +1,70 @@ +import Foundation + +struct LicenseFile: Codable { + let appKey: String + let appName: String? + let companyName: String? + let baseUrl: String? + let issuedAt: String? + let expiresAt: String? +} + +public struct LicenseUserInfo: Sendable { + public let userId: String? + public let name: String? + public let email: String? + public let phone: String? + + public init(userId: String? = nil, name: String? = nil, email: String? = nil, phone: String? = nil) { + self.userId = userId + self.name = name + self.email = email + self.phone = phone + } +} + +public enum LicenseStatus: Sendable { + case ok + case denied + case unknown +} + +public enum LicenseResult: Sendable { + case success(String) + case error(String) +} + +struct UserInfoPayload: Encodable { + let userId: String? + let name: String? + let email: String? + let phone: String? +} + +struct RegisterRequest: Encodable { + let appKey: String + let deviceId: String + let deviceName: String? + let deviceModel: String + let deviceVendor: String + let osVersion: String + let userInfo: UserInfoPayload? +} + +struct VerifyRequest: Encodable { + let appKey: String + let deviceId: String + let token: String + let userInfo: UserInfoPayload? +} + +struct RegisterResponse: Decodable { + let success: Bool + let token: String? + let message: String? +} + +struct VerifyResponse: Decodable { + let valid: Bool + let error: String? +} diff --git a/Sources/XuqmSDK/License/LicenseSDK.swift b/Sources/XuqmSDK/License/LicenseSDK.swift new file mode 100644 index 0000000..f4a3c88 --- /dev/null +++ b/Sources/XuqmSDK/License/LicenseSDK.swift @@ -0,0 +1,131 @@ +import Foundation + +public final class LicenseSDK: @unchecked Sendable { + + public static let shared = LicenseSDK() + + private static let defaultBaseUrl = "https://auth.dev.xuqinmin.com/" + private static let statusOk = "ok" + private static let statusDenied = "denied" + private static let cacheWindowMs: Double = 10 * 60 * 1000 + + private var appKey: String? + private var baseUrl: String = defaultBaseUrl + private var deviceName: String? + private let store = LicenseStore() + private var initialized = false + + private init() {} + + /// Manual initialization. Most apps only need to place the license file at + /// assets/xuqm/license.xuqm (bundle) and call checkLicense() — auto-init handles the rest. + public func initialize(appKey: String, baseUrl: String? = nil, deviceName: String? = nil) { + let url = normalize(baseUrl ?? Self.defaultBaseUrl) + if store.appKey != nil && store.appKey != appKey { store.clear() } + self.appKey = appKey + self.baseUrl = url + self.deviceName = deviceName ?? DeviceInfoProvider.getDeviceName() + store.appKey = appKey + initialized = true + } + + /// Check license validity. Returns cached result within 10-minute window. + /// On network error, falls back to last cached OK status. + public func checkLicense(userInfo: LicenseUserInfo? = nil) async -> LicenseResult { + if !initialized { tryAutoInitialize() } + guard initialized, let appKey else { + return .error("LicenseSDK not initialized") + } + + let cachedStatus = store.status + let cachedTime = store.statusTime + if cachedStatus == Self.statusOk && (Date().timeIntervalSince1970 * 1000 - cachedTime) < Self.cacheWindowMs { + return .success("Cached") + } + + let deviceId = DeviceInfoProvider.getDeviceId(store: store) + let payload = userInfo.map { UserInfoPayload(userId: $0.userId, name: $0.name, email: $0.email, phone: $0.phone) } + + do { + if let token = store.token { + let req = VerifyRequest(appKey: appKey, deviceId: deviceId, token: token, userInfo: payload) + if let resp = try? await post(VerifyResponse.self, path: "api/license/verify", body: req), resp.valid { + persistStatus(Self.statusOk) + return .success("Verified") + } + store.token = nil + } + + let req = RegisterRequest( + appKey: appKey, + deviceId: deviceId, + deviceName: deviceName ?? DeviceInfoProvider.getDeviceName(), + deviceModel: DeviceInfoProvider.getDeviceModel(), + deviceVendor: DeviceInfoProvider.getDeviceVendor(), + osVersion: DeviceInfoProvider.getOsVersion(), + userInfo: payload + ) + let resp = try await post(RegisterResponse.self, path: "api/license/register", body: req) + if resp.success, let token = resp.token { + store.token = token + persistStatus(Self.statusOk) + return .success("Registered") + } + persistStatus(Self.statusDenied) + return .error(resp.message ?? "Registration denied") + } catch { + if cachedStatus == Self.statusOk { return .success("Offline - cached ok") } + return .error(error.localizedDescription) + } + } + + public func getStatus() -> LicenseStatus { + if !initialized { tryAutoInitialize() } + switch store.status { + case Self.statusOk: return .ok + case Self.statusDenied: return .denied + default: return .unknown + } + } + + public func getDeviceId() -> String? { + if !initialized && !tryAutoInitialize() { return nil } + return store.deviceId + } + + public func clear() { store.clear() } + + @discardableResult + private func tryAutoInitialize() -> Bool { + guard let file = LicenseFileReader.read(), !file.appKey.isEmpty else { return false } + initialize(appKey: file.appKey, baseUrl: file.baseUrl) + return true + } + + private func persistStatus(_ status: String) { + store.status = status + store.statusTime = Date().timeIntervalSince1970 * 1000 + } + + private func normalize(_ url: String) -> String { + let s = url.trimmingCharacters(in: .whitespaces) + return s.hasSuffix("/") ? s : s + "/" + } + + private func post(_ type: T.Type, path: String, body: B) async throws -> T { + guard let url = URL(string: baseUrl + path) else { throw URLError(.badURL) } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(body) + let (data, response) = try await URLSession.shared.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + struct Wrapper: Decodable { let data: U? } + guard let wrapper = try? JSONDecoder().decode(Wrapper.self, from: data), let result = wrapper.data else { + throw URLError(.cannotDecodeContentData) + } + return result + } +} diff --git a/Sources/XuqmSDK/License/LicenseStore.swift b/Sources/XuqmSDK/License/LicenseStore.swift new file mode 100644 index 0000000..4a566e6 --- /dev/null +++ b/Sources/XuqmSDK/License/LicenseStore.swift @@ -0,0 +1,85 @@ +import Foundation +import Security + +final class LicenseStore { + private let keychainService = "com.xuqm.license" + private let defaults: UserDefaults + + init() { + defaults = UserDefaults(suiteName: "xuqm_license") ?? .standard + } + + var token: String? { + get { keychainGet("token") } + set { keychainSet("token", value: newValue) } + } + + var deviceId: String? { + get { keychainGet("deviceId") } + set { keychainSet("deviceId", value: newValue) } + } + + var status: String? { + get { defaults.string(forKey: "status") } + set { defaults.set(newValue, forKey: "status") } + } + + // Stored as Double (milliseconds since epoch) to match Android's currentTimeMillis + var statusTime: Double { + get { defaults.double(forKey: "statusTime") } + set { defaults.set(newValue, forKey: "statusTime") } + } + + var appKey: String? { + get { defaults.string(forKey: "appKey") } + set { defaults.set(newValue, forKey: "appKey") } + } + + func clear() { + token = nil + deviceId = nil + status = nil + statusTime = 0 + appKey = nil + } + + private func keychainGet(_ key: String) -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: keychainService, + kSecAttrAccount: key, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private func keychainSet(_ key: String, value: String?) { + guard let value else { keychainDelete(key); return } + let data = Data(value.utf8) + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: keychainService, + kSecAttrAccount: key, + ] + if SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess { + SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary) + } else { + var item = query + item[kSecValueData] = data + SecItemAdd(item as CFDictionary, nil) + } + } + + private func keychainDelete(_ key: String) { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: keychainService, + kSecAttrAccount: key, + ] + SecItemDelete(query as CFDictionary) + } +}