diff --git a/Sources/AmoreLicensing/AmoreLicensing.swift b/Sources/AmoreLicensing/AmoreLicensing.swift index 7099faa..fcaa97e 100644 --- a/Sources/AmoreLicensing/AmoreLicensing.swift +++ b/Sources/AmoreLicensing/AmoreLicensing.swift @@ -39,7 +39,21 @@ public final class AmoreLicensing: Licensing { self.configuration = configuration self.publicKey = try EdDSA.PublicKey(x: publicKey, curve: .ed25519) self.bundleIdentifier = bundleIdentifier - self.tokenStore = FileTokenStore(bundleIdentifier: bundleIdentifier) + + switch configuration.tokenStoreLocation { + case .appGroup(let appGroupIdentifier): + self.tokenStore = FileTokenStore(appGroupIdentifier: appGroupIdentifier) + + case .defaultLocation: + self.tokenStore = FileTokenStore(bundleIdentifier: bundleIdentifier) + + case .directory(let url): + self.tokenStore = FileTokenStore(directory: url) + + case .keychainAccessGroup(let accessGroup): + self.tokenStore = KeychainTokenStore(bundleIdentifier: bundleIdentifier, accessGroup: accessGroup) + } + self.hardwareIdentifier = MacHardwareIdentifier() self.licenseClient = HTTPLicenseClient(server: server ?? .amore(for: bundleIdentifier)) if shouldAutoValidate { diff --git a/Sources/AmoreLicensing/Models/LicensingConfiguration.swift b/Sources/AmoreLicensing/Models/LicensingConfiguration.swift index 8feac3e..a410a97 100644 --- a/Sources/AmoreLicensing/Models/LicensingConfiguration.swift +++ b/Sources/AmoreLicensing/Models/LicensingConfiguration.swift @@ -1,3 +1,5 @@ +import Foundation + /// Configuration for license validation behavior. public struct LicensingConfiguration: Sendable { /// How long to allow usage after token expiry. Defaults to 7 days. @@ -5,16 +7,29 @@ public struct LicensingConfiguration: Sendable { /// How often to re-validate with the server. Defaults to weekly. public var validationFrequency: ValidationFrequency + /// Possible token store locations + public enum TokenStoreLocation: Sendable { + case defaultLocation // The default location. + case directory(URL) // Caller provided directory path. + case appGroup(String) // App Group identifier (e.g. com.company.appname). + case keychainAccessGroup(String) // Access Group name. + } + + /// Location of the token store. + public var tokenStoreLocation: TokenStoreLocation + /// Creates a licensing configuration. /// - Parameters: /// - gracePeriod: How long to allow usage after token expiry. Defaults to 7 days. /// - validationFrequency: How often to re-validate with the server. Defaults to weekly. public init( gracePeriod: GracePeriod = .days(7), - validationFrequency: ValidationFrequency = .weekly + validationFrequency: ValidationFrequency = .weekly, + tokenStoreLocation: TokenStoreLocation = .defaultLocation ) { self.gracePeriod = gracePeriod self.validationFrequency = validationFrequency + self.tokenStoreLocation = tokenStoreLocation } /// A default configuration with a 7-day grace period and weekly validation. diff --git a/Sources/AmoreLicensing/TokenStore/FileTokenStore.swift b/Sources/AmoreLicensing/TokenStore/FileTokenStore.swift index 79f2667..01a1d59 100644 --- a/Sources/AmoreLicensing/TokenStore/FileTokenStore.swift +++ b/Sources/AmoreLicensing/TokenStore/FileTokenStore.swift @@ -9,7 +9,12 @@ struct FileTokenStore: TokenStore { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] self.fileURL = appSupport.appendingPathComponent(bundleIdentifier).appendingPathComponent(Self.fileName) } - + + init(appGroupIdentifier: String) { + let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)! + self.fileURL = appGroupURL.appendingPathComponent(Self.fileName) + } + init(directory: URL) { self.fileURL = directory.appendingPathComponent(Self.fileName) } diff --git a/Sources/AmoreLicensing/TokenStore/KeychainTokenStore.swift b/Sources/AmoreLicensing/TokenStore/KeychainTokenStore.swift new file mode 100644 index 0000000..72d8ec6 --- /dev/null +++ b/Sources/AmoreLicensing/TokenStore/KeychainTokenStore.swift @@ -0,0 +1,92 @@ +import Foundation +import Security + +struct KeychainTokenStore: TokenStore { + private let service: String + private let account: String + private let accessGroup: String + + init(bundleIdentifier: String, accessGroup: String) { + self.service = "\(bundleIdentifier).license" + self.account = bundleIdentifier + self.accessGroup = accessGroup + } + + func store(_ token: String) throws(TokenStoreError) { + guard let data = token.data(using: .utf8) else { + throw .storeFailed("Unable to encode token as UTF-8") + } + + // Delete any existing item first so SecItemAdd always succeeds. + try? delete() + + // Should allow for reading the token even when launched in the background. + // Restricts the item to this device so it won't migrate to another Mac via iCloud Keychain backup. + // kSecUseDataProtectionKeychain is used to place the item into the Local Items keychain where extensions can access them. + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + kSecValueData: data, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecUseDataProtectionKeychain: true, + ] + + let status = SecItemAdd(query as CFDictionary, nil) + + guard status == errSecSuccess else { + throw .storeFailed(statusMessage(status)) + } + } + + func retrieve() throws(TokenStoreError) -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + kSecUseDataProtectionKeychain: true, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + guard let data = result as? Data, let token = String(data: data, encoding: .utf8) else { + throw .retrieveFailed("Keychain item data is not valid UTF-8") + } + return token + + case errSecItemNotFound: + return nil + + default: + throw .retrieveFailed(statusMessage(status)) + } + } + + func delete() throws(TokenStoreError) { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + kSecUseDataProtectionKeychain: true, + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw .deleteFailed(statusMessage(status)) + } + } + + // MARK: - Private + + private func statusMessage(_ status: OSStatus) -> String { + SecCopyErrorMessageString(status, nil) as String? ?? "OSStatus \(status)" + } +}