function pemToBytes(pem) { const b64 = pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""); return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));} const privateKey = await crypto.subtle.importKey( "pkcs8", // "BEGIN PRIVATE KEY" body pemToBytes(privateKeyPem), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"],); const signature = new Uint8Array( await crypto.subtle.sign( { name: "ECDSA", hash: "SHA-256" }, privateKey, new TextEncoder().encode(message), ),);console.log(signature.length); // always 64: raw r‖s, not DERHow it works
importKey("pkcs8", …)accepts only the modern PKCS#8 layout. LegacyBEGIN EC PRIVATE KEYfiles must be converted once:openssl pkey -in legacy.pem -out pkcs8.pem.- The hash is chosen at sign time in the algorithm object — the key itself carries only the curve.
- The resolved
ArrayBufferis fixed-length raw output: 64 bytes (P-256), 96 (P-384), 132 (P-521). TextEncoderis only for string messages — binary payloads (file bytes, hashes of larger documents) go intosubtle.signas aUint8Arraydirectly.
Gotchas
- The signature is not DER:
openssl dgst -verifyand Python's defaultverifyreject it. Convert raw → DER for those consumers, or verify in Node withdsaEncoding: "ieee-p1363". - Setting
extractabletofalsefor signing keys is good hygiene — signing still works; onlyexportKeyis blocked. - Each call yields a different signature for the same input (random nonce, as the ECDSA spec requires) — caching or deduplicating by signature bytes is a design error.