Skip to content
ecdsa.com

Convert DER ⇄ raw · PHP

How to convert an ECDSA signature between DER and raw in PHP

PHP's OpenSSL extension signs and verifies only ASN.1 DER, while JWS (ES256) and WebCrypto use the fixed 64-byte r‖s form — so you regularly need to convert an ECDSA signature between DER and raw in PHP. Both directions are plain string handling with substr, ltrim and str_pad; no extension or library is needed.

Tested with PHP 8.5.11 (ext/openssl, OpenSSL 3.5.7).

signature-formats.php — derToRaw and rawToDer
<?php
/** DER SEQUENCE { INTEGER r, INTEGER s } to fixed-width r‖s; $size: 32 P-256, 48 P-384, 66 P-521. */
function derToRaw(string $der, int $size): string
{
$offset = 0;
$read = function (int $length) use ($der, &$offset): string {
if ($offset + $length > strlen($der)) {
throw new InvalidArgumentException('truncated DER signature');
}
$bytes = substr($der, $offset, $length);
$offset += $length;
return $bytes;
};
if ($read(1) !== "\x30") throw new InvalidArgumentException('not a DER SEQUENCE');
$length = ord($read(1));
if ($length === 0x81) $length = ord($read(1)); // long form: P-521 only
if ($length !== strlen($der) - $offset) throw new InvalidArgumentException('bad SEQUENCE length');
$raw = '';
for ($i = 0; $i < 2; $i++) {
if ($read(1) !== "\x02") throw new InvalidArgumentException('expected INTEGER');
$value = ltrim($read(ord($read(1))), "\0"); // drop the sign byte
if (strlen($value) > $size) throw new InvalidArgumentException('integer does not fit the curve');
$raw .= str_pad($value, $size, "\0", STR_PAD_LEFT); // left-pad to the fixed width
}
return $raw;
}
/** Fixed-width r‖s to minimal DER. */
function rawToDer(string $raw): string
{
$integer = function (string $bytes): string {
$bytes = ltrim($bytes, "\0");
if ($bytes === '' || ord($bytes[0]) & 0x80) {
$bytes = "\0" . $bytes; // DER INTEGERs are signed: keep the value positive
}
return "\x02" . chr(strlen($bytes)) . $bytes;
};
$half = intdiv(strlen($raw), 2);
$body = $integer(substr($raw, 0, $half)) . $integer(substr($raw, $half));
return "\x30" . (strlen($body) > 127 ? "\x81" : '') . chr(strlen($body)) . $body;
}
usage.php — convert the shared example signature, check the round trip, print the JWS form
<?php
require 'signature-formats.php';
$der = hex2bin('30450221009f4118b059a34a2f344c39a1d085d93bab560402938281dc1cb2f3dfb8eee58602200bd54b34a72042551c3316e10f461a1d69036ba4f0d72d95d42465340b56def8');
$raw = derToRaw($der, 32);
echo 'raw: ', bin2hex($raw), "\n";
echo 'round trip: ', rawToDer($raw) === $der ? 'true' : 'false', "\n";
echo 'JWS form: ', rtrim(strtr(base64_encode($raw), '+/', '-_'), '='), "\n"; // base64url, no padding

How it works

  1. A DER signature is 30 len 02 len r 02 len s. derToRaw reads that layout with bounds checks, so truncated input, a wrong tag, a mismatched length or trailing bytes raise InvalidArgumentException instead of producing garbage.
  2. Each INTEGER may carry a leading 0x00 sign byte and may be shorter than the curve size. ltrim removes the zeros and str_pad(..., STR_PAD_LEFT) restores the fixed width.
  3. rawToDer reverses it: strip leading zeros, add a 0x00 when the top bit is set (DER INTEGERs are signed), wrap both in a SEQUENCE; P-521 signatures need the long-form length 0x81.
  4. The JWS form is the raw bytes in base64url without padding: strtr swaps the alphabet and rtrim drops the =.

Gotchas

  • $x ?: "\0" is a tempting shortcut for "empty string to zero byte", but the one-character string "0" (byte 0x30) is falsy in PHP too — an integer whose remaining byte is 0x30 would be silently corrupted. Compare with === '', as the code does.
  • Slicing DER at fixed offsets (substr($der, 4, 32)) works on most signatures and fails on the ones with a 31-byte r or s, or a 33-byte one with a sign byte. The run converts two real 31-byte cases and 1,000 fresh signatures on P-256 and P-521.
  • $size must match the curve: 32 for P-256, 48 for P-384, 66 for P-521. A wrong size raises "integer does not fit the curve" or produces a signature of the wrong length.
  • openssl_verify given raw bytes returns -1, not 0 — and -1 is truthy. Convert before verifying, and compare the result with === 1.

Related recipes