---
title: "PHP signature verification"
description: "Run the complete PHP Ed25519 verifier against KDPika's real signed JSON and positive, tampered, malformed and expired test cases."
slug: "/docs/v1/security/verify-ed25519/php"
section: "Signature examples"
order: 202
audience: "all"
level: "advanced"
last_updated: "2026-10-17"
version: "v1"
related: ["/docs/v1/security/signed-responses","/docs/v1/security/test-vectors"]
draft: false
---
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](/docs/v1/start/glossary#license).

## Prerequisites
- Read [signed responses](/docs/v1/security/signed-responses).
- Install PHP 8.2+ with ext-sodium; no Composer dependency.
- Download the [complete verification source archive](/docs/downloads/security-verifiers.zip) 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
```bash
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
```bash
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](/docs/v1/start/glossary#official-sdk).

## Complete source
```php
<?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](/docs/v1/start/glossary#feature).

After signature success, check [product](/docs/v1/start/glossary#product), environment, installation hash, active state, start/expiry, application-version policy and signed validation/grace deadlines. See [offline mode](/docs/v1/security/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](/docs/v1/security/test-vectors) and apply the [signed-response policy checks](/docs/v1/security/signed-responses).

## Related
- [Verification language index](/docs/v1/security/verify-ed25519)
- [Secure token storage](/docs/v1/security/secure-token-storage)
