Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/admin_v2.php
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
180 changes: 171 additions & 9 deletions FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand All @@ -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,
],
]);
}

Expand Down Expand Up @@ -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' => [
Expand Down Expand Up @@ -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' => [
Expand Down Expand Up @@ -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' => [
Expand Down Expand Up @@ -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',
]);
}
3 changes: 3 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
38 changes: 38 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/database/license_p1_hwbind_migration.sql
Original file line number Diff line number Diff line change
@@ -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;
43 changes: 42 additions & 1 deletion FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
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() {
Expand Down Expand Up @@ -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<string, string>
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 || '' })
}
37 changes: 37 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
generateDevLicense,
claimLicense,
migrateLegacyLicense,
redetectHardware,
rebindLicense,
} from '@/api/license'

export function useLicenseStatus() {
Expand Down Expand Up @@ -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),
})
}
Loading
Loading