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
1 change: 1 addition & 0 deletions TCPViewer/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
}

private func presentLicenseSheet(presentationMode: TCPViewerLicensePresentationMode, sender: Any?) {
TCPViewerLicenseService.shared.refreshLicense()
// Reuse one sheet owner while allowing Trial and menu actions to open different license modes.
guard let parentWindow = licenseSheetParentWindow() ?? createLicenseSheetParentWindow() else {
NSApp.activate(ignoringOtherApps: true)
Expand Down
45 changes: 32 additions & 13 deletions TCPViewer/Features/License/Models/TCPViewerLicense.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ enum TCPViewerLicenseType: String, Codable {
case standardLicense = "standard_license"
case comboLicense = "combo_license"
case lifetimeLicense = "lifetime_license"
case teamLicense = "team_license"
}

struct TCPViewerLicense: Codable, Equatable {
private static let maximumOneYearUpdateWindowDays = 366
private static let maximumLegacyUpdateWindowDays = 366

private enum CodingKeys: String, CodingKey {
case signature
Expand All @@ -23,29 +24,42 @@ struct TCPViewerLicense: Codable, Equatable {
case purchaseAt
case expiryDate = "expiryAt"
case licenseType
case receipt, activationId, numberOfSeats, usedSeats
}

let signature: String
var signature: String
let deviceUUID: String
let email: String
let purchaseAt: String
var expiryDate: String
let licenseType: TCPViewerLicenseType
var receipt: TCPViewerLicenseReceipt?
var activationId: String?
var numberOfSeats: Int?
var usedSeats: Int?

init(
signature: String,
deviceUUID: String,
email: String,
purchaseAt: String,
expiryDate: String,
licenseType: TCPViewerLicenseType = .standardLicense
licenseType: TCPViewerLicenseType = .standardLicense,
receipt: TCPViewerLicenseReceipt? = nil,
activationId: String? = nil,
numberOfSeats: Int? = nil,
usedSeats: Int? = nil
) {
self.signature = signature
self.deviceUUID = deviceUUID
self.email = email
self.purchaseAt = purchaseAt
self.expiryDate = expiryDate
self.licenseType = licenseType
self.receipt = receipt
self.activationId = activationId
self.numberOfSeats = numberOfSeats
self.usedSeats = usedSeats
}

init(from decoder: Decoder) throws {
Expand All @@ -56,6 +70,10 @@ struct TCPViewerLicense: Codable, Equatable {
purchaseAt = try container.decode(String.self, forKey: .purchaseAt)
expiryDate = try container.decode(String.self, forKey: .expiryDate)
licenseType = try container.decodeIfPresent(TCPViewerLicenseType.self, forKey: .licenseType) ?? .standardLicense
receipt = try container.decodeIfPresent(TCPViewerLicenseReceipt.self, forKey: .receipt)
activationId = try container.decodeIfPresent(String.self, forKey: .activationId)
numberOfSeats = try container.decodeIfPresent(Int.self, forKey: .numberOfSeats)
usedSeats = try container.decodeIfPresent(Int.self, forKey: .usedSeats)
}

func encode(to encoder: Encoder) throws {
Expand All @@ -66,6 +84,10 @@ struct TCPViewerLicense: Codable, Equatable {
try container.encode(purchaseAt, forKey: .purchaseAt)
try container.encode(expiryDate, forKey: .expiryDate)
try container.encode(licenseType, forKey: .licenseType)
try container.encodeIfPresent(receipt, forKey: .receipt)
try container.encodeIfPresent(activationId, forKey: .activationId)
try container.encodeIfPresent(numberOfSeats, forKey: .numberOfSeats)
try container.encodeIfPresent(usedSeats, forKey: .usedSeats)
}

var remainingDays: Int? {
Expand All @@ -84,20 +106,17 @@ struct TCPViewerLicense: Codable, Equatable {
return remainingDays < 0
}

var hasOneYearUpdateWindow: Bool {
guard let updateWindowDays else {
return false
}

return (0...Self.maximumOneYearUpdateWindowDays).contains(updateWindowDays)
}

var hasLifetimeUpdates: Bool {
licenseType == .lifetimeLicense
}

var hasValidUpdateEntitlement: Bool {
hasLifetimeUpdates || hasOneYearUpdateWindow
var hasValidLegacyUpdateEntitlement: Bool {
guard licenseType == .standardLicense || licenseType == .comboLicense,
let updateWindowDays else {
return hasLifetimeUpdates
}

return (0...Self.maximumLegacyUpdateWindowDays).contains(updateWindowDays)
}

var formattedExpiryDate: String {
Expand Down
37 changes: 34 additions & 3 deletions TCPViewer/Features/License/Models/TCPViewerLicenseError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,53 @@ enum TCPViewerLicenseError: Error, Equatable, LocalizedError {
case expired
case couldNotGetDeviceUUID
case noInternetConnection
case verificationRequired
case offlineVerificationRequired
case clockChanged
case invalidReceipt
case deviceRevoked
case licenseDisabled
case appUpdateRequired
case temporaryFailure
case error(String)

var isTemporary: Bool {
switch self {
case .noInternetConnection, .temporaryFailure, .error: return true
default: return false
}
}

var errorDescription: String? {
switch self {
case .invalidLicense:
return "Invalid license key."
return "Check the license key in your purchase email. Contact support if you need help."
case .outOfSeats:
return "Your license has no available device seats."
return "All seats are occupied. Free a seat in License Manager, or add seats to your Team license."
case .renewalRequired:
return "This TCP Viewer build was released after your license update window. Your license is still valid for builds released before the update expiry date; download an older build or renew to use this build."
case .expired:
return "Your license is expired."
return "Updates do not cover this build. Covered releases remain usable; renew to use newer releases."
case .couldNotGetDeviceUUID:
return "Could not get this Mac's device identifier."
case .noInternetConnection:
return "No internet connection."
case .verificationRequired:
return "Connect to the internet to verify this license for this version of TCP Viewer."
case .offlineVerificationRequired:
return "Your Team license needs an online check every seven days. Reconnect and retry verification."
case .clockChanged:
return "Your Mac’s clock changed. Set the correct date and time, then retry verification online."
case .invalidReceipt:
return "The license receipt could not be verified. Reconnect and retry verification."
case .deviceRevoked:
return "This Mac was removed in License Manager. Activate the license again to use an available seat."
case .licenseDisabled:
return "This license has been disabled. Contact support for help."
case .appUpdateRequired:
return "Update TCP Viewer to activate this Team license."
case .temporaryFailure:
return "The license server is temporarily unavailable. Please retry shortly."
case .error(let message):
return message
}
Expand Down
83 changes: 83 additions & 0 deletions TCPViewer/Features/License/Models/TCPViewerLicenseReceipt.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// TCPViewerLicenseReceipt.swift
// TCPViewer
//
// Created by Proxyman LLC on 9/7/26.
//

import CryptoKit
import Foundation

struct TCPViewerLicenseReceipt: Codable, Equatable {
let version: Int
let keyId: String
let payload: String
let signature: String
}

struct TCPViewerLicenseReceiptClaims: Codable, Equatable {
let activationId: String
let activationTokenHash: String
let productID: String
let device_uuid: String
let licenseType: TCPViewerLicenseType
let email: String
let purchaseAt: String
let expiryAt: String
let numberOfSeats: Int
let usedSeats: Int
let buildNumber: String
let issuedAt: TimeInterval
let offlineUntil: TimeInterval?
}

struct TCPViewerLicenseReceiptVerifier {
let publicKeys: [String: Data]

init(publicKeys: [String: Data] = TCPViewerLicenseSigningKeys.publicKeys) {
self.publicKeys = publicKeys
}

// Authenticate the exact wire bytes before decoding; only signed fields become entitlements.
func verify(_ license: TCPViewerLicense, deviceMatches: (String) -> Bool,
buildNumber: String, now: Date) throws -> (TCPViewerLicense, TCPViewerLicenseReceiptClaims) {
guard let receipt = license.receipt else { throw TCPViewerLicenseError.verificationRequired }
guard receipt.version == 1, receipt.payload.count <= 16384,
let keyData = publicKeys[receipt.keyId],
let key = try? Curve25519.Signing.PublicKey(rawRepresentation: keyData),
let signature = Self.decodeBase64URL(receipt.signature),
let payload = Self.decodeBase64URL(receipt.payload),
key.isValidSignature(signature, for: Data("1.\(receipt.keyId).\(receipt.payload)".utf8)),
let claims = try? JSONDecoder().decode(TCPViewerLicenseReceiptClaims.self, from: payload) else {
throw TCPViewerLicenseError.invalidReceipt
}
let tokenHash = SHA256.hash(data: Data(license.signature.utf8)).map { String(format: "%02x", $0) }.joined()
guard claims.productID == "com.proxyman.TCPViewer",
!claims.activationId.isEmpty, claims.activationTokenHash == tokenHash,
deviceMatches(claims.device_uuid), claims.device_uuid == license.deviceUUID,
claims.numberOfSeats > 0, claims.usedSeats >= 0, claims.usedSeats <= claims.numberOfSeats,
let purchase = TCPViewerLicenseDateParser.date(from: claims.purchaseAt),
let expiry = TCPViewerLicenseDateParser.date(from: claims.expiryAt), expiry >= purchase,
claims.issuedAt > 0, claims.issuedAt <= now.timeIntervalSince1970 + 300 else {
throw TCPViewerLicenseError.invalidReceipt
}
guard claims.buildNumber == buildNumber else { throw TCPViewerLicenseError.verificationRequired }
if claims.licenseType == .teamLicense {
guard claims.numberOfSeats >= 5, let deadline = claims.offlineUntil,
deadline == claims.issuedAt + 7 * 86400 else { throw TCPViewerLicenseError.invalidReceipt }
guard now.timeIntervalSince1970 < deadline else { throw TCPViewerLicenseError.offlineVerificationRequired }
} else if claims.offlineUntil != nil {
throw TCPViewerLicenseError.invalidReceipt
}
let authenticated = TCPViewerLicense(signature: license.signature, deviceUUID: claims.device_uuid,
email: claims.email, purchaseAt: claims.purchaseAt, expiryDate: claims.expiryAt,
licenseType: claims.licenseType, receipt: receipt, activationId: claims.activationId,
numberOfSeats: claims.numberOfSeats, usedSeats: claims.usedSeats)
return (authenticated, claims)
}

static func decodeBase64URL(_ value: String) -> Data? {
let base64 = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
return Data(base64Encoded: base64 + String(repeating: "=", count: (4 - base64.count % 4) % 4))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ final class TCPViewerLicenseNetworkClient: TCPViewerLicenseNetworkClienting {
"deviceUuid": deviceUUID,
"licenseKey": licenseKey,
"platform": "macos",
"receiptVersion": 1,
"buildNumber": buildNumber,
"appVersion": appVersion,
"osVersion": osVersion,
Expand All @@ -145,6 +146,7 @@ final class TCPViewerLicenseNetworkClient: TCPViewerLicenseNetworkClienting {
"buildNumber": buildNumber,
"signature": license.signature,
"platform": "macos",
"receiptVersion": 1,
"deviceUuid": deviceUUID,
"appVersion": appVersion,
"osVersion": osVersion,
Expand Down Expand Up @@ -214,6 +216,8 @@ final class TCPViewerLicenseNetworkClient: TCPViewerLicenseNetworkClienting {
} catch {
completion(.failure(.error(error.localizedDescription)))
}
case 429, 500...599:
completion(.failure(.temporaryFailure))
default:
completion(.failure(Self.mapServerError(from: data)))
}
Expand All @@ -231,6 +235,8 @@ final class TCPViewerLicenseNetworkClient: TCPViewerLicenseNetworkClienting {
}
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = 30
request.cachePolicy = .reloadIgnoringLocalCacheData
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
return request
Expand All @@ -255,6 +261,19 @@ final class TCPViewerLicenseNetworkClient: TCPViewerLicenseNetworkClienting {
}

private static func mapServerError(from data: Data?) -> TCPViewerLicenseError {
if let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let code = json["code"] as? String {
switch code {
case "out_of_seats": return .outOfSeats
case "renewal_required": return .renewalRequired
case "device_revoked": return .deviceRevoked
case "license_disabled": return .licenseDisabled
case "invalid_license", "invalid_activation": return .invalidLicense
case "app_update_required": return .appUpdateRequired
case "rate_limited", "temporary_failure", "release_unavailable": return .temporaryFailure
default: break
}
}
guard let message = serverErrorMessage(from: data) else {
return .error("Unknown license server error.")
}
Expand Down
Loading