diff --git a/NotchIA.xcodeproj/project.pbxproj b/NotchIA.xcodeproj/project.pbxproj index 92a695a..1b47fb1 100644 --- a/NotchIA.xcodeproj/project.pbxproj +++ b/NotchIA.xcodeproj/project.pbxproj @@ -1277,7 +1277,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20802; + CURRENT_PROJECT_VERSION = 20803; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1285,7 +1285,7 @@ INFOPLIST_KEY_CFBundleDisplayName = NotchIAXPCHelper; INFOPLIST_KEY_NSHumanReadableCopyright = ""; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.8.2; + MARKETING_VERSION = 2.8.3; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia.NotchIAXPCHelper; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -1303,7 +1303,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20802; + CURRENT_PROJECT_VERSION = 20803; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1311,7 +1311,7 @@ INFOPLIST_KEY_CFBundleDisplayName = NotchIAXPCHelper; INFOPLIST_KEY_NSHumanReadableCopyright = ""; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.8.2; + MARKETING_VERSION = 2.8.3; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia.NotchIAXPCHelper; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -1462,7 +1462,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20802; + CURRENT_PROJECT_VERSION = 20803; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"NotchIA/Preview Content\""; DEVELOPMENT_TEAM = ""; @@ -1490,7 +1490,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.8.2; + MARKETING_VERSION = 2.8.3; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1518,7 +1518,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 20802; + CURRENT_PROJECT_VERSION = 20803; DEAD_CODE_STRIPPING = YES; DEPLOYMENT_POSTPROCESSING = YES; DEVELOPMENT_ASSET_PATHS = "\"NotchIA/Preview Content\""; @@ -1546,7 +1546,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 2.8.2; + MARKETING_VERSION = 2.8.3; PRODUCT_BUNDLE_IDENTIFIER = com.coaxel2.notchia; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/NotchIA/Localizable.xcstrings b/NotchIA/Localizable.xcstrings index 9811dd6..881f7dd 100644 --- a/NotchIA/Localizable.xcstrings +++ b/NotchIA/Localizable.xcstrings @@ -42695,7 +42695,119 @@ } } }, - "Σ": {} + "Σ": {}, + "Cette clé est expirée.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This key has expired." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette clé est expirée." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta clave ha expirado." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Schlüssel ist abgelaufen." + } + } + } + }, + "Signature de la clé invalide.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid key signature." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Signature de la clé invalide." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Firma de clave no válida." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültige Schlüsselsignatur." + } + } + } + }, + "La signature de la clé est invalide.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The key signature is invalid." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La signature de la clé est invalide." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La firma de la clave no es válida." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Schlüsselsignatur ist ungültig." + } + } + } + }, + "Vérification du statut de la licence.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking license status." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification du statut de la licence." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Verificando el estado de la licencia." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lizenzstatus wird überprüft." + } + } + } + } }, "version": "1.0" } diff --git a/NotchIA/NotchIAApp.swift b/NotchIA/NotchIAApp.swift index 5aec4b6..0154ae6 100644 --- a/NotchIA/NotchIAApp.swift +++ b/NotchIA/NotchIAApp.swift @@ -151,7 +151,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { case .machineLimit(let limit, _): return String(localized: "Tu as atteint la limite de \(limit) Mac sur cette licence. Désactive un appareil dans Réglages → Licence.") case .signatureInvalid: - return String(localized: "La réponse du serveur n'a pas pu être vérifiée. Réessaie plus tard.") + return String(localized: "La signature de la clé est invalide.") + case .expired: + return String(localized: "Cette clé est expirée.") } } diff --git a/NotchIA/components/Settings/LicenseSettings.swift b/NotchIA/components/Settings/LicenseSettings.swift index 17c8543..d125cb7 100644 --- a/NotchIA/components/Settings/LicenseSettings.swift +++ b/NotchIA/components/Settings/LicenseSettings.swift @@ -123,7 +123,6 @@ struct LicenseSettings: View { private var statusIcon: String { switch license.state { case .proActive: return "checkmark.seal.fill" - case .proGrace: return "clock.fill" case .proExpired: return "calendar.badge.exclamationmark" case .proRevoked: return "xmark.shield.fill" case .proInvalidKey: return "questionmark.diamond.fill" @@ -135,7 +134,6 @@ struct LicenseSettings: View { private var statusColor: Color { switch license.state { case .proActive: return .green - case .proGrace: return .orange case .proExpired, .proRevoked, .proInvalidKey: return .red default: return .secondary } @@ -145,8 +143,6 @@ struct LicenseSettings: View { switch license.state { case .proActive(let tier, _): return tier == .lifetime ? String(localized: "Pro à vie — actif") : "Pro mensuel — actif" - case .proGrace(_, _, let days): - return String(localized: "Pro — période de grâce (\(days) j)") case .proExpired: return String(localized: "Abonnement expiré") case .proRevoked: @@ -169,8 +165,6 @@ struct LicenseSettings: View { return String(localized: "Renouvellement le \(formattedDate(exp))") } return String(localized: "Aucune date d'expiration.") - case .proGrace: - return String(localized: "Connexion impossible. Vérifie ta connexion ou tu perdras l'accès Pro à la fin de la grâce.") case .proExpired: return String(localized: "Réactive un abonnement pour retrouver l'accès aux fonctionnalités Pro.") case .proRevoked: @@ -178,7 +172,7 @@ struct LicenseSettings: View { case .proInvalidKey: return String(localized: "La clé saisie n'a pas été reconnue. Vérifie la saisie ou récupère-la par email.") case .checking: - return String(localized: "Connexion au serveur de licence.") + return String(localized: "Vérification du statut de la licence.") case .free: return String(localized: "Claude Code, Codex et Shelf nécessitent NotchIA Pro.") case .unknown: @@ -221,7 +215,8 @@ struct LicenseSettings: View { case .revoked: return String(localized: "Cette licence a été révoquée.") case .machineLimit(let limit, _): return String(localized: "Limite de \(limit) Mac atteinte sur cette licence.") - case .signatureInvalid: return String(localized: "Réponse serveur invalide. Réessaie plus tard.") + case .signatureInvalid: return String(localized: "Signature de la clé invalide.") + case .expired: return String(localized: "Cette clé est expirée.") } } diff --git a/NotchIA/managers/LicenseManager.swift b/NotchIA/managers/LicenseManager.swift index f3b93d1..e30ce23 100644 --- a/NotchIA/managers/LicenseManager.swift +++ b/NotchIA/managers/LicenseManager.swift @@ -4,13 +4,15 @@ // // Source of truth for the user's NotchIA Pro entitlement. // -// Flow: -// - License key is stored in the Keychain (kSecClassGenericPassword). -// - At launch + every 24h we POST the key + a stable machine id to /v1/license/verify. -// - The server returns an Ed25519-signed payload. We verify the signature with the embedded -// public key, then trust the payload offline for VERIFY_TTL_SECONDS. -// - When offline beyond TTL, we enter a 14-day grace period (still Pro) before reverting to -// the Free tier. This protects users from short outages without indefinite piracy. +// Architecture (v2.8.3+) : self-contained signed token. +// - La clé reçue par email est de la forme `nia_live_.`. +// - Le payload JSON contient { v, sub, plan, iat, exp?, jti, max }. +// - La signature Ed25519 porte sur les OCTETS du payload (pas la base64 string). +// - L'app vérifie la signature 100% offline avec la clé publique embarquée. +// - Le serveur (https://notchia.app/api/license/validate) n'intervient que pour +// la révocation périodique (best-effort, 1×/semaine + au lancement si >7j). +// - Si pas de réseau : on garde le Pro. La signature offline fait foi. +// - La clé `nia_admin_…` reste un bypass dev local (SHA-256 du body). // import Foundation @@ -21,10 +23,8 @@ import Security import os // macOS ptrace request value to deny attachment of debuggers (lldb, etc.) to this process. -// Documented but private — Apple doesn't ship it in a header. private let PT_DENY_ATTACH: Int32 = 31 -// Imported via @_silgen_name because Foundation does not re-export ptrace. @_silgen_name("ptrace") private func _ptrace( _ request: Int32, @@ -38,28 +38,29 @@ final class LicenseManager: ObservableObject { static let shared = LicenseManager() // MARK: Configuration - /// Base URL of the licensing backend (Cloudflare Worker). - static let apiBase = URL(string: "https://notchia-license.axel-courty.workers.dev")! - /// Ed25519 public key (hex, 32 bytes / 64 chars). Must match the private key set as a - /// Worker secret. Generated by scripts/generate-keys.ts in notchia-license. - static let verifyPublicKeyHex = "7f20486562fe7e102aed2e4f0f3249cb60691fb28598c9bead39ee8724a1314b" + /// Clé publique Ed25519 utilisée pour vérifier la signature des tokens + /// `nia_live_…`. DOIT correspondre à la clé privée détenue par le backend + /// (notchia.app) qui signe les payloads. 32 octets, hex. + static let verifyPublicKeyHex = "2f0af78c529aa333ccbe593ada8c5e791dce8838aba96273a7479c2376bb8c8a" - /// SHA-256 hex of an admin/master license key. When the user enters a key whose hash - /// matches this, the app grants Pro lifetime access **without contacting the server**. - /// This is a dev/personal escape hatch: works offline, doesn't depend on the backend - /// being deployed, and never leaks the key to any log or server. Set to "" to disable. + /// SHA-256 hex de la clé admin (master). Activée localement sans appel + /// réseau ni vérif de signature — bypass dev / personnel. Set à "" pour + /// désactiver. private static let adminKeySha256Hex = "8b2e90af1908ea5031492f1d4ce113863f64a165f19e11fd8155dff34d05d737" - private static let verifyInterval: TimeInterval = 24 * 3_600 - private static let cacheTTL: TimeInterval = 24 * 3_600 - private static let gracePeriodSeconds: TimeInterval = 14 * 24 * 3_600 + /// Endpoint de révocation. Appelé périodiquement pour détecter les + /// remboursements / annulations / limites d'appareils. Pas utilisé à + /// l'activation (la vérif signature locale suffit). + static let revocationURL = URL(string: "https://notchia.app/api/license/validate")! + + /// Intervalle entre 2 checks de révocation (1 semaine). + private static let revocationInterval: TimeInterval = 7 * 24 * 3_600 private static let keychainService = "app.notchia.license" private static let keyAccount = "license_key" private static let machineIdAccount = "machine_id" - - private static let cacheDefaultsKey = "license.cache.v1" + private static let lastRevocationCheckKey = "license.revocation.lastCheck.v1" // MARK: State enum Tier: String, Codable { case monthly, lifetime } @@ -68,7 +69,6 @@ final class LicenseManager: ObservableObject { case unknown case free case proActive(tier: Tier, expiresAt: Date?) - case proGrace(tier: Tier, expiresAt: Date?, daysLeft: Int) case proExpired case proRevoked case proInvalidKey @@ -76,7 +76,7 @@ final class LicenseManager: ObservableObject { var isPro: Bool { switch self { - case .proActive, .proGrace: return true + case .proActive: return true default: return false } } @@ -88,10 +88,12 @@ final class LicenseManager: ObservableObject { @Published private(set) var state: State = .unknown @Published private(set) var lastVerifiedAt: Date? + /// Email du payload une fois la clé activée. Affiché dans l'UI Réglages. + @Published private(set) var subscriberEmail: String? // MARK: Internals private let logger = Logger(subsystem: "app.notchia", category: "License") - private var verifyTimer: Timer? + private var revocationTimer: Timer? private var inFlight: Task? private let session: URLSession = { let cfg = URLSessionConfiguration.ephemeral @@ -103,22 +105,18 @@ final class LicenseManager: ObservableObject { private init() {} - /// Call from app startup. Loads the key from Keychain, applies cached state immediately, - /// then kicks off an online verification. + /// Appelé au lancement de l'app. Charge la clé du Keychain, applique l'état + /// vérifié localement (signature Ed25519), puis lance un check de + /// révocation si > 7 jours depuis le dernier. func bootstrap() { - // Anti-debug: refuse debugger attachment in Release builds. A determined attacker can - // bypass this with kernel patches, but it stops trivial `lldb -p $(pgrep NotchIA)`. - // No-op in Debug so Xcode can still attach during development. + // Anti-debug : refuse l'attachement de débogueur en Release. #if !DEBUG _ = _ptrace(PT_DENY_ATTACH, 0, nil, 0) #endif - applyCachedStateIfPossible() - // Defer the first online check so we don't block launch. - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.refreshNow() - } - startTimer() + applyLocalStateFromKeychain() + scheduleRevocationCheckIfDue() + startRevocationTimer() } // MARK: - Public API @@ -130,43 +128,96 @@ final class LicenseManager: ObservableObject { case revoked case machineLimit(limit: Int, registered: Int) case signatureInvalid + case expired } - /// Validates and stores a license key, then verifies online. + /// Valide et stocke une clé. Vérification 100% locale (Ed25519 sur le + /// payload du token), aucun appel réseau requis à l'activation. @discardableResult func activate(key rawKey: String) async -> Result { let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard isPlausibleKey(key) else { return .failure(.invalidFormat) } - guard saveKeyToKeychain(key) else { - logger.error("Keychain save failed") - return .failure(.server(reason: "keychain")) - } - - // Admin/master key: short-circuit, no network call, no DB. + // Admin key : bypass local, SHA-256 match. if isAdminKey(key) { + guard saveKeyToKeychain(key) else { + logger.error("Keychain save failed") + return .failure(.server(reason: "keychain")) + } applyAdminBypass() return .success(()) } - state = .checking + // Token `nia_live_.` : parse + vérif Ed25519 locale. do { - let result = try await verifyOnline(key: key) - applyVerifyResult(result) + let payload = try parseAndVerifyToken(key) + + // Check expiration (lifetime → exp absent → toujours valide). + if let exp = payload.exp { + let now = Int(Date().timeIntervalSince1970) + if exp < now { + return .failure(.expired) + } + } + + guard saveKeyToKeychain(key) else { + logger.error("Keychain save failed") + return .failure(.server(reason: "keychain")) + } + + applyTokenPayload(payload) + + // Best-effort revocation check après activation (ne bloque pas). + Task { await self.runRevocationCheck() } return .success(()) } catch let err as ActivationError { - // Don't keep an unusable key around if the server says it's invalid/revoked. - if case .server = err { /* keep, retry later */ } - else if case .network = err { /* keep, retry later */ } - else { deleteKeyFromKeychain() } return .failure(err) } catch { - return .failure(.network) + return .failure(.signatureInvalid) } } - /// Returns true if the provided plaintext key hashes to the embedded admin hash. - /// Empty `adminKeySha256Hex` disables the feature. + /// Force un re-check de révocation (bouton "Vérifier maintenant" Réglages). + func refreshNow() { + inFlight?.cancel() + inFlight = Task { [weak self] in + await self?.runRevocationCheck() + } + } + + /// Supprime la licence. Sign-out depuis Réglages. + func clear() { + deleteKeyFromKeychain() + UserDefaults.standard.removeObject(forKey: Self.lastRevocationCheckKey) + state = .free + lastVerifiedAt = nil + subscriberEmail = nil + } + + // MARK: - Format validation + + /// Format attendu : + /// - `nia_admin_<16-64 chars Crockford base32>` (dev/master) + /// - `nia_live_.` (token signé) + private func isPlausibleKey(_ key: String) -> Bool { + if key.hasPrefix("nia_admin_") { + let body = key.dropFirst("nia_admin_".count) + guard body.count >= 16, body.count <= 64 else { return false } + let allowed = CharacterSet(charactersIn: "0123456789ABCDEFGHJKMNPQRSTVWXYZ") + return body.unicodeScalars.allSatisfy { allowed.contains($0) } + } + guard key.hasPrefix("nia_live_") else { return false } + let body = String(key.dropFirst("nia_live_".count)) + let parts = body.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { return false } + // base64url alphabet : A-Z a-z 0-9 - _ + let b64url = CharacterSet(charactersIn: + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") + return parts[0].unicodeScalars.allSatisfy { b64url.contains($0) } + && parts[1].unicodeScalars.allSatisfy { b64url.contains($0) } + } + + // MARK: - Admin bypass + private func isAdminKey(_ key: String) -> Bool { guard !Self.adminKeySha256Hex.isEmpty else { return false } let h = SHA256.hash(data: Data(key.utf8)) @@ -174,29 +225,12 @@ final class LicenseManager: ObservableObject { return constantTimeEqual(hex, Self.adminKeySha256Hex.lowercased()) } - /// Set the manager into Pro-lifetime state without touching the network. Persists a fake - /// cached payload so the state survives relaunches even fully offline. private func applyAdminBypass() { state = .proActive(tier: .lifetime, expiresAt: nil) lastVerifiedAt = Date() - let now = Int(Date().timeIntervalSince1970) - // Route through the signed-cache writer so the admin payload also - // carries an HMAC and cannot be forged via `defaults write`. - let synthetic = VerifyPayload( - v: 1, - status: "active", - tier: Tier.lifetime.rawValue, - keyHash: "", - machineId: machineIdHexHashed(), - expiresAt: nil, - issuedAt: now, - ttl: Int(Self.cacheTTL), - gracePeriodDays: Int(Self.gracePeriodSeconds / 86_400) - ) - cachePayload(synthetic) + subscriberEmail = nil } - /// Constant-time string comparison to defeat timing-based hash discovery. private func constantTimeEqual(_ a: String, _ b: String) -> Bool { guard a.utf8.count == b.utf8.count else { return false } var diff: UInt8 = 0 @@ -204,209 +238,172 @@ final class LicenseManager: ObservableObject { return diff == 0 } - /// Removes the license. Used for sign-out from settings. - func clear() { - deleteKeyFromKeychain() - UserDefaults.standard.removeObject(forKey: Self.cacheDefaultsKey) - state = .free - lastVerifiedAt = nil - } + // MARK: - Token parsing & signature verification - /// Forces an online re-verification (e.g., user clicked "Verify now" in settings). - func refreshNow() { - inFlight?.cancel() - inFlight = Task { [weak self] in - await self?.runVerification() + /// Parse le token `nia_live_.`, vérifie la signature + /// Ed25519 sur les octets du payload, et décode le JSON. + private func parseAndVerifyToken(_ key: String) throws -> LicenseTokenPayload { + guard isPlausibleKey(key), key.hasPrefix("nia_live_") else { + throw ActivationError.invalidFormat } - } + let body = String(key.dropFirst("nia_live_".count)) + let parts = body.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2 else { throw ActivationError.invalidFormat } - // MARK: - Periodic verify + guard let payloadBytes = Data(base64UrlEncoded: String(parts[0])), + let sigBytes = Data(base64UrlEncoded: String(parts[1])), + let pubBytes = Data(hex: Self.verifyPublicKeyHex) + else { throw ActivationError.invalidFormat } - private func startTimer() { - verifyTimer?.invalidate() - verifyTimer = Timer.scheduledTimer(withTimeInterval: Self.verifyInterval, repeats: true) { - [weak self] _ in - Task { @MainActor in self?.refreshNow() } + let pubKey: Curve25519.Signing.PublicKey + do { pubKey = try Curve25519.Signing.PublicKey(rawRepresentation: pubBytes) } + catch { + logger.error("Embedded public key malformed") + throw ActivationError.signatureInvalid + } + + guard pubKey.isValidSignature(sigBytes, for: payloadBytes) else { + throw ActivationError.signatureInvalid + } + + do { return try JSONDecoder().decode(LicenseTokenPayload.self, from: payloadBytes) } + catch { + logger.error("Token payload JSON decode failed") + throw ActivationError.signatureInvalid } } - // MARK: - Verification core + private func applyTokenPayload(_ payload: LicenseTokenPayload) { + let tier: Tier = (payload.plan.lowercased() == "lifetime") ? .lifetime : .monthly + let expiresAt = payload.exp.map { Date(timeIntervalSince1970: TimeInterval($0)) } + state = .proActive(tier: tier, expiresAt: expiresAt) + lastVerifiedAt = Date() + subscriberEmail = payload.sub + } + + // MARK: - Local state restoration (no network) - private func runVerification() async { + private func applyLocalStateFromKeychain() { guard let key = loadKeyFromKeychain() else { state = .free return } - // Admin key: never call the network; refresh the local Pro state and exit. + if isAdminKey(key) { applyAdminBypass() return } - if !state.isPro { state = .checking } + do { - let result = try await verifyOnline(key: key) - applyVerifyResult(result) - } catch ActivationError.revoked { + let payload = try parseAndVerifyToken(key) + if let exp = payload.exp { + let now = Int(Date().timeIntervalSince1970) + if exp < now { + state = .proExpired + subscriberEmail = payload.sub + return + } + } + applyTokenPayload(payload) + } catch { + // Clé en Keychain mais cryptographiquement invalide — on l'efface. + logger.error("Stored key fails signature verification — clearing") deleteKeyFromKeychain() - state = .proRevoked - } catch ActivationError.signatureInvalid { - // Cryptographic proof of tampering — never grant grace. Force free - // until a verifiable signed payload comes back. Keep the key in - // Keychain so the next verification can recover automatically once - // the network path is clean again. - logger.error("License: signature invalid — refusing to grant grace period") state = .free - } catch ActivationError.server(let reason) where reason == "invalid_license" { - deleteKeyFromKeychain() - state = .proInvalidKey - } catch { - // Network or server error — fall back to cached state w/ grace. - applyOfflineFallback() } } - private func applyVerifyResult(_ result: VerifyPayload) { - let tier = Tier(rawValue: result.tier) ?? .monthly - let expiresAt = result.expiresAt.flatMap { $0 > 0 ? Date(timeIntervalSince1970: TimeInterval($0)) : nil } - state = .proActive(tier: tier, expiresAt: expiresAt) - lastVerifiedAt = Date() - cachePayload(result) - } + // MARK: - Revocation (best-effort, weekly) - private func applyOfflineFallback() { - guard let cached = loadCachedPayload() else { - state = .free - return - } - let now = Date() - let issuedAt = Date(timeIntervalSince1970: TimeInterval(cached.issuedAt)) - let elapsed = now.timeIntervalSince(issuedAt) - - // Hard expiration for monthly: respect the cached expires_at even offline. - if let exp = cached.expiresAt, cached.tier == "monthly" { - if TimeInterval(exp) < now.timeIntervalSince1970 { - state = .proExpired - return - } - } + private struct RevocationResponse: Decodable { + let valid: Bool + let email: String? + let plan: String? + let status: String? + let activeDevices: Int? + let maxDevices: Int? + let expiresAt: Int? + let reason: String? - let graceLimit = TimeInterval(cached.ttl) + Self.gracePeriodSeconds - if elapsed > graceLimit { - state = .proExpired - return + enum CodingKeys: String, CodingKey { + case valid, email, plan, status, reason + case activeDevices = "active_devices" + case maxDevices = "max_devices" + case expiresAt = "expires_at" } + } - let tier = Tier(rawValue: cached.tier) ?? .monthly - let expiresAt = cached.expiresAt.flatMap { - $0 > 0 ? Date(timeIntervalSince1970: TimeInterval($0)) : nil - } - if elapsed > TimeInterval(cached.ttl) { - let remaining = max(0, Int((graceLimit - elapsed) / 86_400)) - state = .proGrace(tier: tier, expiresAt: expiresAt, daysLeft: remaining) - } else { - state = .proActive(tier: tier, expiresAt: expiresAt) + private func startRevocationTimer() { + revocationTimer?.invalidate() + revocationTimer = Timer.scheduledTimer( + withTimeInterval: Self.revocationInterval, repeats: true + ) { [weak self] _ in + Task { @MainActor in self?.refreshNow() } } } - private func applyCachedStateIfPossible() { - guard loadKeyFromKeychain() != nil else { - state = .free - return + private func scheduleRevocationCheckIfDue() { + let last = UserDefaults.standard.double(forKey: Self.lastRevocationCheckKey) + let now = Date().timeIntervalSince1970 + guard now - last >= Self.revocationInterval else { return } + // Différé pour ne pas bloquer le launch. + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.refreshNow() } - applyOfflineFallback() } - // MARK: - Network + private func runRevocationCheck() async { + guard let key = loadKeyFromKeychain() else { return } + if isAdminKey(key) { return } - private struct VerifyResponseEnvelope: Decodable { - let ok: Bool - let payload: String? - let signature: String? - let reason: String? - let limit: Int? - let registered: Int? - } + // Si la clé locale est crypto-invalide, on ne contacte pas le serveur. + guard (try? parseAndVerifyToken(key)) != nil else { return } - private func verifyOnline(key: String) async throws -> VerifyPayload { - var req = URLRequest(url: Self.apiBase.appendingPathComponent("/v1/license/verify")) + var req = URLRequest(url: Self.revocationURL) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("NotchIA/\(appVersion())", forHTTPHeaderField: "User-Agent") let body: [String: Any] = [ "key": key, - "machine_id": machineIdHex(), - "machine_name": Host.current().localizedName ?? "Mac", + "device_id": machineIdHex(), ] - req.httpBody = try JSONSerialization.data(withJSONObject: body) + req.httpBody = try? JSONSerialization.data(withJSONObject: body) - let (data, resp): (Data, URLResponse) - do { (data, resp) = try await session.data(for: req) } - catch { throw ActivationError.network } - guard let http = resp as? HTTPURLResponse else { throw ActivationError.network } - - let env: VerifyResponseEnvelope - do { env = try JSONDecoder().decode(VerifyResponseEnvelope.self, from: data) } - catch { throw ActivationError.network } - - switch http.statusCode { - case 200 where env.ok: - guard let payloadB64 = env.payload, let sigB64 = env.signature else { - throw ActivationError.signatureInvalid - } - return try verifySignedPayload(payloadB64Url: payloadB64, signatureB64Url: sigB64) - case 402: - // status not active (past_due, canceled, etc.) - throw ActivationError.server(reason: env.reason ?? "not_active") - case 403: - throw ActivationError.revoked - case 404: - throw ActivationError.server(reason: "invalid_license") - case 409: - throw ActivationError.machineLimit( - limit: env.limit ?? 3, - registered: env.registered ?? 3 - ) - case 429: - throw ActivationError.network - default: - throw ActivationError.server(reason: "http_\(http.statusCode)") + let resp: RevocationResponse + do { + let (data, _) = try await session.data(for: req) + resp = try JSONDecoder().decode(RevocationResponse.self, from: data) + } catch { + // Offline / parse error → on garde l'état local (offline-first). + logger.notice("Revocation check unreachable — keeping local state") + return } - } - // MARK: - Signature verification (Ed25519) - - private func verifySignedPayload(payloadB64Url: String, signatureB64Url: String) throws - -> VerifyPayload - { - guard let payloadBytes = Data(base64UrlEncoded: payloadB64Url), - let sigBytes = Data(base64UrlEncoded: signatureB64Url), - let pubBytes = Data(hex: Self.verifyPublicKeyHex) - else { throw ActivationError.signatureInvalid } - - let pubKey: Curve25519.Signing.PublicKey - do { pubKey = try Curve25519.Signing.PublicKey(rawRepresentation: pubBytes) } - catch { throw ActivationError.signatureInvalid } + UserDefaults.standard.set(Date().timeIntervalSince1970, + forKey: Self.lastRevocationCheckKey) + lastVerifiedAt = Date() - guard pubKey.isValidSignature(sigBytes, for: payloadBytes) else { - throw ActivationError.signatureInvalid + if resp.valid { + // OK — l'état Pro est confirmé. On rafraîchit juste l'email/plan + // au cas où le serveur aurait des infos plus à jour. + if let email = resp.email { subscriberEmail = email } + return } - let payload: VerifyPayload - do { payload = try JSONDecoder().decode(VerifyPayload.self, from: payloadBytes) } - catch { throw ActivationError.signatureInvalid } - - // Bind to this machine: a replayed payload signed for another machine must not pass. - let expected = machineIdHexHashed() - guard payload.machineId == expected else { - logger.warning("verify payload machine mismatch") - throw ActivationError.signatureInvalid - } - // Reject obviously stale payloads (clock skew tolerance ±5 min). - let now = Int(Date().timeIntervalSince1970) - if payload.issuedAt > now + 300 || payload.issuedAt + payload.ttl < now - 300 { - throw ActivationError.signatureInvalid + // valid:false — révocation décidée par le serveur. + switch resp.reason { + case "status_refunded", "status_cancelled", "not_found": + deleteKeyFromKeychain() + state = .proRevoked + subscriberEmail = nil + case "expired": + state = .proExpired + case "device_limit": + // Pas de révocation locale, juste un signal UI. + logger.notice("Device limit reached: \(resp.activeDevices ?? 0)/\(resp.maxDevices ?? 0)") + default: + logger.notice("Revocation check returned valid:false (reason: \(resp.reason ?? "unknown")) — ignoring") } - return payload } // MARK: - Keychain @@ -460,153 +457,57 @@ final class LicenseManager: ObservableObject { SecItemDelete(q as CFDictionary) } - // MARK: - Machine ID + // MARK: - Machine ID (device_id pour le compteur côté serveur) - /// Returns a stable hex-encoded machine identifier scoped to this device + app install. - /// We don't read the hardware UUID directly to keep the token opaque even if the Keychain - /// is later inspected. The server re-hashes this value before storing it. + /// UUID stable par installation. Sert au serveur pour compter combien de + /// Mac sont activés par licence (max=1 monthly, max=2 lifetime). private func machineIdHex() -> String { if let existing = keychainGet(account: Self.machineIdAccount).flatMap({ String(data: $0, encoding: .utf8) }) { return existing } - var bytes = [UInt8](repeating: 0, count: 32) + var bytes = [UInt8](repeating: 0, count: 16) let res = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) precondition(res == errSecSuccess) + // UUID v4 canonical text form + bytes[6] = (bytes[6] & 0x0F) | 0x40 // version 4 + bytes[8] = (bytes[8] & 0x3F) | 0x80 // variant 10 let hex = bytes.map { String(format: "%02x", $0) }.joined() - _ = keychainSet(account: Self.machineIdAccount, value: Data(hex.utf8)) - return hex - } - - /// Mirrors the server-side `normalizeMachineId`: SHA-256("notchia-machine:" + machine_id). - /// Used to compare against the `machine_id` returned in signed payloads. - private func machineIdHexHashed() -> String { - let input = "notchia-machine:" + machineIdHex() - let digest = SHA256.hash(data: Data(input.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() - } - - // MARK: - Cache - - private struct CachedPayload: Codable { - let tier: String - let status: String - let issuedAt: Int - let ttl: Int - let expiresAt: Int? - } - - /// Wrapper persisted in UserDefaults. The HMAC is computed over the JSON - /// encoding of `payload` using a key derived from the per-machine Keychain - /// secret. This prevents an attacker with `defaults write` access from - /// forging a Pro state — they can't compute a matching HMAC without the - /// Keychain key. - private struct SignedCachedPayload: Codable { - let payload: CachedPayload - let hmac: String // hex-encoded HMAC-SHA256 - } - - private func cacheSigningKey() -> SymmetricKey { - // Derive from the Keychain-bound machine id so the key is per-install - // and never persisted in plaintext on disk. - let salt = "notchia-cache-hmac:v1" - let input = Data((salt + machineIdHex()).utf8) - return SymmetricKey(data: SHA256.hash(data: input)) - } - - private func cachePayload(_ p: VerifyPayload) { - let cached = CachedPayload( - tier: p.tier, - status: p.status, - issuedAt: p.issuedAt, - ttl: p.ttl, - expiresAt: p.expiresAt - ) - guard let payloadData = try? JSONEncoder().encode(cached) else { return } - let mac = HMAC.authenticationCode(for: payloadData, using: cacheSigningKey()) - let hmacHex = mac.map { String(format: "%02x", $0) }.joined() - let signed = SignedCachedPayload(payload: cached, hmac: hmacHex) - if let data = try? JSONEncoder().encode(signed) { - UserDefaults.standard.set(data, forKey: Self.cacheDefaultsKey) - } - } - - private func loadCachedPayload() -> CachedPayload? { - guard let data = UserDefaults.standard.data(forKey: Self.cacheDefaultsKey) else { - return nil - } - guard let signed = try? JSONDecoder().decode(SignedCachedPayload.self, from: data) else { - // Legacy unsigned cache from older builds — refuse to trust it. - // Wipe so we don't keep flagging the same bytes forever. - logger.warning("License cache: legacy/unsigned format — discarding") - UserDefaults.standard.removeObject(forKey: Self.cacheDefaultsKey) - return nil - } - guard let expectedPayloadData = try? JSONEncoder().encode(signed.payload), - let providedMac = Data(hex: signed.hmac) else { - return nil - } - let expected = HMAC.authenticationCode( - for: expectedPayloadData, - using: cacheSigningKey() - ) - let expectedData = Data(expected) - // Constant-time compare via CryptoKit's HMAC verifier. - let ok = HMAC.isValidAuthenticationCode( - providedMac, - authenticating: expectedPayloadData, - using: cacheSigningKey() - ) - guard ok, expectedData == providedMac else { - logger.error("License cache: HMAC mismatch — refusing to use cache") - UserDefaults.standard.removeObject(forKey: Self.cacheDefaultsKey) - return nil - } - return signed.payload + let uuid = "\(hex.prefix(8))-\(hex.dropFirst(8).prefix(4))-\(hex.dropFirst(12).prefix(4))-\(hex.dropFirst(16).prefix(4))-\(hex.dropFirst(20))" + _ = keychainSet(account: Self.machineIdAccount, value: Data(uuid.utf8)) + return uuid } // MARK: - Misc - private func isPlausibleKey(_ key: String) -> Bool { - // "nia_live_", "nia_test_" or "nia_admin_" + base32 body. Allow some flexibility on length. - let prefixes = ["nia_live_", "nia_test_", "nia_admin_"] - guard let prefix = prefixes.first(where: { key.hasPrefix($0) }) else { return false } - let body = key.dropFirst(prefix.count) - guard body.count >= 16, body.count <= 64 else { return false } - let allowed = CharacterSet(charactersIn: "0123456789ABCDEFGHJKMNPQRSTVWXYZ") - return body.unicodeScalars.allSatisfy { allowed.contains($0) } - } - private func appVersion() -> String { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" } } -// MARK: - Verify payload model +// MARK: - Token payload model -struct VerifyPayload: Decodable { +/// Payload signé contenu dans `nia_live_.`. +/// Émis par le backend `notchia.app` après paiement Stripe réussi. +struct LicenseTokenPayload: Codable { + /// Version du format (incrémentée si le payload change). let v: Int - let status: String - let tier: String - let keyHash: String - let machineId: String - let expiresAt: Int? - let issuedAt: Int - let ttl: Int - let gracePeriodDays: Int - - enum CodingKeys: String, CodingKey { - case v, status, tier, ttl - case keyHash = "key_hash" - case machineId = "machine_id" - case expiresAt = "expires_at" - case issuedAt = "issued_at" - case gracePeriodDays = "grace_period_days" - } + /// Email de l'acheteur (sub = subject). + let sub: String + /// `monthly` ou `lifetime`. + let plan: String + /// Issued-at, epoch secondes. + let iat: Int + /// Expiration, epoch secondes. ABSENT pour `lifetime`. + let exp: Int? + /// Identifiant unique de la licence (sert au serveur pour la révocation). + let jti: String + /// Nombre de Mac autorisés (1 monthly, 2 lifetime). Géré côté serveur. + let max: Int } -// MARK: - Helpers +// MARK: - Helpers (hex + base64url) private extension Data { init?(hex: String) { @@ -624,6 +525,7 @@ private extension Data { self.init(bytes) } + /// Décode base64url (sans padding, alphabet `-_` à la place de `+/`). init?(base64UrlEncoded: String) { var s = base64UrlEncoded .replacingOccurrences(of: "-", with: "+") diff --git a/updater/appcast.xml b/updater/appcast.xml index 665799d..685f4e3 100644 --- a/updater/appcast.xml +++ b/updater/appcast.xml @@ -32,6 +32,16 @@ --> + + 2.8.3 + Mon, 25 May 2026 20:34:13 +0000 + https://github.com/coaxel2/NotchIA/releases + 20803 + 2.8.3 + 15.0 + + + 2.8.2 Sun, 17 May 2026 18:14:35 +0000 @@ -71,16 +81,6 @@ Le code de détection ne fonctionne pas fiable sur macOS 26+ à cause d'un bug A - - 2.8.0 - Sat, 16 May 2026 16:56:37 +0000 - https://github.com/coaxel2/NotchIA/releases - 20800 - 2.8.0 - 15.0 - - - 2.7.3 Mon, 24 Nov 2025 08:07:37 +0000