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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ FINAL_PRODUCTION_SYSTEM/uploads/client-resources/
FINAL_PRODUCTION_SYSTEM/install/install.log
FINAL_PRODUCTION_SYSTEM/install/.progress.json

# ── License-server private keys (never committed; uploaded to CF) ──
license-server/.keys/

# ── PHP Dependencies (managed by Composer) ────────────────
FINAL_PRODUCTION_SYSTEM/vendor/

Expand Down
2 changes: 2 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/admin_v2.php
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,8 @@
'license_register' => ['LicenseController.php', 'handle_license_register', true, true],
'license_deactivate' => ['LicenseController.php', 'handle_license_deactivate', true, true],
'license_generate_dev' => ['LicenseController.php', 'handle_license_generate_dev', true, true],
'license_claim' => ['LicenseController.php', 'handle_license_claim', true, true],
'license_migrate' => ['LicenseController.php', 'handle_license_migrate', true, true],

// system upgrade
'upgrade_check_github' => ['UpgradeController.php', 'handle_upgrade_check_github', false, true],
Expand Down
166 changes: 145 additions & 21 deletions FINAL_PRODUCTION_SYSTEM/controllers/admin/LicenseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,11 @@ function handle_license_deactivate(PDO $pdo, array $admin_session, $json_input):
jsonResponse(['success' => true, 'message' => 'License deactivated']);
}

// ── Generate Development License (dev/testing only) ──

// ── Generate Development License (calls Worker /api/dev-issue) ──
//
// Local signing was removed in v2.3.0 (P0 hardening). The Worker is the
// only entity that holds the RS256 private key. Founder provides a
// DEV_TOKEN (matching the Cloudflare secret) to authorize.
function handle_license_generate_dev(PDO $pdo, array $admin_session, $json_input): void {
requirePermission('system_settings', $admin_session);

Expand All @@ -101,34 +104,155 @@ function handle_license_generate_dev(PDO $pdo, array $admin_session, $json_input
return;
}

$tier = $json_input['tier'] ?? 'pro';
$tier = $json_input['tier'] ?? 'pro';
$devToken = $json_input['dev_token'] ?? '';
if (!isset(LICENSE_TIERS[$tier])) {
jsonResponse(['success' => false, 'error' => 'Invalid tier']);
return;
}
if (empty($devToken)) {
jsonResponse([
'success' => false,
'error' => 'DEV_TOKEN required. Set the same value via wrangler secret put DEV_TOKEN on the Worker.',
]);
return;
}

$instanceId = getInstanceId($pdo);
$tierDef = LICENSE_TIERS[$tier];

$payload = [
'iss' => 'keygate-dev',
'tier' => $tier,
'instance_id' => $instanceId,
'email' => 'dev@localhost',
'name' => 'Development License',
'max_technicians' => $tierDef['max_technicians'],
'max_keys' => $tierDef['max_keys'],
'iat' => time(),
'exp' => time() + (365 * 86400), // 1 year
];

$jwt = createLicenseJwt($payload);

$body = json_encode([
'tier' => $tier,
'email' => 'dev@localhost',
'instance_id' => $instanceId,
'dev_token' => $devToken,
]);
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => $body,
'timeout' => 10,
'ignore_errors' => true,
],
]);
$resp = @file_get_contents(KEYGATE_LICENSE_SERVER . '/api/dev-issue', false, $ctx);
if ($resp === false) {
jsonResponse(['success' => false, 'error' => 'Could not reach license server. Check network.']);
return;
}
$decoded = json_decode($resp, true);
if (!is_array($decoded)) {
jsonResponse(['success' => false, 'error' => 'License server returned invalid response.']);
return;
}

if (empty($decoded['success'])) {
jsonResponse(['success' => false, 'error' => $decoded['error'] ?? 'Worker rejected request.']);
return;
}

jsonResponse([
'success' => true,
'license_key' => $jwt,
'tier' => $tier,
'license_key' => $decoded['license_key'],
'tier' => $decoded['tier'] ?? $tier,
'instance_id' => $instanceId,
'expires_at' => $decoded['expires_at'] ?? null,
'message' => 'Development license issued by Worker. Paste into Registration field.',
]);
}

// ── Claim license (GitHub Sponsors / pending payments) ──
//
// Body: { email, sponsor_login? }
// Calls Worker /api/claim with the local instance_id. Worker mints an
// RS256 JWT bound to this install.
function handle_license_claim(PDO $pdo, array $admin_session, $json_input): void {
requirePermission('system_settings', $admin_session);

$email = trim($json_input['email'] ?? '');
$sponsorLogin = trim($json_input['sponsor_login'] ?? '');
if (empty($email)) {
jsonResponse(['success' => false, 'error' => 'Email is required']);
return;
}

$instanceId = getInstanceId($pdo);
$body = json_encode([
'email' => $email,
'instance_id' => $instanceId,
'sponsor_login' => $sponsorLogin ?: null,
]);
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => $body,
'timeout' => 10,
'ignore_errors' => true,
],
]);
$resp = @file_get_contents(KEYGATE_LICENSE_SERVER . '/api/claim', false, $ctx);
if ($resp === false) {
jsonResponse(['success' => false, 'error' => 'Could not reach license server']);
return;
}
$decoded = json_decode($resp, true);
if (!is_array($decoded) || empty($decoded['success'])) {
jsonResponse(['success' => false, 'error' => $decoded['error'] ?? 'Claim failed']);
return;
}

