feat(license): add LicenseSDK module

Implements device authorization registration and verification:
- AES-256-GCM + PBKDF2 decryption of .xuqmlicense files
- Keychain storage for token and deviceId
- UIDevice.identifierForVendor for device ID
- 10-minute cache with offline fallback
- LicenseResult sealed type (success/error)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-16 02:25:26 +08:00
父节点 979fd7d033
当前提交 60ac8f0283
共有 6 个文件被更改,包括 418 次插入0 次删除

查看文件

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

查看文件

@ -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)
}
}

查看文件

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

查看文件

@ -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?
}

查看文件

@ -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<T: Decodable, B: Encodable>(_ 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<U: Decodable>: Decodable { let data: U? }
guard let wrapper = try? JSONDecoder().decode(Wrapper<T>.self, from: data), let result = wrapper.data else {
throw URLError(.cannotDecodeContentData)
}
return result
}
}

查看文件

@ -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)
}
}