Skip to content
Closed
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
6 changes: 5 additions & 1 deletion Clipy/Sources/Managers/MenuManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4122,6 +4122,7 @@ class BoardManPanel: NSPanel {
switch plan {
case .free: return "Free"
case .pro: return "Pro"
case .ownerLifetime: return "Owner Lifetime"
}
}

Expand All @@ -4130,6 +4131,7 @@ class BoardManPanel: NSPanel {
case .free: return "Free"
case .trial: return "Trial"
case .proActive: return "Pro Active"
case .ownerLifetime: return "Owner Lifetime"
case .proExpired: return "Expired"
case .invalid: return "Invalid"
case .offlineGrace: return "Offline Grace"
Expand All @@ -4155,6 +4157,8 @@ class BoardManPanel: NSPanel {
return "Temporary Pro access\(dateSuffix(snapshot.expiresAt, prefix: " until "))."
case .proActive:
return "Verified Pro entitlement\(dateSuffix(snapshot.lastVerifiedAt, prefix: ", checked "))."
case .ownerLifetime:
return "Verified owner lifetime entitlement\(dateSuffix(snapshot.lastVerifiedAt, prefix: ", checked "))."
case .proExpired:
return "Pro entitlement is no longer active."
case .invalid:
Expand All @@ -4168,7 +4172,7 @@ class BoardManPanel: NSPanel {

private func licenseStateColor(_ state: LicenseState) -> NSColor {
switch state {
case .proActive: return .systemGreen
case .proActive, .ownerLifetime: return .systemGreen
case .trial, .offlineGrace: return .systemOrange
case .invalid, .proExpired, .locked: return .systemRed
case .free: return .secondaryLabelColor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,19 @@ final class CPYBetaPreferenceViewController: NSViewController {
private func refreshPlanState() {
let snapshot = EntitlementGate.currentSnapshot()
let active = snapshot.isProEntitled
planLabel.stringValue = active ? "Board-Man Pro" : "Free Plan"
statusPill.stringValue = active ? "Active" : "Free"
let isOwnerLifetime = snapshot.licenseState == .ownerLifetime && snapshot.plan == .ownerLifetime
let planName = isOwnerLifetime ? "Owner Lifetime" : (active ? "Board-Man Pro" : "Free Plan")
let statusName = isOwnerLifetime ? "Lifetime" : (active ? "Active" : "Free")
planLabel.stringValue = planName
statusPill.stringValue = statusName
statusPill.layer?.backgroundColor = (active ? BoardManPreferenceUI.redSoft : BoardManPreferenceUI.card).cgColor
currentPlanValue.stringValue = active ? "Board-Man Pro" : "Free Plan"
statusValue.stringValue = active ? "Active" : "Free"
currentPlanValue.stringValue = planName
statusValue.stringValue = statusName
lastVerifiedValue.stringValue = active ? "Verified" : "Not verified / Offline"
lastVerifiedValue.textColor = active ? .systemGreen : BoardManPreferenceUI.red
deviceStatus.stringValue = active ? "Activated" : "Not activated"
deviceStatus.textColor = active ? .systemGreen : BoardManPreferenceUI.secondaryText
validationLabel.stringValue = active ? "Activated" : "Not connected yet"
validationLabel.stringValue = isOwnerLifetime ? "Signed owner token required" : (active ? "Activated" : "Not connected yet")
validationLabel.textColor = active ? .systemGreen : BoardManPreferenceUI.secondaryText
}

Expand Down
40 changes: 29 additions & 11 deletions Clipy/Sources/Services/Entitlement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum LicenseState: String, Equatable {
case free
case trial
case proActive
case ownerLifetime
case proExpired
case invalid
case offlineGrace
Expand All @@ -19,6 +20,7 @@ enum LicenseState: String, Equatable {
enum Plan: String, Equatable {
case free
case pro
case ownerLifetime
}

typealias EntitlementPlan = Plan
Expand Down Expand Up @@ -80,6 +82,24 @@ struct LicenseMetadata: Equatable {
let activatedAt: Date?
let lastVerifiedAt: Date?
let status: String
let licenseKind: LicenseKind?
let issuedTo: String?

init(licenseKeyMasked: String?,
deviceIdMasked: String?,
activatedAt: Date?,
lastVerifiedAt: Date?,
status: String,
licenseKind: LicenseKind? = nil,
issuedTo: String? = nil) {
self.licenseKeyMasked = licenseKeyMasked
self.deviceIdMasked = deviceIdMasked
self.activatedAt = activatedAt
self.lastVerifiedAt = lastVerifiedAt
self.status = status
self.licenseKind = licenseKind
self.issuedTo = issuedTo
}
}

struct Entitlement: Equatable {
Expand Down Expand Up @@ -154,6 +174,8 @@ struct Entitlement: Equatable {
switch licenseState {
case .trial, .proActive:
return plan == .pro
case .ownerLifetime:
return plan == .ownerLifetime
case .free, .proExpired, .invalid, .offlineGrace, .locked:
return false
}
Expand Down Expand Up @@ -193,15 +215,14 @@ struct Entitlement: Equatable {
)
}

static func founderLifetime(activatedAt: Date = Date()) -> Entitlement {
let metadata = LicenseMetadata(
licenseKeyMasked: "internal-founder-lifetime",
deviceIdMasked: nil,
activatedAt: activatedAt,
lastVerifiedAt: activatedAt,
status: LicenseState.proActive.rawValue
static func ownerLifetime(metadata: LicenseMetadata) -> Entitlement {
return Entitlement(
plan: .ownerLifetime,
licenseState: .ownerLifetime,
features: Set(EntitlementFeature.allCases),
limits: .proDefault,
licenseMetadata: metadata
)
return .proActive(metadata: metadata)
}
}

Expand Down Expand Up @@ -232,9 +253,6 @@ final class EntitlementService {
self.snapshot = snapshot
}

func activateFounderLifetime(activatedAt: Date = Date()) {
replaceSnapshot(.founderLifetime(activatedAt: activatedAt))
}
}

enum EntitlementGate {
Expand Down
84 changes: 2 additions & 82 deletions Clipy/Sources/Services/LicenseActivationClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
//

import Foundation
import CryptoKit
import Security

struct LicenseActivationRequest: Equatable {
let licenseKey: String
Expand Down Expand Up @@ -45,87 +43,9 @@ final class StubLicenseActivationClient: LicenseActivationClient {
)
}

if LocalFounderLicenseStore.shared.activateIfFounderCode(request.licenseKey) {
EntitlementService.shared.activateFounderLifetime()
return LicenseActivationResponse(
status: .activated,
message: "Founder Lifetime activated locally."
)
}

return LicenseActivationResponse(
status: .unsupported,
message: "Unknown code. Production license activation still requires a signed token backend."
status: .notConfigured,
message: "Activation requires future signed token verification and is not enabled in this build."
)
}
}

final class LocalFounderLicenseStore {

static let shared = LocalFounderLicenseStore()

private enum Keychain {
static let service = "com.uniplanck.BoardMan.FounderLicense"
static let account = "founderLifetimeActivation"
}

private static let founderCodeSHA256 = "65ad70f678859f024d41b9594298d6f25da87ce06b80318f329052d7648a56dd"

func entitlementSnapshot() -> EntitlementSnapshot? {
guard let activatedAt = activationDate() else { return nil }
return .founderLifetime(activatedAt: activatedAt)
}

func activateIfFounderCode(_ code: String) -> Bool {
guard sha256Hex(code.trimmingCharacters(in: .whitespacesAndNewlines)) == Self.founderCodeSHA256 else {
return false
}
return storeActivation(Date())
}

private func activationDate() -> Date? {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne

var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess,
let data = item as? Data,
let value = String(data: data, encoding: .utf8),
let seconds = TimeInterval(value) else {
return nil
}
return Date(timeIntervalSince1970: seconds)
}

private func storeActivation(_ date: Date) -> Bool {
let value = String(date.timeIntervalSince1970)
guard let data = value.data(using: .utf8) else { return false }

var addQuery = baseQuery()
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly

let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus == errSecSuccess { return true }
if addStatus == errSecDuplicateItem {
let attributes = [kSecValueData as String: data]
return SecItemUpdate(baseQuery() as CFDictionary, attributes as CFDictionary) == errSecSuccess
}
return false
}

private func baseQuery() -> [String: Any] {
return [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Keychain.service,
kSecAttrAccount as String: Keychain.account
]
}

private func sha256Hex(_ value: String) -> String {
let digest = SHA256.hash(data: Data(value.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
}
54 changes: 51 additions & 3 deletions Clipy/Sources/Services/LicenseToken.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

import Foundation

enum LicenseKind: String, Equatable {
case free
case pro
case ownerLifetime
}

struct SignedLicenseToken: Equatable {

struct Header: Equatable {
Expand All @@ -16,12 +22,16 @@ struct SignedLicenseToken: Equatable {

struct Payload: Equatable {
let licenseID: String
let licenseKind: LicenseKind
let plan: EntitlementPlan
let state: LicenseState
let features: Set<EntitlementFeature>
let limits: EntitlementLimits
let issuedTo: String?
let subject: String?
let issuedAt: Date?
let expiresAt: Date?
let isLifetime: Bool
let deviceID: String?
let bundleID: String?
let tokenVersion: Int?
Expand All @@ -33,7 +43,9 @@ struct SignedLicenseToken: Equatable {
deviceIdMasked: deviceID.map(Self.masked),
activatedAt: issuedAt,
lastVerifiedAt: lastVerifiedAt,
status: state.rawValue
status: state.rawValue,
licenseKind: licenseKind,
issuedTo: issuedTo ?? subject
)
return EntitlementSnapshot(
plan: plan,
Expand Down Expand Up @@ -89,10 +101,15 @@ struct SignedLicenseToken: Equatable {
guard let algorithm = headerDTO.alg,
let licenseID = payloadDTO.licenseID,
let planRawValue = payloadDTO.plan,
let stateRawValue = payloadDTO.state else {
let stateRawValue = payloadDTO.state,
let licenseKindRawValue = payloadDTO.licenseKind ?? payloadDTO.plan else {
throw ParseError.missingRequiredClaim
}

guard let licenseKind = LicenseKind(rawValue: licenseKindRawValue) else {
throw ParseError.unknownPlan(licenseKindRawValue)
}

guard let plan = EntitlementPlan(rawValue: planRawValue) else {
throw ParseError.unknownPlan(planRawValue)
}
Expand All @@ -108,12 +125,16 @@ struct SignedLicenseToken: Equatable {
self.header = Header(algorithm: algorithm, keyID: headerDTO.kid, type: headerDTO.typ)
self.payload = Payload(
licenseID: licenseID,
licenseKind: licenseKind,
plan: plan,
state: state,
features: Set((payloadDTO.features ?? []).compactMap(EntitlementFeature.init(rawValue:))),
limits: payloadDTO.limits?.entitlementLimits ?? (plan.isUnlimited ? .proDefault : .freeDefault),
issuedTo: payloadDTO.issuedTo,
subject: payloadDTO.subject,
issuedAt: payloadDTO.iat.flatMap(Date.init(licenseClaimValue:)),
expiresAt: payloadDTO.exp.flatMap(Date.init(licenseClaimValue:)),
isLifetime: payloadDTO.isLifetime ?? (state == .ownerLifetime),
deviceID: payloadDTO.deviceID,
bundleID: payloadDTO.bundleID,
tokenVersion: payloadDTO.tokenVersion
Expand All @@ -129,24 +150,32 @@ private struct HeaderDTO: Decodable {

private struct PayloadDTO: Decodable {
let licenseID: String?
let licenseKind: String?
let plan: String?
let state: String?
let features: [String]?
let limits: LimitsDTO?
let issuedTo: String?
let subject: String?
let iat: LicenseClaimDate?
let exp: LicenseClaimDate?
let isLifetime: Bool?
let deviceID: String?
let bundleID: String?
let tokenVersion: Int?

enum CodingKeys: String, CodingKey {
case licenseID = "license_id"
case licenseKind = "license_kind"
case plan
case state
case features
case limits
case issuedTo = "issued_to"
case subject = "sub"
case iat
case exp
case isLifetime = "is_lifetime"
case deviceID = "device_id"
case bundleID = "bundle_id"
case tokenVersion = "token_version"
Expand Down Expand Up @@ -178,7 +207,26 @@ private struct LimitsDTO: Decodable {

private extension EntitlementPlan {
var isUnlimited: Bool {
return self == .pro
return self == .pro || self == .ownerLifetime
}
}

protocol LicenseTokenStoring {
func loadSignedLicenseToken() -> String?
func storeVerifiedSignedLicenseToken(_ token: SignedLicenseToken) throws
}

enum LicenseTokenStorageError: Error, Equatable {
case notImplemented
}

final class FutureKeychainLicenseTokenStore: LicenseTokenStoring {
func loadSignedLicenseToken() -> String? {
return nil
}

func storeVerifiedSignedLicenseToken(_ token: SignedLicenseToken) throws {
throw LicenseTokenStorageError.notImplemented
}
}

Expand Down
Loading
Loading