cboxdk / license
Framework-agnostic, offline license core: mint and verify compact Ed25519-signed license artifacts (EdDSA JWTs) with entitlements, limits, deployment/domain binding, grace windows and signed revocation lists. The shared leaf both a billing issuer and a self-hosted verifier depend on.
Requires
- php: ^8.4
- ext-sodium: *
- firebase/php-jwt: ^7.1
Requires (Dev)
- laravel/pint: ^1.18
- pestphp/pest: ^3.5 || ^4.0
- phpstan/phpstan: ^2.0
README
Framework-agnostic, offline license core for PHP 8.4+. It mints and verifies compact, signed license artifacts — an issuer holds an Ed25519 private key and signs licenses; every consumer bundles the matching public key and verifies them fully offline, with no network call and no license server to phone home to.
It is the shared leaf that two very different sides depend on: a billing/issuer service that sells and signs licenses, and a self-hosted deployment that has to decide, on its own, whether a given feature is licensed. Both link this one package so they agree — byte for byte — on the artifact format and the verification rules.
composer require cboxdk/license
What it is
- A signed artifact, not a checked-out token. A license is a compact EdDSA (Ed25519) JWT. Verification is a local signature check against a bundled public key — it works on an air-gapped box.
- Deny-by-default. Anything that is not provably a valid, in-window,
correctly-bound, non-revoked license is refused. The verifier is a total
function:
verify()never throws — every failure comes back as a typed status with an operator-visible reason. - Entitlements are opaque data. The verifier never hardcodes which plan grants what; it treats entitlements as opaque strings. Tiers are data owned by the issuer, not logic baked into the verifier.
- A pure leaf. No framework dependency. A host application wires it into its
container in ~15 lines and depends only on the
Contracts\interfaces.
Honest crypto
Signing and verification wrap the vetted firebase/php-jwt
library over libsodium's Ed25519 — no bespoke signing. The algorithm is pinned
to EdDSA on both sides: the verifier decodes with a single allowed algorithm,
JWT::decode($licenseKey, new Key($this->publicKeyBase64, 'EdDSA'));
which closes alg:none and algorithm-confusion downgrade attacks (a token
re-signed under HS256 with the public key as the HMAC secret is rejected at
decode). Keys are base64-encoded raw sodium keys.
This library is tamper-evident, not tamper-proof: a determined operator with control of their own runtime can always patch the check out. The goal is a cryptographically sound, offline-verifiable licensing signal for honest deployments — not DRM.
Quickstart
1. Mint an issuer keypair (once, operator side)
use Cbox\License\Support\Ed25519KeyPair; $keys = Ed25519KeyPair::generate(); // $keys['privateKey'] stays on the issuer; $keys['publicKey'] ships with verifiers.
2. Issue a license (billing / issuer side)
use Cbox\License\Ed25519LicenseIssuer; use Cbox\License\ValueObjects\{LicenseRequest, LicenseLimits}; use Cbox\License\Capabilities; $issuer = new Ed25519LicenseIssuer($privateKeyBase64); $licenseKey = $issuer->issue(new LicenseRequest( plan: 'enterprise', entitlements: [Capabilities::SSO, Capabilities::SAML, Capabilities::ANALYTICS], limits: new LicenseLimits(organizations: 10, seats: 500, environments: null), customerId: 'cus_ACME', deploymentId: 'dep_ACME_PRIMARY', licensedDomain: 'id.acme.example', // or null to leave domain-unbound issuedAt: new DateTimeImmutable('now'), notBefore: new DateTimeImmutable('now'), expiresAt: new DateTimeImmutable('+1 year'), ));
3. Verify it (self-hosted / consumer side, offline)
use Cbox\License\Ed25519LicenseVerifier; use Cbox\License\ValueObjects\VerificationContext; $verifier = new Ed25519LicenseVerifier( publicKeyBase64: $publicKeyBase64, graceSeconds: 7 * 24 * 3600, // keep serving for a week past expiry clockSkewSeconds: 60, ); $result = $verifier->verify($licenseKey, new VerificationContext( deploymentId: 'dep_ACME_PRIMARY', domain: 'id.acme.example', now: new DateTimeImmutable('now'), revocations: $revocationList, // optional; null = no revocation check )); if ($result->isLicensed()) { $entitlements = $result->entitlements(); // list<string>, empty unless licensed $limits = $result->limits(); // LicenseLimits|null } // Otherwise inspect $result->status (an enum) and $result->reason (operator text).
Verification decision order
verify() resolves a status in this fixed order, short-circuiting on the first
failure:
- Signature (Ed25519, algorithm pinned) →
SignatureInvalidon failure. - Claims parse (deny-by-default) →
Malformedon failure. - Not-before (
nbf, with skew) →NotYetValid. - Expiry (
exp, with skew, then grace) → within period, elseInGrace(still licensed), elseExpired. - Deployment binding →
BindingMismatch. - Domain binding (only when the license pins a domain) →
BindingMismatch. - Revocation (only when a list is supplied) →
Revoked.
Any unexpected error is caught and returned as Unlicensed — verify() never
throws.
Revocation
Revocation lists are themselves signed (same key, same pinned algorithm) and
verified offline. They are fail-open: a missing, malformed, or tampered list is
treated as "no revocations known" rather than bricking a legitimately licensed
deployment. See docs/core-concepts/revocation.md.
Where this sits
This package is UI-free primitives. If you want a finished, deployable identity platform built on top of it rather than assembling the app layer yourself, that lives in a separate application package that composes this core — this library does not ship it.
Requirements
PHP ^8.4 (tested on 8.4 and 8.5), ext-sodium, and firebase/php-jwt ^7.1. See
docs/requirements.md.
Development
composer lint # Pint (code style) composer analyse # PHPStan level max composer test # Pest composer license-check # dependency licenses are permissive composer sbom # regenerate the CycloneDX SBOM composer qa # everything above + composer audit
License
MIT — see LICENSE.