Skip to content

Commit 3d3eda4

Browse files
authored
Merge pull request #9 from AmoreComputer/feat/hashed-device-identifier
feat(licensing): hash device identifiers before they leave the device
2 parents 03a2b8a + f976b92 commit 3d3eda4

9 files changed

Lines changed: 135 additions & 15 deletions

File tree

‎Sources/AmoreLicensing/AmoreLicensing.swift‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,11 @@ public final class AmoreLicensing: Licensing {
4747
self.tokenStore = tokenStore ?? FileTokenStore(bundleIdentifier: bundleIdentifier)
4848
self.deviceIdentity = deviceIdentity
4949
self.licenseClient = HTTPLicenseClient(server: server ?? .amore(for: bundleIdentifier))
50-
self.verifier = LicenseTokenVerifier(publicKey: signingKey, deviceIdentity: deviceIdentity)
50+
self.verifier = LicenseTokenVerifier(
51+
publicKey: signingKey,
52+
deviceIdentity: deviceIdentity,
53+
salt: bundleIdentifier
54+
)
5155
if configuration.validationFrequency.shouldValidateAtLaunch {
5256
validateLocally()
5357
Task { [self] in try? await validate() }
@@ -96,7 +100,11 @@ public final class AmoreLicensing: Licensing {
96100
self.tokenStore = tokenStore
97101
self.deviceIdentity = deviceIdentity
98102
self.licenseClient = licenseClient
99-
self.verifier = LicenseTokenVerifier(publicKey: publicKey, deviceIdentity: deviceIdentity)
103+
self.verifier = LicenseTokenVerifier(
104+
publicKey: publicKey,
105+
deviceIdentity: deviceIdentity,
106+
salt: bundleIdentifier
107+
)
100108
}
101109

102110
/// Activates a license on this device using the given license key.
@@ -106,7 +114,9 @@ public final class AmoreLicensing: Licensing {
106114
let nonce = UUID().uuidString
107115
let token = try await mapClientErrors {
108116
try await self.licenseClient.activate(
109-
licenseKey: licenseKey, hardwareId: self.deviceIdentity.identifier, nonce: nonce,
117+
licenseKey: licenseKey,
118+
hardwareId: self.deviceIdentity.hashedIdentifier(salt: self.bundleIdentifier),
119+
nonce: nonce,
110120
name: self.deviceIdentity.deviceName
111121
)
112122
}

‎Sources/AmoreLicensing/DeviceIdentity/DeviceIdentity.swift‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import Crypto
2+
import Foundation
3+
14
/// Identifies the device a license is bound to.
25
///
36
/// AmoreLicensing ships a built-in implementation for macOS. On every other
@@ -10,7 +13,24 @@ public protocol DeviceIdentity: Sendable {
1013

1114
/// A stable, machine-unique identifier used to bind a license to this device.
1215
///
16+
/// Return the raw, stable value; AmoreLicensing hashes it (salted with the
17+
/// app's bundle identifier) before it leaves the device, so the raw value is
18+
/// never sent to the server.
19+
///
1320
/// This value must stay constant for the lifetime of the install: if it
1421
/// changes, the bound license stops validating and a re-activation is required.
1522
var identifier: String { get }
1623
}
24+
25+
extension DeviceIdentity {
26+
/// The identifier as it leaves the device: an HMAC-SHA256 of ``identifier``
27+
/// keyed with `salt` (the app's bundle identifier), so the raw value never
28+
/// reaches the server and the same device yields a different ID per app.
29+
func hashedIdentifier(salt: String) -> String {
30+
let mac = HMAC<SHA256>.authenticationCode(
31+
for: Data(identifier.utf8),
32+
using: SymmetricKey(data: Data(salt.utf8))
33+
)
34+
return "h1:" + Data(mac).base64EncodedString()
35+
}
36+
}

‎Sources/AmoreLicensing/Documentation.docc/Architecture & Security.md‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Server stores per-app private keys for signing JWTs. Clients verify signatures a
1414

1515
1. User enters license key in app
1616
2. Client generates:
17-
- Device ID from ``DeviceIdentity`` (the built-in macOS identity uses IOPlatformSerialNumber)
17+
- Hardware ID: a salted hash of this device's identifier (see Device Identifier Privacy)
1818
- Random nonce (UUID)
1919
3. Client sends HTTPS POST to server:
2020
{ license_key, hardware_id, nonce }
@@ -30,7 +30,7 @@ Server stores per-app private keys for signing JWTs. Clients verify signatures a
3030
- JWT signature valid (using server's public key)
3131
- Nonce matches what client sent
3232
- JWT not expired
33-
1. Client stores JWT in the file system
33+
9. Client stores JWT in the file system
3434

3535
### Ongoing Validation (offline-first)
3636

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

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

78+
### Device Identifier Privacy
79+
- 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:`
80+
- The raw device identifier (the built-in macOS ``DeviceIdentity`` reads IOPlatformSerialNumber) never leaves the device
81+
- The bundle identifier salt makes the same device pseudonymous per app, so the server cannot correlate a device across apps
82+
- 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
83+
7884
### Grace Period
7985
- App continues working N days after last successful validation
8086
- Handles temporary network outages or server downtime

‎Sources/AmoreLicensing/Documentation.docc/Custom Device Identity.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ On macOS you can use this initializer too, to override the built-in identity.
4949
- **Stable**: constant for the lifetime of the install. If it changes, the bound license stops validating and the user has to re-activate.
5050
- **Unique**: distinct per device, so a license cannot be shared across machines.
5151

52+
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.
53+
5254
On Linux, for example, you might read `/etc/machine-id`:
5355

5456
```swift

‎Sources/AmoreLicensing/Payload/LicenseTokenVerifier.swift‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ struct LicenseTokenVerifier: Sendable {
1818

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

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

‎Tests/AmoreLicensingTests/AmoreClientTests.swift‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,21 @@ import Testing
5757
}
5858

5959
// MARK: - Activation
60-
60+
61+
@Test func activationSendsHashedHardwareId() async throws {
62+
let (privateKey, publicKey) = makeKeys()
63+
let mock = MockLicenseClient()
64+
mock.onActivate = { _, hwId, nonce in
65+
try self.signToken(privateKey: privateKey, hardwareId: hwId, nonce: nonce)
66+
}
67+
let (client, _, _) = makeClient(publicKey: publicKey, licenseClient: mock)
68+
69+
try await client.activate(licenseKey: "KEY")
70+
71+
let expected = MockDeviceIdentity(identifier: hardwareId).hashedIdentifier(salt: bundleId)
72+
#expect(mock.lastActivateHardwareId == expected)
73+
}
74+
6175
@Test func activationHardwareIdMismatch() async throws {
6276
let (privateKey, publicKey) = makeKeys()
6377
let mock = MockLicenseClient()
@@ -384,14 +398,33 @@ import Testing
384398
}
385399

386400
@Test func validateValidStoredToken() async throws {
401+
let (privateKey, publicKey) = makeKeys()
402+
let store = MockTokenStore()
403+
let hashed = MockDeviceIdentity(identifier: hardwareId).hashedIdentifier(salt: bundleId)
404+
let token = try signToken(privateKey: privateKey, hardwareId: hashed, nonce: "stored")
405+
try store.store(token)
406+
let (client, _, _) = makeClient(publicKey: publicKey, tokenStore: store)
407+
408+
let result = try await client.validate()
409+
410+
guard case .valid = result, case .valid = client.status else {
411+
Issue.record("Expected valid, got \(result)")
412+
return
413+
}
414+
}
415+
416+
/// A token stored before the SDK hashed identifiers carries the raw value;
417+
/// it must keep validating so existing installs stay valid without
418+
/// re-activation.
419+
@Test func validateValidStoredTokenWithLegacyRawHardwareId() async throws {
387420
let (privateKey, publicKey) = makeKeys()
388421
let store = MockTokenStore()
389422
let token = try signToken(privateKey: privateKey, hardwareId: hardwareId, nonce: "stored")
390423
try store.store(token)
391424
let (client, _, _) = makeClient(publicKey: publicKey, tokenStore: store)
392-
425+
393426
let result = try await client.validate()
394-
427+
395428
guard case .valid = result, case .valid = client.status else {
396429
Issue.record("Expected valid, got \(result)")
397430
return
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Testing
2+
3+
@testable import AmoreLicensing
4+
5+
@Suite("DeviceIdentity.hashedIdentifier")
6+
struct HashedIdentifierTests {
7+
private let identity = MockDeviceIdentity(identifier: "TEST-SERIAL-123")
8+
9+
@Test func differsPerIdentifier() {
10+
let other = MockDeviceIdentity(identifier: "OTHER-SERIAL")
11+
#expect(identity.hashedIdentifier(salt: "com.test.a") != other.hashedIdentifier(salt: "com.test.a"))
12+
}
13+
14+
@Test func differsPerSalt() {
15+
#expect(identity.hashedIdentifier(salt: "com.test.a") != identity.hashedIdentifier(salt: "com.test.b"))
16+
}
17+
18+
/// Known-answer vector pinning the exact scheme (HMAC-SHA256, base64,
19+
/// `h1:` prefix). If this fails the wire format changed, which would orphan
20+
/// every activation made with the old scheme.
21+
@Test func matchesKnownVector() {
22+
#expect(
23+
identity.hashedIdentifier(salt: "com.test.a")
24+
== "h1:AideSWRk8IeW/FWbWvpzggwyKBJZwv8waJmcLKYiZII="
25+
)
26+
}
27+
}

‎Tests/AmoreLicensingTests/LicenseClient/MockLicenseClient.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@ final class MockLicenseClient: LicenseClient, @unchecked Sendable {
55
var onDeactivate: ((String) async throws -> Void)?
66
var onValidate: ((String, String) async throws -> String)?
77

8+
/// The `hardwareId` passed to the most recent `activate` call.
9+
private(set) var lastActivateHardwareId: String?
10+
811
/// The `name` passed to the most recent `activate` call.
912
private(set) var lastActivateName: String?
1013

1114
func activate(licenseKey: String, hardwareId: String, nonce: String, name: String?) async throws -> String {
15+
lastActivateHardwareId = hardwareId
1216
lastActivateName = name
1317
guard let handler = onActivate else {
1418
throw AmoreError.client(.licensingNotConfigured)

‎Tests/AmoreLicensingTests/LicenseTokenVerifierTests.swift‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,25 @@ import Testing
99
struct LicenseTokenVerifierTests {
1010
private let hardwareId = "TEST-SERIAL-123"
1111
private let privateKey = Curve25519.Signing.PrivateKey()
12-
12+
private let salt = "com.test.amorekit"
13+
1314
private func makeVerifier(
1415
publicKey: Curve25519.Signing.PublicKey? = nil,
1516
hardwareId: String? = nil
1617
) -> LicenseTokenVerifier {
1718
LicenseTokenVerifier(
1819
publicKey: publicKey ?? privateKey.publicKey,
19-
deviceIdentity: MockDeviceIdentity(identifier: hardwareId ?? self.hardwareId)
20+
deviceIdentity: MockDeviceIdentity(identifier: hardwareId ?? self.hardwareId),
21+
salt: salt
2022
)
2123
}
2224

2325
// MARK: - decode
2426

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

50+
@Test func decodeAcceptsHashedHardwareId() throws {
51+
let hashed = MockDeviceIdentity(identifier: hardwareId).hashedIdentifier(salt: salt)
52+
let token = try signV2Token(privateKey: privateKey, hardwareId: hashed, nonce: "n-1")
53+
let payload = try makeVerifier().decode(token, expectedNonce: "n-1")
54+
#expect(payload.hardwareId == hashed)
55+
}
56+
4557
@Test func decodeThrowsHardwareIdMismatchForOtherDevice() throws {
4658
let token = try signV2Token(privateKey: privateKey, hardwareId: "OTHER-HW", nonce: "n-1")
4759
#expect(throws: AmoreError.hardwareIdMismatch) {

0 commit comments

Comments
 (0)