From 08a935b70c34053fab899ac5dbba337252633dd8 Mon Sep 17 00:00:00 2001 From: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com> Date: Fri, 8 May 2026 10:55:46 +0300 Subject: [PATCH 1/2] P2: phone-home grace + revocation list + clock-drift defense MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "register once, never check in again" bypass and adds forensic anchors (jti) for license revocation. Phase 3 of 3 in the anti-piracy hardening initiative. 1. Phone-home cadence (PHP) - functions/license-phone-home.php — phoneHomeValidate(), applyValidateResponse(), recordPhoneHomeFailure(), checkPhoneHomeGrace(), firePhoneHomeAsync(). - On every getEffectiveLicense() call, lazy-include the helper and fire async phone-home (POSIX: spawn `php cli/license-validate.php` in background; Windows: synchronous fallback with 6s timeout). - Throttled: at most one validate per `license_phonehome_interval` (default 86400s = 24h). - cli/license-validate.php — CLI shim for cron entry; idempotent (re-honors throttle), exits 0 on no-op. 2. Grace bands (enforced in getEffectiveLicense()) 0–14d → cached tier, no banner. 14–30d → cached tier + banner ("validation failed for N days"). >30d → community tier (validation_status='expired'), banner. revoked (Worker says so) → community immediately. must_rebind (Worker says so) → 'rebinding_required' (P1 path). User-confirmed 14d/30d thresholds. 3. Revocation by jti (Worker) - createJwt() now stamps a `jti` (UUID) into every minted token. - Worker maintains `revoked:{jti}` KV records (10y TTL). - GitHub Sponsors `cancelled`, LemonSqueezy `subscription_cancelled`/ `subscription_expired`, T-Bank `REVERSED`/`REFUNDED` webhook branches all extract jti from the stored JWT and call revokeJti() via best-effort try/catch. - /api/validate checks `revoked:{jti}` before issuing valid:true. 4. /api/validate extensions (Worker) - Returns: { valid, tier, revoked, expires_at, hardware_fingerprint, rebind_quota_remaining, rebind_quota_limit, server_time, must_rebind, jti, reason? } - hwfp drift: caller-supplied hardware_fingerprint vs KV-stored fp → must_rebind:true (PHP-side then sets validation_status to 'rebinding_required', P1 grace path serves prev tier 7 days). 5. Clock-drift defense (PHP) - server_time_drift_seconds and clock_drift_strikes columns track local-vs-server clock delta. Drift >5min for 3 consecutive checks → validation_status='clock_drift'. Defeats pirates rolling clocks back to dodge expires_at. 6. Cache HMAC (defeats UPDATE-the-cache forgery) - system_config('license_validation_cache') stores last validate response + an HMAC anchored to license_row_secret (rotated on every register/rebind). Direct UPDATE of system_config is not enough — attacker also needs the rotated secret. 7. Schema (license_p2_phonehome_migration.sql, phase 29) - validation_failure_count INT UNSIGNED NOT NULL DEFAULT 0 - last_validation_error TEXT NULL - server_time_drift_seconds INT NOT NULL DEFAULT 0 - clock_drift_strikes TINYINT UNSIGNED NOT NULL DEFAULT 0 - current_jti CHAR(36) NULL - system_config slots: license_validation_cache, license_phonehome_interval 8. Backend handlers (controllers/admin/LicenseController.php) - handle_license_status — exposes `phonehome` block (last_validated_at, failure_count, last_error, drift, jti, grace band, banners). - handle_license_force_validate — admin-triggered force phone-home bypassing the 24h throttle. - admin_v2.php registers license_force_validate (POST + CSRF). 9. Frontend (license page) - api/license.ts: forceValidate(); LicensePhoneHome interface added to LicenseStatusResponse. - hooks/use-license.ts: useForceValidate(). Toast variants for OK / revoked / must_rebind / failed. - pages/license/index.tsx: new "License validation (phone-home)" card showing last validated, failure count, drift, jti, last_error, and "Validate now" button. Card colors itself amber on banner band, red on expired band. - test/api-contracts.test.ts: license_force_validate. - i18n/en.json + ru.json: 15 new keys. Backward-compat: pre-P2 rows have NULL for the new columns; phone-home is opt-in (fires only when license-phone-home.php is present and the license has last_validated_at/license_key). First boot post-upgrade inserts last_validated_at = NOW() on the next register/validate cycle. Verification (live PHP smoke): - checkPhoneHomeGrace bands at 0d/13d/20d/35d return ok / ok / banner / expired with correct banner text. - Migration applies clean: validation_failure_count, last_validation_error, server_time_drift_seconds, clock_drift_strikes, current_jti columns present. - Worker JS parses cleanly (node syntax check). Cloudflare deploy after merge: cd license-server && wrangler deploy # picks up jti claim, revokeJti, /api/validate extensions Plan: ~/.claude/plans/polymorphic-coalescing-bumblebee.md Co-Authored-By: Claude Opus 4.7 (1M context) --- FINAL_PRODUCTION_SYSTEM/admin_v2.php | 1 + .../cli/license-validate.php | 49 ++++ .../controllers/admin/LicenseController.php | 77 +++++ .../database/docker-init/00-init.sh | 3 + .../license_p2_phonehome_migration.sql | 42 +++ .../frontend/src/api/license.ts | 35 +++ .../frontend/src/hooks/use-license.ts | 27 ++ .../frontend/src/i18n/en.json | 12 + .../frontend/src/i18n/ru.json | 12 + .../frontend/src/pages/license/index.tsx | 83 ++++++ .../frontend/src/test/api-contracts.test.ts | 1 + .../functions/license-helpers.php | 51 ++++ .../functions/license-phone-home.php | 264 ++++++++++++++++++ FINAL_PRODUCTION_SYSTEM/install/ajax.php | 1 + license-server/worker.js | 146 ++++++++-- 15 files changed, 781 insertions(+), 23 deletions(-) create mode 100644 FINAL_PRODUCTION_SYSTEM/cli/license-validate.php create mode 100644 FINAL_PRODUCTION_SYSTEM/database/license_p2_phonehome_migration.sql create mode 100644 FINAL_PRODUCTION_SYSTEM/functions/license-phone-home.php diff --git a/FINAL_PRODUCTION_SYSTEM/admin_v2.php b/FINAL_PRODUCTION_SYSTEM/admin_v2.php index be4c7b3..c825fbe 100644 --- a/FINAL_PRODUCTION_SYSTEM/admin_v2.php +++ b/FINAL_PRODUCTION_SYSTEM/admin_v2.php @@ -341,6 +341,7 @@ 'license_migrate' => ['LicenseController.php', 'handle_license_migrate', true, true], 'license_redetect_hw' => ['LicenseController.php', 'handle_license_redetect_hw', true, true], 'license_rebind' => ['LicenseController.php', 'handle_license_rebind', true, true], + 'license_force_validate' => ['LicenseController.php', 'handle_license_force_validate', true, true], // system upgrade 'upgrade_check_github' => ['UpgradeController.php', 'handle_upgrade_check_github', false, true], diff --git a/FINAL_PRODUCTION_SYSTEM/cli/license-validate.php b/FINAL_PRODUCTION_SYSTEM/cli/license-validate.php new file mode 100644 index 0000000..5ca4f5f --- /dev/null +++ b/FINAL_PRODUCTION_SYSTEM/cli/license-validate.php @@ -0,0 +1,49 @@ +> /var/log/keygate-phonehome.log 2>&1 + * + * The Windows/IIS path goes through firePhoneHomeAsync()'s synchronous + * fallback — the 6-second Worker timeout is tolerable as a once-per-day + * blocking call. + */ + +// Run only from CLI — refuse to expose this over HTTP. +if (PHP_SAPI !== 'cli') { + http_response_code(403); + echo "This script must be run from the command line.\n"; + exit(1); +} + +require_once __DIR__ . '/../config.php'; +require_once __DIR__ . '/../functions/admin-helpers.php'; +require_once __DIR__ . '/../functions/license-helpers.php'; +require_once __DIR__ . '/../functions/license-phone-home.php'; + +$force = in_array('--force', $argv ?? [], true); +echo '[' . date('c') . "] phone-home start (force=" . ($force ? '1' : '0') . ")\n"; + +try { + $resp = phoneHomeValidate($pdo, $force); + if ($resp === null) { + echo "[" . date('c') . "] no-op (throttled or no license)\n"; + exit(0); + } + echo '[' . date('c') . '] OK valid=' . (!empty($resp['valid']) ? '1' : '0') + . ' tier=' . ($resp['tier'] ?? '-') + . ' must_rebind=' . (!empty($resp['must_rebind']) ? '1' : '0') + . ' revoked=' . (!empty($resp['revoked']) ? '1' : '0') + . ' jti=' . substr((string)($resp['jti'] ?? ''), 0, 8) + . "\n"; + exit(0); +} catch (Exception $e) { + fwrite(STDERR, '[' . date('c') . '] ERROR: ' . $e->getMessage() . "\n"); + exit(2); +} diff --git a/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php b/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php index db50566..d0ebd33 100644 --- a/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php +++ b/FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php @@ -6,6 +6,7 @@ */ require_once __DIR__ . '/../../functions/license-helpers.php'; +require_once __DIR__ . '/../../functions/license-phone-home.php'; // ── Get License Status (no auth required — needed for registration wall) ── @@ -67,6 +68,82 @@ function handle_license_status(PDO $pdo, array $admin_session, $json_input): voi 'rebind_quota_limit' => 3, 'rebind_window_days' => 365, ], + 'phonehome' => _phonehomeStatus($pdo, $license), + ]); +} + +/** + * Build the phone-home status block for the License page UI (P2). + * Reads license_info row + grace band + cached validation response. + */ +function _phonehomeStatus(PDO $pdo, array $effective): array { + try { + $stmt = $pdo->query("SELECT last_validated_at, validation_failure_count, + last_validation_error, server_time_drift_seconds, + clock_drift_strikes, current_jti + FROM `" . t('license_info') . "` + WHERE is_active = 1 ORDER BY id DESC LIMIT 1"); + $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; + } catch (Exception $e) { + return ['available' => false]; + } + $grace = function_exists('checkPhoneHomeGrace') + ? checkPhoneHomeGrace($row) + : ['band' => 'ok', 'days_since' => 0, 'banner' => null]; + + return [ + 'available' => true, + 'last_validated_at' => $row['last_validated_at'] ?? null, + 'failure_count' => (int)($row['validation_failure_count'] ?? 0), + 'last_error' => $row['last_validation_error'] ?? null, + 'server_time_drift_seconds' => (int)($row['server_time_drift_seconds'] ?? 0), + 'clock_drift_strikes' => (int)($row['clock_drift_strikes'] ?? 0), + 'current_jti' => $row['current_jti'] ?? null, + 'grace_band' => $grace['band'], + 'grace_days' => $grace['days_since'], + 'grace_banner' => $grace['banner'], + 'grace_banner_threshold_d' => 14, + 'grace_hard_threshold_d' => 30, + // Surface the P2 phone-home banner from getEffectiveLicense() so the + // UI can show the same message even after community degrade. + 'effective_band' => $effective['phonehome_band'] ?? null, + 'effective_banner' => $effective['phonehome_banner'] ?? null, + ]; +} + +// ── Force phone-home validate (P2) ── +// +// Body: {} — admin clicks "Validate now" in the Phone-home card. +// Bypasses the 24h throttle and synchronously calls the Worker. +function handle_license_force_validate(PDO $pdo, array $admin_session, $json_input): void { + requirePermission('system_settings', $admin_session); + + $resp = phoneHomeValidate($pdo, /*force=*/true); + if ($resp === null) { + jsonResponse([ + 'success' => false, + 'error' => 'Phone-home failed (network or no active license)', + ]); + return; + } + + logAdminActivity( + $admin_session['admin_id'], + $admin_session['id'] ?? 0, + 'LICENSE_FORCE_VALIDATE', + 'Forced phone-home validate (jti=' . substr((string)($resp['jti'] ?? ''), 0, 8) . ')' + ); + + jsonResponse([ + 'success' => true, + 'valid' => !empty($resp['valid']), + 'tier' => $resp['tier'] ?? null, + 'revoked' => !empty($resp['revoked']), + 'must_rebind' => !empty($resp['must_rebind']), + 'expires_at' => $resp['expires_at'] ?? null, + 'jti' => $resp['jti'] ?? null, + 'server_time' => $resp['server_time'] ?? null, + 'message' => 'Validated against license server', ]); } diff --git a/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh b/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh index 2808463..93d85ad 100644 --- a/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh +++ b/FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh @@ -155,6 +155,9 @@ run_sql "license_p0_hmac_migration.sql" 27 # Phase 16: License hardware-fingerprint binding + rebind quota (P1) run_sql "license_p1_hwbind_migration.sql" 28 +# Phase 17: License phone-home grace + revocation + clock-drift (P2) +run_sql "license_p2_phonehome_migration.sql" 29 + echo "" echo "=== Database initialization complete ===" echo "" diff --git a/FINAL_PRODUCTION_SYSTEM/database/license_p2_phonehome_migration.sql b/FINAL_PRODUCTION_SYSTEM/database/license_p2_phonehome_migration.sql new file mode 100644 index 0000000..441b01b --- /dev/null +++ b/FINAL_PRODUCTION_SYSTEM/database/license_p2_phonehome_migration.sql @@ -0,0 +1,42 @@ +-- ============================================================= +-- KeyGate v2.3.0 P2 — Phone-home + grace + revocation + clock-drift +-- ============================================================= +-- Phone-home turns the Cloudflare Worker into the authoritative tier +-- source. Without it, a JWT registered once was good forever — pirates +-- could buy one license, export the JWT, and seed unlimited installs. +-- +-- Phone-home cadence: once per 24h on PHP boot OR daily cron, whichever +-- fires first. Non-blocking; cached tier serves until next successful +-- validate. Grace: 0–14d OK, 14–30d banner, >30d community fallback. +-- +-- Revocation: each issued JWT carries a `jti` claim. Worker maintains +-- a KV set `revoked:{jti}`. Validate response carries revoked:true → +-- PHP forces community immediately, regardless of grace window. +-- +-- Clock drift: Worker returns server_time; PHP records server vs local +-- delta. Three consecutive checks with >5min drift → 'clock_drift'. +-- Defeats pirates rolling system clock back to dodge expires_at. +-- ============================================================= + +ALTER TABLE `#__license_info` + ADD COLUMN validation_failure_count INT UNSIGNED NOT NULL DEFAULT 0 + AFTER last_validated_at, + ADD COLUMN last_validation_error TEXT NULL DEFAULT NULL + AFTER validation_failure_count, + ADD COLUMN server_time_drift_seconds INT NOT NULL DEFAULT 0 + AFTER last_validation_error, + ADD COLUMN clock_drift_strikes TINYINT UNSIGNED NOT NULL DEFAULT 0 + AFTER server_time_drift_seconds, + ADD COLUMN current_jti CHAR(36) NULL DEFAULT NULL + AFTER clock_drift_strikes; + +-- system_config slots used by P2. +-- license_validation_cache: JSON of last validate response + HMAC anchor +-- so the cache itself can't be forged via direct UPDATE. +INSERT INTO `#__system_config` (config_key, config_value, description) VALUES + ('license_validation_cache', '', 'Last /api/validate response (JSON, HMAC-anchored to license_row_secret)') +ON DUPLICATE KEY UPDATE config_key = config_key; + +INSERT INTO `#__system_config` (config_key, config_value, description) VALUES + ('license_phonehome_interval', '86400', 'Seconds between phone-home validate calls (default 24h)') +ON DUPLICATE KEY UPDATE config_key = config_key; diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts b/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts index 382bd2d..9a12967 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts @@ -43,11 +43,30 @@ export interface LicenseHardware { rebind_window_days: number } +// P2: phone-home status surfaced to the License page. +export interface LicensePhoneHome { + available: boolean + last_validated_at?: string | null + failure_count?: number + last_error?: string | null + server_time_drift_seconds?: number + clock_drift_strikes?: number + current_jti?: string | null + grace_band?: 'ok' | 'banner' | 'expired' + grace_days?: number + grace_banner?: string | null + grace_banner_threshold_d?: number + grace_hard_threshold_d?: number + effective_band?: string | null + effective_banner?: string | null +} + export interface LicenseStatusResponse { success: boolean license: LicenseInfo & { rebind_required?: boolean; rebind_grace_ends?: string | null } usage: LicenseUsage hardware?: LicenseHardware + phonehome?: LicensePhoneHome } export function getLicenseStatus() { @@ -132,3 +151,19 @@ export function rebindLicense(reason?: string) { error?: string }>('license_rebind', { reason: reason || '' }) } + +// P2: force a phone-home validate now (bypass 24h throttle). +export function forceValidate() { + return apiPostJson<{ + success: boolean + valid?: boolean + tier?: string + revoked?: boolean + must_rebind?: boolean + expires_at?: string | null + jti?: string | null + server_time?: string | null + message?: string + error?: string + }>('license_force_validate') +} diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts b/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts index 689155c..8bd477b 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts @@ -10,6 +10,7 @@ import { migrateLegacyLicense, redetectHardware, rebindLicense, + forceValidate, } from '@/api/license' export function useLicenseStatus() { @@ -127,3 +128,29 @@ export function useRebindLicense() { onError: (e: Error) => toast.error(e.message), }) } + +// P2: force phone-home validate now (bypass 24h throttle). +export function useForceValidate() { + const qc = useQueryClient() + const { t } = useTranslation() + return useMutation({ + mutationFn: () => forceValidate(), + onSuccess: (data) => { + qc.invalidateQueries({ queryKey: ['license-status'] }) + if (data.success) { + if (data.revoked) { + toast.error(t('license.validate_revoked', 'License revoked by issuer')) + } else if (data.must_rebind) { + toast.warning(t('license.validate_rebind', 'Hardware rebind required')) + } else if (data.valid) { + toast.success(t('license.validate_ok', 'Validated successfully')) + } else { + toast.error(t('license.validate_failed', 'Validation failed')) + } + } else { + toast.error(data.error || t('license.validate_failed', 'Validation failed')) + } + }, + onError: (e: Error) => toast.error(e.message), + }) +} diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json index c7a9231..cc2e8c7 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json @@ -1758,6 +1758,18 @@ "license.hw_redetected": "Hardware fingerprint re-detected", "license.rebound": "License rebound to current hardware", "license.rebind_failed": "Rebind failed", + "license.validate_ok": "Validated successfully", + "license.validate_failed": "Validation failed", + "license.validate_revoked": "License revoked by issuer", + "license.validate_rebind": "Hardware rebind required", + "sub.phonehome_title": "License validation (phone-home)", + "sub.phonehome_desc": "Daily check against the issuer. 14-day grace if offline; 30-day hard cutoff.", + "sub.phonehome_last": "Last validated", + "sub.phonehome_never": "never", + "sub.phonehome_failures": "Recent failures", + "sub.phonehome_drift": "Clock drift (sec)", + "sub.phonehome_last_error": "Last error", + "sub.phonehome_force": "Validate now", "sub.registered_to": "Registered to {{email}}", "sub.free_tier": "Free tier — limited to 1 technician and 50 keys", "sub.technicians": "Technicians", diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json index 7cbe7e5..bd223b5 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json @@ -1758,6 +1758,18 @@ "license.hw_redetected": "Отпечаток оборудования обновлён", "license.rebound": "Лицензия перенесена на текущее оборудование", "license.rebind_failed": "Перенос не удался", + "license.validate_ok": "Проверка успешна", + "license.validate_failed": "Проверка не удалась", + "license.validate_revoked": "Лицензия отозвана издателем", + "license.validate_rebind": "Требуется привязка к оборудованию", + "sub.phonehome_title": "Проверка лицензии (phone-home)", + "sub.phonehome_desc": "Ежедневная проверка у издателя. 14 дней льготы при отсутствии сети; жёсткое отключение через 30 дней.", + "sub.phonehome_last": "Последняя проверка", + "sub.phonehome_never": "никогда", + "sub.phonehome_failures": "Недавние сбои", + "sub.phonehome_drift": "Сдвиг часов (сек)", + "sub.phonehome_last_error": "Последняя ошибка", + "sub.phonehome_force": "Проверить сейчас", "sub.registered_to": "Зарегистрирована на {{email}}", "sub.free_tier": "Бесплатный тариф — 1 техник, 50 ключей", "sub.technicians": "Техники", diff --git a/FINAL_PRODUCTION_SYSTEM/frontend/src/pages/license/index.tsx b/FINAL_PRODUCTION_SYSTEM/frontend/src/pages/license/index.tsx index 2f0cce7..01dd460 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/pages/license/index.tsx +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/pages/license/index.tsx @@ -45,6 +45,7 @@ import { useMigrateLegacyLicense, useRedetectHardware, useRebindLicense, + useForceValidate, } from '@/hooks/use-license' const TIER_COLORS: Record = { @@ -80,10 +81,12 @@ export function LicensePage() { const migrateMut = useMigrateLegacyLicense() const redetectMut = useRedetectHardware() const rebindMut = useRebindLicense() + const forceValidateMut = useForceValidate() const license = statusQuery.data?.license const usage = statusQuery.data?.usage const hardware = statusQuery.data?.hardware + const phonehome = statusQuery.data?.phonehome const handleRegister = async () => { if (!licenseKey.trim()) return @@ -132,6 +135,10 @@ export function LicensePage() { if (result.success) setRebindReason('') } + const handleForceValidate = async () => { + await forceValidateMut.mutateAsync() + } + if (statusQuery.isLoading) { return (
@@ -663,6 +670,82 @@ export function LicensePage() { )} + {/* P2: Phone-home / revocation / clock-drift status */} + {phonehome?.available && ( + + + + {phonehome.grace_band === 'expired' || phonehome.effective_band === 'expired' ? ( + + ) : phonehome.grace_band === 'banner' ? ( + + ) : ( + + )} + {t('sub.phonehome_title', 'License validation (phone-home)')} + + + {phonehome.grace_banner || phonehome.effective_banner || + t('sub.phonehome_desc', 'Daily check against the issuer. 14-day grace if offline; 30-day hard cutoff.')} + + + +
+
+
{t('sub.phonehome_last', 'Last validated')}
+
+ {phonehome.last_validated_at + ? new Date(phonehome.last_validated_at).toLocaleString() + : t('sub.phonehome_never', 'never')} +
+
+
+
{t('sub.phonehome_failures', 'Recent failures')}
+
{phonehome.failure_count ?? 0}
+
+
+
{t('sub.phonehome_drift', 'Clock drift (sec)')}
+
+ {phonehome.server_time_drift_seconds ?? 0} + {(phonehome.clock_drift_strikes ?? 0) > 0 && + ` · strikes: ${phonehome.clock_drift_strikes}`} +
+
+
+ + {phonehome.last_error && ( +
+ {t('sub.phonehome_last_error', 'Last error')}: {phonehome.last_error} +
+ )} + + {phonehome.current_jti && ( +
+ JTI: {phonehome.current_jti} +
+ )} + +
+ +
+
+
+ )} + {/* 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 815ada1..fbb85d0 100644 --- a/FINAL_PRODUCTION_SYSTEM/frontend/src/test/api-contracts.test.ts +++ b/FINAL_PRODUCTION_SYSTEM/frontend/src/test/api-contracts.test.ts @@ -181,6 +181,7 @@ const BACKEND_ACTIONS: Record license_migrate: { method: 'POST', csrf: true }, license_redetect_hw: { method: 'POST', csrf: true }, license_rebind: { method: 'POST', csrf: true }, + license_force_validate: { method: 'POST', csrf: true }, // system upgrade upgrade_check_github: { method: 'GET', csrf: false }, diff --git a/FINAL_PRODUCTION_SYSTEM/functions/license-helpers.php b/FINAL_PRODUCTION_SYSTEM/functions/license-helpers.php index 5dda692..5b645c0 100644 --- a/FINAL_PRODUCTION_SYSTEM/functions/license-helpers.php +++ b/FINAL_PRODUCTION_SYSTEM/functions/license-helpers.php @@ -27,6 +27,10 @@ require_once __DIR__ . '/hardware-fingerprint.php'; define('KEYGATE_REBIND_GRACE_SECONDS', 7 * 86400); +// P2: phone-home grace bands (14d soft / 30d hard) live in license-phone-home.php. +// Required there too, but loaded lazily from getEffectiveLicense() to avoid a +// circular include — this file is required by license-phone-home.php. + // ── 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. @@ -399,6 +403,53 @@ function getEffectiveLicense(PDO $pdo): array { } } + // ── 5. Phone-home grace bands (P2) ──────────────────────── + // Lazy-include phone-home helper (avoids circular require at file top). + $phPath = __DIR__ . '/license-phone-home.php'; + if (is_file($phPath)) { + @include_once $phPath; + if (function_exists('checkPhoneHomeGrace') && function_exists('firePhoneHomeAsync')) { + $grace = checkPhoneHomeGrace($license); + if ($grace['band'] === 'expired') { + // >30d since last successful validate → degrade to community. + try { + $stmt = $pdo->prepare("UPDATE `" . t('license_info') . "` + SET validation_status = 'expired', + last_validation_error = ? + WHERE id = ?"); + $stmt->execute([$grace['banner'], $license['id']]); + } catch (Exception $e) { /* legacy */ } + return _communityLicense(true, [ + 'phonehome_band' => 'expired', + 'phonehome_banner' => $grace['banner'], + 'phonehome_days' => $grace['days_since'], + ]); + } + // Fire async phone-home (throttled internally to once per 24h). + try { firePhoneHomeAsync($pdo); } catch (Exception $e) { /* fail open */ } + + $extra = []; + if ($grace['band'] === 'banner') { + $extra = [ + 'phonehome_band' => 'banner', + 'phonehome_banner' => $grace['banner'], + 'phonehome_days' => $grace['days_since'], + ]; + } + return array_merge([ + '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'], + ], $extra); + } + } + return [ 'tier' => $tier, 'label' => $tierDef['label'], diff --git a/FINAL_PRODUCTION_SYSTEM/functions/license-phone-home.php b/FINAL_PRODUCTION_SYSTEM/functions/license-phone-home.php new file mode 100644 index 0000000..1f7b3ff --- /dev/null +++ b/FINAL_PRODUCTION_SYSTEM/functions/license-phone-home.php @@ -0,0 +1,264 @@ +30d → community fallback (validation_status='expired'). + * revoked → community immediately, regardless of grace. + * must_rebind → 'rebinding_required' (P1 grace path takes over). + * clock drift > 5min × 3 consecutive checks → 'clock_drift'. + */ + +require_once __DIR__ . '/license-helpers.php'; + +define('KEYGATE_PHONEHOME_TIMEOUT_SEC', 6); +define('KEYGATE_PHONEHOME_GRACE_BANNER_S', 14 * 86400); +define('KEYGATE_PHONEHOME_GRACE_HARD_S', 30 * 86400); +define('KEYGATE_CLOCK_DRIFT_THRESHOLD_S', 5 * 60); +define('KEYGATE_CLOCK_DRIFT_STRIKE_LIMIT', 3); + +/** + * Validate license against the Cloudflare Worker. Returns the parsed + * response array on success or null on network/parse error. Does NOT + * mutate the DB; caller is responsible for that via applyValidateResponse. + * + * Caller usually passes $force=false so the call is throttled by + * `license_phonehome_interval` (default 86400s). + */ +function phoneHomeValidate(PDO $pdo, bool $force = false): ?array { + $row = getCurrentLicense($pdo); + if (!$row || empty($row['license_key'])) return null; + + if (!$force) { + $intervalCfg = (int)getConfig('license_phonehome_interval'); + $interval = $intervalCfg > 0 ? $intervalCfg : 86400; + if (!empty($row['last_validated_at'])) { + $age = time() - (int)strtotime($row['last_validated_at']); + if ($age < $interval) return null; // throttled + } + } + + $instanceId = (string)($row['instance_id'] ?? ''); + $hwfp = (string)($row['hardware_fingerprint'] ?? ''); + $body = json_encode([ + 'license_key' => $row['license_key'], + 'instance_id' => $instanceId, + 'hardware_fingerprint' => $hwfp, + 'version' => defined('KEYGATE_VERSION') ? KEYGATE_VERSION : '', + ]); + + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => $body, + 'timeout' => KEYGATE_PHONEHOME_TIMEOUT_SEC, + 'ignore_errors' => true, + ], + ]); + $resp = @file_get_contents(KEYGATE_LICENSE_SERVER . '/api/validate', false, $ctx); + if ($resp === false) { + recordPhoneHomeFailure($pdo, $row, 'network_unreachable'); + return null; + } + $decoded = json_decode($resp, true); + if (!is_array($decoded)) { + recordPhoneHomeFailure($pdo, $row, 'parse_error'); + return null; + } + + applyValidateResponse($pdo, $row, $decoded); + return $decoded; +} + +/** + * Persist a successful validate response to the DB + system_config cache. + * Drives all the grace-band / revocation / clock-drift logic. + */ +function applyValidateResponse(PDO $pdo, array $row, array $resp): void { + $now = time(); + + // ── Server time drift ──────────────────────────────────── + $drift = 0; + $strike = (int)($row['clock_drift_strikes'] ?? 0); + if (!empty($resp['server_time'])) { + $serverTs = (int)strtotime($resp['server_time']); + if ($serverTs > 0) { + $drift = $now - $serverTs; // local clock vs server, signed + $absDrift = abs($drift); + if ($absDrift > KEYGATE_CLOCK_DRIFT_THRESHOLD_S) { + $strike++; + } else { + $strike = 0; + } + } + } + + // ── Decide validation_status ───────────────────────────── + $newStatus = $row['validation_status'] ?? 'valid'; + $msg = null; + + if (!empty($resp['revoked'])) { + $newStatus = 'revoked'; + $msg = 'License revoked by issuer'; + } elseif (!empty($resp['must_rebind'])) { + $newStatus = 'rebinding_required'; + $msg = 'Hardware fingerprint mismatch — rebind required'; + // Persist prev_tier so rebind grace path can serve it. + if (function_exists('saveConfigBatch')) { + saveConfigBatch($pdo, ['license_prev_tier' => $row['tier'] ?? 'community']); + } + } elseif (!empty($resp['valid'])) { + $newStatus = 'valid'; + $msg = null; + } else { + // valid:false with reason other than revoked/must_rebind. + $newStatus = $resp['reason'] === 'expired' ? 'expired' : 'invalid'; + $msg = 'Worker validate: ' . ($resp['reason'] ?? 'unknown'); + } + + // Clock drift override — three strikes wins. + if ($strike >= KEYGATE_CLOCK_DRIFT_STRIKE_LIMIT && $newStatus === 'valid') { + $newStatus = 'clock_drift'; + $msg = 'Local clock drifted from server time on ' . $strike . ' consecutive checks'; + } + + // ── Update license_info row ────────────────────────────── + try { + $stmt = $pdo->prepare(" + UPDATE `" . t('license_info') . "` + SET last_validated_at = NOW(), + validation_status = ?, + validation_failure_count = 0, + last_validation_error = ?, + server_time_drift_seconds = ?, + clock_drift_strikes = ?, + current_jti = ? + WHERE id = ? + "); + $stmt->execute([ + $newStatus, + $msg, + $drift, + $strike, + $resp['jti'] ?? null, + $row['id'], + ]); + } catch (Exception $e) { + error_log('KeyGate phonehome: update failed: ' . $e->getMessage()); + } + + // ── HMAC-anchored cache (defeats UPDATE-the-cache forgery) ── + try { + $secret = getLicenseRowSecret($pdo); + $cacheBlob = json_encode($resp, JSON_UNESCAPED_SLASHES); + $hash = hash_hmac('sha256', $cacheBlob, $secret); + if (function_exists('saveConfigBatch')) { + saveConfigBatch($pdo, [ + 'license_validation_cache' => json_encode([ + 'response' => $resp, + 'fetched_at' => $now, + 'jti' => $resp['jti'] ?? null, + 'hmac' => $hash, + ], JSON_UNESCAPED_SLASHES), + ]); + } + } catch (Exception $e) { + error_log('KeyGate phonehome: cache write failed: ' . $e->getMessage()); + } +} + +/** + * Increment validation_failure_count and persist the error reason. + * Caller arrives here when network or parse fails — does NOT change the + * tier (cached tier serves until grace exhausted). + */ +function recordPhoneHomeFailure(PDO $pdo, array $row, string $reason): void { + try { + $stmt = $pdo->prepare(" + UPDATE `" . t('license_info') . "` + SET validation_failure_count = validation_failure_count + 1, + last_validation_error = ? + WHERE id = ? + "); + $stmt->execute([$reason, $row['id']]); + } catch (Exception $e) { /* legacy install */ } +} + +/** + * Compute the current grace band based on last_validated_at age. + * Pure read; getEffectiveLicense() consults this and degrades the tier + * once the >30d band is reached. + * + * Returns: + * ['band' => 'ok'|'banner'|'expired', 'days_since' => N, 'banner' => string|null] + */ +function checkPhoneHomeGrace(?array $row): array { + if (!$row || empty($row['last_validated_at'])) { + return ['band' => 'ok', 'days_since' => 0, 'banner' => null]; + } + $age = time() - (int)strtotime($row['last_validated_at']); + $days = (int)floor($age / 86400); + + if ($age >= KEYGATE_PHONEHOME_GRACE_HARD_S) { + return [ + 'band' => 'expired', + 'days_since' => $days, + 'banner' => 'License has not validated against the issuer for ' . $days . ' days. Reverted to community tier.', + ]; + } + if ($age >= KEYGATE_PHONEHOME_GRACE_BANNER_S) { + return [ + 'band' => 'banner', + 'days_since' => $days, + 'banner' => 'License validation failed for ' . $days . ' days. Re-connect to the network within ' + . (int)floor((KEYGATE_PHONEHOME_GRACE_HARD_S - $age) / 86400) + . ' days to keep your tier.', + ]; + } + return ['band' => 'ok', 'days_since' => $days, 'banner' => null]; +} + +/** + * Fire phoneHomeValidate() in the background so admin page renders don't + * block on it. On Linux/macOS this uses popen(); on Windows it just calls + * synchronously inside the request — the timeout is short enough that it's + * acceptable. + * + * Caller is typically `getEffectiveLicense()` once per request, throttled + * via the last_validated_at check inside phoneHomeValidate(). + */ +function firePhoneHomeAsync(PDO $pdo): void { + // Cheap throttle before forking. + $row = getCurrentLicense($pdo); + if (!$row || empty($row['license_key'])) return; + $intervalCfg = (int)getConfig('license_phonehome_interval'); + $interval = $intervalCfg > 0 ? $intervalCfg : 86400; + if (!empty($row['last_validated_at']) + && (time() - strtotime($row['last_validated_at'])) < $interval) { + return; + } + + if (PHP_OS_FAMILY !== 'Windows' && function_exists('proc_open')) { + // Spawn a one-shot CLI worker. The shim is small + idempotent. + $cli = realpath(__DIR__ . '/../cli/license-validate.php'); + if ($cli && is_file($cli)) { + $cmd = '/usr/bin/env php ' . escapeshellarg($cli) . ' >/dev/null 2>&1 &'; + @exec($cmd); + return; + } + } + // Fallback: synchronous (Windows or no CLI shim found). + @phoneHomeValidate($pdo, false); +} diff --git a/FINAL_PRODUCTION_SYSTEM/install/ajax.php b/FINAL_PRODUCTION_SYSTEM/install/ajax.php index a198410..d53c40f 100644 --- a/FINAL_PRODUCTION_SYSTEM/install/ajax.php +++ b/FINAL_PRODUCTION_SYSTEM/install/ajax.php @@ -465,6 +465,7 @@ function installerMigrationList(): array { ['unallocated_space_migration.sql', 19], ['license_p0_hmac_migration.sql', 20], ['license_p1_hwbind_migration.sql', 21], + ['license_p2_phonehome_migration.sql', 22], ]; } diff --git a/license-server/worker.js b/license-server/worker.js index 53ff60f..7a8b6a9 100644 --- a/license-server/worker.js +++ b/license-server/worker.js @@ -54,7 +54,15 @@ async function importLicensePrivateKey(pemPkcs8) { // Switched from HS256 → RS256 in v2.3.0. Private key lives ONLY in the // Worker secret store (env.LICENSE_PRIVATE_KEY, PEM PKCS#8). KeyGate PHP // instances verify with the matching public key embedded in source. +// +// P2 addition: every minted JWT carries a `jti` (JWT ID) UUID. The Worker +// maintains a `revoked:{jti}` KV set; webhook cancellation/refund handlers +// write to it; /api/validate checks it and returns revoked:true. PHP-side +// then forces community fallback on next phone-home regardless of grace. async function createJwt(payload, env) { + // Stamp jti unless caller already supplied one (rebind preserves chain). + if (!payload.jti) payload.jti = crypto.randomUUID() + const header = base64UrlEncodeString(JSON.stringify({ alg: 'RS256', typ: 'JWT' })) const body = base64UrlEncodeString(JSON.stringify(payload)) const data = `${header}.${body}` @@ -202,12 +210,19 @@ async function handleGitHubWebhook(request, env) { } if (action === 'cancelled') { - // Mark license as revoked in KV + // Mark license as revoked in KV + add jti to revocation set (P2). const existing = await env.LICENSES.get(`license:${email}`, 'json') if (existing) { existing.revoked = true existing.revoked_at = new Date().toISOString() await env.LICENSES.put(`license:${email}`, JSON.stringify(existing)) + // Best-effort: extract jti from stored JWT and revoke it. + try { + if (existing.jwt) { + const p = JSON.parse(atob(existing.jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))) + await revokeJti(env, p.jti, 'github_sponsorship_cancelled') + } + } catch { /* ignore */ } } return new Response(JSON.stringify({ @@ -279,42 +294,117 @@ async function handleRegister(request, env) { }), { status: 200 }) } +// ── /api/validate (P2-extended) ── +// +// Body: { license_key, instance_id, hardware_fingerprint?, version? } +// Response includes everything the PHP-side phone-home decision needs: +// { +// valid, tier, revoked, expires_at, hardware_fingerprint, +// rebind_quota_remaining, server_time, must_rebind, jti, reason? +// } +// PHP-side caches this response (HMAC-anchored) and uses it to drive +// the 14d / 30d grace bands. async function handleValidate(request, env) { - const { license_key, instance_id } = await request.json() - + const { license_key, instance_id, hardware_fingerprint } = await request.json() if (!license_key) { return new Response(JSON.stringify({ valid: false, error: 'License key required' }), { status: 400 }) } + const serverTime = new Date().toISOString() + const nowSec = Math.floor(Date.now() / 1000) - // Decode JWT (basic check — full verification happens client-side too) + let payload try { const parts = license_key.split('.') if (parts.length !== 3) throw new Error('Invalid JWT') + payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) + } catch { + return new Response(JSON.stringify({ + valid: false, reason: 'invalid_format', server_time: serverTime, + }), { status: 400 }) + } - const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) + // Expiration first — short-circuit the lookups for stale tokens. + if (payload.exp && payload.exp < nowSec) { + return new Response(JSON.stringify({ + valid: false, reason: 'expired', + tier: payload.tier, jti: payload.jti || null, + expires_at: new Date(payload.exp * 1000).toISOString(), + server_time: serverTime, + }), { status: 200 }) + } - // Check expiration - if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { - return new Response(JSON.stringify({ valid: false, reason: 'expired' }), { status: 200 }) + // jti revocation check. + if (payload.jti) { + const revoked = await env.LICENSES.get(`revoked:${payload.jti}`) + if (revoked !== null) { + return new Response(JSON.stringify({ + valid: false, reason: 'revoked', revoked: true, + tier: payload.tier, jti: payload.jti, + server_time: serverTime, + }), { status: 200 }) } + } - // Check if revoked - const email = payload.email - if (email) { - const stored = await env.LICENSES.get(`license:${email}`, 'json') - if (stored && stored.revoked) { - return new Response(JSON.stringify({ valid: false, reason: 'revoked' }), { status: 200 }) - } + // Email-anchored KV lookup — provides hwfp, rebind_count, revoked flag. + let stored = null + if (payload.email) { + stored = await env.LICENSES.get(`license:${payload.email}`, 'json') + if (stored && stored.revoked) { + return new Response(JSON.stringify({ + valid: false, reason: 'revoked', revoked: true, + tier: payload.tier, jti: payload.jti || null, + server_time: serverTime, + }), { status: 200 }) } + } - return new Response(JSON.stringify({ - valid: true, - tier: payload.tier, - expires_at: new Date(payload.exp * 1000).toISOString(), - }), { status: 200 }) - } catch (e) { - return new Response(JSON.stringify({ valid: false, error: 'Invalid license format' }), { status: 400 }) + // hwfp drift detection — must_rebind iff JWT and KV agree on fp but + // caller's reported hwfp differs from the bound one. + let mustRebind = false + const boundFp = (stored && stored.hardware_fingerprint) || payload.hwfp || null + if (boundFp && hardware_fingerprint && hardware_fingerprint !== boundFp) { + mustRebind = true + } + + // Optional: instance_id mismatch is informational here; PHP enforces it. + void instance_id + + // Rolling-window rebind quota for the email. + const QUOTA_LIMIT = 3 + const QUOTA_WINDOW_S = 365 * 86400 + let remaining = QUOTA_LIMIT + if (payload.email) { + const list = await env.LICENSES.list({ prefix: `rebind:${payload.email}:` }) + let count = 0 + for (const k of list.keys) { + const ts = Date.parse(k.name.split(':')[2]) / 1000 + if (!isNaN(ts) && (nowSec - ts) < QUOTA_WINDOW_S) count++ + } + remaining = Math.max(0, QUOTA_LIMIT - count) } + + return new Response(JSON.stringify({ + valid: true, + tier: payload.tier, + revoked: false, + expires_at: payload.exp ? new Date(payload.exp * 1000).toISOString() : null, + hardware_fingerprint: boundFp, + rebind_quota_remaining: remaining, + rebind_quota_limit: QUOTA_LIMIT, + server_time: serverTime, + must_rebind: mustRebind, + jti: payload.jti || null, + }), { status: 200 }) +} + +// ── Helper: revoke by jti, called by webhook cancel/refund handlers ── +async function revokeJti(env, jti, reason) { + if (!jti) return + // KV value is a small JSON tag — useful for forensics. + await env.LICENSES.put(`revoked:${jti}`, JSON.stringify({ + revoked_at: new Date().toISOString(), + reason: reason || 'webhook_cancellation', + }), { expirationTtl: 10 * 365 * 86400 }) } // ── LemonSqueezy Webhook ──────────────────────────────────── @@ -422,6 +512,12 @@ async function handleLemonSqueezyWebhook(request, env) { existing.revoked = true existing.revoked_at = new Date().toISOString() await env.LICENSES.put(`license:${email}`, JSON.stringify(existing)) + try { + if (existing.jwt) { + const p = JSON.parse(atob(existing.jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))) + await revokeJti(env, p.jti, eventName) + } + } catch { /* ignore */ } } return new Response(JSON.stringify({ @@ -512,7 +608,7 @@ async function handleTBankWebhook(request, env) { } if (status === 'REVERSED' || status === 'REFUNDED') { - // Attempt to find and revoke the license + // Attempt to find and revoke the license + add jti to revocation set. const parts = orderId.split('_') if (parts.length >= 2) { try { @@ -523,6 +619,10 @@ async function handleTBankWebhook(request, env) { existing.revoked_at = new Date().toISOString() existing.revoke_reason = status.toLowerCase() await env.LICENSES.put(`license:${email}`, JSON.stringify(existing)) + if (existing.jwt) { + const p = JSON.parse(atob(existing.jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))) + await revokeJti(env, p.jti, `tbank_${status.toLowerCase()}`) + } } } catch { /* ignore parse errors */ } } From 1bf1adc8efef7683fd1cd7205033b001f7fb7485 Mon Sep 17 00:00:00 2001 From: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com> Date: Fri, 8 May 2026 11:12:10 +0300 Subject: [PATCH 2/2] ci: trigger workflows for PR #26