diff --git a/.gitignore b/.gitignore index c0bd259..2417e1d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ FINAL_PRODUCTION_SYSTEM/uploads/client-resources/ FINAL_PRODUCTION_SYSTEM/install/install.log FINAL_PRODUCTION_SYSTEM/install/.progress.json +# ── License-server private keys (never committed; uploaded to CF) ── +license-server/.keys/ + # ── PHP Dependencies (managed by Composer) ──────────────── FINAL_PRODUCTION_SYSTEM/vendor/ diff --git a/CLAUDE.md b/CLAUDE.md index b9433bf..b7b2912 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ main_v3.PS1 │ ├── get-key.php │ │ ├── LicenseController.php ├─ Network Diagnostics (MAS-style) │ └── ... (21 total) │ (4-host ping + COM fallback │ - │ + MS licensing server test) ├── admin_v2.php ← 85 action router + │ + MS licensing server test) ├── admin_v2.php ← 90 action router │ │ ├─ QC Compliance ────────POST──► ├── functions/ ← 23 helper modules │ │ ├── email-helpers.php @@ -70,12 +70,12 @@ CLOUDFLARE WORKER (License Server) |--------|-------| | Admin Controllers | 21 | | API Endpoints | 19 | -| Admin Actions | 85 | +| Admin Actions | 90 | | Frontend Pages | 24 | | Frontend Hooks | 22 | | Frontend API Files | 21 | -| PHP Helper Modules | 23 | -| DB Migrations | 26 | +| PHP Helper Modules | 25 | +| DB Migrations | 29 | | Languages | 18 | | Sidebar Nav Items | 30 | @@ -191,8 +191,11 @@ CLOUDFLARE WORKER (License Server) | 24 | usb_devices_migration.sql | USB device registry | | 25 | task_pipeline_migration.sql | Task templates + execution logs | | 26 | production_tracking_migration.sql | CBR reports, key pools, work orders, DPK batches | +| 27 | license_p0_hmac_migration.sql | License row integrity HMAC (P0 anti-piracy) | +| 28 | license_p1_hwbind_migration.sql | Hardware-bound licensing + 3-per-365 rebind quota (P1) | +| 29 | license_p2_phonehome_migration.sql | Phone-home grace + revocation jti + clock-drift (P2) | -### Helper Modules (23 files in `functions/`) +### Helper Modules (25 files in `functions/`) | File | Purpose | |------|---------| | acl.php | Permission checking, role management | @@ -205,7 +208,9 @@ CLOUDFLARE WORKER (License Server) | i18n.php | Translation loading | | integration-helpers.php | Event dispatch to osTicket / 1C | | key-helpers.php | Key status, recycling logic | -| license-helpers.php | JWT license validation, tier enforcement | +| license-helpers.php | JWT license validation, tier enforcement, RS256 verify, row HMAC, hwfp gate | +| license-phone-home.php | Phone-home validate, grace bands, clock drift, revocation handling (P2) | +| hardware-fingerprint.php | Cross-OS server hardware fingerprint (machine-id + system UUID + MAC + volume UUID) (P1) | | logger.php | Structured logging | | network-utils.php | IP whitelisting, trusted networks | | push-helpers.php | VAPID push notifications | diff --git a/FINAL_PRODUCTION_SYSTEM/VERSION.php b/FINAL_PRODUCTION_SYSTEM/VERSION.php index a500890..d176e6b 100644 --- a/FINAL_PRODUCTION_SYSTEM/VERSION.php +++ b/FINAL_PRODUCTION_SYSTEM/VERSION.php @@ -5,6 +5,6 @@ * This file is updated automatically by the upgrade system. * Do NOT edit manually unless you know what you are doing. */ -define('APP_VERSION', '2.2.0'); -define('APP_VERSION_CODE', 220); -define('APP_VERSION_DATE', '2026-05-07'); +define('APP_VERSION', '2.3.0'); +define('APP_VERSION_CODE', 230); +define('APP_VERSION_DATE', '2026-05-08'); diff --git a/FINAL_PRODUCTION_SYSTEM/admin_v2.php b/FINAL_PRODUCTION_SYSTEM/admin_v2.php index 5937524..c825fbe 100644 --- a/FINAL_PRODUCTION_SYSTEM/admin_v2.php +++ b/FINAL_PRODUCTION_SYSTEM/admin_v2.php @@ -337,6 +337,11 @@ 'license_register' => ['LicenseController.php', 'handle_license_register', true, true], 'license_deactivate' => ['LicenseController.php', 'handle_license_deactivate', true, true], 'license_generate_dev' => ['LicenseController.php', 'handle_license_generate_dev', true, true], + 'license_claim' => ['LicenseController.php', 'handle_license_claim', true, true], + 'license_migrate' => ['LicenseController.php', 'handle_license_migrate', true, true], + 'license_redetect_hw' => ['LicenseController.php', 'handle_license_redetect_hw', true, true], + 'license_rebind' => ['LicenseController.php', 'handle_license_rebind', true, true], + 'license_force_validate' => ['LicenseController.php', 'handle_license_force_validate', true, true], // system upgrade 'upgrade_check_github' => ['UpgradeController.php', 'handle_upgrade_check_github', false, true], diff --git a/FINAL_PRODUCTION_SYSTEM/cli/license-validate.php b/FINAL_PRODUCTION_SYSTEM/cli/license-validate.php new file mode 100644 index 0000000..5ca4f5f --- /dev/null +++ b/FINAL_PRODUCTION_SYSTEM/cli/license-validate.php @@ -0,0 +1,49 @@ +> /var/log/keygate-phonehome.log 2>&1 + * + * The Windows/IIS path goes through firePhoneHomeAsync()'s synchronous + * fallback — the 6-second Worker timeout is tolerable as a once-per-day + * blocking call. + */ + +// Run only from CLI — refuse to expose this over HTTP. +if (PHP_SAPI !== 'cli') { + http_response_code(403); + echo "This script must be run from the command line.\n"; + exit(1); +} + +require_once __DIR__ . '/../config.php'; +require_once __DIR__ . '/../functions/admin-helpers.php'; +require_once __DIR__ . '/../functions/license-helpers.php'; +require_once __DIR__ . '/../functions/license-phone-home.php'; + +$force = in_array('--force', $argv ?? [], true); +echo '[' . date('c') . "] phone-home start (force=" . ($force ? '1' : '0') . ")\n"; + +try { + $resp = phoneHomeValidate($pdo, $force); + if ($resp === null) { + echo "[" . date('c') . "] no-op (throttled or no license)\n"; + exit(0); + } + echo '[' . date('c') . '] OK valid=' . (!empty($resp['valid']) ? '1' : '0') + . ' tier=' . ($resp['tier'] ?? '-') + . ' must_rebind=' . (!empty($resp['must_rebind']) ? '1' : '0') + . ' revoked=' . (!empty($resp['revoked']) ? '1' : '0') + . ' jti=' . substr((string)($resp['jti'] ?? ''), 0, 8) + . "\n"; + exit(0); +} catch (Exception $e) { + fwrite(STDERR, '[' . date('c') . '] ERROR: ' . $e->getMessage() . "\n"); + exit(2); +} diff --git a/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php b/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php index 4020115..d0ebd33 100644 --- a/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php +++ b/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php @@ -6,6 +6,7 @@ */ require_once __DIR__ . '/../../functions/license-helpers.php'; +require_once __DIR__ . '/../../functions/license-phone-home.php'; // ── Get License Status (no auth required — needed for registration wall) ── @@ -26,6 +27,20 @@ function handle_license_status(PDO $pdo, array $admin_session, $json_input): voi $keyCount = (int)$stmt->fetchColumn(); } catch (Exception $e) { /* table may not exist */ } + // P1: hardware fingerprint info — current host, bound row, drift state. + $hwfp = ['composite' => '', 'components' => []]; + try { $hwfp = getServerHardwareFingerprint($pdo, false); } catch (Exception $e) { /* fail open */ } + + $rebindCount = 0; + $boundFingerprint = ''; + try { + $stmt = $pdo->query("SELECT hardware_fingerprint, hwfp_rebind_count, hwfp_bound_at, hwfp_last_rebind_at + FROM `" . t('license_info') . "` WHERE is_active = 1 ORDER BY id DESC LIMIT 1"); + $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; + $boundFingerprint = (string)($row['hardware_fingerprint'] ?? ''); + $rebindCount = (int)($row['hwfp_rebind_count'] ?? 0); + } catch (Exception $e) { /* pre-P1 install, columns absent */ } + jsonResponse([ 'success' => true, 'license' => [ @@ -38,11 +53,97 @@ function handle_license_status(PDO $pdo, array $admin_session, $json_input): voi 'max_keys' => $license['max_keys'], 'features' => $license['features'], 'instance_id' => $instanceId, + 'rebind_required' => !empty($license['rebind_required']), + 'rebind_grace_ends' => $license['rebind_grace_ends'] ?? null, ], 'usage' => [ 'technicians' => $techCount, 'keys' => $keyCount, ], + 'hardware' => [ + 'current_fingerprint' => (string)($hwfp['composite'] ?? ''), + 'components' => $hwfp['components'] ?? [], + 'bound_fingerprint' => $boundFingerprint, + 'rebind_count' => $rebindCount, + 'rebind_quota_limit' => 3, + 'rebind_window_days' => 365, + ], + 'phonehome' => _phonehomeStatus($pdo, $license), + ]); +} + +/** + * Build the phone-home status block for the License page UI (P2). + * Reads license_info row + grace band + cached validation response. + */ +function _phonehomeStatus(PDO $pdo, array $effective): array { + try { + $stmt = $pdo->query("SELECT last_validated_at, validation_failure_count, + last_validation_error, server_time_drift_seconds, + clock_drift_strikes, current_jti + FROM `" . t('license_info') . "` + WHERE is_active = 1 ORDER BY id DESC LIMIT 1"); + $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; + } catch (Exception $e) { + return ['available' => false]; + } + $grace = function_exists('checkPhoneHomeGrace') + ? checkPhoneHomeGrace($row) + : ['band' => 'ok', 'days_since' => 0, 'banner' => null]; + + return [ + 'available' => true, + 'last_validated_at' => $row['last_validated_at'] ?? null, + 'failure_count' => (int)($row['validation_failure_count'] ?? 0), + 'last_error' => $row['last_validation_error'] ?? null, + 'server_time_drift_seconds' => (int)($row['server_time_drift_seconds'] ?? 0), + 'clock_drift_strikes' => (int)($row['clock_drift_strikes'] ?? 0), + 'current_jti' => $row['current_jti'] ?? null, + 'grace_band' => $grace['band'], + 'grace_days' => $grace['days_since'], + 'grace_banner' => $grace['banner'], + 'grace_banner_threshold_d' => 14, + 'grace_hard_threshold_d' => 30, + // Surface the P2 phone-home banner from getEffectiveLicense() so the + // UI can show the same message even after community degrade. + 'effective_band' => $effective['phonehome_band'] ?? null, + 'effective_banner' => $effective['phonehome_banner'] ?? null, + ]; +} + +// ── Force phone-home validate (P2) ── +// +// Body: {} — admin clicks "Validate now" in the Phone-home card. +// Bypasses the 24h throttle and synchronously calls the Worker. +function handle_license_force_validate(PDO $pdo, array $admin_session, $json_input): void { + requirePermission('system_settings', $admin_session); + + $resp = phoneHomeValidate($pdo, /*force=*/true); + if ($resp === null) { + jsonResponse([ + 'success' => false, + 'error' => 'Phone-home failed (network or no active license)', + ]); + return; + } + + logAdminActivity( + $admin_session['admin_id'], + $admin_session['id'] ?? 0, + 'LICENSE_FORCE_VALIDATE', + 'Forced phone-home validate (jti=' . substr((string)($resp['jti'] ?? ''), 0, 8) . ')' + ); + + jsonResponse([ + 'success' => true, + 'valid' => !empty($resp['valid']), + 'tier' => $resp['tier'] ?? null, + 'revoked' => !empty($resp['revoked']), + 'must_rebind' => !empty($resp['must_rebind']), + 'expires_at' => $resp['expires_at'] ?? null, + 'jti' => $resp['jti'] ?? null, + 'server_time' => $resp['server_time'] ?? null, + 'message' => 'Validated against license server', ]); } @@ -89,8 +190,11 @@ function handle_license_deactivate(PDO $pdo, array $admin_session, $json_input): jsonResponse(['success' => true, 'message' => 'License deactivated']); } -// ── Generate Development License (dev/testing only) ── - +// ── Generate Development License (calls Worker /api/dev-issue) ── +// +// Local signing was removed in v2.3.0 (P0 hardening). The Worker is the +// only entity that holds the RS256 private key. Founder provides a +// DEV_TOKEN (matching the Cloudflare secret) to authorize. function handle_license_generate_dev(PDO $pdo, array $admin_session, $json_input): void { requirePermission('system_settings', $admin_session); @@ -101,34 +205,293 @@ function handle_license_generate_dev(PDO $pdo, array $admin_session, $json_input return; } - $tier = $json_input['tier'] ?? 'pro'; + $tier = $json_input['tier'] ?? 'pro'; + $devToken = $json_input['dev_token'] ?? ''; if (!isset(LICENSE_TIERS[$tier])) { jsonResponse(['success' => false, 'error' => 'Invalid tier']); return; } + if (empty($devToken)) { + jsonResponse([ + 'success' => false, + 'error' => 'DEV_TOKEN required. Set the same value via wrangler secret put DEV_TOKEN on the Worker.', + ]); + return; + } $instanceId = getInstanceId($pdo); - $tierDef = LICENSE_TIERS[$tier]; - - $payload = [ - 'iss' => 'keygate-dev', - 'tier' => $tier, - 'instance_id' => $instanceId, - 'email' => 'dev@localhost', - 'name' => 'Development License', - 'max_technicians' => $tierDef['max_technicians'], - 'max_keys' => $tierDef['max_keys'], - 'iat' => time(), - 'exp' => time() + (365 * 86400), // 1 year - ]; + $hwfp = getServerHardwareFingerprint($pdo, false); + $hwfpHex = (string)($hwfp['composite'] ?? ''); + if ($hwfpHex === '') { + jsonResponse(['success' => false, 'error' => 'Could not compute server hardware fingerprint']); + return; + } - $jwt = createLicenseJwt($payload); + $body = json_encode([ + 'tier' => $tier, + 'email' => 'dev@localhost', + 'instance_id' => $instanceId, + 'hardware_fingerprint' => $hwfpHex, + 'dev_token' => $devToken, + ]); + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $body, + 'timeout' => 10, + 'ignore_errors' => true, + ], + ]); + $resp = @file_get_contents(KEYGATE_LICENSE_SERVER . '/api/dev-issue', false, $ctx); + if ($resp === false) { + jsonResponse(['success' => false, 'error' => 'Could not reach license server. Check network.']); + return; + } + $decoded = json_decode($resp, true); + if (!is_array($decoded)) { + jsonResponse(['success' => false, 'error' => 'License server returned invalid response.']); + return; + } + + if (empty($decoded['success'])) { + jsonResponse(['success' => false, 'error' => $decoded['error'] ?? 'Worker rejected request.']); + return; + } jsonResponse([ 'success' => true, - 'license_key' => $jwt, - 'tier' => $tier, + 'license_key' => $decoded['license_key'], + 'tier' => $decoded['tier'] ?? $tier, 'instance_id' => $instanceId, - 'message' => "Development {$tierDef['label']} license generated. Paste it into the registration field.", + 'expires_at' => $decoded['expires_at'] ?? null, + 'message' => 'Development license issued by Worker. Paste into Registration field.', + ]); +} + +// ── Claim license (GitHub Sponsors / pending payments) ── +// +// Body: { email, sponsor_login? } +// Calls Worker /api/claim with the local instance_id. Worker mints an +// RS256 JWT bound to this install. +function handle_license_claim(PDO $pdo, array $admin_session, $json_input): void { + requirePermission('system_settings', $admin_session); + + $email = trim($json_input['email'] ?? ''); + $sponsorLogin = trim($json_input['sponsor_login'] ?? ''); + if (empty($email)) { + jsonResponse(['success' => false, 'error' => 'Email is required']); + return; + } + + $instanceId = getInstanceId($pdo); + $hwfp = getServerHardwareFingerprint($pdo, false); + $hwfpHex = (string)($hwfp['composite'] ?? ''); + if ($hwfpHex === '') { + jsonResponse(['success' => false, 'error' => 'Could not compute server hardware fingerprint']); + return; + } + $body = json_encode([ + 'email' => $email, + 'instance_id' => $instanceId, + 'hardware_fingerprint' => $hwfpHex, + 'sponsor_login' => $sponsorLogin ?: null, + ]); + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $body, + 'timeout' => 10, + 'ignore_errors' => true, + ], + ]); + $resp = @file_get_contents(KEYGATE_LICENSE_SERVER . '/api/claim', false, $ctx); + if ($resp === false) { + jsonResponse(['success' => false, 'error' => 'Could not reach license server']); + return; + } + $decoded = json_decode($resp, true); + if (!is_array($decoded) || empty($decoded['success'])) { + jsonResponse(['success' => false, 'error' => $decoded['error'] ?? 'Claim failed']); + return; + } + + // Auto-register the freshly-issued JWT. + $regResult = registerLicense($pdo, $decoded['license_key']); + jsonResponse(array_merge($regResult, [ + 'license_key' => $decoded['license_key'], + 'expires_at' => $decoded['expires_at'] ?? null, + ])); +} + +// ── Migrate legacy HS256 license to RS256 ── +// +// Body: { license_key (legacy HS256) } +// Calls Worker /api/migrate. Re-issues an RS256 JWT bound to this +// instance_id. Auto-registers on success. +function handle_license_migrate(PDO $pdo, array $admin_session, $json_input): void { + requirePermission('system_settings', $admin_session); + + $legacyKey = trim($json_input['license_key'] ?? ''); + if (empty($legacyKey)) { + jsonResponse(['success' => false, 'error' => 'license_key is required']); + return; + } + + $instanceId = getInstanceId($pdo); + $hwfp = getServerHardwareFingerprint($pdo, false); + $hwfpHex = (string)($hwfp['composite'] ?? ''); + if ($hwfpHex === '') { + jsonResponse(['success' => false, 'error' => 'Could not compute server hardware fingerprint']); + return; + } + $body = json_encode([ + 'license_key' => $legacyKey, + 'instance_id' => $instanceId, + 'hardware_fingerprint' => $hwfpHex, + ]); + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $body, + 'timeout' => 10, + 'ignore_errors' => true, + ], + ]); + $resp = @file_get_contents(KEYGATE_LICENSE_SERVER . '/api/migrate', false, $ctx); + if ($resp === false) { + jsonResponse(['success' => false, 'error' => 'Could not reach license server']); + return; + } + $decoded = json_decode($resp, true); + if (!is_array($decoded) || empty($decoded['success'])) { + jsonResponse(['success' => false, 'error' => $decoded['error'] ?? 'Migration failed']); + return; + } + + // Auto-register the freshly-issued RS256 JWT. + $regResult = registerLicense($pdo, $decoded['license_key']); + jsonResponse(array_merge($regResult, [ + 'license_key' => $decoded['license_key'], + 'expires_at' => $decoded['expires_at'] ?? null, + ])); +} + +// ── Re-detect server hardware fingerprint (P1) ── +// +// Body: {} — admin clicks "Re-detect hardware" button. Force-recomputes +// the server hwfp helper (shells out for machine-id / system-uuid / MAC / +// volume UUID), caches the new result, and returns the components. +// +// Does NOT change the license-bound fingerprint — that requires /api/rebind. +function handle_license_redetect_hw(PDO $pdo, array $admin_session, $json_input): void { + requirePermission('system_settings', $admin_session); + + try { + $hwfp = getServerHardwareFingerprint($pdo, true); + } catch (Exception $e) { + error_log("KeyGate redetect: " . $e->getMessage()); + jsonResponse(['success' => false, 'error' => 'Hardware detection failed']); + return; + } + + logAdminActivity( + $admin_session['admin_id'], + $admin_session['id'] ?? 0, + 'LICENSE_HW_REDETECTED', + 'Re-detected server hardware fingerprint' + ); + + jsonResponse([ + 'success' => true, + 'fingerprint'=> (string)($hwfp['composite'] ?? ''), + 'components' => $hwfp['components'] ?? [], + 'computed_at'=> $hwfp['computed_at'] ?? null, + ]); +} + +// ── Rebind license to new hardware (P1) ── +// +// Body: { reason? } +// Calls Worker /api/rebind with current license_key, instance_id, and the +// freshly-detected hardware fingerprint. Worker enforces quota (3 per +// rolling 365 days), mints a new RS256 JWT, returns rebind counters. +// On success applyRebindResponse() updates the local row. +function handle_license_rebind(PDO $pdo, array $admin_session, $json_input): void { + requirePermission('system_settings', $admin_session); + + $current = getCurrentLicense($pdo); + if (!$current || empty($current['license_key'])) { + jsonResponse(['success' => false, 'error' => 'No active license to rebind']); + return; + } + + // Always re-detect when rebinding — admin's intent is "the hardware just changed". + $hwfp = getServerHardwareFingerprint($pdo, true); + $hwfpHex = (string)($hwfp['composite'] ?? ''); + if ($hwfpHex === '') { + jsonResponse(['success' => false, 'error' => 'Could not compute new server hardware fingerprint']); + return; + } + + $reason = trim((string)($json_input['reason'] ?? '')); + $body = json_encode([ + 'license_key' => $current['license_key'], + 'instance_id' => $current['instance_id'], + 'new_hardware_fingerprint' => $hwfpHex, + 'reason' => $reason ?: null, + ]); + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $body, + 'timeout' => 10, + 'ignore_errors' => true, + ], + ]); + $resp = @file_get_contents(KEYGATE_LICENSE_SERVER . '/api/rebind', false, $ctx); + if ($resp === false) { + jsonResponse(['success' => false, 'error' => 'Could not reach license server']); + return; + } + $decoded = json_decode($resp, true); + if (!is_array($decoded) || empty($decoded['success'])) { + jsonResponse([ + 'success' => false, + 'error' => $decoded['error'] ?? 'Rebind failed', + 'quota_window_days' => $decoded['quota_window_days'] ?? null, + 'quota_limit' => $decoded['quota_limit'] ?? null, + 'retry_after_iso' => $decoded['retry_after_iso'] ?? null, + ]); + return; + } + + $apply = applyRebindResponse( + $pdo, + $decoded['license_key'], + (int)($decoded['rebind_count'] ?? 0) + ); + if (!$apply['success']) { + jsonResponse(['success' => false, 'error' => $apply['error'] ?? 'Local rebind apply failed']); + return; + } + + logAdminActivity( + $admin_session['admin_id'], + $admin_session['id'] ?? 0, + 'LICENSE_REBOUND', + "Rebound license to new hardware fingerprint (count: {$apply['rebind_count']}, reason: " . ($reason ?: 'unspecified') . ')' + ); + + jsonResponse([ + 'success' => true, + 'tier' => $apply['tier'], + 'rebind_count' => $apply['rebind_count'], + 'rebind_quota_remaining' => $decoded['rebind_quota_remaining'] ?? null, + 'rebind_quota_limit' => $decoded['rebind_quota_limit'] ?? 3, + 'message' => 'License rebound successfully', ]); } diff --git a/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh b/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh index fb6bcc1..93d85ad 100644 --- a/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh +++ b/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh @@ -149,6 +149,15 @@ run_sql "task_pipeline_migration.sql" 25 # Phase 14: Production tracking & enterprise key management run_sql "production_tracking_migration.sql" 26 +# Phase 15: License row integrity HMAC (P0 anti-piracy) +run_sql "license_p0_hmac_migration.sql" 27 + +# Phase 16: License hardware-fingerprint binding + rebind quota (P1) +run_sql "license_p1_hwbind_migration.sql" 28 + +# Phase 17: License phone-home grace + revocation + clock-drift (P2) +run_sql "license_p2_phonehome_migration.sql" 29 + echo "" echo "=== Database initialization complete ===" echo "" diff --git a/FINAL_PRODUCTION_SYSTEM/database/license_p0_hmac_migration.sql b/FINAL_PRODUCTION_SYSTEM/database/license_p0_hmac_migration.sql new file mode 100644 index 0000000..a6d965d --- /dev/null +++ b/FINAL_PRODUCTION_SYSTEM/database/license_p0_hmac_migration.sql @@ -0,0 +1,18 @@ +-- ============================================================= +-- KeyGate v2.3.0 P0 — License row integrity HMAC +-- ============================================================= +-- Adds an HMAC column to license_info so any row directly INSERTed/UPDATEd +-- without going through registerLicense() (which knows the per-instance +-- row secret) is detected as tampered and forced back to community tier. +-- +-- The HMAC formula and verification live in functions/license-helpers.php +-- (computeLicenseRowHmac / verifyLicenseRow). +-- ============================================================= + +ALTER TABLE `#__license_info` + ADD COLUMN integrity_hmac CHAR(64) NOT NULL DEFAULT '' AFTER validation_status; + +-- Existing rows (legacy paid customers from v2.2.x) have an empty hmac +-- → verifyLicenseRow returns false → they fall back to community on +-- next read. Customers re-register via /api/migrate to get a fresh +-- RS256 JWT, and that path computes a valid HMAC on insert. diff --git a/FINAL_PRODUCTION_SYSTEM/database/license_p1_hwbind_migration.sql b/FINAL_PRODUCTION_SYSTEM/database/license_p1_hwbind_migration.sql new file mode 100644 index 0000000..8ce36b0 --- /dev/null +++ b/FINAL_PRODUCTION_SYSTEM/database/license_p1_hwbind_migration.sql @@ -0,0 +1,38 @@ +-- ============================================================= +-- KeyGate v2.3.0 P1 — Hardware-bound licensing +-- ============================================================= +-- Binds each license_info row to the host's server-side hardware +-- fingerprint (composite SHA256 of machine-id | system-uuid | NIC +-- MAC | root volume UUID). A license cannot be moved to a different +-- VM/host without invoking the Worker /api/rebind route, which is +-- quota-limited (3 rebinds per rolling 365 days). +-- +-- Enum extension adds: +-- 'rebinding_required' — set by getEffectiveLicense() when the +-- host's current hwfp diverges from the row's bound hwfp; UI +-- directs admin to click "Rebind hardware". +-- 'clock_drift' — reserved for P2 (clock-rollback defense). +-- ============================================================= + +ALTER TABLE `#__license_info` + ADD COLUMN hardware_fingerprint CHAR(64) NULL DEFAULT NULL AFTER instance_id, + ADD COLUMN hwfp_bound_at TIMESTAMP NULL DEFAULT NULL AFTER hardware_fingerprint, + ADD COLUMN hwfp_rebind_count TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER hwfp_bound_at, + ADD COLUMN hwfp_last_rebind_at TIMESTAMP NULL DEFAULT NULL AFTER hwfp_rebind_count, + ADD INDEX idx_hwfp (hardware_fingerprint); + +-- Extend validation_status enum to cover hardware rebind + clock drift. +-- MariaDB requires the full enum list on MODIFY. +ALTER TABLE `#__license_info` + MODIFY validation_status + ENUM('valid','expired','revoked','invalid','pending','rebinding_required','clock_drift') + NOT NULL DEFAULT 'pending'; + +-- system_config slots used by P1. +INSERT INTO `#__system_config` (config_key, config_value, description) VALUES + ('server_hwfp', '', 'Cached server-side hardware fingerprint (JSON, components + composite hash)') +ON DUPLICATE KEY UPDATE config_key = config_key; + +INSERT INTO `#__system_config` (config_key, config_value, description) VALUES + ('license_prev_tier', '', 'Last-known good tier; used during 7-day rebinding_required grace') +ON DUPLICATE KEY UPDATE config_key = config_key; diff --git a/FINAL_PRODUCTION_SYSTEM/database/license_p2_phonehome_migration.sql b/FINAL_PRODUCTION_SYSTEM/database/license_p2_phonehome_migration.sql new file mode 100644 index 0000000..441b01b --- /dev/null +++ b/FINAL_PRODUCTION_SYSTEM/database/license_p2_phonehome_migration.sql @@ -0,0 +1,42 @@ +-- ============================================================= +-- KeyGate v2.3.0 P2 — Phone-home + grace + revocation + clock-drift +-- ============================================================= +-- Phone-home turns the Cloudflare Worker into the authoritative tier +-- source. Without it, a JWT registered once was good forever — pirates +-- could buy one license, export the JWT, and seed unlimited installs. +-- +-- Phone-home cadence: once per 24h on PHP boot OR daily cron, whichever +-- fires first. Non-blocking; cached tier serves until next successful +-- validate. Grace: 0–14d OK, 14–30d banner, >30d community fallback. +-- +-- Revocation: each issued JWT carries a `jti` claim. Worker maintains +-- a KV set `revoked:{jti}`. Validate response carries revoked:true → +-- PHP forces community immediately, regardless of grace window. +-- +-- Clock drift: Worker returns server_time; PHP records server vs local +-- delta. Three consecutive checks with >5min drift → 'clock_drift'. +-- Defeats pirates rolling system clock back to dodge expires_at. +-- ============================================================= + +ALTER TABLE `#__license_info` + ADD COLUMN validation_failure_count INT UNSIGNED NOT NULL DEFAULT 0 + AFTER last_validated_at, + ADD COLUMN last_validation_error TEXT NULL DEFAULT NULL + AFTER validation_failure_count, + ADD COLUMN server_time_drift_seconds INT NOT NULL DEFAULT 0 + AFTER last_validation_error, + ADD COLUMN clock_drift_strikes TINYINT UNSIGNED NOT NULL DEFAULT 0 + AFTER server_time_drift_seconds, + ADD COLUMN current_jti CHAR(36) NULL DEFAULT NULL + AFTER clock_drift_strikes; + +-- system_config slots used by P2. +-- license_validation_cache: JSON of last validate response + HMAC anchor +-- so the cache itself can't be forged via direct UPDATE. +INSERT INTO `#__system_config` (config_key, config_value, description) VALUES + ('license_validation_cache', '', 'Last /api/validate response (JSON, HMAC-anchored to license_row_secret)') +ON DUPLICATE KEY UPDATE config_key = config_key; + +INSERT INTO `#__system_config` (config_key, config_value, description) VALUES + ('license_phonehome_interval', '86400', 'Seconds between phone-home validate calls (default 24h)') +ON DUPLICATE KEY UPDATE config_key = config_key; diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts b/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts index 348615f..9a12967 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts @@ -32,10 +32,41 @@ export interface LicenseUsage { keys: number } +// P1: hardware fingerprint metadata exposed to the License page so the +// admin can see current vs. bound state and the rebind quota counter. +export interface LicenseHardware { + current_fingerprint: string + components: Record + bound_fingerprint: string + rebind_count: number + rebind_quota_limit: number + rebind_window_days: number +} + +// P2: phone-home status surfaced to the License page. +export interface LicensePhoneHome { + available: boolean + last_validated_at?: string | null + failure_count?: number + last_error?: string | null + server_time_drift_seconds?: number + clock_drift_strikes?: number + current_jti?: string | null + grace_band?: 'ok' | 'banner' | 'expired' + grace_days?: number + grace_banner?: string | null + grace_banner_threshold_d?: number + grace_hard_threshold_d?: number + effective_band?: string | null + effective_banner?: string | null +} + export interface LicenseStatusResponse { success: boolean - license: LicenseInfo + license: LicenseInfo & { rebind_required?: boolean; rebind_grace_ends?: string | null } usage: LicenseUsage + hardware?: LicenseHardware + phonehome?: LicensePhoneHome } export function getLicenseStatus() { @@ -56,7 +87,7 @@ export function deactivateLicense() { return apiPostJson<{ success: boolean; message: string }>('license_deactivate') } -export function generateDevLicense(tier: string) { +export function generateDevLicense(tier: string, devToken: string) { return apiPostJson<{ success: boolean license_key?: string @@ -64,5 +95,75 @@ export function generateDevLicense(tier: string) { instance_id?: string message?: string error?: string - }>('license_generate_dev', { tier }) + }>('license_generate_dev', { tier, dev_token: devToken }) +} + +// P0: claim a pending GitHub Sponsors / LemonSqueezy / T-Bank purchase by +// binding it to this install's instance_id. Worker mints an RS256 JWT. +export function claimLicense(email: string, sponsorLogin?: string) { + return apiPostJson<{ + success: boolean + license_key?: string + tier?: string + expires_at?: string + message?: string + error?: string + }>('license_claim', { email, sponsor_login: sponsorLogin || '' }) +} + +// P0: migrate a legacy HS256 license to RS256 (90-day window post v2.3.0). +export function migrateLegacyLicense(licenseKey: string) { + return apiPostJson<{ + success: boolean + license_key?: string + tier?: string + expires_at?: string + message?: string + error?: string + }>('license_migrate', { license_key: licenseKey }) +} + +// P1: re-detect the server's hardware fingerprint (admin-triggered, force +// refresh of the cached system_config('server_hwfp')). +export function redetectHardware() { + return apiPostJson<{ + success: boolean + fingerprint?: string + components?: Record + computed_at?: string + error?: string + }>('license_redetect_hw') +} + +// P1: rebind the active license to the current host's hardware +// fingerprint. Worker enforces 3-per-365-day quota. +export function rebindLicense(reason?: string) { + return apiPostJson<{ + success: boolean + tier?: string + rebind_count?: number + rebind_quota_remaining?: number + rebind_quota_limit?: number + quota_window_days?: number + quota_limit?: number + retry_after_iso?: string + message?: string + error?: string + }>('license_rebind', { reason: reason || '' }) +} + +// P2: force a phone-home validate now (bypass 24h throttle). +export function forceValidate() { + return apiPostJson<{ + success: boolean + valid?: boolean + tier?: string + revoked?: boolean + must_rebind?: boolean + expires_at?: string | null + jti?: string | null + server_time?: string | null + message?: string + error?: string + }>('license_force_validate') } diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts b/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts index 678ece9..8bd477b 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts @@ -6,6 +6,11 @@ import { registerLicense, deactivateLicense, generateDevLicense, + claimLicense, + migrateLegacyLicense, + redetectHardware, + rebindLicense, + forceValidate, } from '@/api/license' export function useLicenseStatus() { @@ -47,7 +52,8 @@ export function useDeactivateLicense() { export function useGenerateDevLicense() { const { t } = useTranslation() return useMutation({ - mutationFn: (tier: string) => generateDevLicense(tier), + mutationFn: ({ tier, devToken }: { tier: string; devToken: string }) => + generateDevLicense(tier, devToken), onSuccess: (data) => { if (data.success) { toast.success(data.message || t('license.dev_generated', 'Dev license generated')) @@ -56,3 +62,95 @@ export function useGenerateDevLicense() { onError: (e: Error) => toast.error(e.message), }) } + +export function useClaimLicense() { + const qc = useQueryClient() + const { t } = useTranslation() + return useMutation({ + mutationFn: ({ email, sponsorLogin }: { email: string; sponsorLogin?: string }) => + claimLicense(email, sponsorLogin), + onSuccess: (data) => { + qc.invalidateQueries({ queryKey: ['license-status'] }) + if (data.success) { + toast.success(data.message || t('license.claimed', 'License claimed and activated')) + } + }, + onError: (e: Error) => toast.error(e.message), + }) +} + +export function useMigrateLegacyLicense() { + const qc = useQueryClient() + const { t } = useTranslation() + return useMutation({ + mutationFn: (key: string) => migrateLegacyLicense(key), + onSuccess: (data) => { + qc.invalidateQueries({ queryKey: ['license-status'] }) + if (data.success) { + toast.success(data.message || t('license.migrated', 'License migrated to RS256')) + } + }, + onError: (e: Error) => toast.error(e.message), + }) +} + +// P1: re-detect server hardware fingerprint (force refresh of cached value). +export function useRedetectHardware() { + const qc = useQueryClient() + const { t } = useTranslation() + return useMutation({ + mutationFn: () => redetectHardware(), + onSuccess: (data) => { + qc.invalidateQueries({ queryKey: ['license-status'] }) + if (data.success) { + toast.success(t('license.hw_redetected', 'Hardware fingerprint re-detected')) + } + }, + onError: (e: Error) => toast.error(e.message), + }) +} + +// P1: rebind license to current host's hardware fingerprint. +// Worker enforces 3 rebinds per rolling 365 days. +export function useRebindLicense() { + const qc = useQueryClient() + const { t } = useTranslation() + return useMutation({ + mutationFn: (reason?: string) => rebindLicense(reason), + onSuccess: (data) => { + qc.invalidateQueries({ queryKey: ['license-status'] }) + if (data.success) { + toast.success(data.message || t('license.rebound', 'License rebound to current hardware')) + } else { + toast.error(data.error || t('license.rebind_failed', 'Rebind failed')) + } + }, + onError: (e: Error) => toast.error(e.message), + }) +} + +// P2: force phone-home validate now (bypass 24h throttle). +export function useForceValidate() { + const qc = useQueryClient() + const { t } = useTranslation() + return useMutation({ + mutationFn: () => forceValidate(), + onSuccess: (data) => { + qc.invalidateQueries({ queryKey: ['license-status'] }) + if (data.success) { + if (data.revoked) { + toast.error(t('license.validate_revoked', 'License revoked by issuer')) + } else if (data.must_rebind) { + toast.warning(t('license.validate_rebind', 'Hardware rebind required')) + } else if (data.valid) { + toast.success(t('license.validate_ok', 'Validated successfully')) + } else { + toast.error(t('license.validate_failed', 'Validation failed')) + } + } else { + toast.error(data.error || t('license.validate_failed', 'Validation failed')) + } + }, + onError: (e: Error) => toast.error(e.message), + }) +} diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json index ea1d80d..cc2e8c7 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json @@ -1733,6 +1733,43 @@ "sub.tab_payment": "Payment", "sub.tab_key": "License Key", "sub.tab_billing": "Billing", + "sub.claim_title": "Claim a GitHub Sponsors / pending purchase", + "sub.claim_desc": "If you sponsored ChesnoTech on GitHub or your LemonSqueezy/T-Bank checkout did not include your instance ID, claim your license here. The server binds it to this installation and issues your key.", + "sub.claim_email": "Email used at checkout / GitHub sponsor email", + "sub.claim_sponsor": "GitHub sponsor login (optional)", + "sub.claim_button": "Claim & bind to this install", + "sub.migrate_title": "Migrate legacy license (pre-v2.3)", + "sub.migrate_desc": "If you have a license key issued before May 2026 (HS256 algorithm), use this to upgrade it to the new RS256 format. Available until 2026-08-08.", + "sub.migrate_placeholder": "Paste the legacy HS256 license key...", + "sub.migrate_button": "Migrate to RS256 & activate", + "sub.dev_token_placeholder": "DEV_TOKEN (matches Worker secret)", + "sub.hw_title": "Hardware binding", + "sub.hw_desc": "Each license is anchored to the host hardware fingerprint. Quota: 3 rebinds per 365 days.", + "sub.hw_rebind_required": "License is bound to different hardware. Click Rebind to anchor to current host. Grace ends on", + "sub.hw_current": "Current host fingerprint", + "sub.hw_bound": "Bound by license", + "sub.hw_unbound": "unbound", + "sub.hw_quota": "Rebinds used", + "sub.hw_redetect": "Re-detect hardware", + "sub.hw_rebind": "Rebind to current hardware", + "sub.rebind_reason_placeholder": "Reason (e.g. motherboard RMA, NIC swap)", + "license.claimed": "License claimed and activated", + "license.migrated": "License migrated to RS256", + "license.hw_redetected": "Hardware fingerprint re-detected", + "license.rebound": "License rebound to current hardware", + "license.rebind_failed": "Rebind failed", + "license.validate_ok": "Validated successfully", + "license.validate_failed": "Validation failed", + "license.validate_revoked": "License revoked by issuer", + "license.validate_rebind": "Hardware rebind required", + "sub.phonehome_title": "License validation (phone-home)", + "sub.phonehome_desc": "Daily check against the issuer. 14-day grace if offline; 30-day hard cutoff.", + "sub.phonehome_last": "Last validated", + "sub.phonehome_never": "never", + "sub.phonehome_failures": "Recent failures", + "sub.phonehome_drift": "Clock drift (sec)", + "sub.phonehome_last_error": "Last error", + "sub.phonehome_force": "Validate now", "sub.registered_to": "Registered to {{email}}", "sub.free_tier": "Free tier — limited to 1 technician and 50 keys", "sub.technicians": "Technicians", diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json index 93eb629..bd223b5 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json @@ -1733,6 +1733,43 @@ "sub.tab_payment": "Оплата", "sub.tab_key": "Лицензия", "sub.tab_billing": "Биллинг", + "sub.claim_title": "Привязать спонсорство GitHub / ожидающую покупку", + "sub.claim_desc": "Если вы оформили спонсорство ChesnoTech на GitHub или ваша оплата через LemonSqueezy/Т-Банк не содержала идентификатор экземпляра, привяжите лицензию здесь. Сервер свяжет её с этой установкой и выдаст ключ.", + "sub.claim_email": "Email при оплате / email GitHub-спонсора", + "sub.claim_sponsor": "Логин GitHub-спонсора (необязательно)", + "sub.claim_button": "Привязать к этой установке", + "sub.migrate_title": "Миграция старой лицензии (до v2.3)", + "sub.migrate_desc": "Если у вас лицензия, выданная до мая 2026 (алгоритм HS256), используйте это, чтобы обновить её до нового формата RS256. Доступно до 2026-08-08.", + "sub.migrate_placeholder": "Вставьте старый ключ HS256...", + "sub.migrate_button": "Перевести в RS256 и активировать", + "sub.dev_token_placeholder": "DEV_TOKEN (совпадает с секретом Worker)", + "sub.hw_title": "Привязка к оборудованию", + "sub.hw_desc": "Каждая лицензия привязана к отпечатку оборудования хоста. Квота: 3 переноса за 365 дней.", + "sub.hw_rebind_required": "Лицензия привязана к другому оборудованию. Нажмите «Перенести», чтобы зафиксировать на текущем хосте. Льготный период до", + "sub.hw_current": "Текущий отпечаток хоста", + "sub.hw_bound": "Привязан лицензией", + "sub.hw_unbound": "не привязан", + "sub.hw_quota": "Перенесено", + "sub.hw_redetect": "Обновить отпечаток", + "sub.hw_rebind": "Перенести на текущее оборудование", + "sub.rebind_reason_placeholder": "Причина (например, замена материнской платы, замена NIC)", + "license.claimed": "Лицензия привязана и активирована", + "license.migrated": "Лицензия переведена в RS256", + "license.hw_redetected": "Отпечаток оборудования обновлён", + "license.rebound": "Лицензия перенесена на текущее оборудование", + "license.rebind_failed": "Перенос не удался", + "license.validate_ok": "Проверка успешна", + "license.validate_failed": "Проверка не удалась", + "license.validate_revoked": "Лицензия отозвана издателем", + "license.validate_rebind": "Требуется привязка к оборудованию", + "sub.phonehome_title": "Проверка лицензии (phone-home)", + "sub.phonehome_desc": "Ежедневная проверка у издателя. 14 дней льготы при отсутствии сети; жёсткое отключение через 30 дней.", + "sub.phonehome_last": "Последняя проверка", + "sub.phonehome_never": "никогда", + "sub.phonehome_failures": "Недавние сбои", + "sub.phonehome_drift": "Сдвиг часов (сек)", + "sub.phonehome_last_error": "Последняя ошибка", + "sub.phonehome_force": "Проверить сейчас", "sub.registered_to": "Зарегистрирована на {{email}}", "sub.free_tier": "Бесплатный тариф — 1 техник, 50 ключей", "sub.technicians": "Техники", diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/pages/license/index.tsx b/FINAL_PRODUCTION_SYSTEM/frontend/src/pages/license/index.tsx index f5b661f..01dd460 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/pages/license/index.tsx +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/pages/license/index.tsx @@ -34,12 +34,18 @@ import { Clock, Zap, Check, + AlertTriangle, } from 'lucide-react' import { useLicenseStatus, useRegisterLicense, useDeactivateLicense, useGenerateDevLicense, + useClaimLicense, + useMigrateLegacyLicense, + useRedetectHardware, + useRebindLicense, + useForceValidate, } from '@/hooks/use-license' const TIER_COLORS: Record = { @@ -61,14 +67,26 @@ export function LicensePage() { const [activeTab, setActiveTab] = useState('plan') const [licenseKey, setLicenseKey] = useState('') const [devLicenseKey, setDevLicenseKey] = useState('') + const [devToken, setDevToken] = useState('') + const [claimEmail, setClaimEmail] = useState('') + const [claimSponsor, setClaimSponsor] = useState('') + const [legacyKey, setLegacyKey] = useState('') + const [rebindReason, setRebindReason] = useState('') const statusQuery = useLicenseStatus() const registerMut = useRegisterLicense() const deactivateMut = useDeactivateLicense() const devGenMut = useGenerateDevLicense() + const claimMut = useClaimLicense() + const migrateMut = useMigrateLegacyLicense() + const redetectMut = useRedetectHardware() + const rebindMut = useRebindLicense() + const forceValidateMut = useForceValidate() const license = statusQuery.data?.license const usage = statusQuery.data?.usage + const hardware = statusQuery.data?.hardware + const phonehome = statusQuery.data?.phonehome const handleRegister = async () => { if (!licenseKey.trim()) return @@ -77,12 +95,50 @@ export function LicensePage() { } const handleGenerateDev = async (tier: string) => { - const result = await devGenMut.mutateAsync(tier) + if (!devToken.trim()) { + alert('DEV_TOKEN required. Set it via `wrangler secret put DEV_TOKEN` on the Worker, then paste here.') + return + } + const result = await devGenMut.mutateAsync({ tier, devToken: devToken.trim() }) if (result.license_key) { setDevLicenseKey(result.license_key) } } + const handleClaim = async () => { + if (!claimEmail.trim()) return + const result = await claimMut.mutateAsync({ + email: claimEmail.trim(), + sponsorLogin: claimSponsor.trim() || undefined, + }) + if (result.license_key) { + setClaimEmail('') + setClaimSponsor('') + } + } + + const handleMigrateLegacy = async () => { + if (!legacyKey.trim()) return + const result = await migrateMut.mutateAsync(legacyKey.trim()) + if (result.success) { + setLegacyKey('') + } + } + + const handleRedetectHw = async () => { + await redetectMut.mutateAsync() + } + + const handleRebind = async () => { + const reason = rebindReason.trim() + const result = await rebindMut.mutateAsync(reason || undefined) + if (result.success) setRebindReason('') + } + + const handleForceValidate = async () => { + await forceValidateMut.mutateAsync() + } + if (statusQuery.isLoading) { return (
@@ -479,7 +535,7 @@ export function LicensePage() {