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
16 changes: 13 additions & 3 deletions Sources/AmoreLicensing/AmoreLicensing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ public final class AmoreLicensing: Licensing {
self.tokenStore = tokenStore ?? FileTokenStore(bundleIdentifier: bundleIdentifier)
self.deviceIdentity = deviceIdentity
self.licenseClient = HTTPLicenseClient(server: server ?? .amore(for: bundleIdentifier))
self.verifier = LicenseTokenVerifier(publicKey: signingKey, deviceIdentity: deviceIdentity)
self.verifier = LicenseTokenVerifier(
publicKey: signingKey,
deviceIdentity: deviceIdentity,
salt: bundleIdentifier
)
if configuration.validationFrequency.shouldValidateAtLaunch {
validateLocally()
Task { [self] in try? await validate() }
Expand Down Expand Up @@ -96,7 +100,11 @@ public final class AmoreLicensing: Licensing {
self.tokenStore = tokenStore
self.deviceIdentity = deviceIdentity
self.licenseClient = licenseClient
self.verifier = LicenseTokenVerifier(publicKey: publicKey, deviceIdentity: deviceIdentity)
self.verifier = LicenseTokenVerifier(
publicKey: publicKey,
deviceIdentity: deviceIdentity,
salt: bundleIdentifier
)
}

/// Activates a license on this device using the given license key.
Expand All @@ -106,7 +114,9 @@ public final class AmoreLicensing: Licensing {
let nonce = UUID().uuidString
let token = try await mapClientErrors {
try await self.licenseClient.activate(
licenseKey: licenseKey, hardwareId: self.deviceIdentity.identifier, nonce: nonce,
licenseKey: licenseKey,
hardwareId: self.deviceIdentity.hashedIdentifier(salt: self.bundleIdentifier),
nonce: nonce,
name: self.deviceIdentity.deviceName
)
}
Expand Down
20 changes: 20 additions & 0 deletions Sources/AmoreLicensing/DeviceIdentity/DeviceIdentity.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import Crypto
import Foundation

/// Identifies the device a license is bound to.
///
/// AmoreLicensing ships a built-in implementation for macOS. On every other
Expand All @@ -10,7 +13,24 @@ public protocol DeviceIdentity: Sendable {

/// A stable, machine-unique identifier used to bind a license to this device.
///
/// Return the raw, stable value; AmoreLicensing hashes it (salted with the
/// app's bundle identifier) before it leaves the device, so the raw value is
/// never sent to the server.
///
/// 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 }
}

extension DeviceIdentity {
/// The identifier as it leaves the device: an HMAC-SHA256 of ``identifier``
/// keyed with `salt` (the app's bundle identifier), so the raw value never
/// reaches the server and the same device yields a different ID per app.
func hashedIdentifier(salt: String) -> String {
let mac = HMAC<SHA256>.authenticationCode(
for: Data(identifier.utf8),
using: SymmetricKey(data: Data(salt.utf8))
)
return "h1:" + Data(mac).base64EncodedString()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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:
- Device ID from ``DeviceIdentity`` (the built-in macOS identity uses IOPlatformSerialNumber)
- Hardware ID: a salted hash of this device's identifier (see Device Identifier Privacy)
- Random nonce (UUID)
3. Client sends HTTPS POST to server:
{ license_key, hardware_id, nonce }
Expand All @@ -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 the file system
9. Client stores JWT in the file system

### Ongoing Validation (offline-first)

Expand Down Expand Up @@ -70,11 +70,17 @@ Server stores per-app private keys for signing JWTs. Clients verify signatures a
- Works without network until JWT expires

### Hardware Binding
- License tied to specific device via hardware ID
- JWT contains hardware ID (signed by server)
- Client verifies hardware ID on each validation
- License tied to a specific device via its hardware ID
- JWT contains the hardware ID (signed by server)
- Client verifies the JWT's hardware ID against this device on each validation
- Prevents JWT theft/sharing between devices

### Device Identifier Privacy
- The hardware ID is not the raw device identifier: it is an HMAC-SHA256 of it, keyed with the app's bundle identifier and prefixed `h1:`
- The raw device identifier (the built-in macOS ``DeviceIdentity`` reads IOPlatformSerialNumber) never leaves the device
- The bundle identifier salt makes the same device pseudonymous per app, so the server cannot correlate a device across apps
- Hashing defeats enumeration and leakage of device identifiers, not targeted confirmation: the salt is public, so a candidate identifier can be checked for a match

### Grace Period
- App continues working N days after last successful validation
- Handles temporary network outages or server downtime
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ On macOS you can use this initializer too, to override the built-in identity.
- **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.

Return the raw value; hashing is not your job. AmoreLicensing hashes the identifier, salted with the app's bundle identifier, before it leaves the device, so the raw value is never sent to the server and the same device produces a different ID in every app.

On Linux, for example, you might read `/etc/machine-id`:

```swift
Expand Down
8 changes: 7 additions & 1 deletion Sources/AmoreLicensing/Payload/LicenseTokenVerifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ struct LicenseTokenVerifier: Sendable {

let publicKey: Curve25519.Signing.PublicKey
let deviceIdentity: any DeviceIdentity
/// Salt for the hashed device identifier, the app's bundle identifier.
let salt: String

/// Verifies a token's signature and claims and returns its payload.
/// - Parameters:
Expand All @@ -40,7 +42,11 @@ struct LicenseTokenVerifier: Sendable {
throw .invalidToken
}
if let expectedNonce, payload.nonce != expectedNonce { throw .nonceMismatch }
guard payload.hardwareId == deviceIdentity.identifier else { throw .hardwareIdMismatch }
// Tokens issued before the SDK hashed identifiers carry the raw value;
// accepting it keeps existing installs valid without re-activation.
guard payload.hardwareId == deviceIdentity.hashedIdentifier(salt: salt)
|| payload.hardwareId == deviceIdentity.identifier
else { throw .hardwareIdMismatch }
return payload
}

Expand Down
39 changes: 36 additions & 3 deletions Tests/AmoreLicensingTests/AmoreClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,21 @@ import Testing
}

// MARK: - Activation


@Test func activationSendsHashedHardwareId() async throws {
let (privateKey, publicKey) = makeKeys()
let mock = MockLicenseClient()
mock.onActivate = { _, hwId, nonce in
try self.signToken(privateKey: privateKey, hardwareId: hwId, nonce: nonce)
}
let (client, _, _) = makeClient(publicKey: publicKey, licenseClient: mock)

try await client.activate(licenseKey: "KEY")

let expected = MockDeviceIdentity(identifier: hardwareId).hashedIdentifier(salt: bundleId)
#expect(mock.lastActivateHardwareId == expected)
}

@Test func activationHardwareIdMismatch() async throws {
let (privateKey, publicKey) = makeKeys()
let mock = MockLicenseClient()
Expand Down Expand Up @@ -384,14 +398,33 @@ import Testing
}

@Test func validateValidStoredToken() async throws {
let (privateKey, publicKey) = makeKeys()
let store = MockTokenStore()
let hashed = MockDeviceIdentity(identifier: hardwareId).hashedIdentifier(salt: bundleId)
let token = try signToken(privateKey: privateKey, hardwareId: hashed, nonce: "stored")
try store.store(token)
let (client, _, _) = makeClient(publicKey: publicKey, tokenStore: store)

let result = try await client.validate()

guard case .valid = result, case .valid = client.status else {
Issue.record("Expected valid, got \(result)")
return
}
}

/// A token stored before the SDK hashed identifiers carries the raw value;
/// it must keep validating so existing installs stay valid without
/// re-activation.
@Test func validateValidStoredTokenWithLegacyRawHardwareId() async throws {
let (privateKey, publicKey) = makeKeys()
let store = MockTokenStore()
let token = try signToken(privateKey: privateKey, hardwareId: hardwareId, nonce: "stored")
try store.store(token)
let (client, _, _) = makeClient(publicKey: publicKey, tokenStore: store)

let result = try await client.validate()

guard case .valid = result, case .valid = client.status else {
Issue.record("Expected valid, got \(result)")
return
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Testing

@testable import AmoreLicensing

@Suite("DeviceIdentity.hashedIdentifier")
struct HashedIdentifierTests {
private let identity = MockDeviceIdentity(identifier: "TEST-SERIAL-123")

@Test func differsPerIdentifier() {
let other = MockDeviceIdentity(identifier: "OTHER-SERIAL")
#expect(identity.hashedIdentifier(salt: "com.test.a") != other.hashedIdentifier(salt: "com.test.a"))
}

@Test func differsPerSalt() {
#expect(identity.hashedIdentifier(salt: "com.test.a") != identity.hashedIdentifier(salt: "com.test.b"))
}

/// Known-answer vector pinning the exact scheme (HMAC-SHA256, base64,
/// `h1:` prefix). If this fails the wire format changed, which would orphan
/// every activation made with the old scheme.
@Test func matchesKnownVector() {
#expect(
identity.hashedIdentifier(salt: "com.test.a")
== "h1:AideSWRk8IeW/FWbWvpzggwyKBJZwv8waJmcLKYiZII="
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ final class MockLicenseClient: LicenseClient, @unchecked Sendable {
var onDeactivate: ((String) async throws -> Void)?
var onValidate: ((String, String) async throws -> String)?

/// The `hardwareId` passed to the most recent `activate` call.
private(set) var lastActivateHardwareId: 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 {
lastActivateHardwareId = hardwareId
lastActivateName = name
guard let handler = onActivate else {
throw AmoreError.client(.licensingNotConfigured)
Expand Down
18 changes: 15 additions & 3 deletions Tests/AmoreLicensingTests/LicenseTokenVerifierTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,25 @@ import Testing
struct LicenseTokenVerifierTests {
private let hardwareId = "TEST-SERIAL-123"
private let privateKey = Curve25519.Signing.PrivateKey()

private let salt = "com.test.amorekit"

private func makeVerifier(
publicKey: Curve25519.Signing.PublicKey? = nil,
hardwareId: String? = nil
) -> LicenseTokenVerifier {
LicenseTokenVerifier(
publicKey: publicKey ?? privateKey.publicKey,
deviceIdentity: MockDeviceIdentity(identifier: hardwareId ?? self.hardwareId)
deviceIdentity: MockDeviceIdentity(identifier: hardwareId ?? self.hardwareId),
salt: salt
)
}

// MARK: - decode

@Test func decodeReturnsPayloadForValidToken() throws {
/// Tokens issued before the SDK hashed identifiers carry the raw value;
/// they must keep decoding so existing installs stay valid without
/// re-activation.
@Test func decodeAcceptsLegacyRawHardwareId() throws {
let token = try signV2Token(privateKey: privateKey, hardwareId: hardwareId, nonce: "n-1")
let payload = try makeVerifier().decode(token, expectedNonce: "n-1")
#expect(payload.hardwareId == hardwareId)
Expand All @@ -42,6 +47,13 @@ struct LicenseTokenVerifierTests {
#expect(payload.nonce == "n-1")
}

@Test func decodeAcceptsHashedHardwareId() throws {
let hashed = MockDeviceIdentity(identifier: hardwareId).hashedIdentifier(salt: salt)
let token = try signV2Token(privateKey: privateKey, hardwareId: hashed, nonce: "n-1")
let payload = try makeVerifier().decode(token, expectedNonce: "n-1")
#expect(payload.hardwareId == hashed)
}

@Test func decodeThrowsHardwareIdMismatchForOtherDevice() throws {
let token = try signV2Token(privateKey: privateKey, hardwareId: "OTHER-HW", nonce: "n-1")
#expect(throws: AmoreError.hardwareIdMismatch) {
Expand Down
Loading