2026-05-16 02:25:26 +08:00
|
|
|
import Foundation
|
2026-05-23 01:20:57 +08:00
|
|
|
import XuqmCoreSDK
|
2026-05-16 02:25:26 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|