From 6e2cd91af56ca9443135019dbbe309acfa03b790 Mon Sep 17 00:00:00 2001 From: Leon Zandman Date: Sat, 16 May 2026 23:58:56 +0200 Subject: [PATCH 01/15] Add plan. --- AGENTS.md | 58 +++++++++++ plan-jsrsasign.md | 244 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 AGENTS.md create mode 100644 plan-jsrsasign.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..43ea94bd15 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# AGENTS.md + +Guidance for AI agents (Claude Code, etc.) working on the **jsrsasign removal** in this repo. + +Read [plan-jsrsasign.md](plan-jsrsasign.md) for the full migration plan. This file captures cross-PR conventions and workflow that must survive between sessions. + +## Workflow + +- **One PR per session.** Six PRs total — see the plan's "Phased plan" section. Don't try to fuse phases; each PR has its own test bundle that acts as the correctness gate. +- **At the end of each session, update [plan-jsrsasign.md](plan-jsrsasign.md):** + - Tick the PR in the "Status" block at the top. + - Add an entry to the "Changelog" section at the bottom for any deviation from the original plan (chosen API differed, extra dep added, test fixture updated, gotcha discovered, scope adjusted). + - Leave a one-line "Notes for next session" if anything is partially done or worth flagging. +- **Per-PR verification (must all pass before opening the PR):** + - `npm run lint` + - `npm test` + - `npm run build` + - `grep -rn "from \"jsrsasign\"" src/core/` — count strictly decreases from the previous PR + +## Library decisions (don't relitigate) + +- **ECDSA: `@noble/curves`, NOT Web Crypto.** Web Crypto refuses MD5/SHA-1 digests, which the existing UI exposes. `@noble/hashes/legacy` provides MD5/SHA-1. +- **X.509/CSR/CRL: `@peculiar/x509`** (plus `@peculiar/asn1-*` schemas as needed). Not `pkijs`. +- **SM2: `@noble/curves/sm2`.** Not `sm-crypto`. +- **DSA in `PubKeyFromPrivKey`: keep using `node-forge`** (already a dep, no new lib for a single op). +- **Generic ASN.1 dump: `asn1js`** (transitive via `@peculiar/x509` anyway). +- The "Not adopted, with reasons" list in the plan is final — don't reopen these choices without user input. + +## Cross-PR coding conventions + +- **PEM line endings: `\n` only.** No `\r\n`. The old jsrsasign output used `\r\n` in places; tests have been (or will be) updated to expect `\n`. +- **Hex coord padding: `.padStart(64, "0")`** after `bigint.toString(16)` for SM2/P-256 point coords. **P-521 uses 66 bytes (132 hex chars)**, not 64. +- **JWK field order:** build the object literal in this exact order so `JSON.stringify` emits it correctly: `{ kty, crv, x, y, d? }` for EC, `{ kty, n, e, d?, p?, q?, dp?, dq?, qi? }` for RSA. Insertion order is the serialization order. +- **ECDSA r/s leading-zero quirk:** `parseSigHexInHexRS` historically prepends `00` to r or s when the MSB is set (DER 2's-complement artefact). Replicate this — existing tests depend on it. +- **RFC 6979 determinism:** signature outputs *should* match jsrsasign byte-for-byte. If they diverge for a curve+digest combo, the signature is still valid — update the fixture and note it in the PR's changelog entry. Don't try to massage `@noble/curves` into matching. +- **Cosmetic drift in golden text outputs is accepted** for `ParseX509Certificate`, `ParseCSR`, `ParseX509CRL`, `ParseASN1HexString`. Update fixtures. Note in CHANGELOG in PR 6. +- **Cryptographic correctness is NOT negotiable.** SM2 ciphertext→plaintext fixtures in [tests/operations/tests/SM2.mjs](tests/operations/tests/SM2.mjs) must pass unchanged — those pin actual crypto behavior, not formatting. + +## Shared helper modules + +Created in PR 1 and PR 3. Use these instead of duplicating logic across operations: + +- [src/core/lib/Asn1.mjs](src/core/lib/Asn1.mjs) (PR 1): `oidHexToInt`, `oidIntToHex`, `derToPem`, `dumpAsn1Hex`. +- [src/core/lib/Ecdsa.mjs](src/core/lib/Ecdsa.mjs) (PR 3): `loadEcKey`, `signEcdsa`, `verifyEcdsa`, signature-format converters, `isAsn1Hex`, `generateEcKeyPair`. +- [src/core/lib/PublicKey.mjs](src/core/lib/PublicKey.mjs) (extended in PR 5): `formatDnObj` accepts both legacy and `@peculiar/x509` `JsonName` shapes. +- [src/core/lib/SM2.mjs](src/core/lib/SM2.mjs) (rewritten in PR 2): preserves both GMT 0009 BBB and GMT 0010 C1C2C3/C1C3C2 ciphertext layouts. + +## Key file locations + +- Operations being migrated: [src/core/operations/](src/core/operations/) — 14 files, listed in the plan. +- Tests: [tests/operations/tests/](tests/operations/tests/) — golden fixtures live here. +- Dependency manifest: [package.json](package.json). + +## When in doubt + +- Check the gotcha section of the relevant PR in [plan-jsrsasign.md](plan-jsrsasign.md) before writing code. +- If you discover something the plan didn't anticipate, add a Changelog entry — don't silently work around it. +- If a test fixture needs updating, decide: is it cosmetic drift (OK, update it) or cryptographic divergence (stop and surface to the user)? diff --git a/plan-jsrsasign.md b/plan-jsrsasign.md new file mode 100644 index 0000000000..10df27eba6 --- /dev/null +++ b/plan-jsrsasign.md @@ -0,0 +1,244 @@ +# Replace jsrsasign in CyberChef + +> See [AGENTS.md](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](https://github.com/kjur/jsrsasign) (currently pinned at `^11.1.3` in [package.json:148](package.json#L148)) has [announced end-of-support](https://github.com/kjur/jsrsasign/blob/master/README.md#end-of-support-announcement-for-jsrsasign). 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](https://github.com/PeculiarVentures/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](https://github.com/PeculiarVentures/ASN1.js)** (^3) | Generic ASN.1 parse + tree dump for `ParseASN1HexString` | Same author. BSD-3. Pulled in transitively by @peculiar/x509 anyway. | +| **[@noble/curves](https://github.com/paulmillr/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](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/](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](src/core/lib/SM2.mjs) — full rewrite, drop jsrsasign +- [src/core/lib/PublicKey.mjs](src/core/lib/PublicKey.mjs) — extend `formatDnObj` to accept @peculiar/x509's `Name.toJSON()` shape +- **NEW** [src/core/lib/Asn1.mjs](src/core/lib/Asn1.mjs) — `oidHexToInt`, `oidIntToHex`, `derToPem`, `dumpAsn1Hex` +- **NEW** [src/core/lib/Ecdsa.mjs](src/core/lib/Ecdsa.mjs) — shared ECDSA helpers (key load, sign/verify, sig format conversions) + +**Build/manifest:** +- [package.json](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. + +1. `npm install --save @peculiar/x509 asn1js @noble/curves`. Confirm webpack builds; smoke-test `npm test`. +2. Create [src/core/lib/Asn1.mjs](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 `\n` line endings. + - `dumpAsn1Hex(hex, { truncate, startIndex })` — walks `asn1js.fromBER` output and emits an indented tree. +3. Migrate: + - [HexToObjectIdentifier.mjs](src/core/operations/HexToObjectIdentifier.mjs): `r.KJUR.asn1.ASN1Util.oidHexToInt` → `oidHexToInt`. + - [ObjectIdentifierToHex.mjs](src/core/operations/ObjectIdentifierToHex.mjs): `r.KJUR.asn1.ASN1Util.oidIntToHex` → `oidIntToHex`. + - [HexToPEM.mjs](src/core/operations/HexToPEM.mjs): `r.KJUR.asn1.ASN1Util.getPEMStringFromHex` → `derToPem`. + - [ParseASN1HexString.mjs](src/core/operations/ParseASN1HexString.mjs): `r.ASN1HEX.dump` → `dumpAsn1Hex`. +4. 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 `ParseASN1HexString` golden output to new tree format (drift allowed). + +**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](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")` after `bigint.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](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`. + +1. Create [src/core/lib/Ecdsa.mjs](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` — wrap `Signature.fromBytes(.., 'der'|'compact')` and `.toBytes(format)`. + - `isAsn1Hex(hex)` — try `asn1js.fromBER`. + - `generateEcKeyPair(curveName)` — `curve.utils.randomPrivateKey()`, build SPKI + PKCS#8 via `@peculiar/asn1-*` schemas, derive JWK. + +2. Migrate operations: + - [ECDSASign.mjs](src/core/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](src/core/operations/ECDSAVerify.mjs): `isAsn1Hex` for input-format auto-detect; `verifyEcdsa`. + - [ECDSASignatureConversion.mjs](src/core/operations/ECDSASignatureConversion.mjs): format conversions only — no key handling. + - [GenerateECDSAKeyPair.mjs](src/core/operations/GenerateECDSAKeyPair.mjs): `generateEcKeyPair`; drop the `\r` stripping (was a jsrsasign artefact). + +**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`/`y` encoding. +- **`parseSigHexInHexRS`** historically returns r/s with a leading `00` when 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.stringify` preserves insertion order. + +**Tests:** [tests/operations/tests/ECDSA.mjs](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](src/core/operations/PEMToJWK.mjs), [JWKToPem.mjs](src/core/operations/JWKToPem.mjs), [PubKeyFromPrivKey.mjs](src/core/operations/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 (`\n` not `\r\n`) — see PR 3. +- `JWKToPem`'s `PKCS8PRV` output: 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/JWK.mjs), [tests/operations/tests/PubKeyFromPrivKey.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`. + +1. **Before any code changes:** add a regression test for [ParseX509Certificate.mjs](src/core/operations/ParseX509Certificate.mjs) (currently uncovered — confirm with `ls tests/operations/tests/ | grep -i x509certificate`). Use the existing test certs from [PubKeyFromCert.mjs](tests/operations/tests/PubKeyFromCert.mjs). +2. Extend [src/core/lib/PublicKey.mjs](src/core/lib/PublicKey.mjs): adapt `formatDnObj` to accept either the legacy `{array: [[{type, value}]]}` shape OR @peculiar/x509's `JsonName` array shape. +3. 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](src/core/operations/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 (reuse `formatGeneralNames`, KeyUsage bit mapping, etc. from existing `ParseCSR.mjs`). +- CRL hex input: convert hex → `Uint8Array` before passing to `X509Crl`. +- DSA-in-cert: rare; use `@peculiar/asn1-x509` DSA params or fall back to a placeholder text block. +- Date formatting: produce both raw `yymmddHHMMSSZ` and a human-readable form, as currently displayed. + +**Tests:** Update goldens for [ParseCSR.mjs](tests/operations/tests/ParseCSR.mjs), [ParseX509CRL.mjs](tests/operations/tests/ParseX509CRL.mjs), [PubKeyFromCert.mjs](tests/operations/tests/PubKeyFromCert.mjs) as needed. Add fixtures for the new `ParseX509Certificate` test from step 1. + +### PR 6 — Removal + +1. `grep -rn jsrsasign src/` — must return zero matches. +2. `npm uninstall jsrsasign`; remove from [package.json](package.json) dependencies. +3. `npm run lint && npm test` — full suite green. +4. `npm run build` — confirm bundle builds; check PublicKey and Ciphers chunk size deltas. +5. 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 lint` +- `npm test` (Node + browser test suites) +- `npm run build` succeeds +- `grep -rn "from \"jsrsasign\"" src/core/` shrinks monotonically toward zero + +After **PR 6:** +- `grep -rn jsrsasign .` returns zero results outside `package-lock.json` and `CHANGELOG.md` +- Manual UI smoke test: launch `npm start`, try each migrated operation in the browser with representative input +- Check bundle size: `npm run build` and compare `web/assets/*.js` sizes 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](src/core/lib/SM2.mjs)) could also be retired in favour of `@noble/hashes/sm3` in 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. + + + From f942cf2d41f8b1455131ee737ca94d6aa6f61701 Mon Sep 17 00:00:00 2001 From: Leon Zandman Date: Sun, 17 May 2026 00:23:33 +0200 Subject: [PATCH 02/15] Update plan, so that I do the commits and PR creation. --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 43ea94bd15..b9ec83502d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,11 +7,12 @@ Read [plan-jsrsasign.md](plan-jsrsasign.md) for the full migration plan. This fi ## Workflow - **One PR per session.** Six PRs total — see the plan's "Phased plan" section. Don't try to fuse phases; each PR has its own test bundle that acts as the correctness gate. +- **Stop at "ready to commit." The human handles git.** Do all the implementation, fixture updates, lint/test/build runs, and plan updates — but do NOT `git add`, `git commit`, `git push`, or `gh pr create`. Leave the working tree dirty and hand back a summary of what's staged-worthy. Leon commits and opens the PR himself. - **At the end of each session, update [plan-jsrsasign.md](plan-jsrsasign.md):** - Tick the PR in the "Status" block at the top. - Add an entry to the "Changelog" section at the bottom for any deviation from the original plan (chosen API differed, extra dep added, test fixture updated, gotcha discovered, scope adjusted). - Leave a one-line "Notes for next session" if anything is partially done or worth flagging. -- **Per-PR verification (must all pass before opening the PR):** +- **Per-PR verification (must all pass before handing back):** - `npm run lint` - `npm test` - `npm run build` From e5ba3d4b7edc49e19d0c0fee4cacfba3d6983baa Mon Sep 17 00:00:00 2001 From: Leon Zandman Date: Sun, 17 May 2026 00:24:39 +0200 Subject: [PATCH 03/15] Setup + ASN.1 utilities (PublicKey bundle) --- package-lock.json | 43 +-- package.json | 3 + plan-jsrsasign.md | 14 +- src/core/lib/Asn1.mjs | 338 ++++++++++++++++++ src/core/operations/HexToObjectIdentifier.mjs | 4 +- src/core/operations/HexToPEM.mjs | 4 +- src/core/operations/ObjectIdentifierToHex.mjs | 4 +- src/core/operations/ParseASN1HexString.mjs | 9 +- tests/node/tests/nodeApi.mjs | 8 +- tests/node/tests/operations.mjs | 15 +- tests/operations/index.mjs | 1 + tests/operations/tests/ASN1.mjs | 95 +++++ 12 files changed, 497 insertions(+), 41 deletions(-) create mode 100644 src/core/lib/Asn1.mjs create mode 100644 tests/operations/tests/ASN1.mjs diff --git a/package-lock.json b/package-lock.json index ea1a82bfa8..9e3799a008 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,14 @@ "@alexaltea/capstone-js": "^3.0.5", "@astronautlabs/amf": "^0.0.6", "@blu3r4y/lzma": "^2.3.3", + "@noble/curves": "^2.2.0", "@noble/hashes": "2.2.0", + "@peculiar/x509": "^1.14.3", "@wavesenterprise/crypto-gost-js": "^2.1.0-RC1", "@xmldom/xmldom": "^0.8.13", "argon2-browser": "^1.18.0", "arrive": "^2.5.3", + "asn1js": "^3.0.10", "assert": "^2.1.0", "avsc": "^5.7.9", "bcryptjs": "^3.0.3", @@ -4237,6 +4240,21 @@ "archiver": "^5.3.1" } }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", @@ -4253,7 +4271,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", - "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", @@ -4267,7 +4284,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", - "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", @@ -4280,7 +4296,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", - "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", @@ -4293,7 +4308,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", - "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-cms": "^2.6.1", @@ -4308,7 +4322,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz", "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==", - "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", @@ -4321,7 +4334,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz", "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==", - "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-cms": "^2.6.1", @@ -4338,7 +4350,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", - "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", @@ -4351,7 +4362,6 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", - "dev": true, "license": "MIT", "dependencies": { "asn1js": "^3.0.6", @@ -4363,7 +4373,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", - "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", @@ -4376,7 +4385,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz", "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==", - "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", @@ -4389,7 +4397,6 @@ "version": "1.14.3", "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", - "dev": true, "license": "MIT", "dependencies": { "@peculiar/asn1-cms": "^2.6.0", @@ -5489,14 +5496,13 @@ "license": "MIT" }, "node_modules/asn1js": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", - "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", - "dev": true, + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", - "pvutils": "^1.1.3", + "pvutils": "^1.1.5", "tslib": "^2.8.1" }, "engines": { @@ -15323,7 +15329,6 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -15333,7 +15338,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", - "dev": true, "license": "MIT", "engines": { "node": ">=16.0.0" @@ -17418,14 +17422,12 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tsyringe": { "version": "4.10.0", "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^1.9.3" @@ -17438,7 +17440,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, "license": "0BSD" }, "node_modules/tweetnacl": { diff --git a/package.json b/package.json index e923a5b95d..473e5fb2b8 100644 --- a/package.json +++ b/package.json @@ -96,11 +96,14 @@ "@alexaltea/capstone-js": "^3.0.5", "@astronautlabs/amf": "^0.0.6", "@blu3r4y/lzma": "^2.3.3", + "@noble/curves": "^2.2.0", "@noble/hashes": "2.2.0", + "@peculiar/x509": "^1.14.3", "@wavesenterprise/crypto-gost-js": "^2.1.0-RC1", "@xmldom/xmldom": "^0.8.13", "argon2-browser": "^1.18.0", "arrive": "^2.5.3", + "asn1js": "^3.0.10", "assert": "^2.1.0", "avsc": "^5.7.9", "bcryptjs": "^3.0.3", diff --git a/plan-jsrsasign.md b/plan-jsrsasign.md index 10df27eba6..000ab14920 100644 --- a/plan-jsrsasign.md +++ b/plan-jsrsasign.md @@ -4,14 +4,16 @@ ## Status -- [ ] PR 1 — Setup + ASN.1 utilities +- [x] 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) +_Notes for next session:_ +- **PR 2 blocker:** `@noble/curves` v2 dropped the `/sm2` subpath. v2 exposes only `nist`, `secp256k1`, `bls12-381`, `bn254`, `ed25519`, `ed448` and the `abstract/*` primitives. Before starting PR 2, either pin `@noble/curves` to v1 (which still ships sm2 — but check what other v1→v2 API gaps that introduces) or build SM2 on top of the abstract Weierstrass primitive in `@noble/curves/abstract/weierstrass.js` (curve parameters published in GM/T 0003-2012). +- **PR 5 blocker:** `@peculiar/x509` v2 needs a `reflect-metadata` polyfill at every entry point — PR 1 pinned to `^1.14.3` to avoid that. Stay on v1 unless the polyfill cost gets resolved. ## Context @@ -236,6 +238,14 @@ After **PR 6:** Record deviations from the original plan here, newest at the top. One bullet per change: what changed, why, and which PR. +### PR 1 — 2026-05-17 +- Pinned `@peculiar/x509` to `^1.14.3` instead of the latest (`2.x`). v2 hard-requires a `reflect-metadata` import 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/curves` installed at `^2.2.0`. v2 no longer exports an `/sm2` subpath — see PR 2 note in "Notes for next session" above. +- `derToPem` was deliberately made lenient (whitespace stripped, odd length left-padded with `0`, non-hex chars treated as nibble `0`) to preserve the recipe-API tests that piped non-hex output from `To Morse Code` through `Hex to PEM`. The actual base64 emitted now follows standard byte-pair semantics, not jsrsasign's quirky `hex2b64` (3-hex-chars-→-2-base64-chars) layout — so the expected outputs in `tests/node/tests/nodeApi.mjs` for those recipe-format tests were regenerated. +- `dumpAsn1Hex` returns a plain `ASN.1 parse error: …` string when asn1js can't make sense of the input, rather than throwing. jsrsasign produced a best-effort `UNKNOWN() ` 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\n` to `\n` (per the cross-PR convention in [AGENTS.md](AGENTS.md)). Updated the `Hex to PEM` and `Parse ASN.1 hex string` golden tests in `tests/node/tests/operations.mjs`. +- New per-op fixture file [tests/operations/tests/ASN1.mjs](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`. + - diff --git a/src/core/lib/Asn1.mjs b/src/core/lib/Asn1.mjs index cbe0038d07..0678034248 100644 --- a/src/core/lib/Asn1.mjs +++ b/src/core/lib/Asn1.mjs @@ -1,9 +1,8 @@ /** * ASN.1 / OID / PEM helpers. * - * Replacements for the small jsrsasign utilities used by the - * HexToObjectIdentifier, ObjectIdentifierToHex, HexToPEM and - * ParseASN1HexString operations. + * Helpers used by the HexToObjectIdentifier, ObjectIdentifierToHex, + * HexToPEM and ParseASN1HexString operations. * * @author n1474335 [n1474335@gmail.com] * @copyright Crown Copyright 2016 @@ -109,14 +108,13 @@ export function oidIntToHex(oid) { /** * Wrap a hex-encoded DER blob in a PEM envelope. * - * Uses LF line endings only (the old jsrsasign output used CRLF). + * Uses LF line endings only. * - * Input parsing is intentionally lenient to match the previous jsrsasign - * behaviour: whitespace is stripped, an odd-length string is left-padded - * with a zero, and characters that are not hex digits are treated as the - * nibble `0`. This keeps the operation usable as a generic byte-emitter - * inside larger recipes where the upstream stage may not produce strict - * hex. + * Input parsing is intentionally lenient: whitespace is stripped, an + * odd-length string is left-padded with a zero, and characters that are not + * hex digits are treated as the nibble `0`. This keeps the operation usable + * as a generic byte-emitter inside larger recipes where the upstream stage + * may not produce strict hex. * * @param {string} hex * @param {string} label @@ -146,8 +144,7 @@ export function derToPem(hex, label) { } /** - * Walk an asn1js parse tree and produce an indented dump similar to the - * one jsrsasign's ASN1HEX.dump produced. + * Walk an asn1js parse tree and produce an indented dump. * * @param {string} hex * @param {Object} [options] diff --git a/src/core/lib/Ecdsa.mjs b/src/core/lib/Ecdsa.mjs index 9614a90431..0b1b592199 100644 --- a/src/core/lib/Ecdsa.mjs +++ b/src/core/lib/Ecdsa.mjs @@ -2,7 +2,7 @@ * Shared ECDSA helpers built on @noble/curves and @peculiar/asn1-*. * * Used by the ECDSA Sign/Verify/Signature Conversion/Generate Key Pair - * operations. Migrated from jsrsasign. + * operations. * * @author n1474335 [n1474335@gmail.com] * @copyright Crown Copyright 2016 @@ -72,8 +72,7 @@ export function digestBytes(algo, bytes) { /** * Convert a JS string to bytes by Latin-1 truncation of each code unit (i.e. - * `charCodeAt(i) & 0xff`). This matches how jsrsasign / CryptoJS fed strings - * into MessageDigest.update — keeping signatures interoperable for inputs + * `charCodeAt(i) & 0xff`). This keeps signatures interoperable for inputs * coming out of upstream byte-producing ops, while preserving the * (questionable) UTF-16-truncating behaviour for free-text inputs. * @@ -162,7 +161,7 @@ export function verifyEcdsa(keyInfo, digest, asn1Hex) { /** * Quick test for whether a hex string parses as a single DER-encoded ASN.1 - * value. Mirrors jsrsasign's ASN1HEX.isASN1HEX. + * value. * * @param {string} hex * @returns {boolean} @@ -182,8 +181,8 @@ export function isAsn1Hex(hex) { /** * Parse an ASN.1 DER-encoded ECDSA signature and return the raw r/s INTEGER * bytes as hex. Preserves the DER 2's-complement leading 0x00 when present - * (i.e. r/s may have a leading "00" pair) — this matches the legacy - * jsrsasign ECDSA.parseSigHexInHexRS behaviour that existing tests assume. + * (i.e. r/s may have a leading "00" pair). Existing tests assume this + * compatibility behaviour. * * @param {string} asn1Hex * @returns {{r: string, s: string}} diff --git a/src/core/lib/KeyConvert.mjs b/src/core/lib/KeyConvert.mjs index 47e9f1ab5f..2963790745 100644 --- a/src/core/lib/KeyConvert.mjs +++ b/src/core/lib/KeyConvert.mjs @@ -2,7 +2,7 @@ * Asymmetric key conversion helpers built on @peculiar/asn1-* and asn1js. * * Shared by the PEM to JWK / JWK to PEM / Public Key from Private Key - * operations. Replaces the jsrsasign key-format-conversion plumbing. + * operations. * * @author n1474335 [n1474335@gmail.com] * @copyright Crown Copyright 2016 @@ -370,8 +370,7 @@ function parsePkcs8(bytes) { } if (alg === ID_DSA) { // DSA PKCS#8 carries only x (with p/q/g in the algorithm - // parameters). jsrsasign rejected this layout because no y was - // present and the existing tests assert that we do the same — we + // parameters). Without y, deriving the public key is unsupported; // mark the info accordingly and let derivePublicKeyInfo throw. return { kty: "DSA", isPrivate: true, y: null }; } diff --git a/src/core/lib/PublicKey.mjs b/src/core/lib/PublicKey.mjs index 749f3bb4e8..5cff67a64c 100644 --- a/src/core/lib/PublicKey.mjs +++ b/src/core/lib/PublicKey.mjs @@ -11,8 +11,8 @@ import { toHex, fromHex } from "./Hex.mjs"; /** * Formats Distinguished Name (DN) objects to strings. * - * Accepts either the legacy jsrsasign-style `{ array: [[{type, value}, ...], ...] }` - * shape OR `@peculiar/x509`'s `JsonName` shape — an array of records keyed by + * Accepts either the legacy `{ array: [[{type, value}, ...], ...] }` shape + * OR `@peculiar/x509`'s `JsonName` shape — an array of records keyed by * RDN short-name (`[{ CN: ["foo"], OU: ["bar"] }, ...]`). * * @param {Object|Array} dnObj diff --git a/src/core/lib/SM2.mjs b/src/core/lib/SM2.mjs index 6533c7a558..a2f219bb86 100644 --- a/src/core/lib/SM2.mjs +++ b/src/core/lib/SM2.mjs @@ -58,8 +58,8 @@ function getCurve(name) { Point, n: params.n, coordCharLen: params.coordCharLen, - // Uniform-ish random scalar in [1, n-1] — matches the bias profile of - // the previous jsrsasign-based getBigRandom. + // Uniform-ish random scalar in [1, n-1], matching the previous bias + // profile for compatibility with existing SM2 behaviour. randomScalar: () => bytesToNumberBE(dh.utils.randomSecretKey()) % (params.n - 1n) + 1n, }; curveCache[name] = cached; diff --git a/src/core/lib/X509.mjs b/src/core/lib/X509.mjs index 5bbbc8d4fd..e5f2e97048 100644 --- a/src/core/lib/X509.mjs +++ b/src/core/lib/X509.mjs @@ -2,7 +2,6 @@ * Shared X.509 / CSR / CRL helpers built on @peculiar/x509 + @peculiar/asn1-*. * * Used by ParseX509Certificate / PubKeyFromCert / ParseCSR / ParseX509CRL. - * Replaces the jsrsasign X.509 plumbing. * * @author n1474335 [n1474335@gmail.com] * @copyright Crown Copyright 2016 @@ -116,9 +115,9 @@ export function decodeX509Input(input, format) { // ----- signature algorithm -------------------------------------------------- /** - * Map a signature-algorithm OID to the jsrsasign-style display name - * (e.g. "1.2.840.113549.1.1.11" -> "SHA256withRSA"). Falls back to the - * raw OID if unknown. + * Map a signature-algorithm OID to the compact display name used by these + * operations (e.g. "1.2.840.113549.1.1.11" -> "SHA256withRSA"). Falls back + * to the raw OID if unknown. * * @param {string} oid * @returns {string} diff --git a/src/core/operations/ParseX509Certificate.mjs b/src/core/operations/ParseX509Certificate.mjs index 5222112a00..9fede294f6 100644 --- a/src/core/operations/ParseX509Certificate.mjs +++ b/src/core/operations/ParseX509Certificate.mjs @@ -165,8 +165,8 @@ ${extensionsText}`; } /** - * Format the algorithm label for the Public Key block. Mirrors the legacy - * jsrsasign labelling: "EC", "DSA", "RSA". + * Format the algorithm label for the Public Key block. Mirrors the legacy + * labels: "EC", "DSA", "RSA". * * @param {object} spki * @returns {string} @@ -233,8 +233,8 @@ function formatExtensions(cert) { } /** - * Format a JS Date as the jsrsasign UTCTime/GeneralizedTime string - * `yymmddHHMMSSZ` (or `yyyymmddHHMMSSZ` for dates past 2049). + * Format a JS Date as an ASN.1 UTCTime/GeneralizedTime string: `yymmddHHMMSSZ` + * or `yyyymmddHHMMSSZ` for dates past 2049. * * @param {Date} date * @returns {string} diff --git a/tests/node/tests/operations.mjs b/tests/node/tests/operations.mjs index 1691f53b36..2f5aadefff 100644 --- a/tests/node/tests/operations.mjs +++ b/tests/node/tests/operations.mjs @@ -650,7 +650,7 @@ WWFkYSBZYWRh it("Parse ASN.1 Hex string", () => { // The bytes for "Mouth-watering" don't form a well-formed ASN.1 structure // (tag 0x4d declares length 0x6f but only 12 bytes follow), so we report - // a parse error rather than the partial best-effort dump jsrsasign emitted. + // a parse error rather than a partial best-effort dump. assert.strictEqual( chef.parseASN1HexString(chef.toHex("Mouth-watering")).toString(), "ASN.1 parse error: End of input reached before message was fully decoded (inconsistent offset and length values)" @@ -1146,4 +1146,3 @@ ExifImageHeight: 57`); ]); - diff --git a/tests/operations/tests/ASN1.mjs b/tests/operations/tests/ASN1.mjs index 5d79468839..baadd8516a 100644 --- a/tests/operations/tests/ASN1.mjs +++ b/tests/operations/tests/ASN1.mjs @@ -1,8 +1,7 @@ /** * ASN.1 / OID / PEM tests. * - * Covers the four operations migrated from jsrsasign to the in-house - * Asn1.mjs helper: + * Covers the four operations migrated to the in-house Asn1.mjs helper: * - Hex to Object Identifier * - Object Identifier to Hex * - Hex to PEM diff --git a/tests/operations/tests/ParseX509Certificate.mjs b/tests/operations/tests/ParseX509Certificate.mjs index 0a584868b4..f2b278bf0a 100644 --- a/tests/operations/tests/ParseX509Certificate.mjs +++ b/tests/operations/tests/ParseX509Certificate.mjs @@ -1,8 +1,8 @@ /** * Parse X.509 Certificate tests. * - * Added as part of the jsrsasign → @peculiar/x509 migration (PR 5) to give - * the operation regression coverage it previously lacked. The certificate + * Added as part of the @peculiar/x509 migration (PR 5) to give the + * operation regression coverage it previously lacked. The certificate * fixtures are reused from PubKeyFromCert.mjs. * * @author n1474335 [n1474335@gmail.com] From af572f785bf6230cb7d1087b02368f0b4283c7cd Mon Sep 17 00:00:00 2001 From: Leon Zandman Date: Sun, 17 May 2026 19:36:32 +0200 Subject: [PATCH 09/15] Comments fix. --- src/core/lib/Asn1.mjs | 2 +- src/core/lib/Ecdsa.mjs | 10 +++++----- src/core/lib/KeyConvert.mjs | 6 +++--- src/core/lib/SM2.mjs | 4 ++-- src/core/operations/ParseX509Certificate.mjs | 4 ++-- tests/operations/tests/ASN1.mjs | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/core/lib/Asn1.mjs b/src/core/lib/Asn1.mjs index 0678034248..d64bac7d2e 100644 --- a/src/core/lib/Asn1.mjs +++ b/src/core/lib/Asn1.mjs @@ -144,7 +144,7 @@ export function derToPem(hex, label) { } /** - * Walk an asn1js parse tree and produce an indented dump. + * Walk an asn1js parse tree and produce CyberChef's indented ASN.1 dump. * * @param {string} hex * @param {Object} [options] diff --git a/src/core/lib/Ecdsa.mjs b/src/core/lib/Ecdsa.mjs index 0b1b592199..f78025bca3 100644 --- a/src/core/lib/Ecdsa.mjs +++ b/src/core/lib/Ecdsa.mjs @@ -72,9 +72,9 @@ export function digestBytes(algo, bytes) { /** * Convert a JS string to bytes by Latin-1 truncation of each code unit (i.e. - * `charCodeAt(i) & 0xff`). This keeps signatures interoperable for inputs - * coming out of upstream byte-producing ops, while preserving the - * (questionable) UTF-16-truncating behaviour for free-text inputs. + * `charCodeAt(i) & 0xff`). This preserves the operation's byte-string + * semantics for inputs coming out of upstream byte-producing ops, including + * the questionable UTF-16 truncation for free-text inputs. * * @param {string} str * @returns {Uint8Array} @@ -181,8 +181,8 @@ export function isAsn1Hex(hex) { /** * Parse an ASN.1 DER-encoded ECDSA signature and return the raw r/s INTEGER * bytes as hex. Preserves the DER 2's-complement leading 0x00 when present - * (i.e. r/s may have a leading "00" pair). Existing tests assume this - * compatibility behaviour. + * (i.e. r/s may have a leading "00" pair) because the raw-signature fixtures + * assert the INTEGER byte strings, not canonicalized scalar values. * * @param {string} asn1Hex * @returns {{r: string, s: string}} diff --git a/src/core/lib/KeyConvert.mjs b/src/core/lib/KeyConvert.mjs index 2963790745..0b3e5a83a3 100644 --- a/src/core/lib/KeyConvert.mjs +++ b/src/core/lib/KeyConvert.mjs @@ -369,9 +369,9 @@ function parsePkcs8(bytes) { return rsaPrivateFromBytes(parseRsaPrivateKey(inner)); } if (alg === ID_DSA) { - // DSA PKCS#8 carries only x (with p/q/g in the algorithm - // parameters). Without y, deriving the public key is unsupported; - // mark the info accordingly and let derivePublicKeyInfo throw. + // DSA PKCS#8 carries only the private x value, with p/q/g in the + // algorithm parameters. It does not carry the public y value needed + // here, so mark the info accordingly and let derivePublicKeyInfo throw. return { kty: "DSA", isPrivate: true, y: null }; } // EC and everything else delegate to the existing EC loader by diff --git a/src/core/lib/SM2.mjs b/src/core/lib/SM2.mjs index a2f219bb86..eb5ac8d701 100644 --- a/src/core/lib/SM2.mjs +++ b/src/core/lib/SM2.mjs @@ -58,8 +58,8 @@ function getCurve(name) { Point, n: params.n, coordCharLen: params.coordCharLen, - // Uniform-ish random scalar in [1, n-1], matching the previous bias - // profile for compatibility with existing SM2 behaviour. + // Uniform-ish random scalar in [1, n-1]. The modulo reduction keeps + // the existing scalar-generation distribution for SM2 ciphertexts. randomScalar: () => bytesToNumberBE(dh.utils.randomSecretKey()) % (params.n - 1n) + 1n, }; curveCache[name] = cached; diff --git a/src/core/operations/ParseX509Certificate.mjs b/src/core/operations/ParseX509Certificate.mjs index 9fede294f6..d83b8cc79c 100644 --- a/src/core/operations/ParseX509Certificate.mjs +++ b/src/core/operations/ParseX509Certificate.mjs @@ -165,8 +165,8 @@ ${extensionsText}`; } /** - * Format the algorithm label for the Public Key block. Mirrors the legacy - * labels: "EC", "DSA", "RSA". + * Format the algorithm label for the Public Key block. The golden fixtures + * use short labels for the classic key families: "EC", "DSA", "RSA". * * @param {object} spki * @returns {string} diff --git a/tests/operations/tests/ASN1.mjs b/tests/operations/tests/ASN1.mjs index baadd8516a..af18f2a2c8 100644 --- a/tests/operations/tests/ASN1.mjs +++ b/tests/operations/tests/ASN1.mjs @@ -1,7 +1,7 @@ /** * ASN.1 / OID / PEM tests. * - * Covers the four operations migrated to the in-house Asn1.mjs helper: + * Covers the four operations backed by the in-house Asn1.mjs helper: * - Hex to Object Identifier * - Object Identifier to Hex * - Hex to PEM From 3e420cac1ed38673a762f9dca67fb7f2a21ebc41 Mon Sep 17 00:00:00 2001 From: Leon Zandman Date: Sun, 17 May 2026 19:45:50 +0200 Subject: [PATCH 10/15] Quick fix for EN0ENT messages. --- Gruntfile.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Gruntfile.js b/Gruntfile.js index 6f87b2a69a..82aab460cc 100755 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -140,7 +140,8 @@ module.exports = function (grunt) { new BundleAnalyzerPlugin({ analyzerMode: "static", reportFilename: "BundleAnalyzerReport.html", - openAnalyzer: false + openAnalyzer: false, + excludeAssets: [/Worker\.js$/] }), ] }; From 2c6f5eea5da435638ee3d7440ad75a73eb77c6cb Mon Sep 17 00:00:00 2001 From: Leon Zandman Date: Sun, 17 May 2026 19:50:25 +0200 Subject: [PATCH 11/15] Use stats-only bundle analysis for inline workers. --- Gruntfile.js | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index 82aab460cc..52db238e08 100755 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -8,6 +8,24 @@ const path = require("path"); const nodeFlags = "--no-warnings --no-deprecation"; +/** + * Generates the bundle analyzer report from Webpack stats only. + */ +class StatsOnlyBundleAnalyzerPlugin extends BundleAnalyzerPlugin { + /** + * Prevent webpack-bundle-analyzer from reading emitted bundle files. + * + * Inline worker-loader assets are present in Webpack stats but intentionally + * not written to disk, so stat sizes are the most complete source for this + * production report. + * + * @returns {null} + */ + getBundleDirFromCompiler() { + return null; + } +} + /** * Grunt configuration for building the app in various formats. * @@ -137,11 +155,11 @@ module.exports = function (grunt) { minifyCSS: true } }), - new BundleAnalyzerPlugin({ + new StatsOnlyBundleAnalyzerPlugin({ analyzerMode: "static", reportFilename: "BundleAnalyzerReport.html", openAnalyzer: false, - excludeAssets: [/Worker\.js$/] + defaultSizes: "stat" }), ] }; From 9ae38d52c5a479fc16129d30cf8b25e52f4d585a Mon Sep 17 00:00:00 2001 From: Leon Zandman Date: Mon, 18 May 2026 21:48:16 +0200 Subject: [PATCH 12/15] Post-migration test cleanup. --- plan-jsrsasign.md | 4 ++++ tests/node/tests/nodeApi.mjs | 14 +++++++------- tests/operations/tests/PubKeyFromCert.mjs | 3 +++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/plan-jsrsasign.md b/plan-jsrsasign.md index a561bba004..da67b749b0 100644 --- a/plan-jsrsasign.md +++ b/plan-jsrsasign.md @@ -242,6 +242,10 @@ After **PR 6:** Record deviations from the original plan here, newest at the top. One bullet per change: what changed, why, and which PR. +### Post-migration test cleanup — 2026-05-18 +- **Ed25519 / Ed448 expected pubkeys in [tests/operations/tests/PubKeyFromCert.mjs](tests/operations/tests/PubKeyFromCert.mjs) cross-verified externally.** Ed25519 matches `openssl x509 -pubkey -noout` byte-for-byte; Ed448 was verified by extracting the SPKI directly from the cert's DER bytes (openssl 3.6.2 in this environment doesn't support Ed448). A short comment above each `ED*_PUBKEY` constant records the verification so the fixtures aren't read as circular snapshots of `@peculiar/x509`'s own output. +- **[tests/node/tests/nodeApi.mjs](tests/node/tests/nodeApi.mjs) recipe-format tests no longer feed `Hex to PEM` garbage.** The three `chef.bake` tests previously piped Morse-coded output (dashes/dots) through `Hex to PEM`, which is the reason PR 1 made `derToPem` lenient about non-hex input. They now use `To Hex` → `Hex to PEM` → `To Snake case` and produce well-formed PEM of `"some input"`. Same coverage of compact JSON / clean JSON / optional-args parsing; `derToPem`'s leniency is no longer test-load-bearing (but kept for backwards compatibility). + ### PR 6 — 2026-05-17 - Removed `jsrsasign` from [package.json](package.json) and [package-lock.json](package-lock.json). No runtime code changes were needed because PRs 1-5 had already moved operations to `@noble/curves`, `@peculiar/x509`, and `asn1js`. - Removed stale dependency-name mentions from `src/` comments so `rg -n "jsrsasign" src/` is clean. Remaining mentions are intentionally limited to migration documentation and [CHANGELOG.md](CHANGELOG.md). diff --git a/tests/node/tests/nodeApi.mjs b/tests/node/tests/nodeApi.mjs index a84441f39b..7d09ef62af 100644 --- a/tests/node/tests/nodeApi.mjs +++ b/tests/node/tests/nodeApi.mjs @@ -318,31 +318,31 @@ TestRegister.addApiTests([ }), it("chef.bake: should take compact JSON format from Chef Website as recipe", async () => { - const result = await chef.bake("some input", [{"op": "To Morse Code", "args": ["Dash/Dot", "Backslash", "Comma"]}, {"op": "Hex to PEM", "args": ["SOMETHING"]}, {"op": "To Snake case", "args": [false]}]); - assert.strictEqual(result.toString(), "begin_something_dqanaaaa_cg_aka_ao_a_2_g_da_aaaaaaana_no_adqana_ao_a_cg_aaaaana_ao_a_2_g_a_end_something"); + const result = await chef.bake("some input", [{"op": "To Hex", "args": ["Space", 0]}, {"op": "Hex to PEM", "args": ["SOMETHING"]}, {"op": "To Snake case", "args": [false]}]); + assert.strictEqual(result.toString(), "begin_something_c_29_t_zs_bpbn_b_1_d_a_end_something"); }), it("chef.bake: should accept Clean JSON format from Chef website as recipe", async () => { const result = await chef.bake("some input", [ - { "op": "To Morse Code", - "args": ["Dash/Dot", "Backslash", "Comma"] }, + { "op": "To Hex", + "args": ["Space", 0] }, { "op": "Hex to PEM", "args": ["SOMETHING"] }, { "op": "To Snake case", "args": [false] } ]); - assert.strictEqual(result.toString(), "begin_something_dqanaaaa_cg_aka_ao_a_2_g_da_aaaaaaana_no_adqana_ao_a_cg_aaaaana_ao_a_2_g_a_end_something"); + assert.strictEqual(result.toString(), "begin_something_c_29_t_zs_bpbn_b_1_d_a_end_something"); }), it("chef.bake: should accept Clean JSON format from Chef website - args optional", async () => { const result = await chef.bake("some input", [ - { "op": "To Morse Code" }, + { "op": "To Hex" }, { "op": "Hex to PEM", "args": ["SOMETHING"] }, { "op": "To Snake case", "args": [false] } ]); - assert.strictEqual(result.toString(), "begin_something_aaaaaaaaaaaaaaa_end_something"); + assert.strictEqual(result.toString(), "begin_something_c_29_t_zs_bpbn_b_1_d_a_end_something"); }), it("chef.bake: should accept operation names from Chef Website which contain forward slash", async () => { diff --git a/tests/operations/tests/PubKeyFromCert.mjs b/tests/operations/tests/PubKeyFromCert.mjs index 0fe5a5cdb0..c71445a139 100644 --- a/tests/operations/tests/PubKeyFromCert.mjs +++ b/tests/operations/tests/PubKeyFromCert.mjs @@ -99,6 +99,7 @@ HRMBAf8EBTADAQH/MAUGAytlcANBAI/+03iVq4yJ+DaLVs61w41cVX2UxKvquSzv lllkpkclM9LH5dLrw4ArdTjS9zAjzY/02WkphHhICHXt3KqZTwI= -----END CERTIFICATE-----`; +// Cross-checked against `openssl x509 -pubkey -noout` on ED25519_CERT. const ED25519_PUBKEY = `-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAELP6AflXwsuZ5q4NDIO0LP2iCdKRvds4nwsUmRhOw3g= -----END PUBLIC KEY-----`; @@ -115,6 +116,8 @@ VkLqpoDNMRcM3Eb6h3AJpQM0oxGj8q9arjDXqJkXgaO2e0tVn8KKVfy7S8qO72Kd rWzZowcOjnWKhXm7JgA= -----END CERTIFICATE-----`; +// Cross-checked against the SPKI extracted directly from ED448_CERT's DER +// bytes (openssl 3.6.2 in this environment does not support Ed448). const ED448_PUBKEY = `-----BEGIN PUBLIC KEY----- MEMwBQYDK2VxAzoAVN8kG0TMVyGOu/OvBTe8H0Wi4HJrQAlSv4XLwJbkuoi4EeRl EHQwXsNYLZTtY2Jra6AWhbVYYaEA From e99ec57806546163b6916f1cd9b3ca7749329011 Mon Sep 17 00:00:00 2001 From: Leon Zandman Date: Tue, 19 May 2026 10:04:18 +0200 Subject: [PATCH 13/15] Removed project-specific AGENTS.md and moved info to new CRYPTO_IMPLEMENTATION.md file. --- AGENTS.md | 59 ----------------- CRYPTO_IMPLEMENTATION.md | 136 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 59 deletions(-) delete mode 100644 AGENTS.md create mode 100644 CRYPTO_IMPLEMENTATION.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index b9ec83502d..0000000000 --- a/AGENTS.md +++ /dev/null @@ -1,59 +0,0 @@ -# AGENTS.md - -Guidance for AI agents (Claude Code, etc.) working on the **jsrsasign removal** in this repo. - -Read [plan-jsrsasign.md](plan-jsrsasign.md) for the full migration plan. This file captures cross-PR conventions and workflow that must survive between sessions. - -## Workflow - -- **One PR per session.** Six PRs total — see the plan's "Phased plan" section. Don't try to fuse phases; each PR has its own test bundle that acts as the correctness gate. -- **Stop at "ready to commit." The human handles git.** Do all the implementation, fixture updates, lint/test/build runs, and plan updates — but do NOT `git add`, `git commit`, `git push`, or `gh pr create`. Leave the working tree dirty and hand back a summary of what's staged-worthy. Leon commits and opens the PR himself. -- **At the end of each session, update [plan-jsrsasign.md](plan-jsrsasign.md):** - - Tick the PR in the "Status" block at the top. - - Add an entry to the "Changelog" section at the bottom for any deviation from the original plan (chosen API differed, extra dep added, test fixture updated, gotcha discovered, scope adjusted). - - Leave a one-line "Notes for next session" if anything is partially done or worth flagging. -- **Per-PR verification (must all pass before handing back):** - - `npm run lint` - - `npm test` - - `npm run build` - - `grep -rn "from \"jsrsasign\"" src/core/` — count strictly decreases from the previous PR - -## Library decisions (don't relitigate) - -- **ECDSA: `@noble/curves`, NOT Web Crypto.** Web Crypto refuses MD5/SHA-1 digests, which the existing UI exposes. `@noble/hashes/legacy` provides MD5/SHA-1. -- **X.509/CSR/CRL: `@peculiar/x509`** (plus `@peculiar/asn1-*` schemas as needed). Not `pkijs`. -- **SM2: `@noble/curves/sm2`.** Not `sm-crypto`. -- **DSA in `PubKeyFromPrivKey`: keep using `node-forge`** (already a dep, no new lib for a single op). -- **Generic ASN.1 dump: `asn1js`** (transitive via `@peculiar/x509` anyway). -- The "Not adopted, with reasons" list in the plan is final — don't reopen these choices without user input. - -## Cross-PR coding conventions - -- **PEM line endings: `\n` only.** No `\r\n`. The old jsrsasign output used `\r\n` in places; tests have been (or will be) updated to expect `\n`. -- **Hex coord padding: `.padStart(64, "0")`** after `bigint.toString(16)` for SM2/P-256 point coords. **P-521 uses 66 bytes (132 hex chars)**, not 64. -- **JWK field order:** build the object literal in this exact order so `JSON.stringify` emits it correctly: `{ kty, crv, x, y, d? }` for EC, `{ kty, n, e, d?, p?, q?, dp?, dq?, qi? }` for RSA. Insertion order is the serialization order. -- **ECDSA r/s leading-zero quirk:** `parseSigHexInHexRS` historically prepends `00` to r or s when the MSB is set (DER 2's-complement artefact). Replicate this — existing tests depend on it. -- **RFC 6979 determinism:** signature outputs *should* match jsrsasign byte-for-byte. If they diverge for a curve+digest combo, the signature is still valid — update the fixture and note it in the PR's changelog entry. Don't try to massage `@noble/curves` into matching. -- **Cosmetic drift in golden text outputs is accepted** for `ParseX509Certificate`, `ParseCSR`, `ParseX509CRL`, `ParseASN1HexString`. Update fixtures. Note in CHANGELOG in PR 6. -- **Cryptographic correctness is NOT negotiable.** SM2 ciphertext→plaintext fixtures in [tests/operations/tests/SM2.mjs](tests/operations/tests/SM2.mjs) must pass unchanged — those pin actual crypto behavior, not formatting. - -## Shared helper modules - -Created in PR 1 and PR 3. Use these instead of duplicating logic across operations: - -- [src/core/lib/Asn1.mjs](src/core/lib/Asn1.mjs) (PR 1): `oidHexToInt`, `oidIntToHex`, `derToPem`, `dumpAsn1Hex`. -- [src/core/lib/Ecdsa.mjs](src/core/lib/Ecdsa.mjs) (PR 3): `loadEcKey`, `signEcdsa`, `verifyEcdsa`, signature-format converters, `isAsn1Hex`, `generateEcKeyPair`. -- [src/core/lib/PublicKey.mjs](src/core/lib/PublicKey.mjs) (extended in PR 5): `formatDnObj` accepts both legacy and `@peculiar/x509` `JsonName` shapes. -- [src/core/lib/SM2.mjs](src/core/lib/SM2.mjs) (rewritten in PR 2): preserves both GMT 0009 BBB and GMT 0010 C1C2C3/C1C3C2 ciphertext layouts. - -## Key file locations - -- Operations being migrated: [src/core/operations/](src/core/operations/) — 14 files, listed in the plan. -- Tests: [tests/operations/tests/](tests/operations/tests/) — golden fixtures live here. -- Dependency manifest: [package.json](package.json). - -## When in doubt - -- Check the gotcha section of the relevant PR in [plan-jsrsasign.md](plan-jsrsasign.md) before writing code. -- If you discover something the plan didn't anticipate, add a Changelog entry — don't silently work around it. -- If a test fixture needs updating, decide: is it cosmetic drift (OK, update it) or cryptographic divergence (stop and surface to the user)? diff --git a/CRYPTO_IMPLEMENTATION.md b/CRYPTO_IMPLEMENTATION.md new file mode 100644 index 0000000000..c0f51d666d --- /dev/null +++ b/CRYPTO_IMPLEMENTATION.md @@ -0,0 +1,136 @@ +# Cryptography Implementation Guide + +This document captures the architectural decisions and coding conventions established during the migration away from `jsrsasign`. It serves as a reference for understanding library choices, implementation patterns, and maintenance of cryptographic operations. + +## Library Choices + +The following libraries were selected for specific cryptographic operations. These decisions prioritize compatibility with the existing CyberChef API surface and cryptographic correctness. + +### ECDSA: `@noble/curves` + +Uses `@noble/curves` with `@noble/hashes/legacy` for MD5/SHA-1 digests. + +**Why not Web Crypto?** The Web Crypto API refuses MD5 and SHA-1 digests, which the CyberChef UI exposes to users. `@noble/curves` with legacy hash support maintains this functionality. + +### X.509 / CSR / CRL: `@peculiar/x509` + +Uses `@peculiar/x509` for certificate, CSR, and CRL parsing and generation, with `@peculiar/asn1-*` schema packages as needed. + +**Why not pkijs?** `@peculiar/x509` offers a cleaner API better suited to CyberChef's operation model. + +### SM2: `@noble/curves/sm2` + +Uses the SM2 implementation from `@noble/curves`. + +**Why not sm-crypto?** `@noble/curves` provides a reliable, actively maintained SM2 implementation. + +### DSA: `node-forge` + +The `PubKeyFromPrivKey` operation continues to use `node-forge` for DSA key generation. + +**Why?** `node-forge` is already a project dependency, and DSA is a single, localized operation not worth introducing a new library for. + +### Generic ASN.1 Dump: `asn1js` + +Uses `asn1js` for generic ASN.1 hex structure dumping. + +**Note:** This is a transitive dependency of `@peculiar/x509` and requires no additional imports for most use cases. + +## Coding Conventions + +These conventions ensure consistency across cryptographic operations and maintain compatibility with existing test fixtures. + +### PEM Line Endings + +Use `\n` only for PEM-encoded output. Do not use `\r\n`. + +The legacy `jsrsasign` output used `\r\n` in some cases. All test fixtures have been updated to expect `\n` exclusively. + +### Hex Coordinate Padding + +When converting elliptic curve point coordinates from `bigint` to hex: + +- **P-256 and SM2:** Use `.padStart(64, "0")` (32 bytes = 64 hex characters) +- **P-521:** Use `.padStart(132, "0")` (66 bytes = 132 hex characters) + +Apply this after calling `bigint.toString(16)`. + +### JWK Field Ordering + +Build JWK objects using literal syntax in this exact order so `JSON.stringify` emits fields in the correct sequence: + +**Elliptic Curve:** +```javascript +{ kty, crv, x, y, d? } +``` + +**RSA:** +```javascript +{ kty, n, e, d?, p?, q?, dp?, dq?, qi? } +``` + +JavaScript preserves insertion order for object properties, and downstream consumers may depend on this ordering. + +### ECDSA r/s Leading-Zero Quirk + +The `parseSigHexInHexRS` function historically prepends `00` to the r or s component when its most significant bit is set. This is a quirk from DER 2's-complement encoding. + +**Replicate this behavior.** Existing test fixtures depend on it, and changing it breaks compatibility. + +### RFC 6979 Determinism + +ECDSA signature outputs from `@noble/curves` *should* match `jsrsasign` byte-for-byte when using the same curve and digest algorithm. + +**If they diverge:** The signature is still cryptographically valid. Update the test fixture and document the divergence in the changelog. Do not attempt to massage the library output to force a match. + +### Golden Output Cosmetic Drift + +For text-based operations that dump parsed structures (`ParseX509Certificate`, `ParseCSR`, `ParseX509CRL`, `ParseASN1HexString`), minor formatting differences between `jsrsasign` and the new implementation are acceptable. + +**Action:** Update the test fixture and note the change in the changelog. + +### Cryptographic Correctness is Non-Negotiable + +Fixture data that exercises actual cryptographic operations (e.g., SM2 ciphertext→plaintext transformations in [tests/operations/tests/SM2.mjs](tests/operations/tests/SM2.mjs)) must pass unchanged. These fixtures pin real cryptographic behavior and cannot be updated cosmetically. + +## Shared Helper Modules + +To avoid duplicating logic across operations, use these utility modules: + +### [src/core/lib/Asn1.mjs](src/core/lib/Asn1.mjs) + +ASN.1 utilities for OID and DER manipulation: +- `oidHexToInt()` — convert ASN.1 OID hex encoding to integer dotted notation +- `oidIntToHex()` — convert integer dotted notation to ASN.1 OID hex +- `derToPem()` — wrap DER bytes in PEM format +- `dumpAsn1Hex()` — generic ASN.1 hex structure dump (uses `asn1js`) + +### [src/core/lib/Ecdsa.mjs](src/core/lib/Ecdsa.mjs) + +ECDSA operations with `@noble/curves`: +- `loadEcKey()` — parse EC private/public keys from PEM or JWK +- `signEcdsa()` — sign data with an EC private key +- `verifyEcdsa()` — verify an ECDSA signature +- Signature format converters (ASN.1 DER ↔ r/s hex format) +- `isAsn1Hex()` — detect ASN.1 DER-encoded data +- `generateEcKeyPair()` — generate a new EC key pair + +### [src/core/lib/PublicKey.mjs](src/core/lib/PublicKey.mjs) + +Public key utilities and DN formatting. The `formatDnObj()` function handles both legacy jsrsasign DN objects and `@peculiar/x509` `JsonName` shapes for backward compatibility. + +### [src/core/lib/SM2.mjs](src/core/lib/SM2.mjs) + +SM2 encryption and decryption with support for multiple ciphertext layouts: +- GMT 0009 BBB format +- GMT 0010 C1C2C3 format +- GMT 0010 C1C3C2 format + +Preserves compatibility with existing test fixtures for all three layouts. + +## Key File Locations + +- **Cryptographic operations:** [src/core/operations/](src/core/operations/) +- **Test fixtures:** [tests/operations/tests/](tests/operations/tests/) — golden output fixtures live here +- **Dependencies:** [package.json](package.json) +- **Library modules:** [src/core/lib/](src/core/lib/) From a5c0f5a4883867cd53ae265ed16923159d7e5902 Mon Sep 17 00:00:00 2001 From: Leon Zandman Date: Tue, 19 May 2026 10:15:29 +0200 Subject: [PATCH 14/15] Rename plan file. --- plan-jsrsasign.md => PLAN_JSRSASIGN-REMOVAL.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename plan-jsrsasign.md => PLAN_JSRSASIGN-REMOVAL.md (100%) diff --git a/plan-jsrsasign.md b/PLAN_JSRSASIGN-REMOVAL.md similarity index 100% rename from plan-jsrsasign.md rename to PLAN_JSRSASIGN-REMOVAL.md From aba6e7eace1df4667f1654b254289003f927e60e Mon Sep 17 00:00:00 2001 From: Leon Zandman Date: Tue, 19 May 2026 11:52:56 +0200 Subject: [PATCH 15/15] Persist info on pinning of `@peculiar/x509` package. --- CRYPTO_IMPLEMENTATION.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CRYPTO_IMPLEMENTATION.md b/CRYPTO_IMPLEMENTATION.md index c0f51d666d..b49833183b 100644 --- a/CRYPTO_IMPLEMENTATION.md +++ b/CRYPTO_IMPLEMENTATION.md @@ -16,6 +16,10 @@ Uses `@noble/curves` with `@noble/hashes/legacy` for MD5/SHA-1 digests. Uses `@peculiar/x509` for certificate, CSR, and CRL parsing and generation, with `@peculiar/asn1-*` schema packages as needed. +**Version pin:** Keep `@peculiar/x509` on the `1.14.x` line. + +**Why pinned?** `@peculiar/x509` v2 requires a `reflect-metadata` polyfill/import at each runtime entry point. CyberChef currently avoids that cross-bundle polyfill cost and complexity, so v1 remains the project baseline unless that tradeoff is revisited. + **Why not pkijs?** `@peculiar/x509` offers a cleaner API better suited to CyberChef's operation model. ### SM2: `@noble/curves/sm2`