From accc50440943891a4afe48a5ba63f32c9dcc7210 Mon Sep 17 00:00:00 2001 From: Dave Camp Date: Wed, 27 May 2026 14:05:23 -0700 Subject: [PATCH] Added support for alternate TokenStore implementations Added support to LicensingConfiguration to allow for specifying what kind of token store is used. Updated AmoreLicensing to use the configuration changes to setup the new token stores. Added support for App Groups to FileTokenStore. Added a new KeychainTokenStore that will load/save/delete from the macOS keychain. --- Sources/AmoreLicensing/AmoreLicensing.swift | 16 +++- .../Models/LicensingConfiguration.swift | 17 +++- .../TokenStore/FileTokenStore.swift | 7 +- .../TokenStore/KeychainTokenStore.swift | 92 +++++++++++++++++++ 4 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 Sources/AmoreLicensing/TokenStore/KeychainTokenStore.swift 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)" + } +}