// Auto-register the freshly-issued JWT.
$regResult = registerLicense($pdo, $decoded['license_key']);
jsonResponse(array_merge($regResult, [
'license_key' => $decoded['license_key'],
'expires_at' => $decoded['expires_at'] ?? null,
]));
}

// ── Migrate legacy HS256 license to RS256 ──
//
// Body: { license_key (legacy HS256) }
// Calls Worker /api/migrate. Re-issues an RS256 JWT bound to this
// instance_id. Auto-registers on success.
function handle_license_migrate(PDO $pdo, array $admin_session, $json_input): void {
requirePermission('system_settings', $admin_session);

$legacyKey = trim($json_input['license_key'] ?? '');
if (empty($legacyKey)) {
jsonResponse(['success' => false, 'error' => 'license_key is required']);
return;
}

$instanceId = getInstanceId($pdo);
$body = json_encode([
'license_key' => $legacyKey,
'instance_id' => $instanceId,
'message' => "Development {$tierDef['label']} license generated. Paste it into the registration field.",
]);
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => $body,
'timeout' => 10,
'ignore_errors' => true,
],
]);
$resp = @file_get_contents(KEYGATE_LICENSE_SERVER . '/api/migrate', false, $ctx);
if ($resp === false) {
jsonResponse(['success' => false, 'error' => 'Could not reach license server']);
return;
}
$decoded = json_decode($resp, true);
if (!is_array($decoded) || empty($decoded['success'])) {
jsonResponse(['success' => false, 'error' => $decoded['error'] ?? 'Migration failed']);
return;
}

// Auto-register the freshly-issued RS256 JWT.
$regResult = registerLicense($pdo, $decoded['license_key']);
jsonResponse(array_merge($regResult, [
'license_key' => $decoded['license_key'],
'expires_at' => $decoded['expires_at'] ?? null,
]));
}
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 @@ -149,6 +149,9 @@ run_sql "task_pipeline_migration.sql" 25
# Phase 14: Production tracking & enterprise key management
run_sql "production_tracking_migration.sql" 26

# Phase 15: License row integrity HMAC (P0 anti-piracy)
run_sql "license_p0_hmac_migration.sql" 27

echo ""
echo "=== Database initialization complete ==="
echo ""
Expand Down
18 changes: 18 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/database/license_p0_hmac_migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- =============================================================
-- KeyGate v2.3.0 P0 — License row integrity HMAC
-- =============================================================
-- Adds an HMAC column to license_info so any row directly INSERTed/UPDATEd
-- without going through registerLicense() (which knows the per-instance
-- row secret) is detected as tampered and forced back to community tier.
--
-- The HMAC formula and verification live in functions/license-helpers.php
-- (computeLicenseRowHmac / verifyLicenseRow).
-- =============================================================

ALTER TABLE `#__license_info`
ADD COLUMN integrity_hmac CHAR(64) NOT NULL DEFAULT '' AFTER validation_status;

-- Existing rows (legacy paid customers from v2.2.x) have an empty hmac
-- → verifyLicenseRow returns false → they fall back to community on
-- next read. Customers re-register via /api/migrate to get a fresh
-- RS256 JWT, and that path computes a valid HMAC on insert.
29 changes: 27 additions & 2 deletions FINAL_PRODUCTION_SYSTEM/frontend/src/api/license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,38 @@ export function deactivateLicense() {
return apiPostJson<{ success: boolean; message: string }>('license_deactivate')
}

export function generateDevLicense(tier: string) {
export function generateDevLicense(tier: string, devToken: string) {
return apiPostJson<{
success: boolean
license_key?: string
tier?: string
instance_id?: string
message?: string
error?: string
}>('license_generate_dev', { tier })
}>('license_generate_dev', { tier, dev_token: devToken })
}

// P0: claim a pending GitHub Sponsors / LemonSqueezy / T-Bank purchase by
// binding it to this install's instance_id. Worker mints an RS256 JWT.
export function claimLicense(email: string, sponsorLogin?: string) {
return apiPostJson<{
success: boolean
license_key?: string
tier?: string
expires_at?: string
message?: string
error?: string
}>('license_claim', { email, sponsor_login: sponsorLogin || '' })
}

