Darstellung
Licensing security
The trust boundary, and an honest statement of what it can and cannot do.
What this system does not claim
PHP running on infrastructure the customer controls cannot be made tamper-proof. The customer has FTP, SSH, root, the database and every PHP file. Anyone who wants to patch SignatureVerifier::verify() to return; can.
This design does not pretend otherwise. It makes tampering materially harder and obvious rather than impossible, and it does so without depending on code obfuscation for any part of its security. If obfuscation is ever added it is an additional hurdle, never a load-bearing one.
What it does claim
| Claim | Mechanism |
|---|---|
| Entitlements cannot be forged without the platform private key | Ed25519 over the exact signed bytes |
| Editing local options grants nothing | Verify-on-read, signature stored with the payload |
| A payload cannot be moved between installations | installation_id binding |
| A payload cannot be replayed indefinitely | expires_at plus a bounded offline grace |
| A payload cannot be replayed from the future | issued_at with 300 s skew tolerance |
| UI manipulation grants nothing | Every UI gate has a server-side counterpart |
| No plugin code path can grant a feature | No filter can rewrite entitlements, state or the registry |
| A retired signing key stops working | kid with retires_at |
| Failure is closed | Missing sodium, missing key, malformed payload all land on Free |
Each of these has a test in tests/Unit/Licensing/BypassAttemptTest.php.
The trust boundary
Deliberately small. Three things are trusted, and nothing else:
- The pinned public keys in
PublicKeyRegistry. Hard-coded, not filterable, not settable through a constant. - The signature check in
SignatureVerifier, backed by libsodium. - The API's verdicts, once signed.
Everything else — the local option store, the builder, $_POST, the form definition, wp_get_environment_type(), the hostname, the site URL — is treated as attacker-controlled input.
Why the registry is not configurable
A filter or a constant that let a site define its own public key would collapse the entire system into decoration: a two-line mu-plugin would self-sign a Business payload. So:
PublicKeyRegistry::KEYSis a private constant- the constructor accepts an override, used only by tests
- production code constructs it with no arguments
- there is no filter, no option and no constant path
The same reasoning removed three filters that were sketched early in development (ad_form_entitlements, ad_form_features, ad_form_license_state). Each would have been a documented one-line bypass, which is worse than no protection at all because it looks like protection.
Filters that do exist, and why they are safe
| Filter | Why it grants nothing |
|---|---|
ad_form_license_api_base | Redirecting the API is useless without the private key. Tested |
ad_form_license_dashboard_url | Presentation |
ad_form_license_timeout | Bounded to 1–30 s |
ad_form_upgrade_url | Presentation |
ad_form_billing_url | Presentation |
Actions, not filters
Every licensing hook is an action. None can change an outcome: ad_form_register_features (add-ons register their own keys; core keys cannot be redefined), ad_form_register_gated_modules, ad_form_feature_denied (observation only), ad_form_license_activated, ad_form_license_deactivated, ad_form_license_changed.
Secrets
Never present in the plugin
- the license server private key
- any Stripe secret key
- any Stripe webhook secret
- any platform master secret
- any Stripe price id used as business logic
Stripe price ids may appear at most as a non-sensitive reference. The plugin works with license_status, plan and entitlements, never with price ids.
Handling of the two secrets it does hold
License key. Sent once, on activation. Stored only as ADFORM-****-****-1234 via Mask::license_key(), which keeps the recognisable prefix and the last group so a customer can identify which key is connected. The middle groups never appear in storage, logs or markup. Asserted in ActivationServiceTest::test_masked_key_is_stored_and_the_raw_key_is_not.
Installation token. Held in a non-autoloaded option. Never rendered into HTML, never placed in window.adFormBuilder, never returned by a REST endpoint, never logged even in part. Mask::token() returns **** regardless of input; Mask::fingerprint() gives a 12-character SHA-256 prefix for correlating log lines without revealing the value.
Two independent layers strip it: LicenseLogger::scrub() drops the keys before the call reaches Logger::redact(), which drops them again by substring match. The layer closest to the secret should be the one that removes it.
Fail-closed behaviour
Every failure path lands on the Free baseline. None lands on "everything" and none lands on "nothing".
| Failure | Result |
|---|---|
| sodium unavailable | crypto_unavailable, Free, visible admin notice |
| no pinned key in the build | crypto_unavailable, Free, visible admin notice |
unknown or retired kid | payload discarded |
| wrong algorithm | payload discarded |
| signature invalid | payload discarded |
| malformed payload | payload discarded |
| wrong product | payload discarded |
| wrong installation | payload discarded |
| payload expired on arrival | payload discarded |
| payload dated in the future | payload discarded |
| stored payload fails re-verification | untrusted, Free |
| API unreachable | existing entitlements kept, then Free after the grace |
The one asymmetry is intentional and worth being explicit about: granting requires a signature, withdrawing does not. A documented credential-refusal code on a 401/403/404 drops the local credential without a signature, because it can only reduce access. Treating it as untrusted instead would let an attacker who can block the API keep paid features for the full grace window.
Input handling
| Surface | Handling |
|---|---|
License key from $_POST | Capability check, check_admin_referer, wp_unslash, sanitize_text_field |
Notice flash parameters from $_GET | sanitize_key, mapped through a fixed match so no user string is echoed |
| API response body | json_decode to array, every member type-checked in LicensePayload::from_array() |
| Feature keys from the payload | Filtered to [a-z0-9._-]; sanitize_key() would strip the dots |
| Definition paths in error messages | Derived from sanitised definition ids |
Everything in templates/admin/license.php | esc_html, esc_attr, esc_url, wp_nonce_field |
Admin actions require manage_ad_form_settings plus a matching nonce, checked before any input is read. The manual refresh is rate limited to once a minute via a transient, so the button cannot be used to hammer the API.
Denial of service considerations
- No API call on a frontend page view. Ever.
- Cron jitter is derived from the installation UUID, spreading load across a three-hour window instead of a single minute.
- A failed check retries after an hour, not immediately.
- Opening the License screen only calls the API when a check is actually due.
- HTTP timeout is bounded to 30 seconds maximum, 10 by default.
- No redirects are followed.
Environment claims are not trusted
wp_get_environment_type() is a function the site operator can define. The plugin therefore reports it as one signal among several and never as proof. A declared production does not override a staging hostname, though the reverse is honoured because it can only reduce entitlement.
The API is expected to decide using evidence the plugin cannot forge: registrable domain, the link to a known production installation, activation history, domain patterns and anomaly detection. Details in docs/STAGING_AND_INSTALLATIONS.md.
Privacy
Licensing telemetry and form data are separated at the namespace level. Nothing in Dingfelder\AdForm\Licensing accepts a Submission. The single class that touches a Form is FormEntitlementValidator, which reads a definition's structure to determine required entitlements and transmits none of it.
Never sent: form definitions, form titles, field labels, field keys, entry data, entry values, per-form submission counts, visitor IPs, visitor email addresses, or any row from ad_form_submissions, ad_form_submission_meta or ad_form_payments.
Asserted by three tests that inspect the raw request bodies rather than trusting the implementation: RestGateTest::test_the_activation_request_carries_no_customer_data, ..._the_validation_request_carries_no_customer_data, and ..._no_form_or_entry_data_appears_in_any_request.
Residual risks, stated plainly
| Risk | Assessment |
|---|---|
| A customer patches the PHP to bypass the gate | Possible and accepted. Detection belongs on the platform side, via missing validation traffic from an installation that once checked in |
| A customer freezes time to extend the offline grace | Possible. Bounded to the grace window, and Clock::freeze() is only ever called from tests |
| A customer blocks the API at the firewall | Bounded. The grace expires after seven days and the site degrades to Free |
| A customer runs an old plugin version with a since-retired key | The retired key stops verifying, so the site degrades to Free rather than staying on a stale entitlement |
| A leaked license key is used on many sites | The API sees every activation and enforces the limit. The plugin does not need to |
Every one of these degrades to Free rather than to a broken site, and none of them destroys customer data.