Darstellung
ADR 0002 — Signed entitlements
Status: accepted Date: 2026-08-21
Context
The plugin caches licensing state locally, otherwise every request would need an API call. The customer can edit that cache: it is a WordPress option in a database they own.
So the question is not "how do we stop them editing it" — we cannot — but "how do we make an edited cache useless".
Decision
The API signs the payload. The plugin stores the signature next to it and re-verifies on every read.
- Ed25519 detached signatures via
sodium_crypto_sign_verify_detached(). libsodium has been in core PHP since 7.2, so it is available on the 8.2 floor. - Public keys are pinned in
PublicKeyRegistryas a private constant. Not filterable, not settable through a constant. kidselects the key, with an optionalretires_atso rotation does not break every unrenewed installation at once.- The signature covers the exact bytes the server signed. Preferred wire format is
payload_encoded(base64url of the signed JSON). An object payload is accepted and canonicalised byPayloadCanonicalizerwith fixed rules. - Verification failure discards the whole payload. No partial use, no fallback to a weaker algorithm.
- After verification,
LicenseValidatorbinds the payload to this product and thisinstallation_id, and rejects anything expired on arrival or dated more than 300 seconds in the future. - Verification is memoised per request, so an entitlement lookup in a render loop costs an array read.
There is no filter that can rewrite entitlements, license state or the feature registry. Three such filters were sketched early and removed: each would have been a documented one-line bypass, which is worse than no protection because it looks like protection.
Consequences
Good:
- Editing
ad_form_license_payloadyieldsuntrusted, which resolves to the Free baseline rather than to new entitlements. - A payload cannot be moved between installations or replayed indefinitely.
- Key rotation is a plugin update, not an outage.
- Security does not depend on obfuscation anywhere.
Costs:
- A build must be provisioned with a real public key before release. An unprovisioned build cannot verify anything and stays on Free;
LicenseManager::can_verify()surfaces that on the License screen so it looks like the packaging mistake it is. - Canonicalisation is a contract both sides must implement identically. Mitigated by preferring
payload_encoded, which removes canonicalisation from the trust path entirely. - A host without sodium cannot unlock paid features at all.
Accepted limitation
A customer with PHP access can patch SignatureVerifier::verify() to return early. That is unavoidable and this design does not pretend otherwise. Detection belongs on the platform: an installation that once validated and then goes silent is visible server-side.
Rejected
HMAC with a shared secret. The secret would have to ship in the plugin, so anyone could mint payloads.
RSA. Larger keys and signatures for no benefit; Ed25519 is available on the supported PHP floor.
Trusting the cache without re-verification. Would make a one-line option edit sufficient to unlock everything.
Allowing the public key to be set by constant or filter. Would let a two-line mu-plugin self-sign a Business payload.