Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Sources/AmoreLicensing/AmoreLicensing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion Sources/AmoreLicensing/Models/LicensingConfiguration.swift
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
import Foundation

/// Configuration for license validation behavior.
public struct LicensingConfiguration: Sendable {
/// How long to allow usage after token expiry. Defaults to 7 days.
public var gracePeriod: GracePeriod
/// 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.
Expand Down
7 changes: 6 additions & 1 deletion Sources/AmoreLicensing/TokenStore/FileTokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
92 changes: 92 additions & 0 deletions Sources/AmoreLicensing/TokenStore/KeychainTokenStore.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}