Skip to content

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 PublicKeyRegistry as a private constant. Not filterable, not settable through a constant.
  • kid selects the key, with an optional retires_at so 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 by PayloadCanonicalizer with fixed rules.
  • Verification failure discards the whole payload. No partial use, no fallback to a weaker algorithm.
  • After verification, LicenseValidator binds the payload to this product and this installation_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_payload yields untrusted, 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.

Digitale Lösungen. Persönlich. Zukunftssicher.