Release v2.3.0 — Anti-piracy hardening (P0 + P1 + P2)#27
Merged
Conversation
- Turkish (tr.json): expanded from 399 to 1926 lines (~76% translated) using word-mapping generator. Remaining 463 keys are technical terms. - Register handle_qc_recheck_count and handle_qc_recheck_historical in admin_v2.php action registry (were defined but unregistered). - Add qc_recheck_count and qc_recheck_historical to api-contracts.test.ts. Audit result: 0 action registry mismatches, 14/14 tests pass. Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
One-command installer for Proxmox/Ubuntu VMs: - Auto-installs Docker if missing - Clones KeyGate from GitHub - Generates .env with secure random passwords - Creates self-signed SSL cert - Builds and starts Docker stack - Creates admin user with super_admin role - Verifies health endpoint - Prints access URLs and credentials Usage: curl -fsSL https://raw.githubusercontent.com/.../install.sh | sudo bash Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
deploy/install.sh: - Fix: acl_roles column is role_name not role_key - Fix: admin_users.email is required (NOT NULL) - Fix: set must_change_password=0 for initial admin - Use ON DUPLICATE KEY UPDATE for idempotent role creation docker-compose.yml: - Fix: healthcheck was hitting /activate/ (404) instead of /api/health.php — DocumentRoot IS /var/www/html/activate so the correct internal URL is /api/health.php Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
Wire up the graphify skill (~/.claude/skills/graphify) so it's enforced project-wide, with auto-rebuild on commits and code edits. Changes: - .claude/settings.json: PreToolUse hook for grep/find tools now points to FINAL_PRODUCTION_SYSTEM/graphify-out/ (the actual graph location). - Added PostToolUse hook that tracks Edit/Write/MultiEdit on code files and a Stop hook that triggers `graphify update FINAL_PRODUCTION_SYSTEM` in background when changes touched FINAL_PRODUCTION_SYSTEM/. - CLAUDE.md: graphify rules now reference FINAL_PRODUCTION_SYSTEM/graphify-out/ with correct commands. - .gitignore: ignore graphify-out/ artifacts (12MB+ graph.json + 25MB cache), rebuilt locally by post-commit hook. - Git hooks (post-commit + post-checkout) installed via `graphify hook install`. Initial graph state: 13,138 nodes, 19,511 edges, 1,260 communities, AST-only extraction. Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
Wires up the user's full personal skill stack so every Claude Code session
in this project gets reminded which skills to use and when.
Changes:
- CLAUDE.md: New "Personal Skills (Enforced)" section documenting all
three skills, their triggers, and the workflow rules.
- caveman: full mode by default, drop articles/filler.
- superpowers: table mapping task → skill (brainstorming, writing-plans,
executing-plans, TDD, systematic-debugging, verification-before-
completion, dispatching-parallel-agents, etc.).
- graphify: existing knowledge graph rules consolidated under this
section.
- .claude/settings.json: Added SessionStart hook that injects a reminder
about all 3 skills as additionalContext at session start.
Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
The web installer failed on aaPanel-style stacks because PDO interpreted
'localhost' as a Unix-socket connection request, then looked for the
default socket path (which doesn't match aaPanel's /tmp/mysql.sock or
/www/server/mysql/mysql.sock) and failed with "No such file or directory".
Code review fixes:
1. Default DB host changed from `localhost` -> `127.0.0.1` to force TCP.
2. Auto-coerce `localhost` -> `127.0.0.1` whenever no explicit Unix socket
path was supplied (both in handleTestDb() and getInstallerPdo()).
3. New optional "Socket Path" field in step 2 (collapsible Advanced
section) for installs that genuinely require a Unix socket.
4. New helper installerBuildDsn() to build the DSN consistently for both
TCP and unix_socket modes; replaces 3 duplicated string concatenations.
5. New helper installerFriendlyDbError() that:
- Sanitizes any DSN fragment that could leak host/port/user
- Maps common errors (Access denied, Unknown database, Connection
refused, No such file, getaddrinfo, timeout) to clear, aaPanel-aware
messages with concrete remediation steps.
6. Added 10s/15s PDO timeouts on every connection so the installer can
never hang.
7. Port is cast to int (was passed as string).
8. Socket path is persisted in $_SESSION['install_db'] so subsequent
migration / admin-create / finalize steps reuse it.
9. Frontend now sends `db_socket` in `dbCredentials`.
Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
Phase 0 of the multi-panel compatibility plan. No table-prefix work yet
(that's P1). All changes local to install/ajax.php + install/index.php.
Hardening:
1. Async per-migration runner. handleInstallDb() split into:
- install_db_init → returns ordered file list with applied flags
- install_db_step → applies ONE migration file (browser drives loop)
- install_db_all → legacy single-shot fast path
Browser pumps the step loop, bypassing max_execution_time caps that
panels like aaPanel/Plesk/cPanel enforce (often 30-60s).
2. Per-statement SQL splitter (installerSplitSql) respects backticks,
single/double-quoted strings, line comments (-- and #), and block
comments. Strips DELIMITER + outer BEGIN/COMMIT wrappers. Lets the
runner survive PDO buffer caps and report progress accurately.
3. set_time_limit(0) + ignore_user_abort(true) at top — best-effort.
4. Preflight expansions:
- open_basedir detection — flags if app root outside allowed paths
- disable_functions audit — fails if mkdir/chmod/file_put_contents/
unlink/rmdir/fopen blocked
- Live mkdir+write+read+unlink probe under uploads/
- parent_writable flag returned
- php_version_full echoed for support tickets
5. Charset auto-fallback: SELECT VERSION() on first connect. MariaDB
<5.5.3 or MySQL <5.7 → utf8mb3 (legacy 'utf8'). Persisted in session
and surfaced to UI via dbCharset selector.
6. CREATE DATABASE skip-toggle: new step-2 checkbox + 1044/1142 error
handler returns suggest_skip_create:true so JS can show "Tick & retry"
button. Plesk/CyberPanel/ISPConfig users no longer hit a dead end.
7. Reverse-proxy IP hardening (getClientIp): only honor X-Forwarded-For
/ X-Real-IP / Client-IP when REMOTE_ADDR is in private/loopback
range. Closes the spoofable trusted-network 2FA-bypass surface.
8. Auto-unlock recovery: install.lock + admin_users empty/missing →
silent unlock. Inlined in install/index.php (avoids dragging ajax.php's
JSON header into HTML) and mirrored as installerCheckIncompleteState()
in ajax.php for runtime symmetry. Logged to install/install.log.
9. Unix-socket auto-detect: handleDetectSocket probes /tmp/mysql.sock,
/var/run/mysqld/mysqld.sock, /var/lib/mysql/mysql.sock,
/www/server/mysql/mysql.sock, /var/run/mariadb/mariadb.sock,
/usr/local/mysql/mysql.sock + 2 more. UI Detect button auto-fills.
10. handleHealth post-install probe: SELECT 1 + SHOW TABLES for the
five canonical KeyGate tables + admin_users count. Useful for step
6 link and external monitoring during shared-host installs.
11. installerBuildDsn() now accepts $charset; getInstallerPdo() pulls
it from session. installerFriendlyDbError() adds 1044/1142 hints
and 1045/no-password specific messaging.
12. installerLog() helper: append-only audit trail at install/install.log.
UI changes (install/index.php):
- Step 2: skip-create-DB checkbox, charset selector, prefix input
(P1-ready, hidden behind Advanced section), Detect socket button.
- Step 3: replaced single-shot runMigrations with init+step loop;
per-row spinner; per-row pass/skip/error with file name + message;
stops on first hard error so user reads it.
- Failed test_db with suggest_skip_create renders inline retry button.
Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
Introduces optional database-table prefix so multiple KeyGate instances
can coexist in one database (panels like cPanel/Plesk often share a
single DB across customer apps). Default prefix is empty → bit-for-bit
identical schema to the previous release.
Changes:
1. tools/prefix-codemod.php
New one-shot script. Self-discovers the canonical KeyGate table list
from CREATE TABLE / ALTER TABLE statements in database/*.sql, then
rewrites:
- SQL files (32): every `tablename` and unbacktiked SQL-keyword target
becomes `#__tablename`. The `#__` sentinel is the Joomla convention.
- PHP files (~50): every backticked or bare-name SQL table reference
inside string literals becomes `' . t('tablename') . '` (single-quote
concat) or `" . t('tablename') . "` (double-quote concat).
Token-based PHP parser (token_get_all) so comments / identifiers /
non-string code is never touched. Idempotent — second run is a no-op.
Run: docker compose exec web php /tmp/codemod.php --root /var/www/html/activate --apply
2. FINAL_PRODUCTION_SYSTEM/functions/db-helpers.php
New file. Defines `t(string $name): string` returning DB_PREFIX . name.
Also defines a fallback `define('DB_PREFIX', '')` if config.php hasn't
set it — covers all legacy installs.
3. FINAL_PRODUCTION_SYSTEM/constants.php
Loads functions/db-helpers.php at the top so t() is available before
any controller runs.
4. FINAL_PRODUCTION_SYSTEM/database/*.sql
Codemod output: 32 SQL files with `#__` markers. Schema is identical
when prefix='' (the default). 276 backticked refs converted.
5. FINAL_PRODUCTION_SYSTEM/{controllers,api,functions,*}.php
Codemod output: ~362 site rewrites across ~54 files. Every SQL string
literal that referenced a canonical table now resolves through t().
6. FINAL_PRODUCTION_SYSTEM/install/ajax.php
- installerRunSqlFile() substitutes `#__` → $_SESSION['install_db']['prefix']
before running each migration. Defense-in-depth: aborts if any `#__`
remains post-substitution.
- installerT() helper for installer-time queries (mirrors t() but reads
prefix from session, since DB_PREFIX isn't defined yet).
- handleInstallDbInit/Step/All all use `installerT('schema_versions')`
when checking applied migrations.
- handleCreateAdmin uses installerT() for admin_users/acl_roles.
- handleFinalize uses installerT() for system_config/technicians/
trusted_networks/admin_ip_whitelist; passes prefix + charset to
generateConfig().
- handleHealth probes prefix-aware physical table names.
- installerCheckIncompleteState() reads DB_PREFIX from existing
config.php so auto-unlock works on prefixed installs.
- generateConfig() emits define('DB_PREFIX', '...') AND propagates
the auto-detected charset (utf8mb4 or utf8mb3 fallback).
7. FINAL_PRODUCTION_SYSTEM/install/index.php
Inline auto-unlock logic now reads DB_PREFIX from config.php, so the
admin_users probe targets the correct physical table name.
8. FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh
New KEYGATE_DB_PREFIX env var (default empty). Pre-runs `sed` over
every .sql file into a /tmp staging copy so the original (read-only)
mount stays untouched. schema_versions tracking table picks up the
prefix consistently. Validates prefix against ^[a-z][a-z0-9_]{0,9}$.
Checksum is computed against the original file (stable across prefix
choices).
Backward compatibility:
- Existing installs without DB_PREFIX in config.php → db-helpers.php
defaults to empty string → t('admin_users') === 'admin_users'.
- 32 .sql files use `#__` placeholders. Without substitution they're
invalid SQL — but they're never executed without going through either
installerRunSqlFile() or 00-init.sh's sed pass.
- Verified: live admin login + list_keys both succeed against the
pre-existing dev database after the codemod.
- 14/14 frontend tests pass.
Risk register from the plan:
- ✅ Codemod misses dynamic table refs → CI lint will catch (P2 task).
- ✅ FK / TRIGGER / VIEW unqualified table refs → SQL pass handles them.
- ✅ Empty-prefix path emits literal `#__` → installerRunSqlFile asserts
strpos(#__) === false post-substitution and aborts.
- ✅ Prefix collides with reserved name → step-2 UI deny-list +
00-init.sh regex validation.
- ✅ Async runner slows happy-path → fast path retained (install_db_all).
- ✅ Legacy config.php lacks DB_PREFIX → db-helpers.php fallback.
Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
… probe (#21) Phase 2 of the multi-panel installer plan. Adds operational robustness on top of P0 hardening + P1 prefix support. Changes: 1. Resumable installer - install/.progress.json breadcrumb file. Each step writes its number and timestamp on completion via the new progress_set action. - On page boot, JS calls progress_get; if last_step >= 1, prompts user "Resume from step N+1?" with Cancel = start over (which clears the file). Bypassed for fresh installs. - handleFinalize unlinks the breadcrumb on success (install complete). 2. Per-migration retry / skip - Step 3 UI: when a migration errors, inline Retry + Skip buttons appear next to the failed row. - Retry: re-runs install_db_step for that file. On success, the migration loop resumes from the next file. - Skip: prompts a hard-yes confirmation, then calls the new migration_skip action which inserts a row into schema_versions with checksum prefix `SKIPPED:` (so future audits can tell apart successful applies from forced skips). Loop resumes. - Both paths respect the canonical migration whitelist. 3. Structured install.log - installerLog() is now called from auto-unlock recovery, finalize completion, progress_set, migration_skip, and progress_clear. - Audit format: `[YYYY-MM-DD HH:MM:SS] event_name: details`. 4. Health-probe button on step 6 - "Run health check" button next to "Open Admin Panel". - Calls existing handleHealth action; renders pass/fail per check (DB connect, presence of {prefix}admin_users, oem_keys, technicians, system_config, schema_versions, plus admin account count). 5. install.lock content extended - Now persists db_prefix and db_charset alongside installer_ver, admin_username, php_version, server_software. Makes post-install forensics easier. 6. .gitignore - install/install.log and install/.progress.json — runtime per-host artifacts, never to be committed. Verified live: - POST progress_set/progress_get round-trip works - Lint clean on ajax.php + index.php - 14/14 frontend tests pass Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
…#22) - CLAUDE.md gets a "Multi-Panel Web Installer" section summarizing the P0+P1+P2 features that just landed, plus a DB_PREFIX block explaining the `#__` sentinel, t() runtime helper, backward-compat empty default, and how to add a new table cleanly. - Development Commands section now includes the prefix-codemod commands (dry-run / apply / verify). CI additions (.github/workflows/ci.yml): 1. New "Prefix Codemod Idempotency" job: - Runs tools/prefix-codemod.php in dry-run against the committed tree. - Asserts SQL=0 + PHP=0 changes (idempotent). - Runs --verify mode to confirm no unprefixed table refs left in SQL. - Catches any future PR that introduces hardcoded table names. 2. New "Installer (restricted PHP env)" job: - Boots PHP 8.3 with max_execution_time=15, allow_url_fopen=Off, memory_limit=128M to mimic aaPanel/Plesk-style restrictions. - Lints install/ajax.php + install/index.php under that env. - Loads ajax.php and asserts the seven new helpers are defined (installerBuildDsn, installerRunSqlFile, installerSplitSql, installerProbeSockets, installerCheckIncompleteState, installerT, plus the original). - Drives installerSplitSql across every database/*.sql file and prints statement counts. Catches any regression where the splitter mishandles a real migration. Both new jobs run on push and PR. They join the existing PHP Lint, Frontend Build & Test, and Docker Stack jobs. Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
* P0: anti-piracy hardening — RS256 JWT + DB row HMAC + remove wildcard
Eliminates trivial license-bypass paths from the threat model:
1. JWT signing HS256 -> RS256 (asymmetric)
- Hardcoded secret 'keygate-community-verification-key-2026' no longer
enables forging enterprise tokens. Private key only on Cloudflare
Worker (LICENSE_PRIVATE_KEY secret); public key embedded in
license-helpers.php for verify-only.
- PHP decodeLicenseJwt() uses openssl_verify(OPENSSL_ALGO_SHA256).
- Worker uses crypto.subtle RSASSA-PKCS1-v1_5 SHA-256.
- Removed createLicenseJwt() — local code never signs in prod.
- 90-day legacy HS256 verify window via LEGACY_HS256_SECRET +
/api/migrate route for existing customers.
2. DB row integrity HMAC
- New column license_info.integrity_hmac (CHAR(64)).
- Per-instance license_row_secret in system_config, rotated on every
successful registerLicense().
- getEffectiveLicense(), canAddTechnician(), canAddKeys(), and
isFeatureAvailable() all re-check HMAC; mismatch -> forced
community fallback + validation_status='invalid'.
- Defeats direct INSERT bypass: attacker needs both row fields AND
the per-instance secret, and the secret rotates.
3. Wildcard instance_id='*' removed
- Worker GitHub Sponsors / LemonSqueezy / T-Bank flows no longer
issue wildcard tokens. Pending purchases stored as
pending_claim:true; customer binds via /api/claim with their
installation's instance_id.
- registerLicense() rejects wildcard payloads.
4. Dev license generation hardened
- Local signing removed. /api/dev-issue Worker route gated by
DEV_TOKEN secret. LicenseController calls Worker, requires admin
to paste DEV_TOKEN.
5. Frontend
- "Claim license" card (GitHub Sponsors / pending purchases).
- "Migrate legacy license" card (HS256 -> RS256).
- DEV_TOKEN input on Dev Tools card.
- 12 new i18n keys in en.json + ru.json.
Files:
- license-server/worker.js, wrangler.toml — RS256 signer, /api/claim,
/api/migrate, /api/dev-issue, no-wildcard issuance
- functions/license-helpers.php — RS256 verify, row HMAC, secret
rotation, wildcard rejection
- controllers/admin/LicenseController.php — Worker dev-issue, claim,
migrate handlers
- admin_v2.php — register license_claim, license_migrate actions
- database/license_p0_hmac_migration.sql — integrity_hmac column
- database/docker-init/00-init.sh, install/ajax.php — migration phase 27
- frontend/api/license.ts, hooks/use-license.ts, pages/license/index.tsx,
test/api-contracts.test.ts, i18n/en.json, i18n/ru.json
- .gitignore — exclude license-server/.keys/
Backward-compat: v2.2.0 community installs auto-fallback to community
on first boot post-upgrade (HS256 rejected, banner prompts
re-register). Single existing paid customer migrates via /api/migrate.
Verification (live PHP smoke test):
- RS256 token verifies via openssl_verify -> OK
- Legacy HS256 token verifies during migration window -> OK
- Tampered JWT signature -> rejected
- HMAC compute returns 64 hex chars
Phase 1 of 3. P1 (hardware-fingerprint + rebind quota) and P2
(phone-home grace + revocation) follow in separate PRs.
Plan: ~/.claude/plans/polymorphic-coalescing-bumblebee.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: trigger workflows for PR #24
---------
Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) 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: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* P2: phone-home grace + revocation list + clock-drift defense
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) <noreply@anthropic.com>
* ci: trigger workflows for PR #26
---------
Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cumulative release covering 3 anti-piracy phases shipped on develop: P0 (#24): RS256 JWT signing replaces hardcoded HS256 secret. Per-row HMAC anchored to a per-instance rotated secret defeats direct INSERT bypass. Wildcard instance_id='*' rejected. Dev-license signing moved off the customer host onto the Worker. P1 (#25): Cross-OS server hardware fingerprint binds each license to the host. 3-of-5 component-match threshold tolerates legitimate single-component changes. Worker /api/rebind enforces 3-per-365-day quota. 7-day grace for rebinding_required state. P2 (#26): Phone-home with 14-day soft / 30-day hard grace. Every JWT carries jti; revoked:{jti} KV records consulted on /api/validate. clock_drift detection (5-min threshold × 3 strikes) defeats local clock rollback. HMAC-anchored cache rejects UPDATE-the-cache forgery. Bypass coverage: | Bypass | Status | | Hardcoded HS256 secret in source | closed (P0) | | Direct INSERT INTO license_info | closed (P0) | | Wildcard instance_id='*' JWT | closed (P0) | | VM clone keeps the license | closed (P1) | | Move to fresh hardware | rate-limited (P1) | | Register once, never validate | closed (P2) | | Roll system clock back | closed (P2) | | Tampered installer / patched PHP | deferred (P3) | 3 new migrations (phases 27/28/29). 5 new admin actions (license_claim, license_migrate, license_redetect_hw, license_rebind, license_force_validate). 2 new helper modules (hardware-fingerprint.php, license-phone-home.php). 1 new CLI shim (cli/license-validate.php). Founder action required after merge: cd license-server openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out license_priv.pem openssl pkey -in license_priv.pem -pubout -out license_pub.pem # Paste license_pub.pem into FINAL_PRODUCTION_SYSTEM/functions/license-helpers.php wrangler secret put LICENSE_PRIVATE_KEY wrangler secret put LEGACY_HS256_SECRET wrangler secret put DEV_TOKEN wrangler deploy Customer cron (Linux): 0 3 * * * cd /var/www/keygate && /usr/bin/php FINAL_PRODUCTION_SYSTEM/cli/license-validate.php >> /var/log/keygate-phonehome.log 2>&1 Sunset: 90 days post-deploy → wrangler secret delete LEGACY_HS256_SECRET Plan: ~/.claude/plans/polymorphic-coalescing-bumblebee.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves divergence between main (v2.2.0) and release branch by keeping the release-branch versions of: - VERSION.php (2.3.0 supersedes 2.2.0) - functions/license-helpers.php (full P0+P1+P2 cumulative) - .gitignore (adds license-server/.keys/) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release v2.3.0
Anti-piracy cryptographic core. Closes the trivial license-bypass paths from the threat model. Plan:
~/.claude/plans/polymorphic-coalescing-bumblebee.md.Three phases shipped on develop
Bypass coverage
INSERT INTO license_infoinstance_id='*'JWTSchema additions
license_p0_hmac_migration.sql—integrity_hmaclicense_p1_hwbind_migration.sql—hardware_fingerprint,hwfp_*, enum extensionslicense_p2_phonehome_migration.sql—validation_failure_count,last_validation_error,server_time_drift_seconds,clock_drift_strikes,current_jtiFounder action required after merge
Customer cron (Linux)
Sunset milestones
wrangler secret delete LEGACY_HS256_SECRET— closes 90-day HS256 migration window.Backward compatibility
/licensepage./api/migrateonce, customer pastes new RS256 JWT, then clicks "Rebind hardware" once.ALTER TABLE … ADD COLUMN,INSERT … ON DUPLICATE KEY UPDATE).Test plan
v2.3.0, build upgrade ZIP, founder runs wrangler deploy🤖 Generated with Claude Code