From be1d71c61d3b707cc7196a0befaae86142f47996 Mon Sep 17 00:00:00 2001 From: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com> Date: Fri, 8 May 2026 00:27:54 +0300 Subject: [PATCH 1/2] =?UTF-8?q?P0:=20anti-piracy=20hardening=20=E2=80=94?= =?UTF-8?q?=20RS256=20JWT=20+=20DB=20row=20HMAC=20+=20remove=20wildcard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates trivial license-bypass paths from the threat model: 1. JWT signing HS256 -> RS256 (asymmetric) - Hardcoded secret 'keygate-community-verification-key-2026' no longer enables forging enterprise tokens. Private key only on Cloudflare Worker (LICENSE_PRIVATE_KEY secret); public key embedded in license-helpers.php for verify-only. - PHP decodeLicenseJwt() uses openssl_verify(OPENSSL_ALGO_SHA256). - Worker uses crypto.subtle RSASSA-PKCS1-v1_5 SHA-256. - Removed createLicenseJwt() — local code never signs in prod. - 90-day legacy HS256 verify window via LEGACY_HS256_SECRET + /api/migrate route for existing customers. 2. DB row integrity HMAC - New column license_info.integrity_hmac (CHAR(64)). - Per-instance license_row_secret in system_config, rotated on every successful registerLicense(). - getEffectiveLicense(), canAddTechnician(), canAddKeys(), and isFeatureAvailable() all re-check HMAC; mismatch -> forced community fallback + validation_status='invalid'. - Defeats direct INSERT bypass: attacker needs both row fields AND the per-instance secret, and the secret rotates. 3. Wildcard instance_id='*' removed - Worker GitHub Sponsors / LemonSqueezy / T-Bank flows no longer issue wildcard tokens. Pending purchases stored as pending_claim:true; customer binds via /api/claim with their installation's instance_id. - registerLicense() rejects wildcard payloads. 4. Dev license generation hardened - Local signing removed. /api/dev-issue Worker route gated by DEV_TOKEN secret. LicenseController calls Worker, requires admin to paste DEV_TOKEN. 5. Frontend - "Claim license" card (GitHub Sponsors / pending purchases). - "Migrate legacy license" card (HS256 -> RS256). - DEV_TOKEN input on Dev Tools card. - 12 new i18n keys in en.json + ru.json. Files: - license-server/worker.js, wrangler.toml — RS256 signer, /api/claim, /api/migrate, /api/dev-issue, no-wildcard issuance - functions/license-helpers.php — RS256 verify, row HMAC, secret rotation, wildcard rejection - controllers/admin/LicenseController.php — Worker dev-issue, claim, migrate handlers - admin_v2.php — register license_claim, license_migrate actions - database/license_p0_hmac_migration.sql — integrity_hmac column - database/docker-init/00-init.sh, install/ajax.php — migration phase 27 - frontend/api/license.ts, hooks/use-license.ts, pages/license/index.tsx, test/api-contracts.test.ts, i18n/en.json, i18n/ru.json - .gitignore — exclude license-server/.keys/ Backward-compat: v2.2.0 community installs auto-fallback to community on first boot post-upgrade (HS256 rejected, banner prompts re-register). Single existing paid customer migrates via /api/migrate. Verification (live PHP smoke test): - RS256 token verifies via openssl_verify -> OK - Legacy HS256 token verifies during migration window -> OK - Tampered JWT signature -> rejected - HMAC compute returns 64 hex chars Phase 1 of 3. P1 (hardware-fingerprint + rebind quota) and P2 (phone-home grace + revocation) follow in separate PRs. Plan: ~/.claude/plans/polymorphic-coalescing-bumblebee.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + FINAL_PRODUCTION_SYSTEM/admin_v2.php | 2 + .../controllers/admin/LicenseController.php | 166 +++++++- .../database/docker-init/00-init.sh | 3 + .../database/license_p0_hmac_migration.sql | 18 + .../frontend/src/api/license.ts | 29 +- .../frontend/src/hooks/use-license.ts | 36 +- .../frontend/src/i18n/en.json | 12 + .../frontend/src/i18n/ru.json | 12 + .../frontend/src/pages/license/index.tsx | 99 ++++- .../frontend/src/test/api-contracts.test.ts | 2 + .../functions/license-helpers.php | 238 ++++++++++-- FINAL_PRODUCTION_SYSTEM/install/ajax.php | 1 + license-server/worker.js | 367 ++++++++++++++---- license-server/wrangler.toml | 27 +- 15 files changed, 878 insertions(+), 137 deletions(-) create mode 100644 FINAL_PRODUCTION_SYSTEM/database/license_p0_hmac_migration.sql 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/FINAL_PRODUCTION_SYSTEM/admin_v2.php b/FINAL_PRODUCTION_SYSTEM/admin_v2.php index 5937524..d8d7039 100644 --- a/FINAL_PRODUCTION_SYSTEM/admin_v2.php +++ b/FINAL_PRODUCTION_SYSTEM/admin_v2.php @@ -337,6 +337,8 @@ '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], // system upgrade 'upgrade_check_github' => ['UpgradeController.php', 'handle_upgrade_check_github', false, true], diff --git a/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php b/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php index 4020115..d975954 100644 --- a/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php +++ b/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php @@ -89,8 +89,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 +104,155 @@ 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 - ]; - - $jwt = createLicenseJwt($payload); + + $body = json_encode([ + 'tier' => $tier, + 'email' => 'dev@localhost', + 'instance_id' => $instanceId, + '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, + '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); + $body = json_encode([ + 'email' => $email, + 'instance_id' => $instanceId, + '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); + $body = json_encode([ + 'license_key' => $legacyKey, 'instance_id' => $instanceId, - 'message' => "Development {$tierDef['label']} license generated. Paste it into the registration field.", ]); + $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, + ])); } diff --git a/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh b/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh index fb6bcc1..55ca959 100644 --- a/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh +++ b/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh @@ -149,6 +149,9 @@ 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 + 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/frontend/src/api/license.ts b/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts index 348615f..eb38457 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts @@ -56,7 +56,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 +64,30 @@ 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 }) } diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts b/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts index 678ece9..c8a7447 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts @@ -6,6 +6,8 @@ import { registerLicense, deactivateLicense, generateDevLicense, + claimLicense, + migrateLegacyLicense, } from '@/api/license' export function useLicenseStatus() { @@ -47,7 +49,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 +59,34 @@ 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), + }) +} diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json index ea1d80d..95eab65 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json @@ -1733,6 +1733,18 @@ "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)", + "license.claimed": "License claimed and activated", + "license.migrated": "License migrated to RS256", "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..178ff1d 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json @@ -1733,6 +1733,18 @@ "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)", + "license.claimed": "Лицензия привязана и активирована", + "license.migrated": "Лицензия переведена в RS256", "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..ffd20da 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/pages/license/index.tsx +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/pages/license/index.tsx @@ -40,6 +40,8 @@ import { useRegisterLicense, useDeactivateLicense, useGenerateDevLicense, + useClaimLicense, + useMigrateLegacyLicense, } from '@/hooks/use-license' const TIER_COLORS: Record = { @@ -61,11 +63,17 @@ 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 statusQuery = useLicenseStatus() const registerMut = useRegisterLicense() const deactivateMut = useDeactivateLicense() const devGenMut = useGenerateDevLicense() + const claimMut = useClaimLicense() + const migrateMut = useMigrateLegacyLicense() const license = statusQuery.data?.license const usage = statusQuery.data?.usage @@ -77,12 +85,36 @@ 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('') + } + } + if (statusQuery.isLoading) { return (
@@ -479,7 +511,7 @@ export function LicensePage() {