diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 5cdb38c..0605fd5 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -10,10 +10,18 @@ on: branches: [ "main" ] jobs: - build: - + macos: runs-on: macos-26 + steps: + - uses: actions/checkout@v4 + - name: Build + run: swift build -v + - name: Run tests + run: swift test -v + linux: + runs-on: ubuntu-latest + container: swift:6.2 steps: - uses: actions/checkout@v4 - name: Build diff --git a/Package.swift b/Package.swift index 6eebb37..f7bf263 100644 --- a/Package.swift +++ b/Package.swift @@ -3,59 +3,73 @@ import PackageDescription +// AmoreStore depends on Foundation currency metadata (NumberFormatter) that is +// only reliable on Apple platforms, so it is built only there. +var products: [Product] = [ + .library( + name: "AmoreLicensing", + targets: ["AmoreLicensing"] + ), +] + +var targets: [Target] = [ + .target( + name: "AmoreJWT", + dependencies: [ + .product(name: "Crypto", package: "swift-crypto"), + ] + ), + .target( + name: "AmoreLicensing", + dependencies: [ + "AmoreJWT", + .product(name: "Crypto", package: "swift-crypto"), + ] + ), + .testTarget( + name: "AmoreJWTTests", + dependencies: [ + "AmoreJWT", + .product(name: "JWTKit", package: "jwt-kit"), + ] + ), + .testTarget( + name: "AmoreLicensingTests", + dependencies: [ + "AmoreLicensing", + "AmoreJWT", + .product(name: "JWTKit", package: "jwt-kit"), + ] + ), +] + +#if canImport(Darwin) +products.append( + .library( + name: "AmoreStore", + targets: ["AmoreStore"] + ) +) +targets.append(.target(name: "AmoreStore")) +targets.append( + .testTarget( + name: "AmoreStoreTests", + dependencies: ["AmoreStore"] + ) +) +#endif + let package = Package( name: "AmoreKit", - platforms: [.macOS(.v14)], - products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. - .library( - name: "AmoreLicensing", - targets: ["AmoreLicensing"] - ), - .library( - name: "AmoreStore", - targets: ["AmoreStore"] - ), + platforms: [ + .macOS(.v14), ], + products: products, dependencies: [ .package(url: "https://github.com/apple/swift-crypto.git", "3.8.0"..<"5.0.0"), .package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.1.0"), ], - targets: [ - .target( - name: "AmoreJWT", - dependencies: [ - .product(name: "Crypto", package: "swift-crypto"), - ] - ), - .target( - name: "AmoreLicensing", - dependencies: [ - "AmoreJWT", - .product(name: "Crypto", package: "swift-crypto"), - ], - ), - .target(name: "AmoreStore"), - .testTarget( - name: "AmoreJWTTests", - dependencies: [ - "AmoreJWT", - .product(name: "JWTKit", package: "jwt-kit"), - ] - ), - .testTarget( - name: "AmoreLicensingTests", - dependencies: [ - "AmoreLicensing", - "AmoreJWT", - .product(name: "JWTKit", package: "jwt-kit"), - ] - ), - .testTarget( - name: "AmoreStoreTests", - dependencies: ["AmoreStore"] - ), - ], + targets: targets, swiftLanguageModes: [.v6] ) diff --git a/Sources/AmoreLicensing/AmoreLicensing.swift b/Sources/AmoreLicensing/AmoreLicensing.swift index edbbba5..9cb1f12 100644 --- a/Sources/AmoreLicensing/AmoreLicensing.swift +++ b/Sources/AmoreLicensing/AmoreLicensing.swift @@ -1,6 +1,7 @@ import AmoreJWT import Crypto import Foundation +import Observation /// Manages license activation, deactivation, and validation against an Amore licensing server. /// @@ -9,28 +10,30 @@ import Foundation public final class AmoreLicensing: Licensing { /// The current validation status of the license. public private(set) var status: ValidationStatus = .unknown - + private let bundleIdentifier: String private let configuration: LicensingConfiguration - private let hardwareIdentifier: HardwareIdentifier + private let deviceIdentity: any DeviceIdentity private let licenseClient: LicenseClient private let tokenStore: TokenStore private let verifier: LicenseTokenVerifier private var isValidating = false - + /// Creates a new licensing instance. /// - Parameters: /// - publicKey: The Ed25519 public key used to verify server responses. /// - bundleIdentifier: The app's bundle identifier. Defaults to `Bundle.main.bundleIdentifier`. /// - configuration: The licensing configuration. Defaults to ``LicensingConfiguration/default``. /// - server: The license server to use. Defaults to the Amore server. + /// - deviceIdentity: How this device is identified when binding a license. On macOS, use the initializer without this parameter to default to the built-in identifier. /// - tokenStore: A custom store for persisting the license token. Defaults to a ``FileTokenStore`` in Application Support. Provide a custom ``TokenStore`` to store the token elsewhere. public init( publicKey: String, bundleIdentifier: String? = nil, configuration: LicensingConfiguration = .default, server: LicenseServer? = nil, - tokenStore: (any TokenStore)? = nil + deviceIdentity: (any DeviceIdentity), + tokenStore: (any TokenStore)? = nil, ) throws { let bundleIdentifier = bundleIdentifier ?? Bundle.main.bundleIdentifier ?? publicKey guard @@ -39,35 +42,63 @@ public final class AmoreLicensing: Licensing { else { throw AmoreError.invalidPublicKey } - let hardwareIdentifier = MacHardwareIdentifier() self.configuration = configuration self.bundleIdentifier = bundleIdentifier self.tokenStore = tokenStore ?? FileTokenStore(bundleIdentifier: bundleIdentifier) - self.hardwareIdentifier = hardwareIdentifier + self.deviceIdentity = deviceIdentity self.licenseClient = HTTPLicenseClient(server: server ?? .amore(for: bundleIdentifier)) - self.verifier = LicenseTokenVerifier(publicKey: signingKey, hardwareIdentifier: hardwareIdentifier) + self.verifier = LicenseTokenVerifier(publicKey: signingKey, deviceIdentity: deviceIdentity) if configuration.validationFrequency.shouldValidateAtLaunch { validateLocally() Task { [self] in try? await validate() } } } - + +#if os(macOS) + /// Creates a new licensing instance using the built-in macOS device identity. + /// + /// This is the recommended initializer on macOS. To control how the device is + /// identified, use the initializer that takes a `deviceIdentity` instead. + /// - Parameters: + /// - publicKey: The Ed25519 public key used to verify server responses. + /// - bundleIdentifier: The app's bundle identifier. Defaults to `Bundle.main.bundleIdentifier`. + /// - configuration: The licensing configuration. Defaults to ``LicensingConfiguration/default``. + /// - server: The license server to use. Defaults to the Amore server. + /// - tokenStore: A custom store for persisting the license token. Defaults to a ``FileTokenStore`` in Application Support. Provide a custom ``TokenStore`` to store the token elsewhere. + public convenience init( + publicKey: String, + bundleIdentifier: String? = nil, + configuration: LicensingConfiguration = .default, + server: LicenseServer? = nil, + tokenStore: (any TokenStore)? = nil, + ) throws { + try self.init( + publicKey: publicKey, + bundleIdentifier: bundleIdentifier, + configuration: configuration, + server: server, + deviceIdentity: MacDeviceIdentity(), + tokenStore: tokenStore + ) + } +#endif + internal init( publicKey: Curve25519.Signing.PublicKey, bundleIdentifier: String, configuration: LicensingConfiguration = .default, tokenStore: TokenStore, - hardwareIdentifier: HardwareIdentifier, + deviceIdentity: any DeviceIdentity, licenseClient: LicenseClient ) { self.configuration = configuration self.bundleIdentifier = bundleIdentifier self.tokenStore = tokenStore - self.hardwareIdentifier = hardwareIdentifier + self.deviceIdentity = deviceIdentity self.licenseClient = licenseClient - self.verifier = LicenseTokenVerifier(publicKey: publicKey, hardwareIdentifier: hardwareIdentifier) + self.verifier = LicenseTokenVerifier(publicKey: publicKey, deviceIdentity: deviceIdentity) } - + /// Activates a license on this device using the given license key. /// - Parameter licenseKey: The license key to activate. /// - Throws: ``AmoreError`` if activation fails. @@ -75,15 +106,15 @@ public final class AmoreLicensing: Licensing { let nonce = UUID().uuidString let token = try await mapClientErrors { try await self.licenseClient.activate( - licenseKey: licenseKey, hardwareId: self.hardwareIdentifier.identifier, nonce: nonce, - name: Host.current().localizedName + licenseKey: licenseKey, hardwareId: self.deviceIdentity.identifier, nonce: nonce, + name: self.deviceIdentity.deviceName ) } let payload = try verifier.decode(token, expectedNonce: nonce) do { try tokenStore.store(token) } catch { throw .tokenStore(error) } status = .valid(License(from: payload)) } - + /// Deactivates the current license on this device. /// - Throws: ``AmoreError`` if deactivation fails. public func deactivate() async throws(AmoreError) { @@ -96,7 +127,7 @@ public final class AmoreLicensing: Licensing { do { try tokenStore.delete() } catch { throw .tokenStore(error) } status = .unknown } - + /// Validates the stored license token and updates ``status``. /// /// The token is verified locally (signature, expiry, hardware ID). If it is @@ -116,14 +147,14 @@ public final class AmoreLicensing: Licensing { guard !isValidating else { return status } isValidating = true defer { isValidating = false } - + let stored: String? do { stored = try tokenStore.retrieve() } catch { throw .tokenStore(error) } guard let token = stored else { status = .unknown throw .noStoredToken } - + switch verifier.decodeLocally(token) { case .hardwareMismatch: status = .invalid @@ -139,9 +170,9 @@ public final class AmoreLicensing: Licensing { } return status } - + // MARK: - Private - + private func validateLocally() { guard let token = try? tokenStore.retrieve() else { return } switch verifier.decodeLocally(token) { @@ -158,7 +189,7 @@ public final class AmoreLicensing: Licensing { if let license = graceLicense(for: payload) { status = .gracePeriod(license) } } } - + /// Refreshes the token from the server and updates ``status``. On a transient /// failure it keeps `localPayload` while still valid, falls back to the grace /// period once it has expired, and invalidates when there is nothing to fall @@ -186,7 +217,7 @@ public final class AmoreLicensing: Licensing { status = .valid(License(from: localPayload)) } } - + /// Enters the grace period derived from an already-verified, expired payload, /// or invalidates once that grace period has elapsed. private func applyGracePeriod(payload: LicensePayload) { @@ -196,7 +227,7 @@ public final class AmoreLicensing: Licensing { status = .invalid } } - + /// The license a still-within-grace expired payload represents, with its /// expiry extended to the grace deadline, or `nil` once grace has elapsed. private func graceLicense(for payload: LicensePayload) -> License? { @@ -206,7 +237,7 @@ public final class AmoreLicensing: Licensing { license.expiresAt = graceEnd return license } - + private func mapClientErrors( _ operation: @MainActor @Sendable () async throws -> T ) async throws(AmoreError) -> T { @@ -222,5 +253,5 @@ public final class AmoreLicensing: Licensing { throw .network(.requestFailed(error.localizedDescription)) } } - + } diff --git a/Sources/AmoreLicensing/DeviceIdentity/DeviceIdentity.swift b/Sources/AmoreLicensing/DeviceIdentity/DeviceIdentity.swift new file mode 100644 index 0000000..fc0176d --- /dev/null +++ b/Sources/AmoreLicensing/DeviceIdentity/DeviceIdentity.swift @@ -0,0 +1,16 @@ +/// Identifies the device a license is bound to. +/// +/// AmoreLicensing ships a built-in implementation for macOS. On every other +/// platform, provide your own conformance and inject it when creating an +/// ``AmoreLicensing`` instance. +public protocol DeviceIdentity: Sendable { + /// A human-readable name for this device, sent to the server on activation so + /// the device can be recognised in the licensing dashboard. + var deviceName: String { get } + + /// A stable, machine-unique identifier used to bind a license to this device. + /// + /// This value must stay constant for the lifetime of the install: if it + /// changes, the bound license stops validating and a re-activation is required. + var identifier: String { get } +} diff --git a/Sources/AmoreLicensing/HardwareIdentifier/MacHardwareIdentifier.swift b/Sources/AmoreLicensing/DeviceIdentity/MacDeviceIdentity.swift similarity index 68% rename from Sources/AmoreLicensing/HardwareIdentifier/MacHardwareIdentifier.swift rename to Sources/AmoreLicensing/DeviceIdentity/MacDeviceIdentity.swift index 3105347..34d1f4b 100644 --- a/Sources/AmoreLicensing/HardwareIdentifier/MacHardwareIdentifier.swift +++ b/Sources/AmoreLicensing/DeviceIdentity/MacDeviceIdentity.swift @@ -1,14 +1,21 @@ +#if os(macOS) import Foundation import IOKit +import SystemConfiguration -struct MacHardwareIdentifier: HardwareIdentifier { +struct MacDeviceIdentity: DeviceIdentity { + var deviceName: String { + (SCDynamicStoreCopyComputerName(nil, nil) as String?) + ?? ProcessInfo.processInfo.hostName + } + var identifier: String { let service = IOServiceGetMatchingService( kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice") ) defer { IOObjectRelease(service) } - + guard let data = IORegistryEntryCreateCFProperty( service, "IOPlatformSerialNumber" as CFString, @@ -20,3 +27,4 @@ struct MacHardwareIdentifier: HardwareIdentifier { return data } } +#endif diff --git a/Sources/AmoreLicensing/Documentation.docc/Architecture & Security.md b/Sources/AmoreLicensing/Documentation.docc/Architecture & Security.md index 1e04758..577364c 100644 --- a/Sources/AmoreLicensing/Documentation.docc/Architecture & Security.md +++ b/Sources/AmoreLicensing/Documentation.docc/Architecture & Security.md @@ -14,7 +14,7 @@ Server stores per-app private keys for signing JWTs. Clients verify signatures a 1. User enters license key in app 2. Client generates: - - Hardware ID (IOPlatformSerialNumber or similar) + - Device ID from ``DeviceIdentity`` (the built-in macOS identity uses IOPlatformSerialNumber) - Random nonce (UUID) 3. Client sends HTTPS POST to server: { license_key, hardware_id, nonce } @@ -30,7 +30,7 @@ Server stores per-app private keys for signing JWTs. Clients verify signatures a - JWT signature valid (using server's public key) - Nonce matches what client sent - JWT not expired -1. Client stores JWT in macOS file system +1. Client stores JWT in the file system ### Ongoing Validation (offline-first) diff --git a/Sources/AmoreLicensing/Documentation.docc/Custom Device Identity.md b/Sources/AmoreLicensing/Documentation.docc/Custom Device Identity.md new file mode 100644 index 0000000..74faf3b --- /dev/null +++ b/Sources/AmoreLicensing/Documentation.docc/Custom Device Identity.md @@ -0,0 +1,67 @@ +# Custom Device Identity + +Bind a license to a device on platforms without a built-in identity. + +## Overview + +AmoreLicensing binds each license to the device it was activated on, identifying that device through a ``DeviceIdentity``. On macOS this is automatic: the ``AmoreLicensing`` initializer that takes no `deviceIdentity` uses a built-in implementation backed by the hardware serial number. + +On every other platform there is no built-in identity, so you provide your own: conform to ``DeviceIdentity`` and pass it when creating ``AmoreLicensing``. + +## Conforming to DeviceIdentity + +``DeviceIdentity`` has two requirements: + +- ``DeviceIdentity/identifier``: a stable, machine-unique string the license binds to. +- ``DeviceIdentity/deviceName``: a human-readable name shown in the licensing dashboard. + +```swift +import AmoreLicensing + +struct MyDeviceIdentity: DeviceIdentity { + var identifier: String { + // A stable, machine-unique id for this install. + } + + var deviceName: String { + // A human-readable name, for example the host name. + } +} +``` + +## Injecting your identity + +Pass your conformance through the `deviceIdentity` parameter. This initializer is available on every platform: + +```swift +let licensing = try AmoreLicensing( + publicKey: "sa92JNtsaYefYp0MIWQbKu1hpS9bSN89ta7b8mlPbI8=", + deviceIdentity: MyDeviceIdentity() +) +``` + +On macOS you can use this initializer too, to override the built-in identity. + +## Choosing an identifier + +``DeviceIdentity/identifier`` is the value the license is bound to, so it must be: + +- **Stable**: constant for the lifetime of the install. If it changes, the bound license stops validating and the user has to re-activate. +- **Unique**: distinct per device, so a license cannot be shared across machines. + +On Linux, for example, you might read `/etc/machine-id`: + +```swift +import Foundation + +struct LinuxDeviceIdentity: DeviceIdentity { + var deviceName: String { ProcessInfo.processInfo.hostName } + + var identifier: String { + (try? String(contentsOfFile: "/etc/machine-id", encoding: .utf8))? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown" + } +} +``` + +> Important: Avoid values that change across reboots, OS updates, or network changes (such as IP addresses or dynamic host names), or licenses will repeatedly invalidate. diff --git a/Sources/AmoreLicensing/Documentation.docc/Getting Started.md b/Sources/AmoreLicensing/Documentation.docc/Getting Started.md index a0a1c1f..fdde756 100644 --- a/Sources/AmoreLicensing/Documentation.docc/Getting Started.md +++ b/Sources/AmoreLicensing/Documentation.docc/Getting Started.md @@ -28,6 +28,8 @@ let licensing = try AmoreLicensing( > Note: All methods on ``AmoreLicensing`` throw ``AmoreError`` with detailed information about what went wrong. +> Tip: The initializer above uses a built-in device identity that ships with macOS. On other platforms you must provide your own. See . + ## Activation To activate your user's license, call ``AmoreLicensing/activate(licenseKey:)`` with a valid license key. diff --git a/Sources/AmoreLicensing/Documentation.docc/Index.md b/Sources/AmoreLicensing/Documentation.docc/Index.md index 0a6e74f..847e292 100644 --- a/Sources/AmoreLicensing/Documentation.docc/Index.md +++ b/Sources/AmoreLicensing/Documentation.docc/Index.md @@ -1,6 +1,6 @@ # ``AmoreLicensing`` -A macOS licensing SDK for license activation, validation, and deactivation. +A licensing SDK for license activation, validation, and deactivation. ## Overview @@ -15,6 +15,7 @@ AmoreLicensing provides an `@Observable` class that manages the full license lif ### Articles - +- - ### Essentials @@ -37,6 +38,10 @@ AmoreLicensing provides an `@Observable` class that manages the full license lif - ``TokenStore`` - ``FileTokenStore`` +### Device Identity + +- ``DeviceIdentity`` + ### Errors - ``AmoreError`` diff --git a/Sources/AmoreLicensing/HardwareIdentifier/HardwareIdentifier.swift b/Sources/AmoreLicensing/HardwareIdentifier/HardwareIdentifier.swift deleted file mode 100644 index 4978734..0000000 --- a/Sources/AmoreLicensing/HardwareIdentifier/HardwareIdentifier.swift +++ /dev/null @@ -1,3 +0,0 @@ -protocol HardwareIdentifier: Sendable { - var identifier: String { get } -} diff --git a/Sources/AmoreLicensing/LicenseClient/HTTPLicenseClient.swift b/Sources/AmoreLicensing/LicenseClient/HTTPLicenseClient.swift index 68bfa27..ca93584 100644 --- a/Sources/AmoreLicensing/LicenseClient/HTTPLicenseClient.swift +++ b/Sources/AmoreLicensing/LicenseClient/HTTPLicenseClient.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif struct HTTPLicenseClient: LicenseClient { private let server: LicenseServer diff --git a/Sources/AmoreLicensing/Payload/LicenseTokenVerifier.swift b/Sources/AmoreLicensing/Payload/LicenseTokenVerifier.swift index 9c7b640..609d5a2 100644 --- a/Sources/AmoreLicensing/Payload/LicenseTokenVerifier.swift +++ b/Sources/AmoreLicensing/Payload/LicenseTokenVerifier.swift @@ -3,7 +3,7 @@ import Crypto import Foundation /// Verifies signed license tokens against the app's public key and this device's -/// hardware identifier. +/// identity. /// /// Pure and stateless: it performs no I/O and holds no mutable state, so the /// signature, nonce, hardware, and expiry checks can be exercised in isolation. @@ -17,7 +17,7 @@ struct LicenseTokenVerifier: Sendable { } let publicKey: Curve25519.Signing.PublicKey - let hardwareIdentifier: HardwareIdentifier + let deviceIdentity: any DeviceIdentity /// Verifies a token's signature and claims and returns its payload. /// - Parameters: @@ -40,7 +40,7 @@ struct LicenseTokenVerifier: Sendable { throw .invalidToken } if let expectedNonce, payload.nonce != expectedNonce { throw .nonceMismatch } - guard payload.hardwareId == hardwareIdentifier.identifier else { throw .hardwareIdMismatch } + guard payload.hardwareId == deviceIdentity.identifier else { throw .hardwareIdMismatch } return payload } diff --git a/Tests/AmoreLicensingTests/AmoreClientTests.swift b/Tests/AmoreLicensingTests/AmoreClientTests.swift index 904cfe8..22dc839 100644 --- a/Tests/AmoreLicensingTests/AmoreClientTests.swift +++ b/Tests/AmoreLicensingTests/AmoreClientTests.swift @@ -43,7 +43,7 @@ import Testing publicKey: publicKey, bundleIdentifier: bundleId, tokenStore: tokenStore, - hardwareIdentifier: MockHardwareIdentifier(identifier: hardwareId), + deviceIdentity: MockDeviceIdentity(identifier: hardwareId), licenseClient: licenseClient ) return (client, tokenStore, licenseClient) @@ -314,7 +314,6 @@ import Testing /// rely on at startup to gate access without awaiting the server. @Test func launchInitializerSurfacesValidStoredTokenSynchronously() throws { let (privateKey, publicKey) = makeKeys() - let hardwareId = MacHardwareIdentifier().identifier let store = MockTokenStore() let token = try signToken(privateKey: privateKey, hardwareId: hardwareId, nonce: "stored") try store.store(token) @@ -323,6 +322,7 @@ import Testing publicKey: publicKey.rawRepresentation.base64URLEncodedString(), bundleIdentifier: bundleId, server: unreachableServer(), + deviceIdentity: MockDeviceIdentity(identifier: hardwareId), tokenStore: store ) @@ -339,7 +339,6 @@ import Testing /// refresh fails and applies grace. @Test func launchInitializerSurfacesGracePeriodForTokenWithinGraceSynchronously() throws { let (privateKey, publicKey) = makeKeys() - let hardwareId = MacHardwareIdentifier().identifier let store = MockTokenStore() let expDate = Date().addingTimeInterval(-2 * 24 * 3600) // expired 2 days ago let token = try signToken( @@ -351,6 +350,7 @@ import Testing publicKey: publicKey.rawRepresentation.base64URLEncodedString(), bundleIdentifier: bundleId, server: unreachableServer(), + deviceIdentity: MockDeviceIdentity(identifier: hardwareId), tokenStore: store ) @@ -367,7 +367,6 @@ import Testing /// async `validate()` makes that call. @Test func launchInitializerStaysUnknownForTokenBeyondGrace() throws { let (privateKey, publicKey) = makeKeys() - let hardwareId = MacHardwareIdentifier().identifier let store = MockTokenStore() let token = try signToken( privateKey: privateKey, hardwareId: hardwareId, nonce: "stored", @@ -379,6 +378,7 @@ import Testing publicKey: publicKey.rawRepresentation.base64URLEncodedString(), bundleIdentifier: bundleId, server: unreachableServer(), + deviceIdentity: MockDeviceIdentity(identifier: hardwareId), tokenStore: store ) diff --git a/Tests/AmoreLicensingTests/DeviceIdentity/MockDeviceIdentity.swift b/Tests/AmoreLicensingTests/DeviceIdentity/MockDeviceIdentity.swift new file mode 100644 index 0000000..102f85e --- /dev/null +++ b/Tests/AmoreLicensingTests/DeviceIdentity/MockDeviceIdentity.swift @@ -0,0 +1,6 @@ +@testable import AmoreLicensing + +struct MockDeviceIdentity: DeviceIdentity { + var deviceName: String = "Test Device" + let identifier: String +} diff --git a/Tests/AmoreLicensingTests/HardwareIdentifier/MockHardwareIdentifier.swift b/Tests/AmoreLicensingTests/HardwareIdentifier/MockHardwareIdentifier.swift deleted file mode 100644 index 9aeb25b..0000000 --- a/Tests/AmoreLicensingTests/HardwareIdentifier/MockHardwareIdentifier.swift +++ /dev/null @@ -1,5 +0,0 @@ -@testable import AmoreLicensing - -struct MockHardwareIdentifier: HardwareIdentifier { - let identifier: String -} diff --git a/Tests/AmoreLicensingTests/LicenseClient/MockLicenseClient.swift b/Tests/AmoreLicensingTests/LicenseClient/MockLicenseClient.swift index 8b05372..d56727f 100644 --- a/Tests/AmoreLicensingTests/LicenseClient/MockLicenseClient.swift +++ b/Tests/AmoreLicensingTests/LicenseClient/MockLicenseClient.swift @@ -4,21 +4,25 @@ final class MockLicenseClient: LicenseClient, @unchecked Sendable { var onActivate: ((String, String, String) async throws -> String)? var onDeactivate: ((String) async throws -> Void)? var onValidate: ((String, String) async throws -> String)? - + + /// The `name` passed to the most recent `activate` call. + private(set) var lastActivateName: String? + func activate(licenseKey: String, hardwareId: String, nonce: String, name: String?) async throws -> String { + lastActivateName = name guard let handler = onActivate else { throw AmoreError.client(.licensingNotConfigured) } return try await handler(licenseKey, hardwareId, nonce) } - + func deactivate(token: String) async throws { guard let handler = onDeactivate else { throw AmoreError.client(.licensingNotConfigured) } try await handler(token) } - + func validate(token: String, nonce: String) async throws -> String { guard let handler = onValidate else { throw AmoreError.client(.licensingNotConfigured) diff --git a/Tests/AmoreLicensingTests/LicenseMigrationTests.swift b/Tests/AmoreLicensingTests/LicenseMigrationTests.swift index d0ead5c..16cd7b6 100644 --- a/Tests/AmoreLicensingTests/LicenseMigrationTests.swift +++ b/Tests/AmoreLicensingTests/LicenseMigrationTests.swift @@ -23,7 +23,7 @@ import Testing publicKey: publicKey, bundleIdentifier: bundleId, tokenStore: tokenStore, - hardwareIdentifier: MockHardwareIdentifier(identifier: hardwareId), + deviceIdentity: MockDeviceIdentity(identifier: hardwareId), licenseClient: licenseClient ) } diff --git a/Tests/AmoreLicensingTests/LicenseTokenVerifierTests.swift b/Tests/AmoreLicensingTests/LicenseTokenVerifierTests.swift index f4eace2..c0711af 100644 --- a/Tests/AmoreLicensingTests/LicenseTokenVerifierTests.swift +++ b/Tests/AmoreLicensingTests/LicenseTokenVerifierTests.swift @@ -16,7 +16,7 @@ struct LicenseTokenVerifierTests { ) -> LicenseTokenVerifier { LicenseTokenVerifier( publicKey: publicKey ?? privateKey.publicKey, - hardwareIdentifier: MockHardwareIdentifier(identifier: hardwareId ?? self.hardwareId) + deviceIdentity: MockDeviceIdentity(identifier: hardwareId ?? self.hardwareId) ) } diff --git a/Tests/AmoreLicensingTests/PublicKeyIngestionTests.swift b/Tests/AmoreLicensingTests/PublicKeyIngestionTests.swift index a34e80e..c126da4 100644 --- a/Tests/AmoreLicensingTests/PublicKeyIngestionTests.swift +++ b/Tests/AmoreLicensingTests/PublicKeyIngestionTests.swift @@ -5,7 +5,7 @@ import Testing @testable import AmoreLicensing -/// Exercises the public ``AmoreLicensing/init(publicKey:bundleIdentifier:configuration:server:tokenStore:)`` +/// Exercises the public ``AmoreLicensing/init(publicKey:bundleIdentifier:configuration:server:deviceIdentity:tokenStore:)`` /// string-to-key path: the one place a deployed app's hardcoded key string is /// ingested. ``ValidationFrequency/manual`` keeps the initializer side-effect /// free (no launch validation, no network). @@ -21,6 +21,7 @@ struct PublicKeyIngestionTests { publicKey: keyString, bundleIdentifier: "com.test.amorekit", configuration: manual, + deviceIdentity: MockDeviceIdentity(identifier: "TEST-DEVICE"), tokenStore: MockTokenStore() ) } @@ -31,6 +32,7 @@ struct PublicKeyIngestionTests { publicKey: "not base64 !!!", bundleIdentifier: "com.test.amorekit", configuration: manual, + deviceIdentity: MockDeviceIdentity(identifier: "TEST-DEVICE"), tokenStore: MockTokenStore() ) } @@ -43,6 +45,7 @@ struct PublicKeyIngestionTests { publicKey: tooShort, bundleIdentifier: "com.test.amorekit", configuration: manual, + deviceIdentity: MockDeviceIdentity(identifier: "TEST-DEVICE"), tokenStore: MockTokenStore() ) } diff --git a/Tests/AmoreLicensingTests/ValidationFrequencyTests.swift b/Tests/AmoreLicensingTests/ValidationFrequencyTests.swift index 25b8fc7..c7fc4c7 100644 --- a/Tests/AmoreLicensingTests/ValidationFrequencyTests.swift +++ b/Tests/AmoreLicensingTests/ValidationFrequencyTests.swift @@ -43,7 +43,7 @@ import Testing bundleIdentifier: bundleId, configuration: configuration, tokenStore: tokenStore, - hardwareIdentifier: MockHardwareIdentifier(identifier: hardwareId), + deviceIdentity: MockDeviceIdentity(identifier: hardwareId), licenseClient: licenseClient ) return (client, tokenStore, licenseClient) diff --git a/readme.md b/readme.md index 51491c6..d563923 100644 --- a/readme.md +++ b/readme.md @@ -1,10 +1,11 @@ # AmoreKit -A macOS JWT-based licensing SDK with offline-first validation and hardware ID binding for [amore.computer](https://amore.computer) +A JWT-based licensing SDK with offline-first validation and device binding for [amore.computer](https://amore.computer) ## Requirements -- macOS 14+ +- macOS 14+: uses the built-in device identity +- Other platforms: provide a custom `DeviceIdentity` ## Installation