31 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:
- PR 2 resolved: SM2 is now built on
weierstrass(...)+ecdh(...)from@noble/curves/abstract/weierstrass.js(curve params per GM/T 0003-2012). No/sm2subpath needed. - PR 3 resolved: ECDSA primitives migrated; new src/core/lib/Ecdsa.mjs is the shared helper module. Existing ECDSA fixture set passes unchanged (sign↔verify round-trips and the canned P-256 signature fixtures both verify against
lowS: false). - PR 4 resolved: Key-conversion ops migrated; new src/core/lib/KeyConvert.mjs wraps RSA/EC/DSA PEM⇄JWK⇄SPKI/PKCS#8. DSA private-key parsing/building goes through
asn1jsdirectly (no peculiarasn1-dsapackage andnode-forgedoesn't expose DSA), reusing the existing EC helpers fromEcdsa.mjs. - PR 5 resolved: X.509 / CSR / CRL ops migrated to
@peculiar/x509v1, plus the matching@peculiar/asn1-*schemas. New src/core/lib/X509.mjs holds the shared helpers (SPKI describer, signature-OID → display-name table, SAN/GeneralName formatter, EC-signature splitter, hex wrapping). Regression coverage added forParseX509Certificate(tests/operations/tests/ParseX509Certificate.mjs). - PR 5 blocker (still relevant for PR 6+):
@peculiar/x509v2 needs areflect-metadatapolyfill at every entry point — PR 1 pinned to^1.14.3to avoid that. Stay on v1 unless the polyfill cost gets resolved.
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.
PR 5 — 2026-05-17
- New src/core/lib/X509.mjs holds the shared X.509 helpers (
decodeX509Input,sigAlgOidToName,describeSpki,parseDerEcdsaSignature,isDerEcdsaSignature,formatJsonName,formatGeneralName,formatHexByteLines/formatHexColonWrapped,asnNameToJson). src/core/lib/PublicKey.mjs'sformatDnObjnow accepts both the legacy jsrsasign shape and@peculiar/x509'sJsonName(array-of-records) shape. Migrated ops: ParseX509Certificate.mjs, PubKeyFromCert.mjs, ParseCSR.mjs, ParseX509CRL.mjs. X509Certificatev1.14.3 doesn't expose.version. Plan called forcert.version, but on the pinned v1 the property is missing. Worked around by parsingcert.rawDatawith the asn1-x509Certificateschema and readingtbsCertificate.version+signatureAlgorithm.algorithmdirectly. The same approach is used inParseCSR(withCertificationRequestfrom@peculiar/asn1-csr) andParseX509CRL(withCertificateListfrom@peculiar/asn1-x509) — bypassing the WebCrypto algorithm mapping is necessary for DSA anyway (peculiar's algorithm provider doesn't know it).- DSA bit-length now matches OpenSSL/jsrsasign output.
describeSpkistrips the leading00byte from the DSS-ParmspINTEGER before computing the bit length, soLength: 2048 bitsis reported rather than the 2056 the raw byte count would give. - P-521 reports
Length: 521 bits, not 528.EC_CURVE_OID_TO_NAMEScarries an explicitbitsfield per curve so the prime field size (rather thanbyteLen * 8) is reported. - KeyUsage rendering order.
KeyUsage.toJSON()on the asn1-x509 wrapper returns the flag names in alphabetical order (crlSign,dataEncipherment, …); the existing CSR golden fixtures expect bit-position order (digitalSignature,nonRepudiation,keyEncipherment, …). The op now parses the BIT STRING and iterates the bits in order so the output is stable. Public Key from Certificatenow works for Ed25519 / Ed448. jsrsasign threw "Unsupported public key type";@peculiar/x509'scert.publicKey.toString("pem")handles both. Updated tests/operations/tests/PubKeyFromCert.mjs — the previously-commentedED25519_PUBKEY/ED448_PUBKEYconstants are live and the two negative test expectations were replaced with the actual PEMs.- PEM line endings switched from
\r\nto\neverywhere (per the cross-PR convention in AGENTS.md). tests/operations/tests/PubKeyFromCert.mjs loses its.replace(/\r/g, "").replace(/\n/g, "\r\n")post-processing. - OtherName payload is parsed loosely. The legacy
ParseCSRfixture forOther: 1.2.3.4::some valuerequires extracting a UTF8String wrapped in the ANY-typedvaluefield.formatGeneralNamefirst tries to parse it as a primitiveUTF8String/BmpString/PrintableString/IA5String, then falls back toDirectoryString, and finally to a hex dump. Same code path serves the CRL OtherName output (withOtherName:label, no space — per the CRL flavor) and the CSR/Cert SAN output (Other:label, with space — per the CSR flavor). ParseX509Certificateno longer relies oncert.getInfo()string-splitting. The legacy code grabbed the extensions block by splitting on hard-coded delimiters. Replaced with a real walker overcert.extensionsthat recognises BasicConstraints, KeyUsage, EKU, SAN, AKI, SKI, CRLDistributionPoints, IssuerAltName; everything else falls back to<oid>:+ raw hex.- Cosmetic drift in the
Certificate Signatureblock for ECDSA certs. The old code split the DER signature with hard-coded offsets (r.ASN1HEX.getV(sig, 4)andgetV(sig, 48)), which only worked for 32-byte components and produced visibly-overlapping output otherwise. The new code uses the proper DER parser. The output difference is a correction, not a regression — but it's still drift, and means theParseX509Certificategolden output now reflects the true r/s values. - New regression coverage for
ParseX509Certificate. tests/operations/tests/ParseX509Certificate.mjs carries golden output for an RSA cert and a P-256 cert (reusing the certs from PubKeyFromCert.mjs), plus an empty-input test. Wired into tests/operations/index.mjs. - No new dependencies — everything reuses what PR 1 already pinned.
PR 4 — 2026-05-17
- New src/core/lib/KeyConvert.mjs holds the shared RSA/EC/DSA conversion helpers (
parseKeyPem,parseCertPublicKey,keyToJwk,keyFromJwk,keyInfoToPem,derivePublicKeyInfo). Plan called for inlining helpers per-op; factoring them out avoided duplicating the RSA SPKI/PKCS#8 plumbing between PEMToJWK.mjs, JWKToPem.mjs and PubKeyFromPrivKey.mjs. - node-forge dropped from the migration. The plan reserved node-forge for the DSA path in
PubKeyFromPrivKey, butnode-forgeonly ships RSA support in itspkimodule — no DSA parser/serialiser. DSA traditional-----BEGIN DSA PRIVATE KEY-----is parsed viaasn1js.fromBERdirectly, and the SPKI is built vianew asn1js.Sequence({value: [new Integer(...), ...]}).toBER(false). No new dependency. - RSA PEMs go through
@peculiar/asn1-rsaschemas (RSAPrivateKey,RSAPublicKey) plus the existingPrivateKeyInfo/SubjectPublicKeyInfo/AlgorithmIdentifierschemas.id_rsaEncryptionis namespace-imported (import * as rsaSchemas from "@peculiar/asn1-rsa") and aliased toID_RSA_ENCRYPTIONto dodge the ESLintcamelcaserule, matching the convention PR 3 established for the EC OID constants. - DER
00/INTEGER plumbing. Parsed INTEGER fields come out of asn1js with the DER 2's-complement leading00still present;stripLeadingZeroremoves it for JWK output. On the way back the helperprefixForDerIntre-adds the00byte when the magnitude's MSB is set so the serialised INTEGER stays positive. JWK base64url is hand-rolled (no padding) —toBase64(..., "A-Za-z0-9-_")inBase64.mjsrequires going throughUtils.strToArrayBufferfor string inputs, which would double-encode the bytes for non-ASCII content. - PEM line endings switched from
\r\nto\nin the test fixtures for tests/operations/tests/JWK.mjs and tests/operations/tests/PubKeyFromPrivKey.mjs, per the cross-PR convention in AGENTS.md. The fixtures previously did.replace(/\n/g, "\r\n")around every expected PEM; those are gone now. - Ed25519/Ed448 error-message drift. PubKeyFromPrivKey's "Unsupported key type" wrapper used to surface jsrsasign's
Error: malformed PKCS8 private key(code:004); with the EC parser now coming from@peculiar/asn1-ecc+loadEcKey, the inner error is theOperationError("Provided key is not an EC key.")raised inside src/core/lib/Ecdsa.mjs. Updated the twoEd25519/Ed448test fixtures to expect the new message (Unsupported key type: Error: Provided key is not an EC key.—OperationError's name field isError, hence theError:prefix). Unsupported JWK key typeerror now referencesjwk.ktyrather than the top-levelinputJson.kty. The legacy op compared againstjwk.ktybut reportedinputJson.kty, which was undefined for JWK Set / array inputs. Existing test passes either way because it uses a single-key OKP input.- No new dependencies; relies on the deps PR 1 already pinned (
@peculiar/asn1-rsaetc. ship with@peculiar/x509).
PR 3 — 2026-05-17
- New src/core/lib/Ecdsa.mjs replaces the jsrsasign calls in ECDSASign.mjs, ECDSAVerify.mjs, ECDSASignatureConversion.mjs and GenerateECDSAKeyPair.mjs.
loadEcKeyhandles SEC1, PKCS#8 and SPKI PEMs by parsing them with@peculiar/asn1-ecc/@peculiar/asn1-pkcs8/@peculiar/asn1-x509. ECDSA itself is@noble/curves'sp256/p384/p521with explicit{ prehash: false, lowS: false, format: "der" }— see notes below. lowS: falseon both sign and verify. Noble defaults tolowS: true(BTC/ETH-style malleability rejection), which (a) would normalise produced signatures and could diverge from jsrsasign byte-for-byte, and (b) would reject existing jsrsasign-produced signatures whoseshappened to be in the upper half. We disable lowS to preserve interoperability with the existing fixtures.prehash: false. The operation hashes the message itself (so MD5/SHA-1 work via@noble/hashes/legacy); noble would otherwise re-hash with the curve's default digest. The Sign/Verify operations calldigestBytes(...)and pass the digest in directly.- Latin-1 truncation for string→bytes. Both Sign (input string) and Verify (
Utils.convertToByteString(msg, "Raw")→ string) follow what jsrsasign did under the hood: each JS code unit is masked to its low byte. That's wrong for free-text UTF-8 but matches the existing behaviour and is what the round-trip tests assume. Encoded asstrToBytesLatin1inEcdsa.mjs. parseAsn1SigToHexRSkeeps the DER00prefix. The jsrsasign helper returned r/s as the raw INTEGER bytes (so a 32-byte r whose MSB was set came back as 33 hex bytes starting with00). The Raw JSON test fixture in tests/operations/tests/ECDSA.mjs depends on that. Replicated with an inline DER reader rather than going throughbigint(which would strip the leading zero).- No fixture changes. All 83 ECDSA tests pass unchanged: deterministic-k sign↔verify cycles, the canned P-256 SHA-256 ASN.1/P1363/JWS/JSON inputs, and the negative tests (RSA key rejected, private-where-public expected, JSON missing r/s, etc.). The Generate ECDSA Key Pair op has no fixtures; manually exercised PEM/DER/JWK output paths.
id_*OID constants from@peculiar/asn1-eccare namespace-imported and re-aliased to SCREAMING_SNAKE locals because ESLint'scamelcaserule trips on the snake_case export names.
PR 2 — 2026-05-17
@noble/curvesv2 has no/sm2subpath, so src/core/lib/SM2.mjs builds the curve itself withweierstrass(...)from@noble/curves/abstract/weierstrass.jsusing the GM/T 0003-2012 parameter set. Curve constructor is memoised per name so repeatednew SM2(...)calls don't rebuild it.- Random
kgeneration:ecdh(Point).utils.randomSecretKey()→bytesToNumberBE(...) % (n-1n) + 1n, which mirrors the[1, n-1]distribution of the oldr.SecureRandom-backedgetBigRandom. The plan suggestedsm2.utils.randomPrivateKey()directly; the abstract-Weierstrass path needs the explicitbytes → bigint modstep because the curve isn't pre-wrapped. Point.fromHex("04" + x + y)validates that the coords lie on the curve and raises beforeis0()can ever run; the explicit infinity check is kept for symmetry with the previous code. Invalid-point errors are caught and rethrown asOperationErrorso user-facing error messages stay unchanged.- Coord padding switched from
("0000000000" + ...).slice(-charlen)to.padStart(charlen, "0")per the cross-PR convention in AGENTS.md. Output hex layout is byte-identical to before (still 64-char fields forsm2p256v1). - No fixture changes: tests/operations/tests/SM2.mjs passes unchanged (the 4 hard-coded ciphertext→plaintext vectors plus the 4 encrypt→decrypt round-trips).
PR 1 — 2026-05-17
- Pinned
@peculiar/x509to^1.14.3instead of the latest (2.x). v2 hard-requires areflect-metadataimport at every entry point and the plan didn't budget for polyfilling every webpack chunk. Sticking with v1 keeps the bundle changes scoped to this PR. @noble/curvesinstalled at^2.2.0. v2 no longer exports an/sm2subpath — see PR 2 note in "Notes for next session" above.derToPemwas deliberately made lenient (whitespace stripped, odd length left-padded with0, non-hex chars treated as nibble0) to preserve the recipe-API tests that piped non-hex output fromTo Morse CodethroughHex to PEM. The actual base64 emitted now follows standard byte-pair semantics, not jsrsasign's quirkyhex2b64(3-hex-chars-→-2-base64-chars) layout — so the expected outputs intests/node/tests/nodeApi.mjsfor those recipe-format tests were regenerated.dumpAsn1Hexreturns a plainASN.1 parse error: …string when asn1js can't make sense of the input, rather than throwing. jsrsasign produced a best-effortUNKNOWN(<tag>) <bytes>dump in this case; replicating that on asn1js would be a meaningful chunk of code and the operation has no automated-fixture exposure of the difference, so we accepted the drift.- PEM line endings switched from
\r\nto\n(per the cross-PR convention in AGENTS.md). Updated theHex to PEMandParse ASN.1 hex stringgolden tests intests/node/tests/operations.mjs. - New per-op fixture file tests/operations/tests/ASN1.mjs covers OID round-trips, the multi-byte OID arc edge case, PEM line wrapping, and basic ASN.1 dumps. Wired in via
tests/operations/index.mjs.