From 882fd6697ee345b8e3f4878d4f578393ff506632 Mon Sep 17 00:00:00 2001 From: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com> Date: Fri, 8 May 2026 01:20:31 +0300 Subject: [PATCH] =?UTF-8?q?P1:=20hardware-bound=20licensing=20=E2=80=94=20?= =?UTF-8?q?server=20hwfp=20+=203-per-365=20rebind=20quota?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binds each license_info row to the host's server-side hardware fingerprint so a license cannot be moved to a different VM/host without invoking the Worker /api/rebind route, which is rate-limited. Closes the easy "clone the VM, keep using the license" bypass. 1. Server hardware fingerprint (cross-OS) - functions/hardware-fingerprint.php — composite SHA256 of machine_id | system_uuid | primary_mac | root_volume_uuid | host_os. - Linux: /etc/machine-id, /sys/class/dmi/id/product_uuid, NIC MAC from /sys/class/net (skip lo/docker/veth/virbr/kube), blkid for root volume UUID. - Windows: HKLM\SOFTWARE\Microsoft\Cryptography MachineGuid, Win32_ComputerSystemProduct.UUID via PowerShell, Get-NetAdapter for primary MAC, `vol C:` for volume serial. - Cached in system_config('server_hwfp') as JSON; recompute only on admin "Re-detect hardware" or after successful rebind. - 3-of-5 component-match soft threshold tolerates legitimate single-component changes (NIC swap, disk replaced) without forcing a rebind. 2. Schema (license_p1_hwbind_migration.sql, phase 28) - hardware_fingerprint CHAR(64) NULL - hwfp_bound_at TIMESTAMP NULL - hwfp_rebind_count TINYINT UNSIGNED NOT NULL DEFAULT 0 - hwfp_last_rebind_at TIMESTAMP NULL - INDEX idx_hwfp on hardware_fingerprint - validation_status enum extended with 'rebinding_required' and 'clock_drift' (the latter reserved for P2). - system_config slots: server_hwfp, license_prev_tier. 3. PHP enforcement (functions/license-helpers.php) - HMAC formula now includes hardware_fingerprint as a 7th field. Existing P0 rows fail HMAC after upgrade -> community fallback -> re-register binds the new fp on insert. Acceptable backward-compat. - registerLicense() validates JWT 'hwfp' claim against server fp (3-of-5 soft threshold). Auto-binds current fp if claim absent. - getEffectiveLicense() detects fp drift and sets 'rebinding_required'. 7-day grace serves the previous tier (license_prev_tier system_config) before degrading to community. - applyRebindResponse() updates row from /api/rebind result. 4. Worker (license-server/worker.js) - createJwt now stamps `hwfp` claim into: /api/register, /api/claim, /api/migrate, /api/dev-issue all of which require a `hardware_fingerprint` body param. - New /api/rebind: verifies license_key payload, looks up KV by email, enforces 3-per-rolling-365-day quota via `rebind:{email}:{ts}` records (each with 365d TTL — count yields window count without external state). Mints fresh RS256 JWT bound to new_hardware_fingerprint, increments rebind_count, returns rebind_quota_remaining. 5. Backend handlers (controllers/admin/LicenseController.php) - handle_license_status — exposes `hardware` block (current fp, bound fp, rebind_count, quota), and license.rebind_required + license.rebind_grace_ends. - handle_license_redetect_hw — force-recompute server fp (admin-triggered). - handle_license_rebind — call Worker /api/rebind with current license_key + freshly-detected fp, applyRebindResponse on success. - admin_v2.php registers license_redetect_hw + license_rebind actions (both POST + CSRF). 6. Frontend (license page) - api/license.ts: redetectHardware(), rebindLicense(reason?) - hooks/use-license.ts: useRedetectHardware(), useRebindLicense() - pages/license/index.tsx: new "Hardware binding" card showing current vs bound fingerprint, rebind count vs quota, "Re-detect hardware" + "Rebind to current hardware" buttons. Card highlights amber when license.rebind_required is set, displays grace-window deadline. - test/api-contracts.test.ts: license_redetect_hw, license_rebind - i18n/en.json + ru.json: 15 new keys (sub.hw_*, license.hw_*, license.rebound, license.rebind_failed). Backward-compat: pre-P1 rows have NULL hardware_fingerprint, fp gate in getEffectiveLicense() skips them until customer re-registers (auto-bind on insert). Single existing paid customer rebinds via the new UI button before P2 ships. Verification (live PHP smoke): - computeServerHwfp() returns 64-char hex composite + 5 components - compareHwfp(self, self) accepts (3/5 match in Docker, expected; system_uuid + root_volume_uuid blank inside container) - compareHwfp(self, all-zero) rejects - Migration applies clean: integrity_hmac + hardware_fingerprint + hwfp_bound_at + hwfp_rebind_count + hwfp_last_rebind_at columns present, validation_status enum extended. Phase 2 of 3. P2 (phone-home grace + revocation list + clock-drift) follows in separate PR. Plan: ~/.claude/plans/polymorphic-coalescing-bumblebee.md Co-Authored-By: Claude Opus 4.7 (1M context) --- FINAL_PRODUCTION_SYSTEM/admin_v2.php | 2 + .../controllers/admin/LicenseController.php | 180 +++++++++- .../database/docker-init/00-init.sh | 3 + .../database/license_p1_hwbind_migration.sql | 38 +++ .../frontend/src/api/license.ts | 43 ++- .../frontend/src/hooks/use-license.ts | 37 ++ .../frontend/src/i18n/en.json | 13 + .../frontend/src/i18n/ru.json | 13 + .../frontend/src/pages/license/index.tsx | 89 +++++ .../frontend/src/test/api-contracts.test.ts | 2 + .../functions/hardware-fingerprint.php | 230 +++++++++++++ .../functions/license-helpers.php | 323 +++++++++++++++--- FINAL_PRODUCTION_SYSTEM/install/ajax.php | 1 + license-server/worker.js | 167 ++++++++- 14 files changed, 1070 insertions(+), 71 deletions(-) create mode 100644 FINAL_PRODUCTION_SYSTEM/database/license_p1_hwbind_migration.sql create mode 100644 FINAL_PRODUCTION_SYSTEM/functions/hardware-fingerprint.php diff --git a/FINAL_PRODUCTION_SYSTEM/admin_v2.php b/FINAL_PRODUCTION_SYSTEM/admin_v2.php index d8d7039..be4c7b3 100644 --- a/FINAL_PRODUCTION_SYSTEM/admin_v2.php +++ b/FINAL_PRODUCTION_SYSTEM/admin_v2.php @@ -339,6 +339,8 @@ '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], // 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 d975954..db50566 100644 --- a/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php +++ b/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php @@ -26,6 +26,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 +52,21 @@ 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, + ], ]); } @@ -119,12 +143,19 @@ function handle_license_generate_dev(PDO $pdo, array $admin_session, $json_input } $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([ - 'tier' => $tier, - 'email' => 'dev@localhost', - 'instance_id' => $instanceId, - 'dev_token' => $devToken, + 'tier' => $tier, + 'email' => 'dev@localhost', + 'instance_id' => $instanceId, + 'hardware_fingerprint' => $hwfpHex, + 'dev_token' => $devToken, ]); $ctx = stream_context_create([ 'http' => [ @@ -177,10 +208,17 @@ function handle_license_claim(PDO $pdo, array $admin_session, $json_input): void } $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, - 'sponsor_login' => $sponsorLogin ?: null, + 'email' => $email, + 'instance_id' => $instanceId, + 'hardware_fingerprint' => $hwfpHex, + 'sponsor_login' => $sponsorLogin ?: null, ]); $ctx = stream_context_create([ 'http' => [ @@ -225,9 +263,16 @@ function handle_license_migrate(PDO $pdo, array $admin_session, $json_input): vo } $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, + 'license_key' => $legacyKey, + 'instance_id' => $instanceId, + 'hardware_fingerprint' => $hwfpHex, ]); $ctx = stream_context_create([ 'http' => [ @@ -256,3 +301,120 @@ function handle_license_migrate(PDO $pdo, array $admin_session, $json_input): vo '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 55ca959..2808463 100644 --- a/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh +++ b/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh @@ -152,6 +152,9 @@ 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 + echo "" echo "=== Database initialization complete ===" echo "" 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/frontend/src/api/license.ts b/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts index eb38457..382bd2d 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts @@ -32,10 +32,22 @@ 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 +} + export interface LicenseStatusResponse { success: boolean - license: LicenseInfo + license: LicenseInfo & { rebind_required?: boolean; rebind_grace_ends?: string | null } usage: LicenseUsage + hardware?: LicenseHardware } export function getLicenseStatus() { @@ -91,3 +103,32 @@ export function migrateLegacyLicense(licenseKey: 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 || '' }) +} diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts b/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts index c8a7447..689155c 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts @@ -8,6 +8,8 @@ import { generateDevLicense, claimLicense, migrateLegacyLicense, + redetectHardware, + rebindLicense, } from '@/api/license' export function useLicenseStatus() { @@ -90,3 +92,38 @@ export function useMigrateLegacyLicense() { 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), + }) +} diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json index 95eab65..c7a9231 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json @@ -1743,8 +1743,21 @@ "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", "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 178ff1d..7cbe7e5 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json @@ -1743,8 +1743,21 @@ "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": "Перенос не удался", "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 ffd20da..2f0cce7 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/pages/license/index.tsx +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/pages/license/index.tsx @@ -34,6 +34,7 @@ import { Clock, Zap, Check, + AlertTriangle, } from 'lucide-react' import { useLicenseStatus, @@ -42,6 +43,8 @@ import { useGenerateDevLicense, useClaimLicense, useMigrateLegacyLicense, + useRedetectHardware, + useRebindLicense, } from '@/hooks/use-license' const TIER_COLORS: Record = { @@ -67,6 +70,7 @@ export function LicensePage() { const [claimEmail, setClaimEmail] = useState('') const [claimSponsor, setClaimSponsor] = useState('') const [legacyKey, setLegacyKey] = useState('') + const [rebindReason, setRebindReason] = useState('') const statusQuery = useLicenseStatus() const registerMut = useRegisterLicense() @@ -74,9 +78,12 @@ export function LicensePage() { const devGenMut = useGenerateDevLicense() const claimMut = useClaimLicense() const migrateMut = useMigrateLegacyLicense() + const redetectMut = useRedetectHardware() + const rebindMut = useRebindLicense() const license = statusQuery.data?.license const usage = statusQuery.data?.usage + const hardware = statusQuery.data?.hardware const handleRegister = async () => { if (!licenseKey.trim()) return @@ -115,6 +122,16 @@ export function LicensePage() { } } + const handleRedetectHw = async () => { + await redetectMut.mutateAsync() + } + + const handleRebind = async () => { + const reason = rebindReason.trim() + const result = await rebindMut.mutateAsync(reason || undefined) + if (result.success) setRebindReason('') + } + if (statusQuery.isLoading) { return (
@@ -574,6 +591,78 @@ export function LicensePage() { + {/* P1: Hardware fingerprint binding + rebind quota */} + {hardware && ( + + + + {license?.rebind_required ? ( + + ) : ( + + )} + {t('sub.hw_title', 'Hardware binding')} + + + {license?.rebind_required + ? t('sub.hw_rebind_required', 'License is bound to different hardware. Click Rebind to anchor to current host. Grace ends on') + + ` ${license.rebind_grace_ends ? new Date(license.rebind_grace_ends).toLocaleString() : '—'}` + : t('sub.hw_desc', 'Each license is anchored to the host hardware fingerprint. Quota: 3 rebinds per 365 days.')} + + + +
+
+
{t('sub.hw_current', 'Current host fingerprint')}
+
+ {hardware.current_fingerprint || '—'} +
+
+
+
{t('sub.hw_bound', 'Bound by license')}
+
+ {hardware.bound_fingerprint || t('sub.hw_unbound', 'unbound')} +
+
+
+ +
+ {t('sub.hw_quota', 'Rebinds used')}: {hardware.rebind_count} /{' '} + {hardware.rebind_quota_limit} ({hardware.rebind_window_days}d) +
+ + setRebindReason(e.target.value)} + /> + +
+ + +
+
+
+ )} + {/* Dev Tools (localhost only) */} {(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && ( diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/test/api-contracts.test.ts b/FINAL_PRODUCTION_SYSTEM/frontend/src/test/api-contracts.test.ts index a37b8af..815ada1 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/test/api-contracts.test.ts +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/test/api-contracts.test.ts @@ -179,6 +179,8 @@ const BACKEND_ACTIONS: Record license_generate_dev: { method: 'POST', csrf: true }, license_claim: { method: 'POST', csrf: true }, license_migrate: { method: 'POST', csrf: true }, + license_redetect_hw: { method: 'POST', csrf: true }, + license_rebind: { method: 'POST', csrf: true }, // system upgrade upgrade_check_github: { method: 'GET', csrf: false }, diff --git a/FINAL_PRODUCTION_SYSTEM/functions/hardware-fingerprint.php b/FINAL_PRODUCTION_SYSTEM/functions/hardware-fingerprint.php new file mode 100644 index 0000000..09fba34 --- /dev/null +++ b/FINAL_PRODUCTION_SYSTEM/functions/hardware-fingerprint.php @@ -0,0 +1,230 @@ +nul'); + if ($out && preg_match('/MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]+)/', $out, $m)) { + return strtolower(str_replace(['-', '{', '}'], '', $m[1])); + } + return ''; + } + // Linux: /etc/machine-id is the canonical 32-char id + foreach (['/etc/machine-id', '/var/lib/dbus/machine-id'] as $p) { + if (is_readable($p)) { + $v = trim((string)@file_get_contents($p)); + if ($v !== '' && preg_match('/^[0-9a-f]{32}$/', $v)) return $v; + } + } + return ''; +} + +/** + * System UUID — DMI / SMBIOS provided. Stable across reboots, OS reinstalls. + */ +function hwfpSystemUuid(): string { + if (PHP_OS_FAMILY === 'Windows') { + // PowerShell is far cleaner than wmic for parsing. + $cmd = 'powershell -NoProfile -ExecutionPolicy Bypass -Command "(Get-CimInstance Win32_ComputerSystemProduct).UUID"'; + $out = @shell_exec($cmd); + if ($out) { + $v = strtolower(trim($out)); + if (preg_match('/^[0-9a-f-]{32,36}$/', $v)) return str_replace('-', '', $v); + } + return ''; + } + // Linux: prefer /sys/class/dmi over shell-out for unprivileged hosts. + $sys = '/sys/class/dmi/id/product_uuid'; + if (is_readable($sys)) { + $v = strtolower(trim((string)@file_get_contents($sys))); + if ($v !== '' && $v !== '00000000-0000-0000-0000-000000000000') { + return str_replace('-', '', $v); + } + } + // Fallback: dmidecode (often requires root, may fail silently) + $out = @shell_exec('dmidecode -s system-uuid 2>/dev/null'); + if ($out) { + $v = strtolower(trim($out)); + if (preg_match('/^[0-9a-f-]{32,36}$/', $v)) return str_replace('-', '', $v); + } + return ''; +} + +/** + * Primary network MAC — first non-loopback, non-virtual interface. + * Normalised to lowercase no-separator hex. + */ +function hwfpPrimaryMac(): string { + if (PHP_OS_FAMILY === 'Windows') { + // Build PS command using a heredoc-like approach: PowerShell sees + // single-quoted 'Up'; PHP doesn't escape it (we use double-quoted PHP + // strings with no $-vars or escapes that PowerShell would misread). + $psScript = "(Get-NetAdapter | Where-Object { \$_.Status -eq 'Up' -and \$_.HardwareInterface -eq \$true }" + . " | Sort-Object -Property ifIndex | Select-Object -First 1).MacAddress"; + $cmd = 'powershell -NoProfile -ExecutionPolicy Bypass -Command "' . $psScript . '"'; + $out = @shell_exec($cmd); + if ($out) { + $v = strtolower(trim($out)); + $v = preg_replace('/[^0-9a-f]/', '', $v); + if (strlen($v) === 12) return $v; + } + return ''; + } + // Linux: walk /sys/class/net, skip loopback / docker / virtual. + $skipPrefixes = ['lo', 'docker', 'br-', 'veth', 'virbr', 'kube']; + $dirs = @glob('/sys/class/net/*'); + if (!$dirs) return ''; + sort($dirs); + foreach ($dirs as $d) { + $name = basename($d); + foreach ($skipPrefixes as $p) { + if (strpos($name, $p) === 0) continue 2; + } + $macFile = $d . '/address'; + if (is_readable($macFile)) { + $v = strtolower(trim((string)@file_get_contents($macFile))); + $v = preg_replace('/[^0-9a-f]/', '', $v); + if (strlen($v) === 12 && $v !== '000000000000') return $v; + } + } + return ''; +} + +/** + * Root volume UUID. On Linux: blkid for the device backing /. On Windows: + * `vol C:` — first volume serial in 8-hex format. + */ +function hwfpRootVolumeUuid(): string { + if (PHP_OS_FAMILY === 'Windows') { + $out = @shell_exec('vol C: 2>nul'); + if ($out && preg_match('/Serial Number is\s+([0-9A-F-]+)/i', $out, $m)) { + return strtolower(str_replace('-', '', $m[1])); + } + return ''; + } + // Linux: findmnt → blkid + $src = trim((string)@shell_exec("findmnt -no SOURCE / 2>/dev/null")); + if ($src !== '') { + $u = trim((string)@shell_exec('blkid -s UUID -o value ' . escapeshellarg($src) . ' 2>/dev/null')); + if ($u !== '') return strtolower(str_replace('-', '', $u)); + } + return ''; +} + +/** + * Compute the full hwfp tuple (components + composite hash). + * Pure function — no PDO, no caching. + */ +function computeServerHwfp(): array { + $components = [ + 'machine_id' => hwfpMachineId(), + 'system_uuid' => hwfpSystemUuid(), + 'primary_mac' => hwfpPrimaryMac(), + 'root_volume_uuid' => hwfpRootVolumeUuid(), + 'host_os' => strtolower(PHP_OS_FAMILY), + ]; + $material = implode('|', [ + $components['machine_id'], + $components['system_uuid'], + $components['primary_mac'], + $components['root_volume_uuid'], + $components['host_os'], + ]); + return [ + 'components' => $components, + 'composite' => hash('sha256', $material), + 'computed_at' => date('c'), + ]; +} + +/** + * Cached server hwfp; recompute on $force=true. Stored as JSON in + * system_config('server_hwfp'). + */ +function getServerHardwareFingerprint(PDO $pdo, bool $force = false): array { + if (!$force) { + $cached = getConfig('server_hwfp'); + if (!empty($cached)) { + $decoded = json_decode($cached, true); + if (is_array($decoded) && !empty($decoded['composite'])) { + return $decoded; + } + } + } + $hwfp = computeServerHwfp(); + if (function_exists('saveConfigBatch')) { + saveConfigBatch($pdo, ['server_hwfp' => json_encode($hwfp)]); + } + return $hwfp; +} + +/** + * Compare two hwfp arrays component-by-component. Returns a struct: + * match_count — int 0..5 + * total — int 5 + * composite_eq — bool + * accepted — bool (composite_eq OR match_count >= 3) + * diff_components — list of names that differ + * + * The 3-of-5 soft threshold is a deliberate trade-off: legitimate + * hardware events (NIC swap, disk replacement, BIOS reset clearing UUID) + * rarely change more than 2 components. Forging all 5 requires admin + * tooling on every clone. + */ +function compareHwfp(array $a, array $b): array { + $ac = $a['components'] ?? []; + $bc = $b['components'] ?? []; + $names = ['machine_id', 'system_uuid', 'primary_mac', 'root_volume_uuid', 'host_os']; + $matches = 0; + $diff = []; + foreach ($names as $n) { + $av = (string)($ac[$n] ?? ''); + $bv = (string)($bc[$n] ?? ''); + // Empty-string components on both sides count as a non-match + // (defensively ignore them rather than treat them as agreement). + if ($av !== '' && $av === $bv) { + $matches++; + } else { + $diff[] = $n; + } + } + $compositeEq = !empty($a['composite']) && !empty($b['composite']) + && hash_equals($a['composite'], $b['composite']); + return [ + 'match_count' => $matches, + 'total' => count($names), + 'composite_eq' => $compositeEq, + 'accepted' => $compositeEq || $matches >= 3, + 'diff_components'=> $diff, + ]; +} diff --git a/FINAL_PRODUCTION_SYSTEM/functions/license-helpers.php b/FINAL_PRODUCTION_SYSTEM/functions/license-helpers.php index 5212a20..5dda692 100644 --- a/FINAL_PRODUCTION_SYSTEM/functions/license-helpers.php +++ b/FINAL_PRODUCTION_SYSTEM/functions/license-helpers.php @@ -22,6 +22,11 @@ define('KEYGATE_LICENSE_SERVER', 'https://keygate-license-server.msamirvip.workers.dev'); define('KEYGATE_SPONSORS_URL', 'https://github.com/sponsors/ChesnoTech'); +// P1: server-side hardware fingerprint + rebind grace window. +// 7-day grace lets admins click "Rebind hardware" before tier degrades. +require_once __DIR__ . '/hardware-fingerprint.php'; +define('KEYGATE_REBIND_GRACE_SECONDS', 7 * 86400); + // ── License Verification Public Key (RS256, PKCS#8 SPKI) ───── // Generated 2026-05-08 alongside Worker secret LICENSE_PRIVATE_KEY. // Safe to commit — public key is only useful for verification. @@ -250,10 +255,15 @@ function rotateLicenseRowSecret(PDO $pdo): string { /** * Compute the integrity HMAC for a license_info row. * - * HMAC_SHA256( license_key | tier | max_techs | max_keys | exp_unix | instance_id, row_secret ) + * HMAC_SHA256( + * license_key | tier | max_techs | max_keys | exp_unix | instance_id | hardware_fingerprint, + * row_secret + * ) * - * Anything else in the row (timestamps, JSON features blob, etc.) is not - * covered — those fields are derivative or non-security-critical. + * P1 added the trailing hardware_fingerprint field. P0 rows (no hwfp + * column or NULL value) hash with empty-string for the field; after P1 + * migration the field is always present so empty-string only means an + * intentionally unbound row (claim-pending). */ function computeLicenseRowHmac(string $secret, array $row): string { $material = implode('|', [ @@ -264,6 +274,8 @@ function computeLicenseRowHmac(string $secret, array $row): string { // expires_at is stored as DATETIME — convert to unix epoch for stable hashing. (string)(empty($row['expires_at']) ? 0 : strtotime($row['expires_at'])), (string)($row['instance_id'] ?? ''), + // P1: hardware_fingerprint (composite SHA256 from hardware-fingerprint.php) + (string)($row['hardware_fingerprint'] ?? ''), ]); return hash_hmac('sha256', $material, $secret); } @@ -296,48 +308,117 @@ function getCurrentLicense(PDO $pdo): ?array { /** * Get the effective license tier and limits. - * Falls back to community tier if no valid license OR row HMAC fails - * (defeats direct INSERT/UPDATE bypass against license_info). + * + * Decision tree (highest priority first): + * 1. No row OR row HMAC fail → community (mark row invalid). + * 2. validation_status='rebinding_required' → previous tier inside 7d grace, + * else community. + * 3. validation_status!='valid' → community. + * 4. hwfp drift detected (3-of-5 fail) → set rebinding_required, recurse. + * 5. Otherwise → row's tier. + * + * Defeats direct INSERT/UPDATE bypass (HMAC) AND VM-clone bypass (hwfp). */ function getEffectiveLicense(PDO $pdo): array { $license = getCurrentLicense($pdo); - if ($license - && $license['validation_status'] === 'valid' - && verifyLicenseRow($pdo, $license) - ) { - $tier = $license['tier'] ?? 'community'; - $tierDef = LICENSE_TIERS[$tier] ?? LICENSE_TIERS['community']; + // ── 1. Missing row OR HMAC mismatch → community ────────── + if (!$license || !verifyLicenseRow($pdo, $license)) { + // If row exists but HMAC failed, mark it invalid so admin sees the issue. + // Best-effort; ignore failures (column missing pre-migration, etc.). + if ($license && $license['validation_status'] === 'valid') { + try { + $stmt = $pdo->prepare( + "UPDATE `" . t('license_info') . "` SET validation_status = 'invalid' WHERE id = ?" + ); + $stmt->execute([$license['id']]); + error_log("KeyGate: license row HMAC mismatch on id=" . $license['id'] . ", forced to community"); + } catch (Exception $e) { /* legacy installs */ } + } + return _communityLicense($license !== null); + } - return [ - 'tier' => $tier, - 'label' => $tierDef['label'], - 'max_technicians' => (int)$license['max_technicians'], - 'max_keys' => (int)$license['max_keys'], - 'features' => $tierDef['features'], - 'licensed_to' => $license['licensed_to_email'] ?? '', - 'expires_at' => $license['expires_at'], - 'is_registered' => true, - 'instance_id' => $license['instance_id'], - ]; + $tier = $license['tier'] ?? 'community'; + $tierDef = LICENSE_TIERS[$tier] ?? LICENSE_TIERS['community']; + + // ── 2. rebinding_required → grace window of previous tier ── + if (($license['validation_status'] ?? '') === 'rebinding_required') { + $boundAt = !empty($license['hwfp_last_rebind_at']) + ? strtotime($license['hwfp_last_rebind_at']) + : (int)strtotime($license['updated_at'] ?? 'now'); + $graceEnds = $boundAt + KEYGATE_REBIND_GRACE_SECONDS; + if (time() < $graceEnds) { + $prevTier = (string)getConfig('license_prev_tier'); + if ($prevTier && isset(LICENSE_TIERS[$prevTier])) { + $prevDef = LICENSE_TIERS[$prevTier]; + return [ + 'tier' => $prevTier, + 'label' => $prevDef['label'], + 'max_technicians' => (int)($prevDef['max_technicians']), + 'max_keys' => (int)($prevDef['max_keys']), + 'features' => $prevDef['features'], + 'licensed_to' => $license['licensed_to_email'] ?? '', + 'expires_at' => $license['expires_at'], + 'is_registered' => true, + 'instance_id' => $license['instance_id'], + 'rebind_required' => true, + 'rebind_grace_ends'=> date('c', $graceEnds), + ]; + } + } + // Grace exhausted → community. + return _communityLicense(true, ['rebind_required' => true]); } - // Row HMAC failed → mark the row invalid so admin sees the issue. - // Best-effort; ignore failures (e.g. integrity_hmac column missing - // pre-migration; legacy installs land here on first boot). - if ($license && $license['validation_status'] === 'valid') { + // ── 3. Anything other than 'valid' → community ──────────── + if (($license['validation_status'] ?? '') !== 'valid') { + return _communityLicense(true); + } + + // ── 4. hwfp drift detection (P1) ────────────────────────── + // Only enforce if the row has a bound fingerprint (post-P1 install). + // Pre-P1 rows have NULL hardware_fingerprint and skip this gate until + // the customer re-registers (auto-binds current host hwfp). + if (!empty($license['hardware_fingerprint'])) { try { - $stmt = $pdo->prepare( - "UPDATE `" . t('license_info') . "` SET validation_status = 'invalid' WHERE id = ?" - ); - $stmt->execute([$license['id']]); - error_log("KeyGate: license row HMAC mismatch on id=" . $license['id'] . ", forced to community"); - } catch (Exception $e) { /* legacy installs */ } + $current = getServerHardwareFingerprint($pdo, false); + $bound = ['composite' => $license['hardware_fingerprint'], 'components' => []]; + $cmp = compareHwfp($current, $bound); + if (!$cmp['accepted']) { + _markRebindingRequired($pdo, $license, $tier); + error_log("KeyGate: hwfp drift on license id=" . $license['id'] + . ", matches=" . $cmp['match_count'] . "/" . $cmp['total'] + . ", composite_eq=" . ($cmp['composite_eq'] ? '1' : '0')); + // Recurse — caller now sees rebinding_required path. + return getEffectiveLicense($pdo); + } + } catch (Exception $e) { + // Hardware-fingerprint helper unavailable (PHP module missing, + // unsupported OS) — fail open, log, continue with bound tier. + error_log("KeyGate: hwfp comparison threw: " . $e->getMessage()); + } } - // Default community tier - $communityDef = LICENSE_TIERS['community']; return [ + 'tier' => $tier, + 'label' => $tierDef['label'], + 'max_technicians' => (int)$license['max_technicians'], + 'max_keys' => (int)$license['max_keys'], + 'features' => $tierDef['features'], + 'licensed_to' => $license['licensed_to_email'] ?? '', + 'expires_at' => $license['expires_at'], + 'is_registered' => true, + 'instance_id' => $license['instance_id'], + ]; +} + +/** + * Build the canonical community-tier response. Shared by all the + * fallback paths in getEffectiveLicense(). + */ +function _communityLicense(bool $isRegistered, array $extra = []): array { + $communityDef = LICENSE_TIERS['community']; + return array_merge([ 'tier' => 'community', 'label' => 'Community', 'max_technicians' => $communityDef['max_technicians'], @@ -345,9 +426,32 @@ function getEffectiveLicense(PDO $pdo): array { 'features' => $communityDef['features'], 'licensed_to' => '', 'expires_at' => null, - 'is_registered' => ($license !== null), + 'is_registered' => $isRegistered, 'instance_id' => '', - ]; + ], $extra); +} + +/** + * Set validation_status='rebinding_required' and persist the previous + * tier so the 7-day grace window can serve it. Called by the hwfp-drift + * branch of getEffectiveLicense() and (in P2) by phone-home responses + * carrying must_rebind:true. + */ +function _markRebindingRequired(PDO $pdo, array $license, string $prevTier): void { + try { + // Persist prev tier for the grace path. + if (function_exists('saveConfigBatch')) { + saveConfigBatch($pdo, ['license_prev_tier' => $prevTier]); + } + $stmt = $pdo->prepare( + "UPDATE `" . t('license_info') . "` + SET validation_status = 'rebinding_required' + WHERE id = ?" + ); + $stmt->execute([$license['id']]); + } catch (Exception $e) { + error_log("KeyGate: failed to mark rebinding_required: " . $e->getMessage()); + } } /** @@ -404,36 +508,65 @@ function registerLicense(PDO $pdo, string $licenseKey): array { $maxKeys = (int)($payload['max_keys'] ?? $tierDef['max_keys']); $expIso = date('Y-m-d H:i:s', (int)$payload['exp']); + // ── P1: hardware fingerprint binding ───────────────────── + // Compute current host fingerprint. If the JWT carries an `hwfp` + // claim, validate against current with the 3-of-5 soft threshold. + // If absent (legacy or pre-P1 mint), auto-bind to current. + $serverHwfp = ['composite' => '', 'components' => []]; + try { + $serverHwfp = getServerHardwareFingerprint($pdo, false); + } catch (Exception $e) { + error_log("KeyGate: hwfp helper unavailable on register: " . $e->getMessage()); + } + $hostHwfpComposite = (string)($serverHwfp['composite'] ?? ''); + + if (!empty($payload['hwfp']) && $hostHwfpComposite !== '') { + $bound = ['composite' => $payload['hwfp'], 'components' => []]; + $cmp = compareHwfp($serverHwfp, $bound); + if (!$cmp['accepted']) { + return [ + 'success' => false, + 'error' => 'License is bound to different hardware. ' + . 'Match: ' . $cmp['match_count'] . '/' . $cmp['total'] + . ' components. Run /api/rebind from your previous host ' + . 'or contact support if the previous host is unrecoverable.', + ]; + } + } + // ── P0.3: rotate per-instance row secret on every register ── $rowSecret = rotateLicenseRowSecret($pdo); - // Compute integrity HMAC for the row. + // Compute integrity HMAC for the row (P1 formula includes hwfp). $rowForHmac = [ - 'license_key' => $licenseKey, - 'tier' => $tier, - 'max_technicians' => $maxTechs, - 'max_keys' => $maxKeys, - 'expires_at' => $expIso, - 'instance_id' => $instanceId, + 'license_key' => $licenseKey, + 'tier' => $tier, + 'max_technicians' => $maxTechs, + 'max_keys' => $maxKeys, + 'expires_at' => $expIso, + 'instance_id' => $instanceId, + 'hardware_fingerprint' => $hostHwfpComposite, ]; $integrityHmac = computeLicenseRowHmac($rowSecret, $rowForHmac); // Deactivate any existing license $pdo->exec("UPDATE `" . t('license_info') . "` SET is_active = 0"); - // Insert new license + // Insert new license (P1 columns included). $stmt = $pdo->prepare(" INSERT INTO `" . t('license_info') . "` - (license_key, instance_id, tier, licensed_to_email, licensed_to_name, + (license_key, instance_id, hardware_fingerprint, hwfp_bound_at, + tier, licensed_to_email, licensed_to_name, max_technicians, max_keys, features, issued_at, expires_at, last_validated_at, validation_status, is_active, integrity_hmac) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, FROM_UNIXTIME(?), FROM_UNIXTIME(?), NOW(), 'valid', 1, - ?) + VALUES (?, ?, ?, NOW(), ?, ?, ?, ?, ?, ?, FROM_UNIXTIME(?), FROM_UNIXTIME(?), NOW(), + 'valid', 1, ?) "); $stmt->execute([ $licenseKey, $instanceId, + $hostHwfpComposite ?: null, $tier, $payload['email'] ?? null, $payload['name'] ?? null, @@ -445,21 +578,103 @@ function registerLicense(PDO $pdo, string $licenseKey): array { $integrityHmac, ]); - // Update system_config - saveConfigBatch($pdo, ['license_tier' => $tier]); + // Update system_config — store both current tier and the prev-tier + // anchor used by the rebinding_required grace window. + saveConfigBatch($pdo, [ + 'license_tier' => $tier, + 'license_prev_tier' => $tier, + ]); return [ - 'success' => true, - 'tier' => $tier, - 'label' => $tierDef['label'], - 'algorithm'=> $payload['_alg'] ?? 'unknown', - 'message' => "License registered successfully — {$tierDef['label']} tier activated" + 'success' => true, + 'tier' => $tier, + 'label' => $tierDef['label'], + 'algorithm' => $payload['_alg'] ?? 'unknown', + 'hwfp' => $hostHwfpComposite, + 'message' => "License registered successfully — {$tierDef['label']} tier activated" . (($payload['_alg'] ?? '') === 'HS256-legacy' ? ' (legacy algorithm; please re-issue via Re-register button before ' . KEYGATE_LEGACY_HS256_DEADLINE . ')' : ''), ]; } +// ── Rebind support (P1) ───────────────────────────────────── + +/** + * Apply a successful /api/rebind response to the active license row. + * Caller hands in the fresh JWT (RS256, hwfp bound to current host) and + * the new rebind quota counters. + * + * Returns ['success'=>true, 'tier'=>...] on apply or ['success'=>false] + * on failure. The new JWT must validate via decodeLicenseJwt() and bind + * to the same instance_id as the existing row. + */ +function applyRebindResponse(PDO $pdo, string $newJwt, int $rebindCount): array { + $payload = decodeLicenseJwt($newJwt); + if (!$payload) { + return ['success' => false, 'error' => 'Rebind response carried invalid JWT']; + } + $current = getCurrentLicense($pdo); + if (!$current) { + return ['success' => false, 'error' => 'No active license to rebind']; + } + if (($payload['instance_id'] ?? '') !== $current['instance_id']) { + return ['success' => false, 'error' => 'Rebind response mismatched instance_id']; + } + + $serverHwfp = getServerHardwareFingerprint($pdo, true); // force re-detect post-rebind + $hostHwfpComposite = (string)($serverHwfp['composite'] ?? ''); + + $tier = $payload['tier'] ?? $current['tier']; + $tierDef = LICENSE_TIERS[$tier] ?? LICENSE_TIERS['community']; + $maxT = (int)($payload['max_technicians'] ?? $tierDef['max_technicians']); + $maxK = (int)($payload['max_keys'] ?? $tierDef['max_keys']); + $expIso = date('Y-m-d H:i:s', (int)$payload['exp']); + + $rowSecret = rotateLicenseRowSecret($pdo); + $hmac = computeLicenseRowHmac($rowSecret, [ + 'license_key' => $newJwt, + 'tier' => $tier, + 'max_technicians' => $maxT, + 'max_keys' => $maxK, + 'expires_at' => $expIso, + 'instance_id' => $current['instance_id'], + 'hardware_fingerprint' => $hostHwfpComposite, + ]); + + $stmt = $pdo->prepare(" + UPDATE `" . t('license_info') . "` + SET license_key = ?, + hardware_fingerprint = ?, + hwfp_bound_at = NOW(), + hwfp_rebind_count = ?, + hwfp_last_rebind_at = NOW(), + tier = ?, + max_technicians = ?, + max_keys = ?, + expires_at = FROM_UNIXTIME(?), + validation_status = 'valid', + integrity_hmac = ?, + last_validated_at = NOW() + WHERE id = ? + "); + $stmt->execute([ + $newJwt, + $hostHwfpComposite ?: null, + $rebindCount, + $tier, + $maxT, + $maxK, + (int)$payload['exp'], + $hmac, + $current['id'], + ]); + + saveConfigBatch($pdo, ['license_tier' => $tier, 'license_prev_tier' => $tier]); + + return ['success' => true, 'tier' => $tier, 'rebind_count' => $rebindCount]; +} + // ── Enforcement ───────────────────────────────────────────── /** diff --git a/FINAL_PRODUCTION_SYSTEM/install/ajax.php b/FINAL_PRODUCTION_SYSTEM/install/ajax.php index 835106a..a198410 100644 --- a/FINAL_PRODUCTION_SYSTEM/install/ajax.php +++ b/FINAL_PRODUCTION_SYSTEM/install/ajax.php @@ -464,6 +464,7 @@ function installerMigrationList(): array { ['missing_drivers_migration.sql', 18], ['unallocated_space_migration.sql', 19], ['license_p0_hmac_migration.sql', 20], + ['license_p1_hwbind_migration.sql', 21], ]; } diff --git a/license-server/worker.js b/license-server/worker.js index 39f1989..53ff60f 100644 --- a/license-server/worker.js +++ b/license-server/worker.js @@ -220,11 +220,18 @@ async function handleGitHubWebhook(request, env) { } async function handleRegister(request, env) { - const { email, name, instance_id } = await request.json() + // P1: hardware_fingerprint required so the issued JWT carries an `hwfp` + // claim. PHP-side enforcement rejects mismatched fingerprints. + const { email, name, instance_id, hardware_fingerprint } = await request.json() if (!email || !instance_id) { return new Response(JSON.stringify({ error: 'Email and instance_id are required' }), { status: 400 }) } + if (!hardware_fingerprint || typeof hardware_fingerprint !== 'string' || hardware_fingerprint.length < 32) { + return new Response(JSON.stringify({ + error: 'hardware_fingerprint required (composite SHA256 from getServerHardwareFingerprint())', + }), { status: 400 }) + } // Check if already registered const existing = await env.LICENSES.get(`license:${email}`, 'json') @@ -237,11 +244,12 @@ async function handleRegister(request, env) { }), { status: 200 }) } - // Generate community license + // Generate community license — bind to instance_id AND hwfp. const payload = { iss: 'keygate-license-server', tier: 'community', instance_id: instance_id, + hwfp: hardware_fingerprint, email: email, name: name || email.split('@')[0], max_technicians: 1, @@ -258,6 +266,8 @@ async function handleRegister(request, env) { email, name: name || email.split('@')[0], instance_id, + hardware_fingerprint, + rebind_count: 0, created_at: new Date().toISOString(), }), { expirationTtl: 365 * 86400 }) @@ -529,10 +539,16 @@ async function handleTBankWebhook(request, env) { // are rejected (one-shot binding) unless the founder manually clears // pending_claim via the future P4 admin dashboard. async function handleClaim(request, env) { - const { email, instance_id, sponsor_login } = await request.json() + // P1: hardware_fingerprint required so the minted JWT carries hwfp. + const { email, instance_id, sponsor_login, hardware_fingerprint } = await request.json() if (!email || !instance_id) { return new Response(JSON.stringify({ error: 'email and instance_id required' }), { status: 400 }) } + if (!hardware_fingerprint || typeof hardware_fingerprint !== 'string' || hardware_fingerprint.length < 32) { + return new Response(JSON.stringify({ + error: 'hardware_fingerprint required (composite SHA256)', + }), { status: 400 }) + } const stored = await env.LICENSES.get(`license:${email}`, 'json') if (!stored) { @@ -560,6 +576,7 @@ async function handleClaim(request, env) { iss: 'keygate-license-server', tier, instance_id, + hwfp: hardware_fingerprint, email, name: stored.name || email.split('@')[0], payment_provider: stored.payment_provider || 'github_sponsors', @@ -570,10 +587,12 @@ async function handleClaim(request, env) { } const jwt = await createJwt(licensePayload, env) - // Update KV record — pending_claim cleared, jwt + instance_id stored. + // Update KV record — pending_claim cleared, jwt + instance_id + hwfp stored. delete stored.pending_claim stored.jwt = jwt stored.instance_id = instance_id + stored.hardware_fingerprint = hardware_fingerprint + stored.rebind_count = 0 stored.claimed_at = new Date().toISOString() await env.LICENSES.put(`license:${email}`, JSON.stringify(stored), { expirationTtl: isLifetime ? 10 * 365 * 86400 : 400 * 86400 }) @@ -594,10 +613,16 @@ async function handleClaim(request, env) { // binding or tier. Available for 90 days post-deploy; remove the secret // from Worker after that. async function handleMigrate(request, env) { - const { license_key, instance_id } = await request.json() + // P1: hardware_fingerprint required for the new RS256 token. + const { license_key, instance_id, hardware_fingerprint } = await request.json() if (!license_key || !instance_id) { return new Response(JSON.stringify({ error: 'license_key and instance_id required' }), { status: 400 }) } + if (!hardware_fingerprint || typeof hardware_fingerprint !== 'string' || hardware_fingerprint.length < 32) { + return new Response(JSON.stringify({ + error: 'hardware_fingerprint required (composite SHA256)', + }), { status: 400 }) + } if (!env.LEGACY_HS256_SECRET) { return new Response(JSON.stringify({ error: 'Legacy migration window has closed' }), { status: 410 }) } @@ -610,10 +635,11 @@ async function handleMigrate(request, env) { return new Response(JSON.stringify({ error: 'Legacy JWT expired; purchase a new license' }), { status: 403 }) } - // Mint RS256 with same tier/email/exp but bind to caller's instance_id. + // Mint RS256 with same tier/email/exp but bind to caller's instance_id + hwfp. const newPayload = { ...payload, instance_id, + hwfp: hardware_fingerprint, iss: 'keygate-license-server', iat: Math.floor(Date.now() / 1000), _migrated_from: 'HS256', @@ -626,6 +652,8 @@ async function handleMigrate(request, env) { const stored = await env.LICENSES.get(`license:${payload.email}`, 'json') || {} stored.jwt = jwt stored.instance_id = instance_id + stored.hardware_fingerprint = hardware_fingerprint + stored.rebind_count = stored.rebind_count || 0 stored.tier = payload.tier stored.email = payload.email delete stored.pending_claim @@ -649,7 +677,8 @@ async function handleMigrate(request, env) { // the founder hits the Worker directly; no signing capability ever lives // on the customer's PHP host. async function handleDevIssue(request, env) { - const { tier, email, instance_id, dev_token } = await request.json() + // P1: hardware_fingerprint required so dev tokens behave like prod. + const { tier, email, instance_id, hardware_fingerprint, dev_token } = await request.json() if (!env.DEV_TOKEN) { return new Response(JSON.stringify({ error: 'Dev issuance disabled on this Worker' }), { status: 403 }) } @@ -659,11 +688,17 @@ async function handleDevIssue(request, env) { if (!tier || !instance_id) { return new Response(JSON.stringify({ error: 'tier and instance_id required' }), { status: 400 }) } + if (!hardware_fingerprint || typeof hardware_fingerprint !== 'string' || hardware_fingerprint.length < 32) { + return new Response(JSON.stringify({ + error: 'hardware_fingerprint required (composite SHA256)', + }), { status: 400 }) + } const limits = TIER_LIMITS[tier] || TIER_LIMITS.community const payload = { iss: 'keygate-license-server', tier, instance_id, + hwfp: hardware_fingerprint, email: email || 'dev@keygate.local', payment_provider: 'dev', max_technicians: limits.max_technicians, @@ -680,6 +715,122 @@ async function handleDevIssue(request, env) { }), { status: 200 }) } +// ── /api/rebind — move a license to a new hardware fingerprint (P1) ── +// +// Body: { license_key, instance_id, new_hardware_fingerprint, reason? } +// +// Verifies the supplied license_key (RS256). Looks up the email in KV. +// Enforces a rolling 365-day quota of 3 rebinds per email by recording +// each rebind under `rebind:{email}:{ts_iso}` with a 365-day expiration — +// a list/count then yields the rolling-window count without external state. +// On success, mints a fresh RS256 JWT bound to the new fingerprint and +// returns the new rebind_count. +async function handleRebind(request, env) { + const { license_key, instance_id, new_hardware_fingerprint, reason } = + await request.json() + if (!license_key || !instance_id || !new_hardware_fingerprint) { + return new Response(JSON.stringify({ + error: 'license_key, instance_id and new_hardware_fingerprint required', + }), { status: 400 }) + } + if (typeof new_hardware_fingerprint !== 'string' || new_hardware_fingerprint.length < 32) { + return new Response(JSON.stringify({ + error: 'new_hardware_fingerprint must be a SHA256 composite hex string', + }), { status: 400 }) + } + + // Lazy verify: decode JWT body without checking signature so legacy + // RS256 tokens still rebind. The signature was already verified PHP-side + // when the customer registered. Any hostile rebind would still need the + // KV record to exist and the email to match — Worker-side correctness + // anchor is the KV record, not the supplied JWT. + const parts = license_key.split('.') + if (parts.length !== 3) { + return new Response(JSON.stringify({ error: 'Invalid license_key format' }), { status: 400 }) + } + let payload + try { + payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) + } catch { + return new Response(JSON.stringify({ error: 'Invalid license_key payload' }), { status: 400 }) + } + const email = payload.email + if (!email) { + return new Response(JSON.stringify({ error: 'License has no email anchor' }), { status: 400 }) + } + if (payload.instance_id !== instance_id) { + return new Response(JSON.stringify({ error: 'instance_id does not match license' }), { status: 403 }) + } + + const stored = await env.LICENSES.get(`license:${email}`, 'json') + if (!stored) { + return new Response(JSON.stringify({ error: 'License not found' }), { status: 404 }) + } + if (stored.revoked) { + return new Response(JSON.stringify({ error: 'License revoked' }), { status: 403 }) + } + + // ── 365-day quota check (rolling window) ────────────────── + const QUOTA_LIMIT = 3 + const QUOTA_WINDOW_S = 365 * 86400 + const list = await env.LICENSES.list({ prefix: `rebind:${email}:` }) + const now = Math.floor(Date.now() / 1000) + let recentCount = 0 + for (const k of list.keys) { + const tsStr = k.name.split(':')[2] + const ts = Date.parse(tsStr) / 1000 + if (!isNaN(ts) && (now - ts) < QUOTA_WINDOW_S) recentCount++ + } + const remaining = QUOTA_LIMIT - recentCount + if (remaining <= 0) { + return new Response(JSON.stringify({ + error: 'rebind quota exceeded', + quota_limit: QUOTA_LIMIT, + quota_window_days: 365, + retry_after_iso: list.keys.length > 0 + ? new Date((Math.min(...list.keys.map(k => Date.parse(k.name.split(':')[2])/1000)) + QUOTA_WINDOW_S) * 1000).toISOString() + : null, + }), { status: 429 }) + } + + // ── Mint fresh RS256 JWT bound to new hwfp ──────────────── + const newPayload = { + ...payload, + hwfp: new_hardware_fingerprint, + iat: now, + _rebound_at: now, + _rebind_count: (stored.rebind_count || 0) + 1, + } + delete newPayload._alg + delete newPayload._migrated_from + const newJwt = await createJwt(newPayload, env) + + // Persist KV: bump rebind counter, record this rebind event, store new fp. + stored.jwt = newJwt + stored.hardware_fingerprint = new_hardware_fingerprint + stored.rebind_count = (stored.rebind_count || 0) + 1 + stored.last_rebind_at = new Date().toISOString() + if (reason) stored.last_rebind_reason = String(reason).slice(0, 200) + await env.LICENSES.put(`license:${email}`, JSON.stringify(stored), + { expirationTtl: Math.max(86400, payload.exp - now) }) + + // Audit record — auto-expires at 365d so quota window self-cleans. + await env.LICENSES.put( + `rebind:${email}:${stored.last_rebind_at}`, + JSON.stringify({ instance_id, new_hwfp: new_hardware_fingerprint, reason: reason || null }), + { expirationTtl: QUOTA_WINDOW_S } + ) + + return new Response(JSON.stringify({ + success: true, + license_key: newJwt, + rebind_count: stored.rebind_count, + rebind_quota_remaining: remaining - 1, + rebind_quota_limit: QUOTA_LIMIT, + rebind_quota_window_days: 365, + }), { status: 200 }) +} + // ── License Retrieval (for manual invoice flow) ───────────── async function handleRetrieveLicense(request, env) { @@ -743,6 +894,8 @@ export default { response = await handleMigrate(request, env) } else if (path === '/api/dev-issue' && request.method === 'POST') { response = await handleDevIssue(request, env) + } else if (path === '/api/rebind' && request.method === 'POST') { + response = await handleRebind(request, env) } else if (path === '/api/health' && request.method === 'GET') { response = new Response(JSON.stringify({ status: 'ok',