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 v = der.subarray(o, o + len); o += len; while (v.length > size && v[0] === 0x00) v = v.subarray(1); const out = new Uint8Array(size); out.set(v, size - v.length); // left-pad to fixed width return out; }; const raw = new Uint8Array(2 * size); raw.set(readInt(), 0); raw.set(readInt(), size); return raw;}const rawSig = derToRaw(derSignature); // derSignature: Uint8Array const ok = await crypto.subtle.verify( { name: "ECDSA", hash: "SHA-256" }, publicKey, rawSig, new TextEncoder().encode(message),);How it works
- DER wraps
randsinSEQUENCE/INTEGERframing with minimal-length rules; raw format is just both integers at fixed width, concatenated. - The function strips the sign-padding
0x00bytes DER adds and left-pads each value tosize— the two operations that differ between the formats. - Work on
Uint8Array: if the signature arrived as anArrayBuffer(e.g. fromfetch), wrap it first —new Uint8Array(buffer).
Gotchas
- Skipping conversion and passing DER to
subtle.verifydoes not throw — it resolves tofalse, indistinguishable from a bad signature. Length is your tell: DER is ~70–72 bytes on P-256, raw is exactly 64. - Both halves need the padding treatment: a conversion that handles
rbut slicessnaively still fails on the subset of signatures whereshas a padding byte. - The reverse direction (raw → DER, for sending WebCrypto signatures to OpenSSL) must strip zeros and re-add
0x00when the top bit is set — see the Node.js recipe for a testedrawToDer.