From 19e753705925e291724d80de11e1f7b8b310e4a2 Mon Sep 17 00:00:00 2001 From: uniplanck <198168437+uniplanck@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:17:36 +0900 Subject: [PATCH] Add Owner Lifetime license foundation --- Clipy/Sources/Managers/MenuManager.swift | 6 +- .../CPYBetaPreferenceViewController.swift | 13 +-- Clipy/Sources/Services/Entitlement.swift | 40 ++++++--- .../Services/LicenseActivationClient.swift | 84 +------------------ Clipy/Sources/Services/LicenseToken.swift | 54 +++++++++++- ClipyTests/EntitlementGateTests.swift | 28 +++++++ docs/boardman-owner-license-foundation.md | 39 +++++++++ 7 files changed, 162 insertions(+), 102 deletions(-) create mode 100644 docs/boardman-owner-license-foundation.md diff --git a/Clipy/Sources/Managers/MenuManager.swift b/Clipy/Sources/Managers/MenuManager.swift index bb73b4c..c812faf 100644 --- a/Clipy/Sources/Managers/MenuManager.swift +++ b/Clipy/Sources/Managers/MenuManager.swift @@ -4122,6 +4122,7 @@ class BoardManPanel: NSPanel { switch plan { case .free: return "Free" case .pro: return "Pro" + case .ownerLifetime: return "Owner Lifetime" } } @@ -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" @@ -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: @@ -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 diff --git a/Clipy/Sources/Preferences/Panels/CPYBetaPreferenceViewController.swift b/Clipy/Sources/Preferences/Panels/CPYBetaPreferenceViewController.swift index c398a1e..671939c 100644 --- a/Clipy/Sources/Preferences/Panels/CPYBetaPreferenceViewController.swift +++ b/Clipy/Sources/Preferences/Panels/CPYBetaPreferenceViewController.swift @@ -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 } diff --git a/Clipy/Sources/Services/Entitlement.swift b/Clipy/Sources/Services/Entitlement.swift index 7b689d8..d7d1ffc 100644 --- a/Clipy/Sources/Services/Entitlement.swift +++ b/Clipy/Sources/Services/Entitlement.swift @@ -10,6 +10,7 @@ enum LicenseState: String, Equatable { case free case trial case proActive + case ownerLifetime case proExpired case invalid case offlineGrace @@ -19,6 +20,7 @@ enum LicenseState: String, Equatable { enum Plan: String, Equatable { case free case pro + case ownerLifetime } typealias EntitlementPlan = Plan @@ -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 { @@ -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 } @@ -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) } } @@ -232,9 +253,6 @@ final class EntitlementService { self.snapshot = snapshot } - func activateFounderLifetime(activatedAt: Date = Date()) { - replaceSnapshot(.founderLifetime(activatedAt: activatedAt)) - } } enum EntitlementGate { diff --git a/Clipy/Sources/Services/LicenseActivationClient.swift b/Clipy/Sources/Services/LicenseActivationClient.swift index 910fb10..904e546 100644 --- a/Clipy/Sources/Services/LicenseActivationClient.swift +++ b/Clipy/Sources/Services/LicenseActivationClient.swift @@ -5,8 +5,6 @@ // import Foundation -import CryptoKit -import Security struct LicenseActivationRequest: Equatable { let licenseKey: String @@ -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() - } -} diff --git a/Clipy/Sources/Services/LicenseToken.swift b/Clipy/Sources/Services/LicenseToken.swift index c9bf816..acd3a53 100644 --- a/Clipy/Sources/Services/LicenseToken.swift +++ b/Clipy/Sources/Services/LicenseToken.swift @@ -6,6 +6,12 @@ import Foundation +enum LicenseKind: String, Equatable { + case free + case pro + case ownerLifetime +} + struct SignedLicenseToken: Equatable { struct Header: Equatable { @@ -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 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? @@ -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, @@ -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) } @@ -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 @@ -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" @@ -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 } } diff --git a/ClipyTests/EntitlementGateTests.swift b/ClipyTests/EntitlementGateTests.swift index 71b1847..3918910 100644 --- a/ClipyTests/EntitlementGateTests.swift +++ b/ClipyTests/EntitlementGateTests.swift @@ -36,6 +36,34 @@ final class EntitlementGateTests { #expect(EntitlementGate.limit(for: .snippetFolders, service: service) == nil) } + @Test + func ownerLifetimeEntitlementUnlocksThroughCentralGate() { + let metadata = LicenseMetadata( + licenseKeyMasked: "owner-token-placeholder", + deviceIdMasked: "****ABCD", + activatedAt: Date(timeIntervalSince1970: 1_700_000_000), + lastVerifiedAt: Date(timeIntervalSince1970: 1_700_000_000), + status: LicenseState.ownerLifetime.rawValue, + licenseKind: .ownerLifetime, + issuedTo: "developer-owner" + ) + let entitlement = EntitlementSnapshot.ownerLifetime(metadata: metadata) + let service = EntitlementService(snapshot: entitlement) + + #expect(entitlement.plan == .ownerLifetime) + #expect(entitlement.licenseState == .ownerLifetime) + #expect(entitlement.isProEntitled) + + for feature in EntitlementFeature.allCases { + #expect(EntitlementGate.canUse(feature: feature, service: service)) + } + + #expect(EntitlementGate.limit(for: .historyItems, service: service) == nil) + #expect(EntitlementGate.limit(for: .pinnedItems, service: service) == nil) + #expect(EntitlementGate.limit(for: .snippetItems, service: service) == nil) + #expect(EntitlementGate.limit(for: .snippetFolders, service: service) == nil) + } + @Test func freeHistoryLimitIsOneHundred() { let service = EntitlementService(snapshot: .freeDefault) diff --git a/docs/boardman-owner-license-foundation.md b/docs/boardman-owner-license-foundation.md new file mode 100644 index 0000000..301ff15 --- /dev/null +++ b/docs/boardman-owner-license-foundation.md @@ -0,0 +1,39 @@ +# Board-Man Owner Lifetime License Foundation + +This foundation adds structural support for an Owner Lifetime entitlement without shipping a usable owner bypass. + +## Safe Future Flow + +1. A trusted issuer creates a signed owner token outside the app. +2. The app verifies that token locally with a bundled public key. +3. Verification checks the token kind, subject, issue time, lifetime flag, bundle id, and device binding. +4. Only a verified token is stored in Keychain. +5. The central entitlement model maps the verified token to `ownerLifetime`. +6. `EntitlementGate` treats `ownerLifetime` as Pro-or-better. + +## Token Shape + +Future owner tokens should include: + +- license kind or plan, such as `ownerLifetime` +- issued subject, such as `issued_to` or `sub` +- device binding claim, such as `device_id` +- issued timestamp, such as `iat` +- nullable expiry or explicit lifetime flag, such as `exp` and `is_lifetime` +- token version and key id for rotation + +The app must not include private signing keys or hardcoded valid owner tokens. + +## Storage + +Owner token storage remains a Keychain-only future step. UserDefaults, local JSON, or plain text files must not become entitlement sources. + +## Forbidden Shortcuts + +- `UserDefaults isPro` +- email match +- device name match +- hardcoded admin key +- plain local JSON `owner=true` +- debug override that grants Pro in normal builds +- unsigned local token that unlocks Pro