// PEM is the base64 of the DER between two labelled lines; crypto.subtle wants the DER export function pemToDer(pem, label) { const match = new RegExp(`-----BEGIN ${label}-----([A-Za-z0-9+/=\\s]+)-----END ${label}-----`).exec(pem); if (!match) throw new Error(`no ${label} block`); const binary = atob(match[1].replace(/\\s+/g, "")); return Uint8Array.from(binary, (char) => char.charCodeAt(0));} export function derToPem(der, label) { let binary = ""; for (const byte of new Uint8Array(der)) binary += String.fromCharCode(byte); const lines = btoa(binary).match(/.{1,64}/g).join("\n"); return `-----BEGIN ${label}-----\n${lines}\n-----END ${label}-----\n`;} How it works
pemToDerextracts the block with the expected label, removes the line breaks and decodes withatob: 563 bytes for the certificate.crypto.subtle.importKey("pkcs8", der, …)takes the key's DER;exportKey("pkcs8", key)returns 138 bytes, andderToPemturns them into a PEM identical to the input.derToPembuilds the binary string byte by byte beforebtoaand splits the base64 into 64-character lines, as PEM requires.- Everything stays in the page: the conversion uses
atob,btoaandcrypto.subtleonly.
Gotchas
importKey("pkcs8", …)with the PEM text itself rejects withDataError— it wants the decoded DER bytes."pkcs8"means PKCS#8 only: a SEC1 key (BEGIN EC PRIVATE KEY) decoded to DER also fails withDataError. Convert it to PKCS#8 first (openssl pkcs8 -topk8 -nocrypt).String.fromCharCode(...bytes)spreads every byte as an argument and throwsRangeErrorfor large inputs (2 MB in the run); build the string in a loop, asderToPemdoes.