Skip to content
Merged
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
12 changes: 10 additions & 2 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 60 additions & 46 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)
81 changes: 56 additions & 25 deletions Sources/AmoreLicensing/AmoreLicensing.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AmoreJWT
import Crypto
import Foundation
import Observation

/// Manages license activation, deactivation, and validation against an Amore licensing server.
///
Expand All @@ -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
Expand All @@ -39,51 +42,79 @@ 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.
public func activate(licenseKey: String) async throws(AmoreError) {
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) {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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? {
Expand All @@ -206,7 +237,7 @@ public final class AmoreLicensing: Licensing {
license.expiresAt = graceEnd
return license
}

private func mapClientErrors<T>(
_ operation: @MainActor @Sendable () async throws -> T
) async throws(AmoreError) -> T {
Expand All @@ -222,5 +253,5 @@ public final class AmoreLicensing: Licensing {
throw .network(.requestFailed(error.localizedDescription))
}
}

}
16 changes: 16 additions & 0 deletions Sources/AmoreLicensing/DeviceIdentity/DeviceIdentity.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading
Loading