Verify KDPika payload authenticity in PHP without losing the server's signed numeric tokens.
What you will learn
- Build and run the complete PHP verifier.
- Reject altered, ambiguous and untrusted envelopes.
- Keep signature checking separate from permission to use a license.
Prerequisites
- Read signed responses.
- Install PHP 8.2+ with ext-sodium; no Composer dependency.
- Download the complete verification source archive and extract it.
- Open a terminal in the extracted security directory; all paths below are relative to it.
- Use Python 3.9+ only for the optional shared test runner, not as a dependency of this verifier.
Install and build
php -r 'exit(extension_loaded("sodium") ? 0 : 1);'
Expected result: dependencies resolve and the build exits successfully. Version commands print the installed runtime. Use a maintained runtime compatible with the minimum; these are tested compatibility floors, not claims of ongoing vendor support.
The program uses no OS-specific identity or machine hardware identifier. File paths and executable suffixes may differ on Windows; use your shell's path quoting.
Run the positive and negative cases
php php/verify.php vectors/positive.key.json vectors/positive.envelope.json
php php/verify.php vectors/tampered.key.json vectors/tampered.envelope.json
php php/verify.php vectors/expired.key.json vectors/expired.envelope.json
python3 selftest.py --command php php/verify.php
Expected output for the first three commands is signature_valid=true, signature_valid=false and signature_valid=true. The tampered command exits 1 intentionally; run commands separately rather than stopping a batch at that expected refusal. The shared runner prints PASS23 and exits 0 when every expected acceptance/refusal matches.
Expired remains cryptographically authentic but must not unlock access after its expiry. This program returns signature validity only. It does not replace a complete license client, network transport, offline policy or the official SDK.
Complete source
<?php
// KDPika signature authenticity only. PHP 8.2+, ext-sodium, no Composer package.
// Run: php verify.php key.json envelope.json. Output signature_valid=true/false.
// Keep key.json trusted independently. A valid signature does not grant access.
declare(strict_types=1);
final class NumberToken { public function __construct(public string $raw) {} }
final class RawJson {
private int $i = 0;
public function __construct(private string $text) {
if (strlen($text) > 1048576) throw new RuntimeException('JSON exceeds 1 MiB');
}
private function ws(): void { $this->i += strspn($this->text, " \t\r\n", $this->i); }
private function string(): string {
if (!preg_match('/\G"(?:[^"\\\\\x00-\x1f]|\\\\(?:["\\\\\/bfnrt]|u[0-9a-fA-F]{4}))*"/s', $this->text, $m, 0, $this->i)) throw new RuntimeException('Invalid string');
$this->i += strlen($m[0]);
return json_decode($m[0], false, 64, JSON_THROW_ON_ERROR);
}
private function value(int $depth): mixed {
if ($depth > 64) throw new RuntimeException('JSON exceeds 64 levels');
$this->ws(); $ch = $this->text[$this->i] ?? '';
if ($ch === '"') return $this->string();
if ($ch === '{' || $ch === '[') {
$object = $ch === '{'; $end = $object ? '}' : ']'; $this->i++; $this->ws();
$result = $object ? new stdClass() : [];
if (($this->text[$this->i] ?? '') === $end) { $this->i++; return $result; }
while (true) {
if ($object) {
$this->ws(); $key = $this->string(); $this->ws();
if (($this->text[$this->i++] ?? '') !== ':' || property_exists($result, $key)) throw new RuntimeException('Duplicate or invalid property');
$result->{$key} = $this->value($depth + 1);
} else { $result[] = $this->value($depth + 1); }
$this->ws(); $next = $this->text[$this->i++] ?? '';
if ($next === $end) return $result;
if ($next !== ',') throw new RuntimeException('Invalid collection');
}
}
foreach (['true' => true, 'false' => false, 'null' => null] as $token => $value) {
if (substr($this->text, $this->i, strlen($token)) === $token) { $this->i += strlen($token); return $value; }
}
if (!preg_match('/\G-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/', $this->text, $m, 0, $this->i)) throw new RuntimeException('Invalid value');
$this->i += strlen($m[0]); return new NumberToken($m[0]);
}
public function parse(): mixed {
$value = $this->value(0); $this->ws();
if ($this->i !== strlen($this->text)) throw new RuntimeException('Trailing content');
return $value;
}
}
function canonical(mixed $value): string {
if ($value instanceof NumberToken) return $value->raw;
if ($value instanceof stdClass) {
$props = get_object_vars($value); ksort($props, SORT_STRING); $parts = [];
foreach ($props as $key => $child) $parts[] = canonical((string)$key) . ':' . canonical($child);
return '{' . implode(',', $parts) . '}';
}
if (is_array($value)) return '[' . implode(',', array_map('canonical', $value)) . ']';
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
function decodeKey(string $value, int $size): string {
if (!preg_match('/^[A-Za-z0-9_-]+$/D', $value)) throw new RuntimeException('Invalid base64url');
$raw = base64_decode(strtr($value, '-_', '+/'), true);
if ($raw === false || strlen($raw) !== $size || rtrim(strtr(base64_encode($raw), '+/', '-_'), '=') !== $value) throw new RuntimeException('Invalid base64url length');
return $raw;
}
function verifyEnvelope(string $trustedKeyRaw, string $envelopeRaw): bool {
try {
$key = (new RawJson($trustedKeyRaw))->parse(); $env = (new RawJson($envelopeRaw))->parse();
if (!($key instanceof stdClass) || !($env instanceof stdClass)) return false;
foreach (['public_key','algorithm','encoding','key_id'] as $field) if (!isset($key->{$field}) || !is_string($key->{$field})) return false;
foreach (['algorithm','key_id','signature','payload'] as $field) if (!isset($env->{$field})) return false;
$public = decodeKey($key->public_key, 32); $id = 'ed25519_' . substr(hash('sha256', $public), 0, 16);
if ($key->algorithm !== 'Ed25519' || $key->encoding !== 'base64url' || $key->key_id !== $id || $env->algorithm !== 'Ed25519' || $env->key_id !== $id || !($env->payload instanceof stdClass)) return false;
return sodium_crypto_sign_verify_detached(decodeKey($env->signature, 64), canonical($env->payload), $public);
} catch (Throwable) { return false; }
}
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
$valid = false;
try { if ($argc === 3) $valid = verifyEnvelope((string)file_get_contents($argv[1]), (string)file_get_contents($argv[2])); }
catch (Throwable) { $valid = false; }
echo 'signature_valid=' . ($valid ? 'true' : 'false') . PHP_EOL; exit($valid ? 0 : 1);
}
Expected result
All 23 cases pass in the shared runner, including integral floats, non-BMP key order, Unicode separators, duplicate JSON names, invalid signatures and excessive depth. The original response's numeric tokens are retained, not recalculated from binary floating-point values.
Common mistakes
- Taking the verification key from the same untrusted message being checked.
- Treating a successful signature as proof of active access, correct installation or recent payment.
- Parsing and reserializing numbers before passing the envelope into this sample.
Security notes
The key input must come from your trusted pin or deliberate fixed-origin HTTPS trust policy. Public vector keys are test anchors, never production configuration. This sample fails closed on malformed data; never fall back to unverified features.
After signature success, check product, environment, installation hash, active state, start/expiry, application-version policy and signed validation/grace deadlines. See offline mode. The current official SDKs have documented canonicalization limitations; this independent verifier does not silently repair their caches or alter their public APIs.
Troubleshooting
| Symptom | Cause | Action |
|---|---|---|
| Positive case fails | Wrong trusted key, changed numeric tokens or missing dependency | Restore the exact archive/vector files and rerun the build |
| Tampered case returns false | Expected rejection | Keep the refusal; do not bypass verification |
| Expired case returns true | Authenticity and expiry are separate | Reject access in the license policy layer |
| Cannot open a file | Terminal is outside the extracted security directory | Change to that directory and retry using quoted paths |
Next steps
Run all test vectors and apply the signed-response policy checks.