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}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]);}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
- A DER ECDSA signature is
SEQUENCE { INTEGER r, INTEGER s }. Each INTEGER may carry a leading0x00(when the top bit is set) that raw format drops. - Going DER → raw: strip any
0x00pad, then left-pad each integer to the curve's coordinate size (32 bytes on P-256). - Going raw → DER: strip leading zeros to make the integer minimal, then re-add one
0x00if 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
sizeparameter 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.