16 KiB
Replace jsrsasign in CyberChef
See AGENTS.md for cross-PR conventions, workflow, and library decisions agents must follow.
Status
- PR 1 — Setup + ASN.1 utilities
- PR 2 — SM2 rewrite
- PR 3 — ECDSA primitives
- PR 4 — PEM/JWK conversion + key extraction
- PR 5 — X.509 / CSR / CRL parsing
- PR 6 — Removal
Notes for next session: (none yet)
Context
jsrsasign (currently pinned at ^11.1.3 in package.json:148) has announced end-of-support. It is used in 14 operations and one library file in CyberChef and covers X.509/CSR/CRL parsing, ECDSA signing/verification, key format conversion, ASN.1/OID utilities, and SM2 elliptic-curve encryption.
Continuing to ship an unmaintained crypto library is a security risk. This plan replaces every jsrsasign call with proven, actively-maintained alternatives, then removes the dependency entirely.
Library choices
Add these production dependencies, all MIT/BSD-3 (compatible with CyberChef's Apache-2.0 license), all pure-JS, all browser + Node:
| Library | Used for | Why this one |
|---|---|---|
| @peculiar/x509 (^1.14) | X.509 certs, PKCS#10 CSRs, X.509 CRLs, SPKI/PKCS#8 key parsing | Peculiar Ventures specialise in browser PKI. Built on Web Crypto + asn1js. Used by Microsoft, Cloudflare. MIT. |
| asn1js (^3) | Generic ASN.1 parse + tree dump for ParseASN1HexString |
Same author. BSD-3. Pulled in transitively by @peculiar/x509 anyway. |
| @noble/curves (^2) | ECDSA sign/verify, signature format conversion, key generation, SM2 curve | Audited by Cure53, Trail of Bits, Kudelski. Zero dependencies. Used by MetaMask, Coinbase, Ethereum Foundation. Built-in @noble/curves/sm2. Same author as the already-installed @noble/hashes. |
| node-forge (already a dep) | DSA path inside PubKeyFromPrivKey only |
Avoids adding another lib just for DSA. |
Not adopted, with reasons: pkijs (covered by @peculiar/x509); jose/panva (overkill for two JWK round-trip ops); sm-crypto/sm-crypto-v2 (@noble/curves/sm2 handles it); native Web Crypto for ECDSA (refuses MD5/SHA-1 digests that the UI exposes, and JWK/format gymnastics outweigh the dependency saving).
End state: npm uninstall jsrsasign; remove from package.json.
Decisions confirmed with user
- Phased delivery: 6 PRs, one per phase below.
- Output fidelity: cosmetic drift accepted for golden text-output tests (
ParseX509Certificate,ParseCSR,ParseX509CRL,ParseASN1HexString). Update fixtures, document in CHANGELOG. - DSA in
PubKeyFromPrivKey: keep via node-forge (no user-visible regression). - ECDSA signature fixtures: update to @noble values if RFC 6979 outputs diverge (signatures remain valid).
Files to be modified
14 operations (all under src/core/operations/):
- ASN.1 / encoding:
HexToObjectIdentifier.mjs,ObjectIdentifierToHex.mjs,HexToPEM.mjs,ParseASN1HexString.mjs - X.509 / CSR / CRL:
ParseX509Certificate.mjs,PubKeyFromCert.mjs,ParseCSR.mjs,ParseX509CRL.mjs - Key conversion:
PEMToJWK.mjs,JWKToPem.mjs,PubKeyFromPrivKey.mjs - ECDSA:
ECDSASign.mjs,ECDSAVerify.mjs,ECDSASignatureConversion.mjs,GenerateECDSAKeyPair.mjs
Library files:
- src/core/lib/SM2.mjs — full rewrite, drop jsrsasign
- src/core/lib/PublicKey.mjs — extend
formatDnObjto accept @peculiar/x509'sName.toJSON()shape - NEW src/core/lib/Asn1.mjs —
oidHexToInt,oidIntToHex,derToPem,dumpAsn1Hex - NEW src/core/lib/Ecdsa.mjs — shared ECDSA helpers (key load, sign/verify, sig format conversions)
Build/manifest:
- package.json — add 3 deps in PR 1, remove jsrsasign in PR 6
CHANGELOG.md— note in PR 6
Phased plan
PR 1 — Setup + ASN.1 utilities (PublicKey bundle)
Goal: Add the three new deps, ship the Asn1.mjs helper module, migrate the four utility operations.
npm install --save @peculiar/x509 asn1js @noble/curves. Confirm webpack builds; smoke-testnpm test.- Create src/core/lib/Asn1.mjs:
oidHexToInt(hex)— inline BER OID decode (~25 lines, handles arc0/arc1 combined byte and base-128 multibyte arcs).oidIntToHex(oid)— inverse.derToPem(hex, label)— base64 + 64-col wrap with\nline endings.dumpAsn1Hex(hex, { truncate, startIndex })— walksasn1js.fromBERoutput and emits an indented tree.
- Migrate:
- HexToObjectIdentifier.mjs:
r.KJUR.asn1.ASN1Util.oidHexToInt→oidHexToInt. - ObjectIdentifierToHex.mjs:
r.KJUR.asn1.ASN1Util.oidIntToHex→oidIntToHex. - HexToPEM.mjs:
r.KJUR.asn1.ASN1Util.getPEMStringFromHex→derToPem. - ParseASN1HexString.mjs:
r.ASN1HEX.dump→dumpAsn1Hex.
- HexToObjectIdentifier.mjs:
- Tests:
- Add round-trip OID tests (sample OIDs incl.
2.5.4.3,1.3.6.1.4.1.311.2.1.4, edge case with arc > 127). - Update
ParseASN1HexStringgolden output to new tree format (drift allowed).
- Add round-trip OID tests (sample OIDs incl.
Risks: OID encoding edge cases — verify the 40·a + b first byte and base-128 arcs.
PR 2 — SM2 (Ciphers bundle)
Goal: Rewrite src/core/lib/SM2.mjs on top of @noble/curves/sm2.
API mapping:
| jsrsasign | @noble/curves/sm2 |
|---|---|
r.crypto.ECParameterDB.regist("sm2p256v1", …) + getByName |
import { sm2 } from "@noble/curves/sm2" (curve built-in) |
r.SecureRandom + new r.BigInteger(bits, rng).mod(n-1)+1 |
sm2.utils.randomPrivateKey() |
ecParams.G.multiply(k) |
sm2.Point.BASE.multiply(k) (k is bigint) |
ecParams.curve.decodePointHex("04"+x+y) |
sm2.Point.fromHex("04"+x+y) |
point.getX().toBigInteger() / getY() |
point.toAffine().x / .y (both bigint) |
point.isInfinity() |
point.is0() (or equals(sm2.Point.ZERO)) |
new r.BigInteger(hex, 16) |
BigInt("0x" + hex) |
Gotchas:
- Pad point coords with
.padStart(64, "0")afterbigint.toString(16)— existing tests depend on fixed-width hex. - KDF logic (SM3-based) is already pure-JS; do not change it.
- Preserve both ciphertext layouts (GMT 0009 BBB vs GMT 0010 C1C2C3/C1C3C2) — keep current format-string handling.
Tests: tests/operations/tests/SM2.mjs has hard-coded ciphertext→plaintext fixtures. They MUST pass unchanged — they pin cryptographic correctness.
PR 3 — ECDSA primitives (Ciphers bundle)
Goal: Migrate the four ECDSA operations to @noble/curves + @noble/hashes.
-
Create src/core/lib/Ecdsa.mjs with shared helpers:
loadEcKey(pem)— parse SEC1 / PKCS#8 / SPKI via@peculiar/asn1-ecc+@peculiar/asn1-pkcs8; return{ curve, isPrivate, d?, x, y, jwk }.signEcdsa(curve, dBytes, digest),verifyEcdsa(curve, qBytes, digest, sigDer).asn1SigToConcatHex,concatHexToAsn1Sig,parseAsn1SigToHexRS,hexRSToAsn1Sig— wrapSignature.fromBytes(.., 'der'|'compact')and.toBytes(format).isAsn1Hex(hex)— tryasn1js.fromBER.generateEcKeyPair(curveName)—curve.utils.randomPrivateKey(), build SPKI + PKCS#8 via@peculiar/asn1-*schemas, derive JWK.
-
Migrate operations:
- ECDSASign.mjs: use
loadEcKey+ hash (md5/sha1/sha256/sha384/sha512 from@noble/hashes; md5/sha1 come from@noble/hashes/legacy) +signEcdsa. Format conversion via the helpers. - ECDSAVerify.mjs:
isAsn1Hexfor input-format auto-detect;verifyEcdsa. - ECDSASignatureConversion.mjs: format conversions only — no key handling.
- GenerateECDSAKeyPair.mjs:
generateEcKeyPair; drop the\rstripping (was a jsrsasign artefact).
- ECDSASign.mjs: use
Gotchas:
- MD5/SHA-1 digests —
@noble/hashes/legacy(the v2 split). Web Crypto cannot do this, which is why we chose@noble/curves. - P-521 byte length is 66, not 65 — important for P1363 padding and JWK
x/yencoding. parseSigHexInHexRShistorically returns r/s with a leading00when the MSB is set (DER 2's-complement artefact). Replicate this — tests depend on it.- RFC 6979 determinism — outputs should match jsrsasign byte-for-byte. If they don't, update fixtures (signatures still valid; document in CHANGELOG).
- JWK field ordering for the JWK output of
GenerateECDSAKeyPair: build the literal as{kty, crv, x, y, d?}—JSON.stringifypreserves insertion order.
Tests: tests/operations/tests/ECDSA.mjs has 83 tests covering all curves × digests × formats. Run; update fixtures where needed.
PR 4 — PEM/JWK conversion + key extraction (PublicKey bundle)
Goal: Migrate PEMToJWK.mjs, JWKToPem.mjs, PubKeyFromPrivKey.mjs.
API mapping:
| jsrsasign | Replacement |
|---|---|
r.KEYUTIL.getKey(pem) RSA |
node-forge pki.privateKeyFromPem / publicKeyFromPem |
r.KEYUTIL.getKey(pem) EC |
loadEcKey from Ecdsa.mjs |
r.KEYUTIL.getKey(pem) DSA |
node-forge DSA parsing |
r.KEYUTIL.getKey(jwk) |
Inline: detect kty, dispatch to RSA / EC builders |
r.KEYUTIL.getJWKFromKey(key) |
Inline literal { kty, …, [d] } |
r.KEYUTIL.getPEM(key) SPKI |
@peculiar/x509 PublicKey(spki).toString("pem") |
r.KEYUTIL.getPEM(key, "PKCS8PRV") |
Build PKCS#8 via @peculiar/asn1-pkcs8's PrivateKeyInfo |
new r.RSAKey().setPublic(n, e) + getPEM |
node-forge pki.setRsaPublicKey(n, e) + publicKeyToPem |
new r.KJUR.crypto.ECDSA({curve}).setPublicKeyHex/generatePublicKeyHex |
Derive Q with curve.getPublicKey(d, false); build SPKI |
new r.KJUR.crypto.DSA().setPublic(p, q, g, y) |
node-forge DSA public key + publicKeyToPem |
Gotchas:
- JWK field order and PEM line endings (
\nnot\r\n) — see PR 3. JWKToPem'sPKCS8PRVoutput: build via@peculiar/asn1-pkcs8+ the algorithm-specific key schema (RSA or EC).- DSA PKCS#8 with no
y: keep the current "unsupported" throw — test already accepts it.
Tests: tests/operations/tests/JWK.mjs, tests/operations/tests/PubKeyFromPrivKey.mjs — should pass with minor whitespace adjustments.
PR 5 — X.509 / CSR / CRL parsing (PublicKey bundle)
Goal: Migrate the four cert-family operations to @peculiar/x509.
- Before any code changes: add a regression test for ParseX509Certificate.mjs (currently uncovered — confirm with
ls tests/operations/tests/ | grep -i x509certificate). Use the existing test certs from PubKeyFromCert.mjs. - Extend src/core/lib/PublicKey.mjs: adapt
formatDnObjto accept either the legacy{array: [[{type, value}]]}shape OR @peculiar/x509'sJsonNamearray shape. - Migrate:
ParseX509Certificate:
| jsrsasign | @peculiar/x509 |
|---|---|
new r.X509(); readCertHex/readCertPEM |
new X509Certificate(input) (accepts PEM string or BufferSource) |
cert.hex |
bytesToHex(cert.rawData) |
cert.getSerialNumberHex() |
cert.serialNumber |
cert.getIssuer() / getSubject() |
cert.issuerName.toJSON() / cert.subjectName.toJSON() → formatDnObj |
cert.getPublicKey() |
cert.publicKey; parse rawData with @peculiar/asn1-rsa RSAPublicKey or @peculiar/asn1-ecc ECPoint to extract n/e or (x, y) |
cert.getSignatureValueHex() |
bytesToHex(cert.signature) |
cert.version |
cert.version |
cert.getSignatureAlgorithm* |
cert.signatureAlgorithm.name |
cert.getNotBefore() / getNotAfter() |
cert.notBefore / cert.notAfter (Date) — reformat to the existing yymmddHHMMSSZ string |
cert.getInfo() extensions text |
Iterate cert.extensions, format known types (BasicConstraints, KeyUsage, EKU, SAN, AKI, SKI, CRLDistributionPoints, AIA) — reuse formatters in current ParseCSR.mjs |
r.BigInteger keylen |
native BigInt("0x"+hex) |
PubKeyFromCert: new X509Certificate(pem).publicKey.toString("pem").
ParseCSR:
| jsrsasign | @peculiar/x509 |
|---|---|
r.KJUR.asn1.csr.CSRUtil.getParam(input) |
new Pkcs10CertificateRequest(input) |
csrParam.sbjpubkey |
csr.publicKey.toString("pem") |
csrParam.sigalg |
csr.signatureAlgorithm.name |
csrParam.sighex |
bytesToHex(csr.signature) |
csrParam.extreq |
walk csr.attributes, find OID 1.2.840.113549.1.9.14, decode with @peculiar/asn1-pkcs9 |
ParseX509CRL:
| jsrsasign | @peculiar/x509 |
|---|---|
new r.X509CRL(input) |
new X509Crl(input) (convert hex input → Uint8Array first) |
crl.getVersion / SignatureAlgorithm* / Issuer / ThisUpdate / NextUpdate |
crl.version / signatureAlgorithm.name / issuer / thisUpdate / nextUpdate |
crl.getParam().ext |
crl.extensions |
crl.getRevCertArray() |
crl.entries (each has serialNumber, revocationDate, extensions) |
crl.getSignatureValueHex() |
bytesToHex(crl.signature) |
Gotchas:
cert.getInfo()is the trickiest dependency — the current op extracts extensions text by string-splitting on"X509v3 Extensions:\n"and"signature". Replace with a real extension walker (reuseformatGeneralNames, KeyUsage bit mapping, etc. from existingParseCSR.mjs).- CRL hex input: convert hex →
Uint8Arraybefore passing toX509Crl. - DSA-in-cert: rare; use
@peculiar/asn1-x509DSA params or fall back to a placeholder text block. - Date formatting: produce both raw
yymmddHHMMSSZand a human-readable form, as currently displayed.
Tests: Update goldens for ParseCSR.mjs, ParseX509CRL.mjs, PubKeyFromCert.mjs as needed. Add fixtures for the new ParseX509Certificate test from step 1.
PR 6 — Removal
grep -rn jsrsasign src/— must return zero matches.npm uninstall jsrsasign; remove from package.json dependencies.npm run lint && npm test— full suite green.npm run build— confirm bundle builds; check PublicKey and Ciphers chunk size deltas.- Update
CHANGELOG.md: removed jsrsasign; added @peculiar/x509, asn1js, @noble/curves; note any cosmetic output-format changes in the affected ops.
Verification
After each PR:
npm run lintnpm test(Node + browser test suites)npm run buildsucceedsgrep -rn "from \"jsrsasign\"" src/core/shrinks monotonically toward zero
After PR 6:
grep -rn jsrsasign .returns zero results outsidepackage-lock.jsonandCHANGELOG.md- Manual UI smoke test: launch
npm start, try each migrated operation in the browser with representative input - Check bundle size:
npm run buildand compareweb/assets/*.jssizes against the pre-migration baseline
Open follow-ups (not in scope)
- Audit other crypto deps (
crypto-api,crypto-js,node-forge) for similar end-of-life risk. - Consider whether
crypto-api(used for SM3 in SM2.mjs) could also be retired in favour of@noble/hashes/sm3in a future cleanup.
Changelog
Record deviations from the original plan here, newest at the top. One bullet per change: what changed, why, and which PR.