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() {