ecdsa.com

Convert DER ⇄ raw · Node.js

How to convert an ECDSA signature between DER and raw in Node.js

Node.js has no built-in converter between DER and raw ECDSA signatures, but the DER structure — a SEQUENCE of two INTEGERs — is simple enough to handle in a dozen lines. Often you can skip conversion entirely: sign and verify in node:crypto accept a dsaEncoding option for both formats.

Tested with Node.js 26 (OpenSSL 3.x backend).

Same recipe in:Node.jsPythonGoWebCrypto
der-to-raw.mjs — DER → fixed-width r‖s
function derToRaw(der, size = 32) { // size: 32 P-256, 48 P-384, 66 P-521
if (der[0] !== 0x30) throw new Error("not a DER SEQUENCE");
let o = der[1] & 0x80 ? 2 + (der[1] & 0x7f) : 2; // skip long-form length
const readInt = () => {
if (der[o++] !== 0x02) throw new Error("expected INTEGER");
const len = der[o++];
let bytes = der.subarray(o, o + len);
o += len;
while (bytes.length > size && bytes[0] === 0x00) bytes = bytes.subarray(1);
return Buffer.concat([Buffer.alloc(size - bytes.length), bytes]); // left-pad
};
return Buffer.concat([readInt(), readInt()]); // r ‖ s, always 2 × size
}
raw-to-der.mjs — the reverse direction
function rawToDer(raw) {
const half = raw.length / 2;
const derInt = (bytes) => {
let i = 0;
while (i < bytes.length - 1 && bytes[i] === 0x00) i++; // strip padding
bytes = bytes.subarray(i);
const pad = bytes[0] & 0x80 ? Buffer.from([0x00]) : Buffer.alloc(0);
return Buffer.concat([Buffer.from([0x02, bytes.length + pad.length]), pad, bytes]);
};
const body = Buffer.concat([derInt(raw.subarray(0, half)), derInt(raw.subarray(half))]);
const head = body.length < 128 ? [0x30, body.length] : [0x30, 0x81, body.length];
return Buffer.concat([Buffer.from(head), body]);
}
often you don't need to convert at all
import { verify } from "node:crypto";
// node:crypto speaks both formats natively:
verify("sha256", msg, publicKey, derSignature);
verify("sha256", msg, { key: publicKey, dsaEncoding: "ieee-p1363" }, rawSignature);

How it works

  1. A DER ECDSA signature is SEQUENCE { INTEGER r, INTEGER s }. Each INTEGER may carry a leading 0x00 (when the top bit is set) that raw format drops.
  2. Going DER → raw: strip any 0x00 pad, then left-pad each integer to the curve's coordinate size (32 bytes on P-256).
  3. Going raw → DER: strip leading zeros to make the integer minimal, then re-add one 0x00 if the top bit is set, and wrap in SEQUENCE framing.

Gotchas

  • Never slice DER at fixed offsets (der.subarray(4, 36)): the integer lengths vary between signatures, so fixed offsets work in tests and fail in production.
  • Forgetting the left-padding in DER → raw produces a short signature about 1 time in 256 — the intermittent failure rate that makes this bug famously hard to spot.
  • The size parameter must match the curve: pass 48 for P-384 and 66 for P-521. A 64-byte output from a P-384 signature means the conversion silently mangled it.

Related recipes