From d8d80fdd85309ddfe31f3e8b57bce602acdb613a Mon Sep 17 00:00:00 2001 From: Lucas Fischer Date: Thu, 18 Jun 2026 19:16:37 +0800 Subject: [PATCH 1/4] refactor(licensing): replace JWTKit with swift-crypto verifier --- Package.swift | 18 +- Sources/AmoreJWT/Base64URL.swift | 28 +++ Sources/AmoreJWT/EdDSAJWT.swift | 104 +++++++++ Sources/AmoreLicensing/AmoreLicensing.swift | 132 +++++------- .../AmoreLicensing/Errors/AmoreError.swift | 13 +- Sources/AmoreLicensing/Models/License.swift | 6 +- .../Payload/LicensePayload.swift | 51 +---- .../Payload/LicenseTokenVerifier.swift | 58 +++++ Tests/AmoreJWTTests/EdDSAJWTTests.swift | 199 ++++++++++++++++++ Tests/AmoreJWTTests/JWTKitContractTests.swift | 89 ++++++++ .../AmoreClientTests.swift | 198 +++++++++-------- .../Helpers/TokenFixtures.swift | 35 ++- .../JWTKitCompatibilityTests.swift | 104 +++++++++ .../LicenseMigrationTests.swift | 62 +++--- .../LicensePayloadDecodeTests.swift | 1 - .../LicenseTokenVerifierTests.swift | 100 +++++++++ .../PublicKeyIngestionTests.swift | 50 +++++ .../ValidationFrequencyTests.swift | 56 ++--- 18 files changed, 1010 insertions(+), 294 deletions(-) create mode 100644 Sources/AmoreJWT/Base64URL.swift create mode 100644 Sources/AmoreJWT/EdDSAJWT.swift create mode 100644 Sources/AmoreLicensing/Payload/LicenseTokenVerifier.swift create mode 100644 Tests/AmoreJWTTests/EdDSAJWTTests.swift create mode 100644 Tests/AmoreJWTTests/JWTKitContractTests.swift create mode 100644 Tests/AmoreLicensingTests/JWTKitCompatibilityTests.swift create mode 100644 Tests/AmoreLicensingTests/LicenseTokenVerifierTests.swift create mode 100644 Tests/AmoreLicensingTests/PublicKeyIngestionTests.swift diff --git a/Package.swift b/Package.swift index 0b84769..6eebb37 100644 --- a/Package.swift +++ b/Package.swift @@ -18,21 +18,37 @@ let package = Package( ), ], 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: [ - .product(name: "JWTKit", package: "jwt-kit"), + "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"), ] ), diff --git a/Sources/AmoreJWT/Base64URL.swift b/Sources/AmoreJWT/Base64URL.swift new file mode 100644 index 0000000..4220926 --- /dev/null +++ b/Sources/AmoreJWT/Base64URL.swift @@ -0,0 +1,28 @@ +import Foundation + +extension Data { + package func base64URLEncodedString() -> String { + String(base64EncodedString().compactMap { char in + switch char { + case "+": "-" + case "/": "_" + case "=": nil + default: char + } + }) + } +} + +extension String { + package func base64URLDecodedData() -> Data? { + var s = String(map { char in + switch char { + case "-": "+" + case "_": "/" + default: char + } + }) + s.append(String(repeating: "=", count: (4 - s.count % 4) % 4)) + return Data(base64Encoded: s) + } +} diff --git a/Sources/AmoreJWT/EdDSAJWT.swift b/Sources/AmoreJWT/EdDSAJWT.swift new file mode 100644 index 0000000..54ef828 --- /dev/null +++ b/Sources/AmoreJWT/EdDSAJWT.swift @@ -0,0 +1,104 @@ +import Crypto +import Foundation + +/// Sync EdDSA (Ed25519) JWT sign and verify. +/// +/// Only the EdDSA algorithm is supported. Header `alg` is pinned at +/// verification time to prevent algorithm-confusion attacks. The `exp` +/// claim is enforced by default; opt out with +/// ``verify(_:as:using:verifyTimeClaims:)``'s `verifyTimeClaims` flag for +/// callers that intentionally tolerate expired payloads. +package enum EdDSAJWTError: Error, Equatable { + case malformedToken + case unsupportedAlgorithm(String) + case invalidSignature + case headerDecodingFailed + case payloadDecodingFailed + case payloadEncodingFailed + case claimsDecodingFailed + case expired +} + +package enum EdDSAJWT { + private struct VerifyHeader: Decodable { + let alg: String + } + + private struct TimeClaims: Decodable { + var exp: Date? + } + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + return encoder + }() + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + return decoder + }() + + package static func verify( + _ token: String, + as: Payload.Type, + using publicKey: Curve25519.Signing.PublicKey, + verifyTimeClaims: Bool = true + ) throws -> Payload { + let parts = token.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3 else { throw EdDSAJWTError.malformedToken } + + let headerString = String(parts[0]) + let payloadString = String(parts[1]) + let signatureString = String(parts[2]) + + guard + let headerData = headerString.base64URLDecodedData(), + let payloadData = payloadString.base64URLDecodedData(), + let signatureData = signatureString.base64URLDecodedData() + else { throw EdDSAJWTError.malformedToken } + + let header: VerifyHeader + do { header = try decoder.decode(VerifyHeader.self, from: headerData) } + catch { throw EdDSAJWTError.headerDecodingFailed } + + guard header.alg == "EdDSA" else { + throw EdDSAJWTError.unsupportedAlgorithm(header.alg) + } + + let signingInput = Data("\(headerString).\(payloadString)".utf8) + guard publicKey.isValidSignature(signatureData, for: signingInput) else { + throw EdDSAJWTError.invalidSignature + } + + if verifyTimeClaims { + let claims: TimeClaims + do { claims = try decoder.decode(TimeClaims.self, from: payloadData) } + catch { throw EdDSAJWTError.claimsDecodingFailed } + let now = Date() + if let exp = claims.exp, exp <= now { throw EdDSAJWTError.expired } + } + + do { return try decoder.decode(Payload.self, from: payloadData) } + catch { throw EdDSAJWTError.payloadDecodingFailed } + } + + package static func sign( + _ payload: Payload, + using privateKey: Curve25519.Signing.PrivateKey + ) throws -> String { + let headerString: String = + Data(#"{"alg":"EdDSA","typ":"JWT"}"#.utf8).base64URLEncodedString() + let payloadData: Data + do { payloadData = try Self.encoder.encode(payload) } + catch { throw EdDSAJWTError.payloadEncodingFailed } + let payloadString = payloadData.base64URLEncodedString() + + let signingInput = Data("\(headerString).\(payloadString)".utf8) + let signature = try privateKey.signature(for: signingInput) + let signatureString = Data(signature).base64URLEncodedString() + + return "\(headerString).\(payloadString).\(signatureString)" + } +} diff --git a/Sources/AmoreLicensing/AmoreLicensing.swift b/Sources/AmoreLicensing/AmoreLicensing.swift index 74b8435..ab4ff0c 100644 --- a/Sources/AmoreLicensing/AmoreLicensing.swift +++ b/Sources/AmoreLicensing/AmoreLicensing.swift @@ -1,5 +1,6 @@ +import AmoreJWT +import Crypto import Foundation -import JWTKit /// Manages license activation, deactivation, and validation against an Amore licensing server. /// @@ -12,12 +13,10 @@ public final class AmoreLicensing: Licensing { private let bundleIdentifier: String private let configuration: LicensingConfiguration private let hardwareIdentifier: HardwareIdentifier - private let jwtCollection = JWTKeyCollection() private let licenseClient: LicenseClient - private let publicKey: EdDSA.PublicKey private let tokenStore: TokenStore + private let verifier: LicenseTokenVerifier private var isValidating = false - private var keysReady = false /// Creates a new licensing instance. /// - Parameters: @@ -34,19 +33,27 @@ public final class AmoreLicensing: Licensing { tokenStore: (any TokenStore)? = nil ) throws { let bundleIdentifier = bundleIdentifier ?? Bundle.main.bundleIdentifier ?? publicKey + guard + let keyData = publicKey.base64URLDecodedData(), + let signingKey = try? Curve25519.Signing.PublicKey(rawRepresentation: keyData) + else { + throw AmoreError.invalidPublicKey + } + let hardwareIdentifier = MacHardwareIdentifier() self.configuration = configuration - self.publicKey = try EdDSA.PublicKey(x: publicKey, curve: .ed25519) self.bundleIdentifier = bundleIdentifier self.tokenStore = tokenStore ?? FileTokenStore(bundleIdentifier: bundleIdentifier) - self.hardwareIdentifier = MacHardwareIdentifier() + self.hardwareIdentifier = hardwareIdentifier self.licenseClient = HTTPLicenseClient(server: server ?? .amore(for: bundleIdentifier)) + self.verifier = LicenseTokenVerifier(publicKey: signingKey, hardwareIdentifier: hardwareIdentifier) if configuration.validationFrequency.shouldValidateAtLaunch { + validateLocally() Task { [self] in try? await validate() } } } internal init( - publicKey: EdDSA.PublicKey, + publicKey: Curve25519.Signing.PublicKey, bundleIdentifier: String, configuration: LicensingConfiguration = .default, tokenStore: TokenStore, @@ -54,11 +61,11 @@ public final class AmoreLicensing: Licensing { licenseClient: LicenseClient ) { self.configuration = configuration - self.publicKey = publicKey self.bundleIdentifier = bundleIdentifier self.tokenStore = tokenStore self.hardwareIdentifier = hardwareIdentifier self.licenseClient = licenseClient + self.verifier = LicenseTokenVerifier(publicKey: publicKey, hardwareIdentifier: hardwareIdentifier) } /// Activates a license on this device using the given license key. @@ -72,7 +79,7 @@ public final class AmoreLicensing: Licensing { name: Host.current().localizedName ) } - let payload = try await verifyToken(token, expectedNonce: nonce) + let payload = try verifier.decode(token, expectedNonce: nonce) do { try tokenStore.store(token) } catch { throw .tokenStore(error) } status = .valid(License(from: payload)) } @@ -117,81 +124,75 @@ public final class AmoreLicensing: Licensing { throw .noStoredToken } - await ensureKeysConfigured() - - let result: ValidationStatus - do { - let payload = try await jwtCollection.verify(token, as: LicensePayload.self) - guard payload.hardwareId == hardwareIdentifier.identifier else { - status = .invalid - throw AmoreError.hardwareIdMismatch - } - if configuration.validationFrequency.isRefreshDue(issuedAt: payload.iat.value) { - result = try await refreshToken(token, currentPayload: payload) + switch verifier.decodeLocally(token) { + case .hardwareMismatch: + status = .invalid + throw AmoreError.hardwareIdMismatch + case .unverifiable: + try await refreshToken(token) + case .decoded(let payload): + if payload.exp < Date() { + try await refreshToken(token) + } else if configuration.validationFrequency.isRefreshDue(issuedAt: payload.iat) { + try await refreshToken(token, currentPayload: payload) } else { status = .valid(License(from: payload)) - result = status } - } catch let error as AmoreError { - throw error - } catch { - // Token expired or invalid signature — try refresh - result = try await refreshToken(token) } - - return result + return status } // MARK: - Private + private func validateLocally() { + guard let token = try? tokenStore.retrieve() else { return } + switch verifier.decodeLocally(token) { + case .hardwareMismatch: + status = .invalid + case .unverifiable: + break + case .decoded(let payload) where payload.exp > Date(): + status = .valid(License(from: payload)) + case .decoded: + break + } + } + + /// Refreshes the token from the server and updates ``status``. On a transient + /// failure it falls back to `currentPayload` if still locally valid, otherwise + /// to the grace period; a ``ClientError`` invalidates the license. private func refreshToken( _ token: String, currentPayload: LicensePayload? = nil - ) async throws(AmoreError) -> ValidationStatus { + ) async throws(AmoreError) { let nonce = UUID().uuidString do { let newToken = try await licenseClient.validate(token: token, nonce: nonce) - let payload = try await verifyToken(newToken, expectedNonce: nonce) + let payload = try verifier.decode(newToken, expectedNonce: nonce) do { try tokenStore.store(newToken) } catch { throw AmoreError.tokenStore(error) } status = .valid(License(from: payload)) - return status } catch let error as ClientError { status = .invalid throw .client(error) } catch let error as AmoreError { - if let currentPayload { - status = .valid(License(from: currentPayload)) - return status - } - throw error + guard let currentPayload else { throw error } + status = .valid(License(from: currentPayload)) } catch { - if let currentPayload { - // Token still valid locally, keep using it - status = .valid(License(from: currentPayload)) - return status - } - return await applyGracePeriod(token: token) + guard let currentPayload else { applyGracePeriod(token: token); return } + // Token still valid locally, keep using it. + status = .valid(License(from: currentPayload)) } } - private func applyGracePeriod(token: String) async -> ValidationStatus { - await ensureKeysConfigured() - guard let payload = try? await jwtCollection.verify(token, as: GracePeriodPayload.self) else { + private func applyGracePeriod(token: String) { + guard case .decoded(let payload) = verifier.decodeLocally(token) else { status = .invalid - return .invalid + return } - - let graceEnd = payload.exp.value.addingTimeInterval(configuration.gracePeriod.timeInterval) + let graceEnd = payload.exp.addingTimeInterval(configuration.gracePeriod.timeInterval) var license = License(from: payload) license.expiresAt = graceEnd - - if graceEnd > Date() { - status = .gracePeriod(license) - return status - } else { - status = .invalid - return .invalid - } + status = graceEnd > Date() ? .gracePeriod(license) : .invalid } private func mapClientErrors( @@ -210,23 +211,4 @@ public final class AmoreLicensing: Licensing { } } - @discardableResult - private func verifyToken(_ token: String, expectedNonce: String) async throws(AmoreError) -> LicensePayload { - await ensureKeysConfigured() - let payload: LicensePayload - do { - payload = try await jwtCollection.verify(token, as: LicensePayload.self) - } catch { - throw .invalidSignature - } - guard payload.nonce == expectedNonce else { throw .nonceMismatch } - guard payload.hardwareId == hardwareIdentifier.identifier else { throw .hardwareIdMismatch } - return payload - } - - private func ensureKeysConfigured() async { - guard !keysReady else { return } - await jwtCollection.add(eddsa: publicKey) - keysReady = true - } } diff --git a/Sources/AmoreLicensing/Errors/AmoreError.swift b/Sources/AmoreLicensing/Errors/AmoreError.swift index 032ec43..10b5fc4 100644 --- a/Sources/AmoreLicensing/Errors/AmoreError.swift +++ b/Sources/AmoreLicensing/Errors/AmoreError.swift @@ -6,8 +6,11 @@ public enum AmoreError: LocalizedError, Equatable, Sendable { case client(ClientError) /// The license token's hardware ID does not match this device. case hardwareIdMismatch - /// The server response has an invalid cryptographic signature. - case invalidSignature + /// The configured public key is not a valid Ed25519 key. + case invalidPublicKey + /// The server response could not be verified (bad signature, malformed, + /// expired on arrival, or otherwise unparseable). + case invalidToken /// A token store operation failed. case tokenStore(TokenStoreError) /// A network request failed. @@ -23,8 +26,10 @@ public enum AmoreError: LocalizedError, Equatable, Sendable { return error.localizedDescription case .hardwareIdMismatch: return "This license is registered to a different device." - case .invalidSignature: - return "The server response has an invalid signature." + case .invalidPublicKey: + return "The configured public key is invalid." + case .invalidToken: + return "The server response could not be verified." case .tokenStore(let error): return error.localizedDescription case .network(let error): diff --git a/Sources/AmoreLicensing/Models/License.swift b/Sources/AmoreLicensing/Models/License.swift index bf57641..a3a454e 100644 --- a/Sources/AmoreLicensing/Models/License.swift +++ b/Sources/AmoreLicensing/Models/License.swift @@ -16,7 +16,7 @@ public struct License: Identifiable, Hashable, Codable, Sendable { /// The customer this license was issued to, or `nil` if the license has no /// associated customer. Use `customer?.email` to show "Licensed to ". public var customer: Customer? - + /// The product name this license is for. @available(*, deprecated, renamed: "product.name", message: "Use `product.name` instead.") public var name: String { product.name } @@ -24,11 +24,11 @@ public struct License: Identifiable, Hashable, Codable, Sendable { extension License { - init(from payload: some LicensePayloadProtocol) { + init(from payload: LicensePayload) { self = License( id: payload.licenseId, product: payload.product, - expiresAt: payload.exp.value, + expiresAt: payload.exp, entitlements: payload.entitlements, subscriptionState: payload.subscriptionState, customer: payload.customer diff --git a/Sources/AmoreLicensing/Payload/LicensePayload.swift b/Sources/AmoreLicensing/Payload/LicensePayload.swift index 9f9e335..084cb17 100644 --- a/Sources/AmoreLicensing/Payload/LicensePayload.swift +++ b/Sources/AmoreLicensing/Payload/LicensePayload.swift @@ -1,22 +1,9 @@ import Foundation -import JWTKit -protocol LicensePayloadProtocol { - var exp: ExpirationClaim { get } - var hardwareId: String { get } - var iat: IssuedAtClaim { get } - var licenseId: UUID { get } - var nonce: String { get } - var product: Product { get } - var entitlements: Set { get } - var subscriptionState: SubscriptionState? { get } - var customer: Customer? { get } -} - -struct LicensePayload: JWTPayload, LicensePayloadProtocol { - var exp: ExpirationClaim +struct LicensePayload: Codable, Sendable { + var exp: Date var hardwareId: String - var iat: IssuedAtClaim + var iat: Date var licenseId: UUID var nonce: String var product: Product @@ -35,36 +22,4 @@ struct LicensePayload: JWTPayload, LicensePayloadProtocol { case subscriptionState = "subscription_state" case customer } - - func verify(using algorithm: some JWTAlgorithm) throws { - try exp.verifyNotExpired() - } -} - -struct GracePeriodPayload: JWTPayload, LicensePayloadProtocol { - var exp: ExpirationClaim - var hardwareId: String - var iat: IssuedAtClaim - var licenseId: UUID - var nonce: String - var product: Product - var entitlements: Set = [] - var subscriptionState: SubscriptionState? - var customer: Customer? - - enum CodingKeys: String, CodingKey { - case exp - case hardwareId = "hardware_id" - case iat - case licenseId = "license_id" - case nonce - case product - case entitlements - case subscriptionState = "subscription_state" - case customer - } - - func verify(using algorithm: some JWTAlgorithm) throws { - // Signature verified by JWTKit; expiration intentionally unchecked - } } diff --git a/Sources/AmoreLicensing/Payload/LicenseTokenVerifier.swift b/Sources/AmoreLicensing/Payload/LicenseTokenVerifier.swift new file mode 100644 index 0000000..9c7b640 --- /dev/null +++ b/Sources/AmoreLicensing/Payload/LicenseTokenVerifier.swift @@ -0,0 +1,58 @@ +import AmoreJWT +import Crypto +import Foundation + +/// Verifies signed license tokens against the app's public key and this device's +/// hardware identifier. +/// +/// 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. +struct LicenseTokenVerifier: Sendable { + + /// The outcome of a best-effort local decode that tolerates expired tokens. + enum LocalResult: Sendable { + case decoded(LicensePayload) + case hardwareMismatch + case unverifiable + } + + let publicKey: Curve25519.Signing.PublicKey + let hardwareIdentifier: HardwareIdentifier + + /// Verifies a token's signature and claims and returns its payload. + /// - Parameters: + /// - token: The signed JWT to verify. + /// - expectedNonce: If provided, the payload's nonce must match it. + /// - verifyTimeClaims: Whether to reject expired or not-yet-valid tokens. + /// - Throws: ``AmoreError`` if the signature, nonce, or hardware ID is invalid. + func decode( + _ token: String, + expectedNonce: String? = nil, + verifyTimeClaims: Bool = true + ) throws(AmoreError) -> LicensePayload { + let payload: LicensePayload + do { + payload = try EdDSAJWT.verify( + token, as: LicensePayload.self, using: publicKey, + verifyTimeClaims: verifyTimeClaims + ) + } catch { + throw .invalidToken + } + if let expectedNonce, payload.nonce != expectedNonce { throw .nonceMismatch } + guard payload.hardwareId == hardwareIdentifier.identifier else { throw .hardwareIdMismatch } + return payload + } + + /// Decodes a stored token without enforcing time claims, classifying the + /// result so callers can tell a hardware mismatch from an unverifiable token. + func decodeLocally(_ token: String) -> LocalResult { + do { + return .decoded(try decode(token, verifyTimeClaims: false)) + } catch .hardwareIdMismatch { + return .hardwareMismatch + } catch { + return .unverifiable + } + } +} diff --git a/Tests/AmoreJWTTests/EdDSAJWTTests.swift b/Tests/AmoreJWTTests/EdDSAJWTTests.swift new file mode 100644 index 0000000..04548f2 --- /dev/null +++ b/Tests/AmoreJWTTests/EdDSAJWTTests.swift @@ -0,0 +1,199 @@ +import AmoreJWT +import Crypto +import Foundation +import Testing + +@Suite("EdDSAJWT") +struct EdDSAJWTTests { + + struct Sample: Codable, Equatable { + var sub: String + var iat: Date + var exp: Date + } + + private let key = Curve25519.Signing.PrivateKey() + private var publicKey: Curve25519.Signing.PublicKey { key.publicKey } + + private func sample() -> Sample { + let now = floor(Date().timeIntervalSince1970) + return Sample( + sub: "user-42", + iat: Date(timeIntervalSince1970: now - 60), + exp: Date(timeIntervalSince1970: now + 3600) + ) + } + + @Test func roundTripsPayload() throws { + let payload = sample() + let token = try EdDSAJWT.sign(payload, using: key) + let decoded = try EdDSAJWT.verify(token, as: Sample.self, using: publicKey) + #expect(decoded == payload) + } + + @Test func rejectsTamperedSignature() throws { + let token = try EdDSAJWT.sign(sample(), using: key) + let parts = token.split(separator: ".").map(String.init) + var sigBytes = parts[2].base64URLDecodedData()! + sigBytes[0] ^= 0x01 + let tampered = "\(parts[0]).\(parts[1]).\(sigBytes.base64URLEncodedString())" + + #expect(throws: EdDSAJWTError.invalidSignature) { + try EdDSAJWT.verify(tampered, as: Sample.self, using: publicKey) + } + } + + @Test func rejectsTamperedPayload() throws { + let token = try EdDSAJWT.sign(sample(), using: key) + let parts = token.split(separator: ".").map(String.init) + var payloadBytes = parts[1].base64URLDecodedData()! + payloadBytes[0] ^= 0x01 + let tampered = "\(parts[0]).\(payloadBytes.base64URLEncodedString()).\(parts[2])" + + #expect(throws: EdDSAJWTError.invalidSignature) { + try EdDSAJWT.verify(tampered, as: Sample.self, using: publicKey) + } + } + + @Test func rejectsMalformedToken() { + #expect(throws: EdDSAJWTError.malformedToken) { + try EdDSAJWT.verify("not.a.valid.jwt", as: Sample.self, using: publicKey) + } + #expect(throws: EdDSAJWTError.malformedToken) { + try EdDSAJWT.verify("only-one-segment", as: Sample.self, using: publicKey) + } + } + + @Test func rejectsTokenWithEmptyTrailingSegment() { + // `omittingEmptySubsequences: false` keeps the empty trailing segment, so a + // trailing dot is four parts and fails the three-part check instead of being + // read as a valid three-segment token. + #expect(throws: EdDSAJWTError.malformedToken) { + try EdDSAJWT.verify("a.b.c.", as: Sample.self, using: publicKey) + } + } + + @Test func rejectsNonEdDSAAlgorithm() throws { + let headerJSON = #"{"alg":"none","typ":"JWT"}"# + let payloadJSON = "{}" + let header = Data(headerJSON.utf8).base64URLEncodedString() + let payload = Data(payloadJSON.utf8).base64URLEncodedString() + let signature = Data().base64URLEncodedString() + let token = "\(header).\(payload).\(signature)" + + #expect(throws: EdDSAJWTError.unsupportedAlgorithm("none")) { + try EdDSAJWT.verify(token, as: Sample.self, using: publicKey) + } + } + + @Test func rejectsWrongPublicKey() throws { + let other = Curve25519.Signing.PrivateKey() + let token = try EdDSAJWT.sign(sample(), using: key) + + #expect(throws: EdDSAJWTError.invalidSignature) { + try EdDSAJWT.verify(token, as: Sample.self, using: other.publicKey) + } + } + + @Test func rejectsMalformedBase64URLSegments() { + #expect(throws: EdDSAJWTError.malformedToken) { + try EdDSAJWT.verify("***.***.***", as: Sample.self, using: publicKey) + } + } + + @Test func rejectsValidBase64ButInvalidHeaderJSON() throws { + let badHeader = Data("not json".utf8).base64URLEncodedString() + let payload = Data("{}".utf8).base64URLEncodedString() + let signature = Data().base64URLEncodedString() + let token = "\(badHeader).\(payload).\(signature)" + + #expect(throws: EdDSAJWTError.headerDecodingFailed) { + try EdDSAJWT.verify(token, as: Sample.self, using: publicKey) + } + } + + @Test func rejectsPayloadThatDoesNotMatchSchema() throws { + struct Other: Encodable { let unrelated: Int } + let token = try EdDSAJWT.sign(Other(unrelated: 1), using: key) + + #expect(throws: EdDSAJWTError.payloadDecodingFailed) { + try EdDSAJWT.verify(token, as: Sample.self, using: publicKey) + } + } + + @Test func rejectsExpiredPayload() throws { + struct Expiring: Codable { var exp: Date } + let token = try EdDSAJWT.sign( + Expiring(exp: Date().addingTimeInterval(-3600)), + using: key + ) + #expect(throws: EdDSAJWTError.expired) { + try EdDSAJWT.verify(token, as: Expiring.self, using: publicKey) + } + } + + @Test func rejectsMalformedExpirationClaim() throws { + struct BadExp: Codable { var exp: String } + let token = try EdDSAJWT.sign(BadExp(exp: "not-a-number"), using: key) + #expect(throws: EdDSAJWTError.claimsDecodingFailed) { + try EdDSAJWT.verify(token, as: BadExp.self, using: publicKey) + } + } + + @Test func skipsTimeChecksWhenDisabled() throws { + struct Expiring: Codable { var exp: Date } + let expired = Date(timeIntervalSince1970: 1_700_000_000) + let token = try EdDSAJWT.sign(Expiring(exp: expired), using: key) + let decoded = try EdDSAJWT.verify( + token, as: Expiring.self, using: publicKey, + verifyTimeClaims: false + ) + #expect(decoded.exp == expired) + } + + @Test func acceptsPayloadWithoutTimeClaims() throws { + struct NoClaims: Codable, Equatable { var sub: String } + let payload = NoClaims(sub: "user-42") + let token = try EdDSAJWT.sign(payload, using: key) + let decoded = try EdDSAJWT.verify(token, as: NoClaims.self, using: publicKey) + #expect(decoded == payload) + } + + @Test func surfacesPayloadEncodingFailure() { + struct Boom: Encodable { + func encode(to encoder: Encoder) throws { + throw EncodingError.invalidValue( + self, + .init(codingPath: [], debugDescription: "boom") + ) + } + } + + #expect(throws: EdDSAJWTError.payloadEncodingFailed) { + try EdDSAJWT.sign(Boom(), using: key) + } + } +} + +@Suite("Base64URL") +struct Base64URLTests { + + @Test func roundTripsArbitraryBytes() { + let data = Data((0..<256).map { UInt8($0) }) + let encoded = data.base64URLEncodedString() + #expect(!encoded.contains("=")) + #expect(!encoded.contains("+")) + #expect(!encoded.contains("/")) + #expect(encoded.base64URLDecodedData() == data) + } + + @Test func decodesWithoutPadding() { + // "Many hands" base64url-encoded is "TWFueSBoYW5kcw" + let decoded = "TWFueSBoYW5kcw".base64URLDecodedData() + #expect(decoded == Data("Many hands".utf8)) + } + + @Test func decodesEmpty() { + #expect("".base64URLDecodedData() == Data()) + } +} diff --git a/Tests/AmoreJWTTests/JWTKitContractTests.swift b/Tests/AmoreJWTTests/JWTKitContractTests.swift new file mode 100644 index 0000000..a7a7446 --- /dev/null +++ b/Tests/AmoreJWTTests/JWTKitContractTests.swift @@ -0,0 +1,89 @@ +import AmoreJWT +import Crypto +import Foundation +import JWTKit +import Testing + +/// Proves that tokens produced by JWTKit (what the server uses to sign) +/// decode correctly with the AmoreJWT parser shipped to clients. Catches +/// any drift in wire format (header bytes, base64url, claim shape) +/// between the two libraries. +@Suite("JWTKit ↔ AmoreJWT wire contract") +struct JWTKitContractTests { + + struct ContractPayload: JWTPayload, Codable, Equatable { + var exp: ExpirationClaim + var iat: IssuedAtClaim + var sub: String + + func verify(using algorithm: some JWTAlgorithm) throws {} + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.sub == rhs.sub + && abs(lhs.exp.value.timeIntervalSince(rhs.exp.value)) < 1 + && abs(lhs.iat.value.timeIntervalSince(rhs.iat.value)) < 1 + } + } + + struct Decoded: Decodable, Equatable { + var exp: Date + var iat: Date + var sub: String + } + + @Test func jwtKitSignedTokenVerifiesWithAmoreJWT() async throws { + let amoreKey = Curve25519.Signing.PrivateKey() + let jwtKitKey = EdDSA.PrivateKey(backing: amoreKey) + + let exp = Date().addingTimeInterval(3600) + let iat = Date() + let payload = ContractPayload( + exp: .init(value: exp), + iat: .init(value: iat), + sub: "user-42" + ) + let keys = await JWTKeyCollection().add(eddsa: jwtKitKey) + let token = try await keys.sign(payload) + + let decoded = try EdDSAJWT.verify(token, as: Decoded.self, using: amoreKey.publicKey) + + #expect(decoded.sub == "user-42") + #expect(abs(decoded.exp.timeIntervalSince(exp)) < 1) + #expect(abs(decoded.iat.timeIntervalSince(iat)) < 1) + } + + @Test func rejectsJWTKitTokenSignedWithNonEdDSAAlgorithm() async throws { + struct HMACPayload: JWTPayload { + var sub: String + func verify(using algorithm: some JWTAlgorithm) throws {} + } + let amoreKey = Curve25519.Signing.PrivateKey() + let keys = await JWTKeyCollection().add(hmac: "secret", digestAlgorithm: .sha256) + let token = try await keys.sign(HMACPayload(sub: "user-42")) + + #expect(throws: EdDSAJWTError.unsupportedAlgorithm("HS256")) { + try EdDSAJWT.verify(token, as: HMACPayload.self, using: amoreKey.publicKey) + } + } + + @Test func amoreJWTSignedTokenVerifiesWithJWTKit() async throws { + let amoreKey = Curve25519.Signing.PrivateKey() + let jwtKitKey = EdDSA.PrivateKey(backing: amoreKey) + + let exp = Date().addingTimeInterval(3600) + let iat = Date() + let payload = ContractPayload( + exp: .init(value: exp), + iat: .init(value: iat), + sub: "user-42" + ) + let token = try EdDSAJWT.sign(payload, using: amoreKey) + + let keys = await JWTKeyCollection().add(eddsa: jwtKitKey) + let decoded = try await keys.verify(token, as: ContractPayload.self) + + #expect(decoded.sub == "user-42") + #expect(abs(decoded.exp.value.timeIntervalSince(exp)) < 1) + #expect(abs(decoded.iat.value.timeIntervalSince(iat)) < 1) + } +} diff --git a/Tests/AmoreLicensingTests/AmoreClientTests.swift b/Tests/AmoreLicensingTests/AmoreClientTests.swift index 4ce723b..44be405 100644 --- a/Tests/AmoreLicensingTests/AmoreClientTests.swift +++ b/Tests/AmoreLicensingTests/AmoreClientTests.swift @@ -1,5 +1,6 @@ +import AmoreJWT +import Crypto import Foundation -import JWTKit import Testing @testable import AmoreLicensing @@ -8,34 +9,33 @@ import Testing @Suite struct AmoreClientTests { private let hardwareId = "TEST-SERIAL-123" private let bundleId = "com.test.amorekit" - - private func makeKeys() throws -> (EdDSA.PrivateKey, EdDSA.PublicKey) { - let privateKey = try EdDSA.PrivateKey(curve: .ed25519) + + private func makeKeys() -> (Curve25519.Signing.PrivateKey, Curve25519.Signing.PublicKey) { + let privateKey = Curve25519.Signing.PrivateKey() return (privateKey, privateKey.publicKey) } - + private func signToken( - privateKey: EdDSA.PrivateKey, + privateKey: Curve25519.Signing.PrivateKey, hardwareId: String, nonce: String, exp: Date = Date().addingTimeInterval(30 * 24 * 3600), licenseId: UUID = UUID(), product: Product = .testSample - ) async throws -> String { + ) throws -> String { let payload = LicensePayload( - exp: .init(value: exp), + exp: exp, hardwareId: hardwareId, - iat: .init(value: Date()), + iat: Date(), licenseId: licenseId, nonce: nonce, product: product ) - let keys = await JWTKeyCollection().add(eddsa: privateKey) - return try await keys.sign(payload) + return try EdDSAJWT.sign(payload, using: privateKey) } - + private func makeClient( - publicKey: EdDSA.PublicKey, + publicKey: Curve25519.Signing.PublicKey, tokenStore: MockTokenStore = MockTokenStore(), licenseClient: MockLicenseClient = MockLicenseClient() ) -> (AmoreLicensing, MockTokenStore, MockLicenseClient) { @@ -48,38 +48,38 @@ import Testing ) return (client, tokenStore, licenseClient) } - + // MARK: - Activation - + @Test func activationHardwareIdMismatch() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let mock = MockLicenseClient() mock.onActivate = { _, _, nonce in - try await self.signToken(privateKey: privateKey, hardwareId: "OTHER-HW", nonce: nonce) + try self.signToken(privateKey: privateKey, hardwareId: "OTHER-HW", nonce: nonce) } let (client, _, _) = makeClient(publicKey: publicKey, licenseClient: mock) - + await #expect(throws: AmoreError.hardwareIdMismatch) { try await client.activate(licenseKey: "KEY") } } - + @Test func activationInvalidSignature() async throws { - let (_, publicKey) = try makeKeys() - let (wrongPrivate, _) = try makeKeys() + let (_, publicKey) = makeKeys() + let (wrongPrivate, _) = makeKeys() let mock = MockLicenseClient() mock.onActivate = { [self] _, hwId, nonce in - try await signToken(privateKey: wrongPrivate, hardwareId: hwId, nonce: nonce) + try signToken(privateKey: wrongPrivate, hardwareId: hwId, nonce: nonce) } let (client, _, _) = makeClient(publicKey: publicKey, licenseClient: mock) - - await #expect(throws: AmoreError.invalidSignature) { + + await #expect(throws: AmoreError.invalidToken) { try await client.activate(licenseKey: "KEY") } } - + @Test func activationRateLimited() async throws { - let (_, publicKey) = try makeKeys() + let (_, publicKey) = makeKeys() let mock = MockLicenseClient() mock.onActivate = { _, _, _ in throw NetworkError.rateLimited } let (client, _, _) = makeClient(publicKey: publicKey, licenseClient: mock) @@ -88,89 +88,89 @@ import Testing try await client.activate(licenseKey: "KEY") } } - + @Test func activationNetworkFailure() async throws { - let (_, publicKey) = try makeKeys() + let (_, publicKey) = makeKeys() let mock = MockLicenseClient() mock.onActivate = { _, _, _ in throw URLError(.notConnectedToInternet) } let (client, _, _) = makeClient(publicKey: publicKey, licenseClient: mock) - + await #expect(throws: AmoreError.self) { try await client.activate(licenseKey: "KEY") } } - + @Test func activationNonceMismatch() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let mock = MockLicenseClient() mock.onActivate = { [self] _, hwId, _ in - try await signToken(privateKey: privateKey, hardwareId: hwId, nonce: "wrong-nonce") + try signToken(privateKey: privateKey, hardwareId: hwId, nonce: "wrong-nonce") } let (client, _, _) = makeClient(publicKey: publicKey, licenseClient: mock) - + await #expect(throws: AmoreError.nonceMismatch) { try await client.activate(licenseKey: "KEY") } } - + @Test func activationSuccess() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let mock = MockLicenseClient() mock.onActivate = { [self] _, hwId, nonce in - try await signToken(privateKey: privateKey, hardwareId: hwId, nonce: nonce) + try signToken(privateKey: privateKey, hardwareId: hwId, nonce: nonce) } let (client, store, _) = makeClient(publicKey: publicKey, licenseClient: mock) - + try await client.activate(licenseKey: "VALID-KEY") - + guard case .valid = client.status else { Issue.record("Expected valid, got \(client.status)") return } #expect(try store.retrieve() != nil) } - + // MARK: - Deactivation - + @Test func deactivationSuccess() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let mock = MockLicenseClient() var serverCalled = false mock.onActivate = { [self] _, hwId, nonce in - try await signToken(privateKey: privateKey, hardwareId: hwId, nonce: nonce) + try signToken(privateKey: privateKey, hardwareId: hwId, nonce: nonce) } mock.onDeactivate = { _ in serverCalled = true } let (client, store, _) = makeClient(publicKey: publicKey, licenseClient: mock) - + try await client.activate(licenseKey: "KEY") try await client.deactivate() - + #expect(client.status == .unknown) #expect(try store.retrieve() == nil) #expect(serverCalled) } - + @Test func deactivationNoStoredToken() async throws { - let (_, publicKey) = try makeKeys() + let (_, publicKey) = makeKeys() let (client, _, _) = makeClient(publicKey: publicKey) - + await #expect(throws: AmoreError.noStoredToken) { try await client.deactivate() } } - + @Test func deactivationNetworkFailure() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let mock = MockLicenseClient() mock.onActivate = { [self] _, hwId, nonce in - try await signToken(privateKey: privateKey, hardwareId: hwId, nonce: nonce) + try signToken(privateKey: privateKey, hardwareId: hwId, nonce: nonce) } mock.onDeactivate = { _ in throw URLError(.notConnectedToInternet) } let (client, store, _) = makeClient(publicKey: publicKey, licenseClient: mock) - + try await client.activate(licenseKey: "KEY") let tokenBefore = try store.retrieve() - + await #expect(throws: AmoreError.self) { try await client.deactivate() } @@ -180,25 +180,25 @@ import Testing return } } - + // MARK: - Validation - + @Test func validateExpiredTokenGracePeriod() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() let expDate = Date().addingTimeInterval(-2 * 24 * 3600) // expired 2 days ago - let expired = try await signToken( + let expired = try signToken( privateKey: privateKey, hardwareId: hardwareId, nonce: "old", exp: expDate ) try store.store(expired) - + let mock = MockLicenseClient() mock.onValidate = { _, _ in throw URLError(.notConnectedToInternet) } let (client, _, _) = makeClient(publicKey: publicKey, tokenStore: store, licenseClient: mock) - + let result = try await client.validate() - + guard case .gracePeriod(let license) = result else { Issue.record("Expected gracePeriod, got \(result)") return @@ -206,80 +206,110 @@ import Testing let expectedEnd = expDate.addingTimeInterval(7 * 86_400) #expect(abs(license.expiresAt!.timeIntervalSince(expectedEnd)) < 1) } - + @Test func validateExpiredTokenValidates() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let expired = try await signToken( + let expired = try signToken( privateKey: privateKey, hardwareId: hardwareId, nonce: "old", exp: Date().addingTimeInterval(-100) ) try store.store(expired) - + let mock = MockLicenseClient() mock.onValidate = { [self] _, nonce in - try await signToken(privateKey: privateKey, hardwareId: hardwareId, nonce: nonce) + try signToken(privateKey: privateKey, hardwareId: hardwareId, nonce: nonce) } let (client, _, _) = makeClient(publicKey: publicKey, tokenStore: store, licenseClient: mock) - + let result = try await client.validate() - + guard case .valid = result, case .valid = client.status else { Issue.record("Expected valid, got \(result)") return } } - + @Test func validateGracePeriodExpired() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let expired = try await signToken( + let expired = try signToken( privateKey: privateKey, hardwareId: hardwareId, nonce: "old", exp: Date().addingTimeInterval(-10 * 24 * 3600) // expired 10 days ago ) try store.store(expired) - + let mock = MockLicenseClient() mock.onValidate = { _, _ in throw URLError(.notConnectedToInternet) } let (client, _, _) = makeClient(publicKey: publicKey, tokenStore: store, licenseClient: mock) - + let result = try await client.validate() - + #expect(result == .invalid) #expect(client.status == .invalid) } - + + /// A stored token that is BOTH expired AND bound to a different machine must + /// throw `hardwareIdMismatch` without attempting a server refresh: re-binding + /// across machines requires an explicit `activate(licenseKey:)`, not a silent + /// refresh of a foreign token. + @Test func validateHardwareIdMismatchOnExpiredStoredToken() async throws { + let (privateKey, publicKey) = makeKeys() + let store = MockTokenStore() + let token = try signToken( + privateKey: privateKey, + hardwareId: "OTHER-HW", + nonce: "n", + exp: Date().addingTimeInterval(-1 * 24 * 3600) + ) + try store.store(token) + + let mock = MockLicenseClient() + var refreshAttempted = false + mock.onValidate = { _, _ in + refreshAttempted = true + throw URLError(.notConnectedToInternet) + } + let (client, _, _) = makeClient(publicKey: publicKey, tokenStore: store, licenseClient: mock) + + await #expect(throws: AmoreError.hardwareIdMismatch) { + try await client.validate() + } + #expect(client.status == .invalid) + #expect(refreshAttempted == false) + } + @Test func validateHardwareIdMismatchOnStoredToken() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let token = try await signToken(privateKey: privateKey, hardwareId: "OTHER-HW", nonce: "n") + let token = try signToken(privateKey: privateKey, hardwareId: "OTHER-HW", nonce: "n") try store.store(token) let (client, _, _) = makeClient(publicKey: publicKey, tokenStore: store) - + await #expect(throws: AmoreError.hardwareIdMismatch) { try await client.validate() } #expect(client.status == .invalid) } - + @Test func validateNoStoredToken() async throws { - let (_, publicKey) = try makeKeys() + let (_, publicKey) = makeKeys() let (client, _, _) = makeClient(publicKey: publicKey) - + await #expect(throws: AmoreError.noStoredToken) { try await client.validate() } } - + @Test func validateValidStoredToken() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let token = try await signToken(privateKey: privateKey, hardwareId: hardwareId, nonce: "stored") + 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 diff --git a/Tests/AmoreLicensingTests/Helpers/TokenFixtures.swift b/Tests/AmoreLicensingTests/Helpers/TokenFixtures.swift index 98bc2d3..0ad2d9d 100644 --- a/Tests/AmoreLicensingTests/Helpers/TokenFixtures.swift +++ b/Tests/AmoreLicensingTests/Helpers/TokenFixtures.swift @@ -1,5 +1,6 @@ +import AmoreJWT +import Crypto import Foundation -import JWTKit @testable import AmoreLicensing @@ -14,37 +15,36 @@ extension Product { /// Signs a production (v2) token carrying an object `product` claim. func signV2Token( - privateKey: EdDSA.PrivateKey, + privateKey: Curve25519.Signing.PrivateKey, hardwareId: String, nonce: String, product: Product = .testSample, exp: Date = Date().addingTimeInterval(30 * 24 * 3600), iat: Date = Date(), licenseId: UUID = UUID() -) async throws -> String { +) throws -> String { let payload = LicensePayload( - exp: .init(value: exp), + exp: exp, hardwareId: hardwareId, - iat: .init(value: iat), + iat: iat, licenseId: licenseId, nonce: nonce, product: product ) - let keys = await JWTKeyCollection().add(eddsa: privateKey) - return try await keys.sign(payload) + return try EdDSAJWT.sign(payload, using: privateKey) } /// Test-only mirror of the pre-v2 payload: `product` is a bare string, with the /// same JSON coding keys the old SDK shipped. Used to mint "old" cached tokens. -struct LicensePayloadV1Fixture: JWTPayload { - var exp: ExpirationClaim +struct LicensePayloadV1Fixture: Encodable { + var exp: Date var hardwareId: String - var iat: IssuedAtClaim + var iat: Date var licenseId: UUID var nonce: String var product: String var entitlements: Set = [] - + enum CodingKeys: String, CodingKey { case exp case hardwareId = "hardware_id" @@ -54,28 +54,25 @@ struct LicensePayloadV1Fixture: JWTPayload { case product case entitlements } - - func verify(using algorithm: some JWTAlgorithm) throws {} } /// Signs an "old" (v1) token carrying a bare-string `product` claim. func signV1Token( - privateKey: EdDSA.PrivateKey, + privateKey: Curve25519.Signing.PrivateKey, hardwareId: String, nonce: String, productName: String = "Amore", exp: Date = Date().addingTimeInterval(30 * 24 * 3600), iat: Date = Date(), licenseId: UUID = UUID() -) async throws -> String { +) throws -> String { let payload = LicensePayloadV1Fixture( - exp: .init(value: exp), + exp: exp, hardwareId: hardwareId, - iat: .init(value: iat), + iat: iat, licenseId: licenseId, nonce: nonce, product: productName ) - let keys = await JWTKeyCollection().add(eddsa: privateKey) - return try await keys.sign(payload) + return try EdDSAJWT.sign(payload, using: privateKey) } diff --git a/Tests/AmoreLicensingTests/JWTKitCompatibilityTests.swift b/Tests/AmoreLicensingTests/JWTKitCompatibilityTests.swift new file mode 100644 index 0000000..36f2040 --- /dev/null +++ b/Tests/AmoreLicensingTests/JWTKitCompatibilityTests.swift @@ -0,0 +1,104 @@ +import AmoreJWT +import Crypto +import Foundation +import JWTKit +import Testing + +@testable import AmoreLicensing + +/// Proves the shipped client stays byte-compatible with the JWTKit-based +/// tooling that mints keys and signs tokens server-side, for the *real* +/// ``LicensePayload`` wire shape rather than a synthetic stand-in. Catches +/// drift in the public-key encoding, snake_case keys, or nested claim types. +@Suite("JWTKit ↔ AmoreLicensing wire contract") +struct JWTKitCompatibilityTests { + + /// Mirrors the exact bytes the JWTKit-based server emits: NumericDate + /// `exp`/`iat` claims and the snake_case keys ``LicensePayload`` expects. + /// Frozen on purpose: if ``LicensePayload``'s coding keys or nested types + /// drift away from this, the decode below fails. + private struct ServerLicensePayload: JWTPayload { + var exp: ExpirationClaim + var hardwareId: String + var iat: IssuedAtClaim + var licenseId: UUID + var nonce: String + var product: Product + var entitlements: Set + var subscriptionState: SubscriptionState? + var customer: Customer? + + enum CodingKeys: String, CodingKey { + case exp + case hardwareId = "hardware_id" + case iat + case licenseId = "license_id" + case nonce + case product + case entitlements + case subscriptionState = "subscription_state" + case customer + } + + func verify(using algorithm: some JWTAlgorithm) throws {} + } + + // MARK: - Public key string + + @Test func publicKeyStringIngestsIdenticallyToJWTKit() throws { + let privateKey = Curve25519.Signing.PrivateKey() + let keyString = privateKey.publicKey.rawRepresentation.base64URLEncodedString() + + // JWTKit's interpretation (what deployed apps used) and AmoreLicensing's + // must yield identical key bytes. + let jwtKitKey = try EdDSA.PublicKey(x: keyString, curve: .ed25519) + let amoreData = try #require(keyString.base64URLDecodedData()) + let amoreKey = try Curve25519.Signing.PublicKey(rawRepresentation: amoreData) + #expect(amoreKey.rawRepresentation == jwtKitKey.rawRepresentation) + + // And the ingested key actually verifies a token the matching private key signed. + let token = try signV2Token(privateKey: privateKey, hardwareId: "HW-1", nonce: "n-1") + let payload = try EdDSAJWT.verify(token, as: LicensePayload.self, using: amoreKey) + #expect(payload.hardwareId == "HW-1") + } + + // MARK: - Payload wire shape + + @Test func jwtKitSignedLicensePayloadDecodesWithAmoreJWT() async throws { + let privateKey = Curve25519.Signing.PrivateKey() + let exp = Date().addingTimeInterval(3600) + let iat = Date() + let renewsAt = Date().addingTimeInterval(30 * 86_400) + let licenseId = UUID() + + let server = ServerLicensePayload( + exp: .init(value: exp), + hardwareId: "HW-42", + iat: .init(value: iat), + licenseId: licenseId, + nonce: "n-1", + product: .testSample, + entitlements: ["pro", "team"], + subscriptionState: .renewing(renewsAt: renewsAt), + customer: Customer(email: "licensed@example.com") + ) + let keys = await JWTKeyCollection().add(eddsa: EdDSA.PrivateKey(backing: privateKey)) + let token = try await keys.sign(server) + + let decoded = try EdDSAJWT.verify(token, as: LicensePayload.self, using: privateKey.publicKey) + + #expect(decoded.hardwareId == "HW-42") + #expect(decoded.licenseId == licenseId) + #expect(decoded.nonce == "n-1") + #expect(decoded.product == .testSample) + #expect(decoded.entitlements == ["pro", "team"]) + #expect(decoded.customer?.email == "licensed@example.com") + #expect(abs(decoded.exp.timeIntervalSince(exp)) < 1) + #expect(abs(decoded.iat.timeIntervalSince(iat)) < 1) + guard case .renewing(let decodedRenewsAt) = decoded.subscriptionState else { + Issue.record("Expected .renewing subscription state") + return + } + #expect(abs(decodedRenewsAt.timeIntervalSince(renewsAt)) < 1) + } +} diff --git a/Tests/AmoreLicensingTests/LicenseMigrationTests.swift b/Tests/AmoreLicensingTests/LicenseMigrationTests.swift index 138a95f..d0ead5c 100644 --- a/Tests/AmoreLicensingTests/LicenseMigrationTests.swift +++ b/Tests/AmoreLicensingTests/LicenseMigrationTests.swift @@ -1,5 +1,5 @@ +import Crypto import Foundation -import JWTKit import Testing @testable import AmoreLicensing @@ -8,14 +8,14 @@ import Testing @Suite struct LicenseMigrationTests { private let hardwareId = "TEST-SERIAL-123" private let bundleId = "com.test.amorekit" - - private func makeKeys() throws -> (EdDSA.PrivateKey, EdDSA.PublicKey) { - let privateKey = try EdDSA.PrivateKey(curve: .ed25519) + + private func makeKeys() -> (Curve25519.Signing.PrivateKey, Curve25519.Signing.PublicKey) { + let privateKey = Curve25519.Signing.PrivateKey() return (privateKey, privateKey.publicKey) } - + private func makeClient( - publicKey: EdDSA.PublicKey, + publicKey: Curve25519.Signing.PublicKey, tokenStore: MockTokenStore = MockTokenStore(), licenseClient: MockLicenseClient = MockLicenseClient() ) -> AmoreLicensing { @@ -27,18 +27,18 @@ import Testing licenseClient: licenseClient ) } - + @Test func licenseNameReturnsProductName() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let token = try await signV2Token( + let token = try signV2Token( privateKey: privateKey, hardwareId: hardwareId, nonce: "n", product: .testSample ) try store.store(token) let client = makeClient(publicKey: publicKey, tokenStore: store) - + let result = try await client.validate() - + guard case .valid(let license) = result else { Issue.record("Expected valid, got \(result)") return @@ -47,23 +47,23 @@ import Testing #expect(license.name == "Amore") #expect(license.product.identifier == "pro") } - + @Test func v1CachedTokenUpgradesToV2Online() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let v1 = try await signV1Token(privateKey: privateKey, hardwareId: hardwareId, nonce: "old") + let v1 = try signV1Token(privateKey: privateKey, hardwareId: hardwareId, nonce: "old") try store.store(v1) - + let mock = MockLicenseClient() var validateCalled = false mock.onValidate = { _, nonce in validateCalled = true - return try await signV2Token(privateKey: privateKey, hardwareId: self.hardwareId, nonce: nonce) + return try signV2Token(privateKey: privateKey, hardwareId: self.hardwareId, nonce: nonce) } let client = makeClient(publicKey: publicKey, tokenStore: store, licenseClient: mock) - + let result = try await client.validate() - + guard case .valid(let license) = result else { Issue.record("Expected valid after upgrade, got \(result)") return @@ -73,25 +73,25 @@ import Testing #expect(license.product.name == Product.testSample.name) #expect(license.product.identifier == Product.testSample.identifier) } - + @Test func v1CachedTokenOfflineDegradesThenRecovers() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let v1 = try await signV1Token(privateKey: privateKey, hardwareId: hardwareId, nonce: "old") + let v1 = try signV1Token(privateKey: privateKey, hardwareId: hardwareId, nonce: "old") try store.store(v1) - + let mock = MockLicenseClient() mock.onValidate = { _, _ in throw URLError(.notConnectedToInternet) } let client = makeClient(publicKey: publicKey, tokenStore: store, licenseClient: mock) - + // Offline: local decode of the v1 token fails (object product), refresh // fails (network), grace decode of the v1 token also fails → .invalid. let offline = try await client.validate() #expect(offline == .invalid) - + // Server reachable again, returns a v2 token → self-heals. mock.onValidate = { _, nonce in - try await signV2Token(privateKey: privateKey, hardwareId: self.hardwareId, nonce: nonce) + try signV2Token(privateKey: privateKey, hardwareId: self.hardwareId, nonce: nonce) } let recovered = try await client.validate() guard case .valid = recovered else { @@ -99,24 +99,24 @@ import Testing return } } - + @Test func v2CachedTokenValidatesWithoutCallingServer() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let v2 = try await signV2Token( + let v2 = try signV2Token( privateKey: privateKey, hardwareId: hardwareId, nonce: "stored", iat: Date() ) try store.store(v2) - + let mock = MockLicenseClient() mock.onValidate = { _, _ in Issue.record("validate() must not call the server for a fresh v2 token") throw URLError(.badServerResponse) } let client = makeClient(publicKey: publicKey, tokenStore: store, licenseClient: mock) - + let result = try await client.validate() - + guard case .valid(let license) = result else { Issue.record("Expected valid, got \(result)") return diff --git a/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift b/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift index 7be3b6d..49e9877 100644 --- a/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift +++ b/Tests/AmoreLicensingTests/LicensePayloadDecodeTests.swift @@ -1,6 +1,5 @@ @testable import AmoreLicensing import Foundation -import JWTKit import Testing @Suite("LicensePayload decode") diff --git a/Tests/AmoreLicensingTests/LicenseTokenVerifierTests.swift b/Tests/AmoreLicensingTests/LicenseTokenVerifierTests.swift new file mode 100644 index 0000000..f4eace2 --- /dev/null +++ b/Tests/AmoreLicensingTests/LicenseTokenVerifierTests.swift @@ -0,0 +1,100 @@ +import AmoreJWT +import Crypto +import Foundation +import Testing + +@testable import AmoreLicensing + +@Suite("LicenseTokenVerifier") +struct LicenseTokenVerifierTests { + private let hardwareId = "TEST-SERIAL-123" + private let privateKey = Curve25519.Signing.PrivateKey() + + private func makeVerifier( + publicKey: Curve25519.Signing.PublicKey? = nil, + hardwareId: String? = nil + ) -> LicenseTokenVerifier { + LicenseTokenVerifier( + publicKey: publicKey ?? privateKey.publicKey, + hardwareIdentifier: MockHardwareIdentifier(identifier: hardwareId ?? self.hardwareId) + ) + } + + // MARK: - decode + + @Test func decodeReturnsPayloadForValidToken() 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) + #expect(payload.nonce == "n-1") + } + + @Test func decodeThrowsNonceMismatchWhenNonceDiffers() throws { + let token = try signV2Token(privateKey: privateKey, hardwareId: hardwareId, nonce: "issued") + #expect(throws: AmoreError.nonceMismatch) { + try makeVerifier().decode(token, expectedNonce: "expected") + } + } + + @Test func decodeSkipsNonceCheckWhenNoneExpected() throws { + let token = try signV2Token(privateKey: privateKey, hardwareId: hardwareId, nonce: "n-1") + let payload = try makeVerifier().decode(token) + #expect(payload.nonce == "n-1") + } + + @Test func decodeThrowsHardwareIdMismatchForOtherDevice() throws { + let token = try signV2Token(privateKey: privateKey, hardwareId: "OTHER-HW", nonce: "n-1") + #expect(throws: AmoreError.hardwareIdMismatch) { + try makeVerifier().decode(token, expectedNonce: "n-1") + } + } + + @Test func decodeThrowsInvalidTokenForWrongKey() throws { + let token = try signV2Token(privateKey: privateKey, hardwareId: hardwareId, nonce: "n-1") + let otherKey = Curve25519.Signing.PrivateKey().publicKey + #expect(throws: AmoreError.invalidToken) { + try makeVerifier(publicKey: otherKey).decode(token, expectedNonce: "n-1") + } + } + + @Test func decodeRejectsExpiredTokenWhenVerifyingTimeClaims() throws { + let token = try signV2Token( + privateKey: privateKey, hardwareId: hardwareId, nonce: "n-1", + exp: Date().addingTimeInterval(-3600) + ) + #expect(throws: AmoreError.invalidToken) { + try makeVerifier().decode(token, expectedNonce: "n-1") + } + } + + // MARK: - decodeLocally + + @Test func decodeLocallyToleratesExpiredToken() throws { + let token = try signV2Token( + privateKey: privateKey, hardwareId: hardwareId, nonce: "n-1", + exp: Date().addingTimeInterval(-3600) + ) + guard case .decoded(let payload) = makeVerifier().decodeLocally(token) else { + Issue.record("Expected .decoded for an expired but otherwise valid token") + return + } + #expect(payload.hardwareId == hardwareId) + } + + @Test func decodeLocallyReturnsHardwareMismatch() throws { + let token = try signV2Token(privateKey: privateKey, hardwareId: "OTHER-HW", nonce: "n-1") + guard case .hardwareMismatch = makeVerifier().decodeLocally(token) else { + Issue.record("Expected .hardwareMismatch") + return + } + } + + @Test func decodeLocallyReturnsUnverifiableForWrongKey() throws { + let token = try signV2Token(privateKey: privateKey, hardwareId: hardwareId, nonce: "n-1") + let otherKey = Curve25519.Signing.PrivateKey().publicKey + guard case .unverifiable = makeVerifier(publicKey: otherKey).decodeLocally(token) else { + Issue.record("Expected .unverifiable") + return + } + } +} diff --git a/Tests/AmoreLicensingTests/PublicKeyIngestionTests.swift b/Tests/AmoreLicensingTests/PublicKeyIngestionTests.swift new file mode 100644 index 0000000..a34e80e --- /dev/null +++ b/Tests/AmoreLicensingTests/PublicKeyIngestionTests.swift @@ -0,0 +1,50 @@ +import AmoreJWT +import Crypto +import Foundation +import Testing + +@testable import AmoreLicensing + +/// Exercises the public ``AmoreLicensing/init(publicKey:bundleIdentifier:configuration:server: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). +@MainActor +@Suite("Public key ingestion") +struct PublicKeyIngestionTests { + private let manual = LicensingConfiguration(validationFrequency: .manual) + + @Test func acceptsWellFormedKeyString() throws { + let keyString = Curve25519.Signing.PrivateKey() + .publicKey.rawRepresentation.base64URLEncodedString() + _ = try AmoreLicensing( + publicKey: keyString, + bundleIdentifier: "com.test.amorekit", + configuration: manual, + tokenStore: MockTokenStore() + ) + } + + @Test func rejectsUndecodableKeyString() { + #expect(throws: AmoreError.invalidPublicKey) { + _ = try AmoreLicensing( + publicKey: "not base64 !!!", + bundleIdentifier: "com.test.amorekit", + configuration: manual, + tokenStore: MockTokenStore() + ) + } + } + + @Test func rejectsWrongLengthKeyString() { + let tooShort = Data([1, 2, 3, 4]).base64URLEncodedString() + #expect(throws: AmoreError.invalidPublicKey) { + _ = try AmoreLicensing( + publicKey: tooShort, + bundleIdentifier: "com.test.amorekit", + configuration: manual, + tokenStore: MockTokenStore() + ) + } + } +} diff --git a/Tests/AmoreLicensingTests/ValidationFrequencyTests.swift b/Tests/AmoreLicensingTests/ValidationFrequencyTests.swift index 06d2879..25b8fc7 100644 --- a/Tests/AmoreLicensingTests/ValidationFrequencyTests.swift +++ b/Tests/AmoreLicensingTests/ValidationFrequencyTests.swift @@ -1,5 +1,6 @@ +import AmoreJWT +import Crypto import Foundation -import JWTKit import Testing @testable import AmoreLicensing @@ -9,31 +10,30 @@ import Testing private let hardwareId = "TEST-SERIAL-123" private let bundleId = "com.test.amorekit" - private func makeKeys() throws -> (EdDSA.PrivateKey, EdDSA.PublicKey) { - let privateKey = try EdDSA.PrivateKey(curve: .ed25519) + private func makeKeys() -> (Curve25519.Signing.PrivateKey, Curve25519.Signing.PublicKey) { + let privateKey = Curve25519.Signing.PrivateKey() return (privateKey, privateKey.publicKey) } private func signToken( - privateKey: EdDSA.PrivateKey, + privateKey: Curve25519.Signing.PrivateKey, nonce: String, iat: Date = Date(), exp: Date = Date().addingTimeInterval(30 * 24 * 3600) - ) async throws -> String { + ) throws -> String { let payload = LicensePayload( - exp: .init(value: exp), + exp: exp, hardwareId: hardwareId, - iat: .init(value: iat), + iat: iat, licenseId: UUID(), nonce: nonce, product: .testSample ) - let keys = await JWTKeyCollection().add(eddsa: privateKey) - return try await keys.sign(payload) + return try EdDSAJWT.sign(payload, using: privateKey) } private func makeClient( - publicKey: EdDSA.PublicKey, + publicKey: Curve25519.Signing.PublicKey, configuration: LicensingConfiguration, tokenStore: MockTokenStore = MockTokenStore(), licenseClient: MockLicenseClient = MockLicenseClient() @@ -52,9 +52,9 @@ import Testing // MARK: - afterExpiration (default behavior) @Test func validTokenSkipsServerWhenAfterExpiration() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let token = try await signToken(privateKey: privateKey, nonce: "stored") + let token = try signToken(privateKey: privateKey, nonce: "stored") try store.store(token) let mock = MockLicenseClient() @@ -83,10 +83,10 @@ import Testing // MARK: - Proactive refresh @Test func validTokenContactsServerWhenIntervalElapsed() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() // Token issued 2 days ago — daily check should trigger - let token = try await signToken( + let token = try signToken( privateKey: privateKey, nonce: "old", iat: Date().addingTimeInterval(-2 * 86_400) ) @@ -94,7 +94,7 @@ import Testing let mock = MockLicenseClient() mock.onValidate = { [self] _, nonce in - try await signToken(privateKey: privateKey, nonce: nonce) + try signToken(privateKey: privateKey, nonce: nonce) } let config = LicensingConfiguration(validationFrequency: .daily) @@ -116,10 +116,10 @@ import Testing } @Test func validTokenSkipsServerWhenIntervalNotElapsed() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() // Token issued 1 hour ago — daily check should NOT trigger - let token = try await signToken( + let token = try signToken( privateKey: privateKey, nonce: "fresh", iat: Date().addingTimeInterval(-3600) ) @@ -150,9 +150,9 @@ import Testing } @Test func proactiveCheckNetworkFailureKeepsValid() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let token = try await signToken( + let token = try signToken( privateKey: privateKey, nonce: "stored", iat: Date().addingTimeInterval(-2 * 86_400) ) @@ -178,9 +178,9 @@ import Testing } @Test func proactiveCheckServerRejectionSetsInvalid() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let token = try await signToken( + let token = try signToken( privateKey: privateKey, nonce: "stored", iat: Date().addingTimeInterval(-2 * 86_400) ) @@ -204,16 +204,16 @@ import Testing } @Test func everyLaunchAlwaysContactsServer() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let token = try await signToken(privateKey: privateKey, nonce: "fresh") + let token = try signToken(privateKey: privateKey, nonce: "fresh") try store.store(token) let mock = MockLicenseClient() var serverCalled = false mock.onValidate = { [self] _, nonce in serverCalled = true - return try await signToken(privateKey: privateKey, nonce: nonce) + return try signToken(privateKey: privateKey, nonce: nonce) } let config = LicensingConfiguration(validationFrequency: .everyLaunch) @@ -236,9 +236,9 @@ import Testing // MARK: - Consumer-driven lifecycle @Test func manualFrequencyDoesNotRefresh() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let token = try await signToken(privateKey: privateKey, nonce: "stored") + let token = try signToken(privateKey: privateKey, nonce: "stored") try store.store(token) let mock = MockLicenseClient() @@ -266,9 +266,9 @@ import Testing } @Test func deactivateResetsStatusToUnknown() async throws { - let (privateKey, publicKey) = try makeKeys() + let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() - let token = try await signToken(privateKey: privateKey, nonce: "stored") + let token = try signToken(privateKey: privateKey, nonce: "stored") try store.store(token) let mock = MockLicenseClient() From c350791c0cf72e08aa538deeadeb951de7dfb9ea Mon Sep 17 00:00:00 2001 From: Lucas Fischer Date: Thu, 18 Jun 2026 19:30:11 +0800 Subject: [PATCH 2/4] refactor(licensing): thread decoded payload through token refresh --- Sources/AmoreLicensing/AmoreLicensing.swift | 30 ++++++++++----------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/Sources/AmoreLicensing/AmoreLicensing.swift b/Sources/AmoreLicensing/AmoreLicensing.swift index ab4ff0c..05384eb 100644 --- a/Sources/AmoreLicensing/AmoreLicensing.swift +++ b/Sources/AmoreLicensing/AmoreLicensing.swift @@ -131,10 +131,8 @@ public final class AmoreLicensing: Licensing { case .unverifiable: try await refreshToken(token) case .decoded(let payload): - if payload.exp < Date() { - try await refreshToken(token) - } else if configuration.validationFrequency.isRefreshDue(issuedAt: payload.iat) { - try await refreshToken(token, currentPayload: payload) + if payload.exp < Date() || configuration.validationFrequency.isRefreshDue(issuedAt: payload.iat) { + try await refreshToken(token, localPayload: payload) } else { status = .valid(License(from: payload)) } @@ -159,11 +157,12 @@ public final class AmoreLicensing: Licensing { } /// Refreshes the token from the server and updates ``status``. On a transient - /// failure it falls back to `currentPayload` if still locally valid, otherwise - /// to the grace period; a ``ClientError`` invalidates the license. + /// 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 + /// back on; a ``ClientError`` always invalidates the license. private func refreshToken( _ token: String, - currentPayload: LicensePayload? = nil + localPayload: LicensePayload? = nil ) async throws(AmoreError) { let nonce = UUID().uuidString do { @@ -175,20 +174,19 @@ public final class AmoreLicensing: Licensing { status = .invalid throw .client(error) } catch let error as AmoreError { - guard let currentPayload else { throw error } - status = .valid(License(from: currentPayload)) + guard let localPayload, localPayload.exp > Date() else { throw error } + status = .valid(License(from: localPayload)) } catch { - guard let currentPayload else { applyGracePeriod(token: token); return } + guard let localPayload else { status = .invalid; return } + guard localPayload.exp > Date() else { applyGracePeriod(payload: localPayload); return } // Token still valid locally, keep using it. - status = .valid(License(from: currentPayload)) + status = .valid(License(from: localPayload)) } } - private func applyGracePeriod(token: String) { - guard case .decoded(let payload) = verifier.decodeLocally(token) else { - status = .invalid - return - } + /// Enters the grace period derived from an already-verified, expired payload, + /// or invalidates once that grace period has elapsed. + private func applyGracePeriod(payload: LicensePayload) { let graceEnd = payload.exp.addingTimeInterval(configuration.gracePeriod.timeInterval) var license = License(from: payload) license.expiresAt = graceEnd From 071dffe683a3bdcff0fcda1e8bbaf6f2d710fa41 Mon Sep 17 00:00:00 2001 From: Lucas Fischer Date: Fri, 19 Jun 2026 09:35:22 +0800 Subject: [PATCH 3/4] test(licensing): assert valid stored token seeds status synchronously at launch --- .../AmoreClientTests.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Tests/AmoreLicensingTests/AmoreClientTests.swift b/Tests/AmoreLicensingTests/AmoreClientTests.swift index 44be405..9266869 100644 --- a/Tests/AmoreLicensingTests/AmoreClientTests.swift +++ b/Tests/AmoreLicensingTests/AmoreClientTests.swift @@ -49,6 +49,13 @@ import Testing return (client, tokenStore, licenseClient) } + /// A server whose endpoints refuse instantly, so the `validate()` the launch + /// initializer spawns fails harmlessly instead of hitting the real backend. + private func unreachableServer() -> LicenseServer { + let url = URL(string: "http://127.0.0.1:1")! + return LicenseServer(activateURL: url, deactivateURL: url, validateURL: url) + } + // MARK: - Activation @Test func activationHardwareIdMismatch() async throws { @@ -301,6 +308,32 @@ import Testing } } + /// The launch initializer must surface a stored, unexpired token as `.valid` + /// synchronously: the very first `status` read has to be authoritative before + /// the background `validate()` round-trip finishes. This is the path consumers + /// 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) + + let client = try AmoreLicensing( + publicKey: publicKey.rawRepresentation.base64URLEncodedString(), + bundleIdentifier: bundleId, + server: unreachableServer(), + tokenStore: store + ) + + // No await between init and this read: the result must come from the + // synchronous local decode, not the async validate() the initializer spawns. + guard case .valid = client.status else { + Issue.record("Expected valid on first synchronous read, got \(client.status)") + return + } + } + @Test func validateValidStoredToken() async throws { let (privateKey, publicKey) = makeKeys() let store = MockTokenStore() From 2ace863da28d7a35e4fc8a91e1fa30bc928ab8f4 Mon Sep 17 00:00:00 2001 From: Lucas Fischer Date: Fri, 19 Jun 2026 09:36:58 +0800 Subject: [PATCH 4/4] feat(licensing): resolve grace period synchronously at launch --- Sources/AmoreLicensing/AmoreLicensing.swift | 20 +++++-- .../AmoreClientTests.swift | 53 ++++++++++++++++++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/Sources/AmoreLicensing/AmoreLicensing.swift b/Sources/AmoreLicensing/AmoreLicensing.swift index 05384eb..edbbba5 100644 --- a/Sources/AmoreLicensing/AmoreLicensing.swift +++ b/Sources/AmoreLicensing/AmoreLicensing.swift @@ -151,8 +151,11 @@ public final class AmoreLicensing: Licensing { break case .decoded(let payload) where payload.exp > Date(): status = .valid(License(from: payload)) - case .decoded: - break + case .decoded(let payload): + // Expired, but maybe still within grace. Surface grace synchronously so + // an offline launch is authoritative; stay .unknown once grace has + // elapsed and let validate() ask the server, which may still renew it. + if let license = graceLicense(for: payload) { status = .gracePeriod(license) } } } @@ -187,10 +190,21 @@ public final class AmoreLicensing: Licensing { /// Enters the grace period derived from an already-verified, expired payload, /// or invalidates once that grace period has elapsed. private func applyGracePeriod(payload: LicensePayload) { + if let license = graceLicense(for: payload) { + status = .gracePeriod(license) + } else { + 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? { let graceEnd = payload.exp.addingTimeInterval(configuration.gracePeriod.timeInterval) + guard graceEnd >= .now else { return nil } var license = License(from: payload) license.expiresAt = graceEnd - status = graceEnd > Date() ? .gracePeriod(license) : .invalid + return license } private func mapClientErrors( diff --git a/Tests/AmoreLicensingTests/AmoreClientTests.swift b/Tests/AmoreLicensingTests/AmoreClientTests.swift index 9266869..904cfe8 100644 --- a/Tests/AmoreLicensingTests/AmoreClientTests.swift +++ b/Tests/AmoreLicensingTests/AmoreClientTests.swift @@ -333,7 +333,58 @@ import Testing return } } - + + /// An expired token that is still within grace must surface `.gracePeriod` + /// synchronously at launch, rather than reading `.unknown` until the async + /// 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( + privateKey: privateKey, hardwareId: hardwareId, nonce: "stored", exp: expDate + ) + try store.store(token) + + let client = try AmoreLicensing( + publicKey: publicKey.rawRepresentation.base64URLEncodedString(), + bundleIdentifier: bundleId, + server: unreachableServer(), + tokenStore: store + ) + + guard case .gracePeriod(let license) = client.status else { + Issue.record("Expected gracePeriod on first synchronous read, got \(client.status)") + return + } + let expectedEnd = expDate.addingTimeInterval(7 * 86_400) + #expect(abs(license.expiresAt!.timeIntervalSince(expectedEnd)) < 1) + } + + /// An expired token whose grace has already elapsed must stay `.unknown` at + /// launch, not synchronously `.invalid`: the server may still renew it, so the + /// 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", + exp: Date().addingTimeInterval(-10 * 24 * 3600) // expired beyond 7-day grace + ) + try store.store(token) + + let client = try AmoreLicensing( + publicKey: publicKey.rawRepresentation.base64URLEncodedString(), + bundleIdentifier: bundleId, + server: unreachableServer(), + tokenStore: store + ) + + #expect(client.status == .unknown) + } + @Test func validateValidStoredToken() async throws { let (privateKey, publicKey) = makeKeys() let store = MockTokenStore()