Skip to content

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

ClaimMechanism
Entitlements cannot be forged without the platform private keyEd25519 over the exact signed bytes
Editing local options grants nothingVerify-on-read, signature stored with the payload
A payload cannot be moved between installationsinstallation_id binding
A payload cannot be replayed indefinitelyexpires_at plus a bounded offline grace
A payload cannot be replayed from the futureissued_at with 300 s skew tolerance
UI manipulation grants nothingEvery UI gate has a server-side counterpart
No plugin code path can grant a featureNo filter can rewrite entitlements, state or the registry
A retired signing key stops workingkid with retires_at
Failure is closedMissing 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:

  1. The pinned public keys in PublicKeyRegistry. Hard-coded, not filterable, not settable through a constant.
  2. The signature check in SignatureVerifier, backed by libsodium.
  3. 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::KEYS is 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

FilterWhy it grants nothing
ad_form_license_api_baseRedirecting the API is useless without the private key. Tested
ad_form_license_dashboard_urlPresentation
ad_form_license_timeoutBounded to 1–30 s
ad_form_upgrade_urlPresentation
ad_form_billing_urlPresentation

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".

FailureResult
sodium unavailablecrypto_unavailable, Free, visible admin notice
no pinned key in the buildcrypto_unavailable, Free, visible admin notice
unknown or retired kidpayload discarded
wrong algorithmpayload discarded
signature invalidpayload discarded
malformed payloadpayload discarded
wrong productpayload discarded
wrong installationpayload discarded
payload expired on arrivalpayload discarded
payload dated in the futurepayload discarded
stored payload fails re-verificationuntrusted, Free
API unreachableexisting 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

SurfaceHandling
License key from $_POSTCapability check, check_admin_referer, wp_unslash, sanitize_text_field
Notice flash parameters from $_GETsanitize_key, mapped through a fixed match so no user string is echoed
API response bodyjson_decode to array, every member type-checked in LicensePayload::from_array()
Feature keys from the payloadFiltered to [a-z0-9._-]; sanitize_key() would strip the dots
Definition paths in error messagesDerived from sanitised definition ids
Everything in templates/admin/license.phpesc_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

RiskAssessment
A customer patches the PHP to bypass the gatePossible 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 gracePossible. Bounded to the grace window, and Clock::freeze() is only ever called from tests
A customer blocks the API at the firewallBounded. The grace expires after seven days and the site degrades to Free
A customer runs an old plugin version with a since-retired keyThe 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 sitesThe 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.

Digitale Lösungen. Persönlich. Zukunftssicher.