Verify the signed payload with a trusted public key, then independently check whether its license state permits the requested action.
What you will learn
- Reproduce the exact signed bytes without losing numbers or Unicode.
- Bind a response to your product, environment and installation.
- Handle key changes, expiry, clock changes and replay honestly.
Prerequisites
- Read authentication and the activation flow.
- Retain the original UTF-8 JSON response before a normal parser changes numeric tokens.
- Configure a trusted public key separately from the response being verified.
Envelope and trust boundary
Activation and validation return a license envelope. A successful HTTP request alone is not proof of authenticity or permission. The signed part is only payload; activation_token, success, valid and request_id in the outer API response are not covered by this signature.
| Envelope field | Type | Required meaning |
|---|---|---|
| payload | object | The license snapshot whose canonical UTF-8 bytes are signed |
| signature | string | Unpadded base64url representation of a 64-byte detached signature |
| algorithm | string | Must equal Ed25519; do not negotiate a different algorithm from the response |
| key_id | string | Must match the independently trusted key, not authorize downloading an arbitrary key URL |
The public key is 32 raw bytes, not a PEM file. The signature is 64 raw bytes, not a hex string. Ed25519 signs the message directly; do not pre-hash the payload or use Ed25519ph. See RFC 8032.
Exact signed bytes
The server recursively sorts associative PHP-array keys with ksort using SORT_STRING, preserves list order, then encodes the payload with JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE and JSON_PRESERVE_ZERO_FRACTION. The result has no indentation or separating whitespace. Strings and object names use UTF-8 byte ordering; JavaScript and .NET default UTF-16 ordering can differ for non-BMP keys.
Strings use literal Unicode, except U+2028 and U+2029 are escaped. Quotes, backslashes and JSON controls are escaped; forward slashes remain literal. There is no Unicode normalization. A composed character and its decomposed form are different bytes. Duplicate object names are not a supported wire format; reject them before signature checking.
An integral float such as 19.0 remains 19.0. Numeric encoding is PHP-specific and also depends on its serialize_precision setting. This contract is not RFC 8785 / JSON Canonicalization Scheme. Normal JSON.parse followed by JSON.stringify changes some signed numbers; sorting a normal parsed JavaScript object is insufficient. The PHP JSON documentation describes these flags and precision behavior.
The verification samples retain numeric tokens from the original server JSON and canonicalize its keys and strings. They do not reconstruct PHP floating-point formatting from rounded language numbers. Keep those original numeric tokens in any local cache too. An intermediary that changes a numeric representation can cause a safe verification failure. Do not bypass that failure.
Signed payload field reference
Nullable means the field may explicitly contain null. A missing required field is not the same as null. Your application must validate the fields it uses after verifying the signature.
| Field | Type | Meaning and handling |
|---|---|---|
| license_id | string | Public license identifier; compare with the expected license when known |
| product_id | string | Public App ID; must equal your configured product |
| product | string | Product display name; not an authorization identifier |
| plan | string | Product-plan slug; not the developer's platform subscription |
| status | string | Current license state; require active for normal licensed access |
| environment | string | sandbox or live; must match the configured environment |
| license_key_mask | string | Display mask; never a credential |
| starts_at | string or null | UTC ISO 8601 start date; do not grant use before a future start |
| expires_at | string or null | UTC ISO 8601 expiry; null has no license expiry deadline |
| created_at | string or null | Creation display timestamp; not a freshness proof |
| max_devices | integer | Concurrent active-device limit |
| max_activations | integer or null | Cumulative activation limit; null means no configured cumulative limit |
| activation_count | integer | Cumulative activation count |
| active_devices | integer | Active count at snapshot time; not a live counter offline |
| min_version | string or null | Configured lower application-version restriction |
| max_version | string or null | Configured upper application-version restriction |
| customer | object | Customer information; treat as personal data |
| customer.id | string | Customer's public identifier |
| customer.name | string or null | Optional name |
| customer.email | string | Customer email associated with the license |
| customer.company | string or null | Optional company |
| customer.country | string or null | Optional country |
| product_details | object | Product display metadata |
| product_details.id | string | Public App ID, consistent with product_id |
| product_details.name | string | Product display name |
| product_details.platform | string | Lowercase configured application platform |
| plan_details | object | Product-plan snapshot |
| plan_details.id | string | Product-plan identifier |
| plan_details.name | string | Product-plan display name |
| plan_details.slug | string | Product-plan slug |
| plan_details.billing_type | string | Lowercase product-plan billing type |
| plan_details.price | number | PHP float; 19.0 and 19 have different signature bytes |
| plan_details.currency | string | Product-plan currency; not evidence of a payment |
| plan_details.duration_days | integer or null | Configured license duration |
| plan_details.trial_days | integer or null | Configured trial duration |
| features | object or empty array | Dynamic feature keys; empty PHP feature arrays can serialize as [] |
| features[key] | JSON value | Boolean or configured JSON value; validate its type before use |
| device | object | Bound installation metadata |
| device.id | string | Device's public identifier |
| device.installation_hash | string or null | SHA-256 of the persisted installation ID; require a match |
| device.name | string or null | Optional installation label |
| device.platform | string or null | Optional platform description |
| device.app_version | string or null | Optional application version description |
| server_time | string | UTC ISO 8601 snapshot time; signed, but still replayable |
| next_validation_at | string | Suggested online-validation deadline |
| offline_grace_until | string | Absolute offline-fallback deadline |
Activation can return empty device-description strings; validation can return null for the same absent descriptions. Null is signed data, not a reason to remove a property before verification. Omitting X-Installation-ID during validation can produce a null installation hash; always send the persisted ID and reject an unbound response in your client.
Fetch and cache the trusted public key
- Fetch GET /api/v1/public-key from the configured KDPika HTTPS origin, using normal certificate and hostname verification.
- Read key.algorithm, key.key_id, key.public_key and key.encoding from the successful response.
- Require Ed25519, base64url and a decoded 32-byte key.
- Calculate key_id as ed25519_ followed by the first 16 lowercase hexadecimal characters of SHA-256 of those raw public bytes.
- Pin the approved key in your application or save the HTTPS-fetched key in protected application state according to your trust policy.
- Cache the current key for at most the endpoint's one-hour cache lifetime before an intentional refresh.
Expected result: a response cannot choose its own key, host or algorithm. An untrusted file beside the application is not a secure pin merely because its filename is key.json.
Trust on first use from HTTPS depends on the origin, certificate validation and the client's local environment. A compiled pin reduces dependence on a later key fetch, but a fully controlled client machine can still replace the program. Do not fetch a key using a URL supplied in an envelope.
Key changes and rotation
The platform currently exposes one configured public key and reads one signing-key pair. There is no public historical key ring, overlapping-signature period, key-validity schedule or rotation-history endpoint.
- Treat an unknown key_id as untrusted, not as permission to skip verification.
- If your policy allows refresh, fetch once from the fixed HTTPS public-key endpoint and validate its format and identity.
- Accept a new key only through your intended trust-update policy; a strictly pinned client needs an application/configuration rollout.
- Retry verification once with the approved key or return a clear connection/update-required error.
- Discard old cached signed states according to the rollout and expiry policy.
Expected result: rotation is deliberate and failures remain closed. Any client-managed overlap is your integration policy, not an existing server guarantee. Compromise of a signing key requires a separate coordinated response with KDPika support.
Time, binding and replay
After authenticity succeeds, require the intended product, environment and installation hash, active status, acceptable application version, valid start/expiry and the appropriate signed validation/grace deadlines. Use the official SDK's valid result as the first gate before feature helpers.
Both next_validation_at and offline_grace_until are calculated from server_time. Grace is not automatically added after the validation interval. A valid signature can belong to an expired license: the expired vector intentionally demonstrates this distinction.
The payload contains no signed request nonce, client challenge or monotonic sequence number. A previously valid signed snapshot can be replayed until your time/binding policy rejects it. The SDKs use a five-minute local clock-rollback tolerance, but local files and clocks are mutable. Revocation on the server cannot instantly invalidate a disconnected cached response. Shorter intervals and server-side authorization reduce exposure; they do not make a local binary unpatchable.
The current source has a recorded database-timezone discrepancy for created_at on a non-UTC local database. Do not use created_at as proof of freshness. This documentation does not change stored timestamps or signing behavior.
Expected result
The positive vector verifies. A changed payload or wrong key fails. The expired vector verifies cryptographically but must fail your time-based access decision. A response for another product, environment or installation does not unlock your application.
Common mistakes
- Signing the outer response, or signing an already rounded JavaScript number tree.
- Using a public key embedded in the same untrusted response as the trust anchor.
- Removing nulls, normalizing Unicode or rewriting numbers before checking the signature.
- Treating signature_valid as permission to use an expired or revoked snapshot.
Security notes
The public signature prevents undetected edits under a trusted key. It does not encrypt customer information, confirm a buyer payment, make the local clock trustworthy or prove a response is the newest available state. Keep raw caches private and avoid logging customer payloads or activation tokens.
Troubleshooting
| Symptom | Likely cause | Safe next step |
|---|---|---|
| Only integral-price responses fail | A JSON round trip changed 19.0 to 19 | Preserve original numeric tokens; run the integral-float vector |
| Unicode works online but not from cache | The cache changed string escaping or the client canonicalizer preserved escaped raw strings | Run Unicode vectors; reject the cached result and investigate the known SDK discrepancy |
| New responses use an unknown key_id | A key changed or the response is untrusted | Follow the deliberate key-refresh/pin-update policy; never bypass verification |
| Signature passes but access fails | Expiry, status, environment, product or installation checks rejected it | Explain the actual license state to the customer and validate online when appropriate |
Next steps
Use the complete verification samples, then run every test vector.