finext / license-client
Client SDK for self-hosted Finext products to activate, verify, and heartbeat against the Finext License Server.
Requires
- php: ^8.2
- ext-sodium: *
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^11.1
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Client SDK for self-hosted Finext products to activate, verify, and heartbeat against the Finext License Server. Ships as a Composer package consumed by each product (artiqpay, dating-app, ecommerce, finext-website, finsolvitonline, fixpay, ms2pay, proxypayonline, etc.) — not part of the license-server repo itself, since it has an independent release lifecycle and must never leak server-side secrets or paths into redistributed product code.
Installation
composer require finext/license-client
During local development (before this package is published), require it via
a Composer path repository in the host app's composer.json:
{
"repositories": [
{ "type": "path", "options": { "symlink": true }, "url": "../license-client" }
],
"require": {
"finext/license-client": "@dev"
}
}
Publish the config file:
php artisan vendor:publish --tag=license-client-config
Configuration
All values are set via .env; see config/license-client.php for the full
list. The essentials:
LICENSE_SERVER_URL=https://license.finextsolution.com # Issued once per product from the license-server admin panel # (Product → API Keys). Shipped inside this product's source. LICENSE_PRODUCT_ID=5 LICENSE_BOOTSTRAP_KEY_ID=pak_xxxxxxxxxxxxxxxxxxxx LICENSE_BOOTSTRAP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Entered by the customer at install time LICENSE_KEY=XXXXX-XXXXX-XXXXX-XXXXX # Pinned Ed25519 public key so a compromised/MITM'd public-key endpoint # can't silently swap in a different signing key. Get the current value # from GET /api/v1/license/public-key on the license server. LICENSE_PINNED_KEY_ID=v1 LICENSE_PINNED_PUBLIC_KEY=base64-encoded-public-key LICENSE_DEFAULT_GRACE_PERIOD_DAYS=3 LICENSE_HTTP_TIMEOUT_SECONDS=10
Local state (installation ID, per-activation secret, encrypted verification
cache, cached public key) is written under storage/app/license/ with
0600 permissions and should stay out of version control.
Security model
Two different guarantees protect two different directions of traffic:
- Server → client (responses): every response is signed with Ed25519 and verified against the pinned/cached public key before any of its contents are trusted. A response that fails verification is treated exactly like a network failure — never surfaced as a real answer. This is a genuine cryptographic authenticity guarantee.
- Client → server (requests): signed with HMAC-SHA256 under one of two
secrets:
- The bootstrap secret (shipped in source, one per product) signs
only the very first
/activatecall. Because it ships with the product, it is necessarily extractable by anyone with the source — it's a deterrence measure, not a real security boundary. - The activation secret is generated by the server on successful
activation, never shipped, and is the real security boundary for every
call afterward (
verify,heartbeat,deactivate).
- The bootstrap secret (shipped in source, one per product) signs
only the very first
This reflects the fundamental limit of licensing a product whose full source is handed to the customer: it deters casual bypass, it does not achieve DRM-level enforcement.
The local cache (EncryptedLocalCache) is encrypted with
sodium_crypto_secretbox using a key derived from product_id +
activation secret, so it fails closed if tampered with, copied to another
installation, or read without the activation secret.
Usage
Activating an installation
php artisan license:activate
# or: php artisan license:activate LICENSE-KEY --domain=example.com --label="Production"
Falls back to LICENSE_KEY from config, then interactively prompts if
neither the argument nor config value is set.
Checking license validity (recommended entry point)
use Finext\LicenseClient\Facades\License; $result = License::check(); // [ // 'valid' => true, // 'status' => 'active', // or grace_period, grace_period_expired, not_activated, unreachable_no_cache // 'source' => 'live', // or 'cache' // 'days_remaining' => 29, // 'data' => [...], // full verified envelope data, when available // ]
check() never throws. It verifies live against the server when reachable;
if the server can't be reached, it falls back to the signed local cache and
applies the offline grace period (grace_period_days from the server, or
LICENSE_DEFAULT_GRACE_PERIOD_DAYS if no cache exists yet).
Gating routes with the middleware
Route::middleware('license.valid')->group(function () { // ... });
Aborts with 403 and a support message when check()['valid'] is false.
Other operations
License::installationId(); // stable UUID for this installation License::isActivated(); // whether an activation secret is stored License::verify($domain); // one-off live verify call License::heartbeat($appVersion); License::deactivate(); // deactivates server-side and clears local state License::forget(); // clears local state only (secret + cache)
Testing this package in isolation
composer install vendor/bin/phpunit
Tests run as plain PHPUnit (no Laravel container bootstrap) — all pure-logic
classes (HmacSigner, ResponseVerifier, EncryptedLocalCache,
GracePeriodPolicy) avoid Laravel facades so they can be exercised directly,
including a golden-vector cross-check of the canonical JSON format against
the license server's own implementation.
Compatibility
PHP ^8.2 with the sodium extension, Laravel (illuminate/support|http|console)
^10, ^11, ^12, or ^13.