// P0: migrate a legacy HS256 license to RS256 (90-day window post v2.3.0).
export function migrateLegacyLicense(licenseKey: string) {
return apiPostJson<{
success: boolean
license_key?: string
tier?: string
expires_at?: string
message?: string
error?: string
}>('license_migrate', { license_key: licenseKey })
}
36 changes: 35 additions & 1 deletion FINAL_PRODUCTION_SYSTEM/frontend/src/hooks/use-license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
registerLicense,
deactivateLicense,
generateDevLicense,
claimLicense,
migrateLegacyLicense,
} from '@/api/license'

export function useLicenseStatus() {
Expand Down Expand Up @@ -47,7 +49,8 @@ export function useDeactivateLicense() {
export function useGenerateDevLicense() {
const { t } = useTranslation()
return useMutation({
mutationFn: (tier: string) => generateDevLicense(tier),
mutationFn: ({ tier, devToken }: { tier: string; devToken: string }) =>
generateDevLicense(tier, devToken),
onSuccess: (data) => {
if (data.success) {
toast.success(data.message || t('license.dev_generated', 'Dev license generated'))
Expand All @@ -56,3 +59,34 @@ export function useGenerateDevLicense() {
onError: (e: Error) => toast.error(e.message),
})
}

export function useClaimLicense() {
const qc = useQueryClient()
const { t } = useTranslation()
return useMutation({
mutationFn: ({ email, sponsorLogin }: { email: string; sponsorLogin?: string }) =>
claimLicense(email, sponsorLogin),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['license-status'] })
if (data.success) {
toast.success(data.message || t('license.claimed', 'License claimed and activated'))
}
},
onError: (e: Error) => toast.error(e.message),
})
}

export function useMigrateLegacyLicense() {
const qc = useQueryClient()
const { t } = useTranslation()
return useMutation({
mutationFn: (key: string) => migrateLegacyLicense(key),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['license-status'] })
if (data.success) {
toast.success(data.message || t('license.migrated', 'License migrated to RS256'))
}
},
onError: (e: Error) => toast.error(e.message),
})
}
12 changes: 12 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1733,6 +1733,18 @@
"sub.tab_payment": "Payment",
"sub.tab_key": "License Key",
"sub.tab_billing": "Billing",
"sub.claim_title": "Claim a GitHub Sponsors / pending purchase",
"sub.claim_desc": "If you sponsored ChesnoTech on GitHub or your LemonSqueezy/T-Bank checkout did not include your instance ID, claim your license here. The server binds it to this installation and issues your key.",
"sub.claim_email": "Email used at checkout / GitHub sponsor email",
"sub.claim_sponsor": "GitHub sponsor login (optional)",
"sub.claim_button": "Claim & bind to this install",
"sub.migrate_title": "Migrate legacy license (pre-v2.3)",
"sub.migrate_desc": "If you have a license key issued before May 2026 (HS256 algorithm), use this to upgrade it to the new RS256 format. Available until 2026-08-08.",
"sub.migrate_placeholder": "Paste the legacy HS256 license key...",
"sub.migrate_button": "Migrate to RS256 & activate",
"sub.dev_token_placeholder": "DEV_TOKEN (matches Worker secret)",
"license.claimed": "License claimed and activated",
"license.migrated": "License migrated to RS256",
"sub.registered_to": "Registered to {{email}}",
"sub.free_tier": "Free tier — limited to 1 technician and 50 keys",
"sub.technicians": "Technicians",
Expand Down
12 changes: 12 additions & 0 deletions FINAL_PRODUCTION_SYSTEM/frontend/src/i18n/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1733,6 +1733,18 @@
"sub.tab_payment": "Оплата",
"sub.tab_key": "Лицензия",
"sub.tab_billing": "Биллинг",
"sub.claim_title": "Привязать спонсорство GitHub / ожидающую покупку",
"sub.claim_desc": "Если вы оформили спонсорство ChesnoTech на GitHub или ваша оплата через LemonSqueezy/Т-Банк не содержала идентификатор экземпляра, привяжите лицензию здесь. Сервер свяжет её с этой установкой и выдаст ключ.",
"sub.claim_email": "Email при оплате / email GitHub-спонсора",
"sub.claim_sponsor": "Логин GitHub-спонсора (необязательно)",
"sub.claim_button": "Привязать к этой установке",
"sub.migrate_title": "Миграция старой лицензии (до v2.3)",
"sub.migrate_desc": "Если у вас лицензия, выданная до мая 2026 (алгоритм HS256), используйте это, чтобы обновить её до нового формата RS256. Доступно до 2026-08-08.",
"sub.migrate_placeholder": "Вставьте старый ключ HS256...",
"sub.migrate_button": "Перевести в RS256 и активировать",
"sub.dev_token_placeholder": "DEV_TOKEN (совпадает с секретом Worker)",
"license.claimed": "Лицензия привязана и активирована",
"license.migrated": "Лицензия переведена в RS256",
"sub.registered_to": "Зарегистрирована на {{email}}",
"sub.free_tier": "Бесплатный тариф — 1 техник, 50 ключей",
"sub.technicians": "Техники",
Expand Down
Loading
Loading