diff --git a/Sources/XuqmCoreSDK/ConfigFileCrypto.swift b/Sources/XuqmCoreSDK/ConfigFileCrypto.swift new file mode 100644 index 0000000..c3db6f5 --- /dev/null +++ b/Sources/XuqmCoreSDK/ConfigFileCrypto.swift @@ -0,0 +1,63 @@ +import Foundation +import CryptoKit +import CommonCrypto + +/// Decrypts the init config file (format: XUQM-CONFIG-V1...). +/// Algorithm: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (120,000 iterations). +enum ConfigFileCrypto { + private static let magic = "XUQM-CONFIG-V1" + private static let passphrase = "xuqm-config-file-v1.2026.internal" + private static let keyByteCount = 32 + private static let pbkdf2Iterations: UInt32 = 120_000 + + static func decrypt(_ content: String) throws -> String { + let parts = content.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: ".") + guard parts.count == 4, parts[0] == magic else { + throw ConfigFileError.invalidFormat + } + guard let salt = base64UrlDecode(parts[1]), + let iv = base64UrlDecode(parts[2]), + let ciphertext = base64UrlDecode(parts[3]) else { + throw ConfigFileError.invalidFormat + } + let key = try deriveKey(salt: salt) + 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 ConfigFileError.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 ConfigFileError.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) + } +} + +enum ConfigFileError: Error { + case invalidFormat + case keyDerivationFailed + case decryptionFailed +} diff --git a/Sources/XuqmCoreSDK/ConfigFileReader.swift b/Sources/XuqmCoreSDK/ConfigFileReader.swift new file mode 100644 index 0000000..e8ac088 --- /dev/null +++ b/Sources/XuqmCoreSDK/ConfigFileReader.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Reads and decrypts the init config file from the app bundle. +/// Looks for xuqm/config.xuqm first, then falls back to any *.xuqmconfig file. +/// This is used by XuqmSDK.autoInitialize() and does NOT depend on XuqmLicenseSDK. +public enum ConfigFileReader { + + public static func read() -> ConfigFileData? { + guard let url = Bundle.main.url(forResource: "config", withExtension: "xuqm", subdirectory: "xuqm"), + let encrypted = try? String(contentsOf: url, encoding: .utf8) else { + // Fallback: try any *.xuqmconfig file + if let fallbackURL = findConfigFallback(), + let encrypted = try? String(contentsOf: fallbackURL, encoding: .utf8) { + return parse(encrypted) + } + return nil + } + return parse(encrypted) + } + + static func parse(_ encrypted: String) -> ConfigFileData? { + guard let json = try? ConfigFileCrypto.decrypt(encrypted), + let data = json.data(using: .utf8), + let file = try? JSONDecoder().decode(ConfigFileData.self, from: data) else { + return nil + } + return file + } + + private static func findConfigFallback() -> URL? { + guard let resourceURL = Bundle.main.resourceURL else { return nil } + let xuqmDir = resourceURL.appendingPathComponent("xuqm") + guard let contents = try? FileManager.default.contentsOfDirectory(at: xuqmDir, includingPropertiesForKeys: nil) else { + return nil + } + return contents.first { $0.pathExtension.lowercased() == "xuqmconfig" } + } +} diff --git a/Sources/XuqmCoreSDK/XuqmSDK.swift b/Sources/XuqmCoreSDK/XuqmSDK.swift index 8169507..ca41739 100644 --- a/Sources/XuqmCoreSDK/XuqmSDK.swift +++ b/Sources/XuqmCoreSDK/XuqmSDK.swift @@ -1,6 +1,9 @@ import Foundation -public struct LicenseFileData: Sendable { +/// Decrypted content of the init config file (xuqm/config.xuqm). +/// Contains the appKey and optional server URL needed to bootstrap the SDK. +/// This is separate from XuqmLicenseSDK's LicenseFile (device activation). +public struct ConfigFileData: Codable, Sendable { public let appKey: String public let appName: String? public let companyName: String? @@ -57,23 +60,20 @@ public final class XuqmSDK: NSObject { public var onLogin: (@MainActor @Sendable (String, String) async throws -> Void)? public var onLogout: (@MainActor @Sendable (String) async throws -> Void)? public var onDeviceToken: (@MainActor @Sendable (String, String) async throws -> Void)? - public var licenseReader: (@Sendable () throws -> LicenseFileData?)? private override init() { super.init() } - /// Auto-initialize from the embedded license file using the registered licenseReader hook. + /// Auto-initialize from the embedded config file (xuqm/config.xuqm). + /// Reads and decrypts the config file directly — no external hooks needed. public func autoInitialize(debug: Bool = false) throws { - guard let reader = licenseReader else { - throw XuqmSDKError.noLicenseFile - } - guard let file = try reader() else { - throw XuqmSDKError.noLicenseFile + guard let file = ConfigFileReader.read() else { + throw XuqmSDKError.noConfigFile } let bundleId = Bundle.main.bundleIdentifier ?? "" - if let licenseBundle = file.iosBundleId, !licenseBundle.isEmpty, licenseBundle != bundleId { - throw XuqmSDKError.packageMismatch(license: licenseBundle, local: bundleId) + if let configBundle = file.iosBundleId, !configBundle.isEmpty, configBundle != bundleId { + throw XuqmSDKError.packageMismatch(license: configBundle, local: bundleId) } let cfg = SDKConfig(appKey: file.appKey, debug: debug) initialize(config: cfg) @@ -161,6 +161,7 @@ public final class XuqmSDK: NSObject { } public enum XuqmSDKError: Error { + case noConfigFile case noLicenseFile case packageMismatch(license: String, local: String) } diff --git a/Sources/XuqmLicenseSDK/LicenseSDK.swift b/Sources/XuqmLicenseSDK/LicenseSDK.swift index 88ccedd..20baf8b 100644 --- a/Sources/XuqmLicenseSDK/LicenseSDK.swift +++ b/Sources/XuqmLicenseSDK/LicenseSDK.swift @@ -18,8 +18,8 @@ public final class LicenseSDK: @unchecked Sendable { 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. + /// Manual initialization. Typically not needed — call checkLicense() directly + /// after XuqmSDK is initialized (via config file or manual init). 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() } @@ -32,13 +32,14 @@ public final class LicenseSDK: @unchecked Sendable { /// Check license validity. Returns cached result within 10-minute window. /// On network error, falls back to last cached OK status. + /// Automatically uses appKey from XuqmSDK if not manually initialized. public func checkLicense(userInfo: LicenseUserInfo? = nil) async -> LicenseResult { if !initialized { await XuqmSDK.shared.awaitInitialization() - tryAutoInitialize() + ensureInitializedFromSDK() } guard initialized, let appKey else { - return .error("LicenseSDK not initialized") + return .error("LicenseSDK not initialized. Ensure XuqmSDK is initialized first.") } let cachedStatus = store.status @@ -86,7 +87,7 @@ public final class LicenseSDK: @unchecked Sendable { public func getStatus() async -> LicenseStatus { if !initialized { await XuqmSDK.shared.awaitInitialization() - tryAutoInitialize() + ensureInitializedFromSDK() } switch store.status { case Self.statusOk: return .ok @@ -98,7 +99,7 @@ public final class LicenseSDK: @unchecked Sendable { public func getDeviceId() async -> String? { if !initialized { await XuqmSDK.shared.awaitInitialization() - tryAutoInitialize() + ensureInitializedFromSDK() } if !initialized { return nil } return store.deviceId @@ -106,10 +107,11 @@ public final class LicenseSDK: @unchecked Sendable { public func clear() { store.clear() } + /// Initialize from XuqmSDK's config. Called when LicenseSDK is used before manual init. @discardableResult - private func tryAutoInitialize() -> Bool { - guard let file = LicenseFileReader.read(), !file.appKey.isEmpty else { return false } - initialize(appKey: file.appKey, baseUrl: file.baseUrl) + private func ensureInitializedFromSDK() -> Bool { + guard XuqmSDK.shared.initialized, let config = XuqmSDK.shared.config else { return false } + initialize(appKey: config.appKey) return true } diff --git a/Sources/XuqmSDK/XuqmModuleRegistration.swift b/Sources/XuqmSDK/XuqmModuleRegistration.swift index 3da08ae..48659bd 100644 --- a/Sources/XuqmSDK/XuqmModuleRegistration.swift +++ b/Sources/XuqmSDK/XuqmModuleRegistration.swift @@ -13,22 +13,7 @@ extension XuqmSDK { /// Register all module hooks. Called automatically when any module is first used. public func registerModuleHooks() { - // License reader hook - licenseReader = { - guard let file = LicenseFileReader.read() else { return nil } - return LicenseFileData( - appKey: file.appKey, - appName: file.appName, - companyName: file.companyName, - packageName: file.packageName, - iosBundleId: file.iosBundleId, - harmonyBundleName: file.harmonyBundleName, - baseUrl: file.baseUrl, - serverUrl: file.serverUrl, - issuedAt: file.issuedAt, - expiresAt: file.expiresAt - ) - } + // Note: licenseReader hook removed — autoInitialize now reads config.xuqm directly via ConfigFileReader. // Initialize hook — auto-start push if configured onInitialize = { @MainActor config in