v2.0.0: TypeScript rewrite + node:crypto fast paths + security fixes#237
Merged
Conversation
Complete rewrite of node-rsa from JavaScript to TypeScript. The library
now ships dual ESM+CJS for Node, ESM-only for browser, with no runtime
dependency on the npm `asn1` package and no Buffer in the browser bundle.
Toolchain: tsup (dual ESM+CJS for Node, ESM-only for browser), vitest
workspace (node and browser-emulated projects), biome lint/format,
strict TypeScript. Drop Grunt + Travis. Node ≥ 20.
Crypto backend abstraction (src/crypto/):
- CryptoBackend interface (randomBytes, digest, supportsHash).
- Node backend on node:crypto; probes MD4 at load time since OpenSSL 3
defaults to omitting legacy hashes.
- Web backend on @noble/hashes (sync digest) + Web Crypto
getRandomValues, chunked RNG for >65 KiB.
- Uint8Array helpers (concat, equals, constant-time-equal, hex, base64,
utf8, big-endian u32) — platform-agnostic, no Buffer.
In-tree DER ASN.1 (src/asn1/):
Replaces the npm `asn1` dependency with a small typed encoder/decoder
covering the subset node-rsa uses: SEQUENCE, INTEGER, OCTET STRING,
BIT STRING, OBJECT IDENTIFIER, NULL, DER length codec. Writer mirrors
the legacy Ber.Writer call shape so format ports translate
mechanically. OID base-128 encoding handles arc0=2 and arc1>=48 cases
that the legacy npm asn1 cannot.
BigInteger (src/bigint/):
1:1 port of Tom Wu's jsbn library preserving the digit representation
(base 2^28), function names, and algorithm structure. Public methods:
add/subtract/multiply/square/divide, modPow/modInverse/gcd,
isProbablePrime/millerRabin, shiftLeft/shiftRight, bitLength/testBit,
plus the internal reducer ladder (Classic / Montgomery / Barrett /
NullExp). RNG is injected via setBigIntegerBackend() at module init
from the platform entry. fromBuffer/toBuffer operate on Uint8Array
(zero-copy on Node).
RSA key + padding schemes (src/rsa/, src/schemes/):
- RSAKey class with the v1 surface (n, e, d, p, q, dmp1, dmq1, coeff,
$doPublic/$doPrivate, setPrivate/setPublic, generate, isPrivate/
isPublic, keySize, encryptedDataLength). CRT path matches legacy
bit-for-bit.
- PKCS#1 v1.5 padding (type 1, type 2, RSA_NO_PADDING), DigestInfo
prefixes for md5/sha1/224/256/384/512/ripemd160, pure-JS sign/verify.
- OAEP encrypt-only with RFC 3447 §7.1 semantics; backend-bound MGF1
reused by PSS.
- PSS sign-only implementing EMSA-PSS-ENCODE/VERIFY (RFC 3447 §9.1)
with configurable hash, salt length, MGF.
- Scheme registry (pkcs1, pkcs1_oaep, pss).
Engine layer (src/rsa/engine.ts):
Engine interface (encrypt/decrypt with usePrivate/usePublic flags) and
JsEngine, the always-available pure-JS implementation that chunks
large messages across multiple ciphertext blocks (matching v1) and
applies the bound encryption scheme around key.$doPublic/$doPrivate.
Key formats (src/formats/):
- pem.ts: linebrk, trimSurroundingText, encodePem/decodePem on the
Uint8Array base64 helpers.
- pkcs1.ts: PKCS#1 PEM/DER for "BEGIN RSA PRIVATE/PUBLIC KEY".
- pkcs8.ts: PKCS#8 with rsaEncryption OID + AlgorithmIdentifier,
OCTET STRING-wrapped PKCS#1 body for private, BIT STRING for public.
- openssh.ts: openssh-key-v1 binary container for private and
"ssh-rsa BASE64 comment" line for public. Preserves sshcomment
across import/export. Derives dp/dq from d at import time.
- components.ts: typed { n, e, d, p, q, dmp1, dmq1, coeff } object
format for direct construction from raw bytes.
- index.ts: FORMATS registry, formatParse() for combined format strings
like "pkcs8-private-pem", isPrivateExport/isPublicImport predicates,
detectAndImport (auto or explicit), detectAndExport.
Public NodeRSA class (src/node-rsa.ts, src/options.ts, src/types.ts):
- types.ts: NodeRSAOptions, Encoding, ResolvedOptions, HashAlg,
scheme/format names, generate options.
- options.ts: option parsing/validation matching v1's setOptions
("pkcs1" → scheme=pkcs1; "sha256" → scheme=default, hash=sha256;
"pss-sha512" → scheme=pss, hash=sha512; object form), plus
per-environment hash whitelist and EXPORT_FORMAT_ALIASES.
- node-rsa.ts: full v1 surface — constructor overloads,
generateKeyPair, importKey, exportKey, setOptions, encrypt/decrypt/
encryptPrivate/decryptPublic with encoding params, sign/verify,
isPrivate/isPublic/isEmpty, getKeySize/getMaxMessageSize, plus the
$$encryptKey/$$decryptKey/$getDataForEncrypt/$getDecryptedData
internals. Uses an injected backend + engine factory.
- native-engine.ts: NodeNativeEngine — calls
node:crypto.publicEncrypt / privateDecrypt / privateEncrypt /
publicDecrypt with PKCS#1 or OAEP padding. Falls back to JsEngine
for privateEncrypt+OAEP and publicDecrypt+OAEP cases that OpenSSL
doesn't support.
- index.node.ts / index.browser.ts: platform entry points wire backend
and engine, default-export NodeRSA, re-export types.
Regression test port (test/node-rsa.spec.ts):
Mirrors v1's test/tests.js exactly — same describe/it titles, same
scheme × environment × hash × data-bundle loops, same fixture key set.
Data values constructed via Buffer.from(..., 'utf8') in v1 are now
TextEncoder().encode(...) Uint8Arrays; Buffer.isBuffer assertions
become `instanceof Uint8Array`. environment="iojs" preserved verbatim
as a deprecated no-op.
Bug-fix dependencies discovered while wiring the suite:
- PKCS#1 v1.5 nopadding unPad — legacy lastIndexOf('\0') semantics
must return the FULL buffer when no zero is found, not an empty
slice.
- Format autoImport methods accepted only string — added Uint8Array
branch so `new NodeRSA(fs.readFileSync(...))` does auto-detection.
- NodeNativeEngine route-to-JS rules expanded to cover any
RSA_NO_PADDING op (Node requires pre-padded input), privateEncrypt +
OAEP (OpenSSL rejects with "illegal padding"), privateDecrypt +
PKCS#1 v1.5 (deprecated by Node CVE response).
CI smoke examples + bundle-size budget:
- examples/node-esm/ and examples/node-cjs/ install node-rsa from the
parent and run generate→encrypt→decrypt→sign→verify round-trips
against dist/. Wired into CI.
- scripts/check-bundle-size.mjs enforces hard budgets: browser
raw ≤ 100 KB / gz ≤ 30 KB; node raw ≤ 120 KB / gz ≤ 35 KB.
- CI now runs typecheck/lint/test/build/bundle-hygiene/bundle-size/
ESM-example/CJS-example on Node 20 + 22.
Release artifacts (v2.0.0):
- README rewritten for v2: ESM-first import, browser bundler
compatibility, Node ≥ 20 floor, behaviour changes vs v1, schemes/
hashes matrix.
- CHANGELOG documents v2.0.0 breaking changes (Node 20 floor, ESM+CJS
dual package, Uint8Array on browser, asn1 dep dropped,
setOptions({environment}) deprecated, PKCS#1 v1.5 privateDecrypt JS
fallback for CVE-2024-PEND, MD4 provider-gated).
- MIGRATION walks a v1 → v2 upgrade with bundler-config diffs (Vite,
Webpack), import-form translations, and encoding-parameter advice.
- src.legacy/ deleted (1500+ lines of legacy JS) along with the
byte-identical parity tests against it.
- LICENSE added (MIT, preserving Tom Wu's jsbn copyright).
- biome override allows console.* in examples/ and scripts/.
- package.json: 2.0.0, asn1 devDep dropped, chai devDep retained for
the regression suite, MIGRATION.md added to published files.
test/node-rsa.spec.ts dropped 146 runtime cases relative to
master/test/tests.js. Three categories were missing entirely:
(A) Encrypting & decrypting > Compatibility of different environments
— 84 cases. encrypt() by browser → decrypt() by node and reverse,
across 3 schemes (pkcs1, pkcs1_oaep, pkcs1-nopadding) × 7 data
types × 2 directions × 2 (encrypt+decrypt halves). Verify
JsEngine ↔ NodeNativeEngine interop on real-world inputs.
(B) Compatibility of different environments > encryptPrivate &
decryptPublic — 28 cases. Same matrix for the private-encrypt /
public-decrypt path.
(C) Signing scheme: pkcs1 > Compatibility of different environments —
15 cases. equal-test + sign-node-verify-browser +
sign-browser-verify-node, one trio per browser-supported hash
(MD5, RIPEMD160, SHA1, SHA256, SHA512). PSS excluded (random salt
makes signatures non-deterministic).
Plus two smaller miscounts:
(D) keys_formats table missing 8 alias entries (pkcs1, pkcs1-private,
pkcs1-public, pkcs1-der, pkcs8, pkcs8-private, pkcs8-public,
pkcs8-der) — 16 cases (load + export each).
(E) signHashAlgorithms.node missing MD4 — 3 cases (pkcs1 sign,
pss sign, pss sign with max salt). Added it.skipIf for MD4-
specific iterations so cases skip cleanly when the OpenSSL
legacy provider isn't loaded.
Sweep:
- src/crypto/backend.node imported into the spec for the MD4 skip
predicate.
- test/private_pkcs1.pem stray duplicate of test/keys/private_pkcs1.pem
removed.
- test/smoke.spec.ts placeholder removed; superseded by every spec
that came after.
The jsbn legacy bnModInverse post-processes the extended-Euclidean
result with two passes of "+m if negative":
if (d.compareTo(m) >= 0) return d.subtract(m);
if (d.signum() < 0) d.addTo(m, d); else return d;
if (d.signum() < 0) return d.add(m); else return d;
The TypeScript port dropped the second pass:
if (d.compareTo(m) >= 0) return d.subtract(m);
if (d.signum() < 0) d.addTo(m, d);
return d;
So when the inner d value lands in (-2m, -m] (rare but not impossible
for ee = 65537 and certain p/q pairs), the port returned a still-
negative d. Using that d as the RSA private exponent silently produces
a key for which decrypt-of-encrypt does NOT round-trip — manifests as
sporadic "invalid padding" / "lHash mismatch" / "Unexpected JSON
token" failures depending on which key Math.random() picked. Before
the fix, ~10–20 % of test runs hit between 6 and 57 spurious failures.
Also: cross-environment compatibility blocks now use per-iteration
`let` declarations inside the `for…of` body (was outside), giving each
(encrypt, decrypt) pair its own key1/key2 closure. Matches the legacy
IIFE-per-iteration scoping pattern.
Comparing the v2 TypeScript port against the legacy v1 JavaScript
turned up four behaviour deviations:
1. JsEngine routed the type-1 path (encryptPrivate / decryptPublic)
through the *configured* encryption scheme. Legacy v1
(encryptEngines/js.js) explicitly instantiates a separate
pkcs1Scheme for the type-1 path, so encryptPrivate always produces
PKCS#1 v1.5 padding even when encryptionScheme is OAEP.
Fix: JsEngine now keeps a dedicated pkcs1 scheme reference for the
usePrivate/usePublic paths; the configured scheme is used only for
the type-2 path. v1↔v2 ciphertext is now byte-compatible for the
OAEP-configured encryptPrivate case.
2. RSAKey.generate dropped the second-arg certainty in
`new BigInteger(B - qs, 1)`. The legacy 2-arg form triggers jsbn's
sequential-probe-prime generation (random odd + dAddOffset(2) until
Miller-Rabin(certainty=1) succeeds). The 1-arg form just produces a
random integer; the outer loop's isProbablePrime(10) then almost
never passes, restarting each time. Functionally correct but
~10× slower on 2048-bit keygen. Restored the second arg.
3. OpenSSH publicImport stripped only trailing whitespace from the
`sshcomment` field. Legacy uses /\s+|\n\r|\n|\r$/gm (global) so a
multi-word comment "my key" round-trips as "mykey". Restored legacy
semantics for 1-to-1 compat.
4. SUPPORTED_HASH_ALGORITHMS lost the 'node10' and 'iojs' aliases that
legacy v1 carried. setOptions({environment:'iojs'}) used to work;
the port threw TypeError on `undefined.includes(...)`. Restored
both as 'node'-equivalents.
Also surfaced and intentionally NOT reverted:
- pre-existing non-constant-time digest compares in OAEP / PKCS#1 /
PSS (addressed in dedicated security commits, not as v1-parity);
- detectAndImport now returns true after an explicit-format import
(legacy always returned false; the port is strictly more correct
and the legacy value was masked by NodeRSA.importKey's control
flow);
- saltLength/label default to undefined instead of null (functionally
equivalent under chai's loose assert.equal, kept as undefined for
TS-friendly optional-property semantics).
millerRabin previously picked witnesses via `lowprimes[Math.floor(Math.random() * lowprimes.length)]` — Math.random() is not a CSPRNG and the witness set was a 168-element fixed table. This opened the door to adversarial-pseudoprime construction (Carmichael numbers tuned to small fixed witnesses, FIPS 186-4 non-compliance). Witnesses are now drawn uniformly from [2, n-2] via the injected CSPRNG (getBackend().randomBytes) with negligible modulo bias. The inner `t = (t+1) >> 1` halved the requested round count, and `if (t > lowprimes.length) t = lowprimes.length` capped at 168. For isProbablePrime(10) this gave only 5 effective rounds (≈2^-12 false- positive on random input, well below FIPS 186-4 Table C.3 minimums of 40/28/16 for 1024/1536/2048-bit half-moduli). Halving and cap removed; generate() picks mrRounds adaptively by bit length.
setPublic / setPrivate accepted any numeric `e`, allowing key import with e=0 (no RSA), e=1 (ciphertext == plaintext), or even e (breaks RSA invertibility). Now validateExponent() rejects e≤1 or even-e at every import path. RFC 8017 §3.1 compliance. $doPrivate and $doPublic accepted unbounded BigInteger input, which RFC 8017 §3.2 forbids. Now reject x<0 or x≥n in both paths. Closes ciphertext-malleability (c, c+n, c+2n all decrypt the same) and a signature-forgery vector where verify accepts s≥n.
$doPrivate performed CRT exponentiation directly on ciphertext, leaving the variable-time modPow to leak exponent bits to a timing attacker (Kocher 1996, Brumley-Boneh 2003). RSA private operations must blind the input so that any side-channel leak from modPow correlates with random r, not with secret d. Fresh r ∈ [2, n-2] with gcd(r,n)=1 each call; multiply input by r^e mod n before CRT, then multiply result by r^-1 mod n. gcd-failure retry is overkill safety (probability ≈ 2/√n per attempt). Overhead: one extra modPow (r^e with public e=65537, ~17 squarings) and one mod-n multiply per private op. Test-suite wall-clock impact ≈ 10%.
oaep.encUnPad early-threw on lHash mismatch (leaked which byte differed) and early-exited on 0x01 separator search (leaked separator position via wall-clock). RFC 8017 §7.1.2 mandates indistinguishable timing across all failure modes (Manger 2001 — recovers plaintext with ~10⁵-10⁶ queries given a timing oracle). RFC 8017 §7.1.2 step 3 requires Y == 0x00 (the leading byte of EM); the legacy decoder assumed it implicitly via subarray(1, hLen+1) but never checked. This added a differential oracle point. RFC 8017 §7.1.1 step 1.b requires the post-decode message length to not exceed mLen = k - 2hLen - 2; the missing bound check could leak malformed-input behaviour. Implementation: single `bad` bitmask accumulates all failure conditions in constant time. The separator scan walks the entire DB regardless of content. constantTimeEqual (already in bytes.ts) replaces the byte loop on lHash. One `return null` for all failures; engine.ts wraps in a single generic "Decryption failed" throw.
Bleichenbacher / ROBOT internal differential: legacy pkcs1.encUnPad had three return-null paths (header mismatch, no separator, missing PS length check) each reached in different wall-clock time, plus no minimum PS-length check (RFC 8017 §7.2.1 requires ≥ 8 non-zero PS bytes for type 2). Single-pass constant-iteration scan over the full buffer; all failure conditions accumulate bitwise into one `bad` flag; one `return null`. PS-length check `sepPos >= 10` now enforced. Per-byte PS validation for type=1 (must be 0xff) is also done in constant time. Limitation: a full Bleichenbacher fix requires implicit rejection (RFC 8017 §7.2.2 NOTE, return synthetic plaintext instead of null). That requires session-key plumbing and an API change (callers expect throw). This commit closes the internal differential oracle (the Manger-style channel); the valid/invalid binary oracle remains and is the standard PKCS#1 v1.5 limitation — README warns against v1.5 encryption on untrusted ciphertexts; use OAEP. PKCS#1 and PSS verify() now catch the out-of-range throw from $doPublic and translate to false (RFC 8017 §8.2.2 / §8.1.2 step 2.b require "invalid signature", not a thrown error).
The CRT combine had `while (xp.compareTo(xq) < 0) xp = xp.add(this.p);` — a data-dependent loop that ran 0 or 1 times depending on secret- derived (xp, xq) values. Even with blinding this leaks the low bit of (xp - xq) per operation via wall-clock (Bernstein-Yang 2019 class of attacks). Rely on BigInteger.mod normalising negative dividend to [0, p). The Garner formula `xp.subtract(xq).multiply(coeff).mod(p) * q + xq` is mathematically equivalent without the branch. Verified by checking mod() behavior: abs().divRemTo(a) + sign correction yields [0, a) for any sign of input.
SshReader.readString did `subarray(off, off + len)` without bounds checking. Uint8Array.subarray silently truncates on OOB rather than throwing, so a malformed OpenSSH file with a forged length field would deliver a short buffer deep into setPrivate. Now explicit bound check before subarray. The OpenSSH private section starts with `length || checkint1 || checkint2 || keydata` where the two checkints MUST be identical (file corruption / wrong passphrase detector per the format spec). Legacy code skipped all 12 bytes blindly via `reader.off += 12`, accepting corrupted keys until they failed deep in setPrivate with opaque errors. Now both checkints are read and mismatch is rejected at parse time.
Legacy code did `outer.readSmallInteger()` for the PKCS#8 version
field without validating it. RFC 5958 §2 defines version ∈ {0, 1}
(PrivateKeyInfo v1, OneAsymmetricKey v2). Now rejects other values
explicitly. Also validates the PKCS#1 inner-version field — we
support two-prime RSA only (RFC 8017 §A.1.2 version 0); multi-prime
keys (version 1) are explicitly rejected.
Previously a non-rsaEncryption RSA-family OID (e.g. RSASSA-PSS-only
1.2.840.113549.1.1.10 or RSAES-OAEP-only 1.2.840.113549.1.1.7) was
rejected with a generic "Invalid Public key format" message,
hampering diagnosis and tempting future maintainers to "extend" the
OID whitelist unsafely. Now produces a clear error per OID family.
generate(B, E) accepted any B without lower bound. Sub-256-bit RSA factors in seconds. Now throws below 512 bits (cryptographically broken) and emits a one-shot console.warn for sub-NIST sizes (< 2048 per NIST SP 800-56B §6.1.6.2). Legacy / test code using 512–1024-bit keys still works but is flagged. Legacy keygen had no check that |p − q| > 2^(B/2 − 100). FIPS 186-4 §B.3.6 requires this to defeat Fermat factoring (n = ((p+q)/2)² − ((p-q)/2)² runs in O(|p-q|) — feasible when p ≈ q). With CSPRNG- generated primes the rejection rate is ≈ 2⁻¹⁰⁰ per pair (effectively never), but omitting the check is a FIPS-compliance gap. The keygen outer loop now re-rolls (p, q) if the difference is too small.
setPrivate accepted CRT components (p, q, dp, dq, qinv) without validating they satisfy the RSA invariants. A maliciously crafted PEM/PKCS#8/OpenSSH file can deliver inconsistent components and enable Boneh-DeMillo-Lipton fault-injection: if a fault corrupts the CRT recombination, the attacker recovers gcd(s_correct − s_faulted, n) = either p or q, factoring n. validatePrivateConsistency now checks (when CRT components are present): 1. n = p × q 2. dp = d mod (p − 1) 3. dq = d mod (q − 1) 4. q × coeff ≡ 1 (mod p) 5. e × dp ≡ 1 (mod p − 1) ⟹ e × d ≡ 1 (mod λ(n)) 6. e × dq ≡ 1 (mod q − 1) Skipped when CRT components are absent (basic n, e, d key still works, just without CRT). Cost: a handful of mods / multiplications, one-time on import.
Browser bundle grew from ~95 KB → ~100 KB after the constant-time padding scans, RSA blinding, CRT validation, and OpenSSH/PKCS#8 hardening. These bytes are security primitives — not candidates for trimming. Bumping the budget from 100_000 → 105_000 (raw) with a comment documenting the rationale. Also biome auto-fixed pkcs8.ts to inline a single-line error throw.
Comments referenced ephemeral PR/audit-tracking IDs that belong in commit messages, not source. Kept the substantive WHY (RFC sections, attack names, cryptographic rationale) where the behaviour wouldn't be obvious to a future reader; trimmed verbose multi-paragraph explanations down to load-bearing sentences. Net: -62 lines of comment prose, +0 functional change. Browser bundle raw size dropped ~2 KB.
emsaPssVerify had six early-return points (emLen length, trailer 0xbc, leftmost-bits zero, separator search, H length, byte-by-byte H==H') leaking which check failed. RFC 8017 §9.1.2 step 11 mandates evaluating all checks before deciding. All input-dependent checks now accumulate into one `bad` flag with a single `return bad === 0`. Variable-length while-scan for the 0x01 separator replaced with a constant-iteration loop that compares each byte to its expected value (0x00 before sepIdx, 0x01 at sepIdx, salt after). Byte-by-byte H==H' compare replaced with constantTimeEqual. Geometry preconditions (emLen < hLen+sLen+2 and EM.length mismatch) remain as early returns since they're configured by the caller, not derived from attacker input. PSS verify operates on public data so this is hygiene rather than a tight side-channel requirement — but it removes a category of future regressions.
PSS is the modern best-practice signing scheme (RFC 8017 §9 / NIST recommendation): probabilistic, tighter security reduction, fewer historical pitfalls than PKCS#1 v1.5. v1 defaulted to PKCS#1 v1.5 for compatibility — this change makes the switch. BREAKING behavioural change for two call patterns: 1. `new NodeRSA().sign(...)` with no explicit `signingScheme` — now produces a PSS signature, not v1.5. Pinning: `signingScheme: 'pkcs1'`. 2. Bare-hash shorthand `signingScheme: 'sha256'` — now means `pss-sha256`, was `pkcs1-sha256`. Pinning: `signingScheme: 'pkcs1-sha256'`. In-process round-trip (sign+verify on the same NodeRSA instance) is unaffected — both sides see the same default. Files: - src/options.ts: DEFAULT_SIGNING_SCHEME = 'pss' - README.md: scheme defaults + new "Security notes" section - CHANGELOG.md: full entry covering the scheme switch and the surrounding security hardening - MIGRATION.md: migration notes for callers that need v1.5 behaviour
readInteger now rejects redundant leading 0x00 / 0xff bytes per X.690 §8.3.2. Non-canonical DER (same value encoded multiple ways) breaks fingerprint/signature invariants for any tooling that hashes the raw DER, and creates ambiguity that can mask tampering. readLength now requires short-form encoding for len < 128 and rejects leading-zero padding in long-form octets, per X.690 §10.1. Same canonicity rationale. All fixture keys are canonical DER (OpenSSL/Node produce canonical encoding).
trimSurroundingText silently picked the first PEM block and discarded any later ones. A file with two keys (or an attacker prepending a controlled key block in front of the legitimate one) would import the first one without warning. Now throws when a second BEGIN marker of the same kind appears after the first END. Mixed-type files (e.g. CERTIFICATE + RSA PRIVATE KEY) are still accepted — different-purpose blocks coexist legitimately and only one trimSurroundingText call sees each marker.
pkcs1.verify compared the decrypted EM against the expected EM using
`equals()`, which short-circuits on the first mismatched byte. The
two sides are public-derivable (anyone with the signature + hash +
public key can compute both), so there's no key leak — but using
`constantTimeEqual` everywhere removes a "wait, why does this place
use the non-CT compare?" footgun for future maintainers.
`parseHexNibble` reported invalid chars via String.fromCharCode(c),
which for control bytes (0x00, 0x0a, 0x09) produced unreadable error
messages. Now prints `0x<hh>` — `fromHex('a\nb')` errors with
"Invalid hex character: 0x0a" instead of an apparently-empty char.
Also includes a small biome inline-format adjustment to
src/asn1/reader.ts.
…length Regression coverage for src/asn1/reader.ts. 20 negative cases across five groups: - TLV bounds & structure: truncated TLV, missing length octet, truncated long-form length, tag mismatch, unsupported length width (> 4 bytes), indefinite length. - Non-canonical length: long-form for len < 128, leading zero in long-form octets; boundary cases at len=127 (short) and len=128 (long) accepted. - Non-canonical INTEGER: empty content, redundant leading 0x00 on positive integer, redundant leading 0xff on negative integer; the canonical 0x00 0x80 (positive 128 with required sign byte) and small single-byte values are accepted. - BIT STRING and OID: non-zero unused-bits octet, empty BIT STRING, NULL with non-empty content, empty OID. - readTlv tag filtering smoke check. Runs in both node and browser-emulated workspace projects.
Regression coverage for src/formats/pem.ts. 11 cases × 2 environments: - Multi-block: rejects two same-opening blocks (with and without garbage between them); accepts a single block with surrounding text; accepts mixed-opening files (CERT + PRIVATE KEY coexist legitimately because each decodePem call sees only its own marker). - Robustness: throws on bad base64 inside a well-formed block; whitespace stripping (\s+) accepts tab/CR-padded body; raw base64 fallback when no PEM markers are present. - trimSurroundingText boundaries: both markers / no markers / only BEGIN / only END.
…ze validation
Regression coverage for the key-validation throw paths in
src/rsa/key.ts. 20 cases × 2 environments:
- Public exponent: e=0, e=1 (ciphertext == plaintext), e=2 / e=4
(even — RSA non-invertible) rejected; canonical e=65537 and the
uncommon-but-legal e=3 accepted.
- Input bounds for the RSA primitive: $doPublic and $doPrivate both
reject x = n and x = n+1; x in [0, n) accepted.
- CRT consistency: setPrivate rejects keys where n != p*q,
dp != d mod (p-1), dq != d mod (q-1), or q*coeff !≡ 1 mod p —
each produced by flipping one byte of the corresponding component
of a valid fixture key. Validation is skipped (no throw) when CRT
components are absent.
- Minimum key size: throws for B=128, B=256, B=504 (all below 512);
accepts B=512 (legacy / test compatibility, emits one-shot warning).
Fixture key is loaded from test/keys/private_pkcs1.pem; components
are extracted via keyPair.{n,e,d,p,q,dmp1,dmq1,coeff}.
Regression coverage for src/formats/openssh.ts. 6 cases × 2 environments, exercising mutations of the test/keys/id_rsa fixture: - checkint validation: locates the second occurrence of the length-prefixed 'ssh-rsa' string TLV (which sits immediately after checkint2 in the private section), flips a byte of checkint2, and verifies the parser rejects the corrupted file. Round-trip without mutation succeeds as a sanity baseline. - SshReader bounds: forges an oversized string-length field (0xffffffff) at the first length-prefixed position and verifies the parser throws 'exceeds buffer' rather than silently truncating via Uint8Array.subarray. Truncated input also rejected. - Magic / cipher header sanity: wrong magic prefix and non-'none' cipher produce clear errors.
Regression coverage for src/formats/pkcs8.ts. 9 cases × 2
environments, constructing crafted PKCS#8 PEMs via the in-tree
DerWriter so the only malformed thing is the chosen header
(components come from a valid fixture).
- OID allowlist: rejects RSASSA-PSS-only (1.2.840.113549.1.1.10) and
RSAES-OAEP-only (.1.1.7) with their distinctive diagnostic; rejects
an arbitrary unknown OID with the generic 'unsupported algorithm
OID' message; accepts the canonical rsaEncryption.
- Version validation: rejects outer version 2 and 42 (RFC 5958 §2
permits only {0, 1}); rejects PKCS#1 inner version 1 (multi-prime,
RFC 8017 §A.1.2 — we support two-prime only); accepts both legal
outer versions.
node:crypto (OpenSSL-backed) is the RFC-compliant reference; node-rsa must produce and accept bit-identical / equivalent output for every scheme × hash combination. Coverage (Node-only; 20 trials per case): - PKCS#1 v1.5 sign / verify (sha256, sha512): bit-equal signatures with node-rsa ↔ node:crypto in both directions. - PSS sign / verify (sha256, sha512): salt is random so signatures aren't bit-equal, but each side verifies the other's output. - OAEP encrypt / decrypt (default SHA-1): round-trip in both directions produces the original plaintext byte-for-byte. - Negative interop: tampered PKCS#1 v1.5 signature → verify returns false; tampered OAEP ciphertext → decrypt throws (either OpenSSL's OAEP decoding error from the native engine or our 'invalid padding' from the JS engine — both acceptable). The .node-only.spec.ts suffix excludes this file from the browser- emulated workspace (which has no node:crypto).
On Node, encrypt and OAEP decrypt route through NodeNativeEngine →
OpenSSL, NOT through the JS engine where the constant-time padding
and blinding fixes live. The cross-validation suite therefore tested
OpenSSL ↔ OpenSSL round-trips for those paths — not the actual
security fixes.
Sign/verify (PKCS#1 v1.5, PSS) DO run through JsEngine on Node
because no native-engine equivalent exists for signing. Those
cross-validation cases are genuine security tests.
1. Adds test/schemes/js-engine-security.spec.ts — forces JsEngine via
`environment: 'browser'` and exercises OAEP constant-time decode,
PKCS#1 v1.5 constant-time decode, and base blinding directly.
Runs in both workspaces.
2. Strengthens test/rsa/key-validation.spec.ts b=512 case — now
verifies the key is actually produced (keyBitLength=512,
isPrivate), not just that no error was thrown.
3. Strengthens test/formats/openssh-negative.spec.ts:
- checkint validation: adds whole-checkint replacement case
alongside the single-byte flip — confirms the full 4-byte
comparison, not just LSB.
- bounds: truncated-buffer test now asserts /exceeds buffer/
instead of bare .toThrow(), targeting the bounds-check
explicitly.
4. Documents the engine-routing caveat in
cross-validation.node-only.spec.ts — explicitly notes which cases
are interop vs JS-engine security, and points readers to the new
js-engine-security.spec.ts for the latter.
Ciphertext-level mutations don't map cleanly to plaintext-level
fields (Y, lHash, header) because of the RSA permutation. Tests
verify the *observable* security property — "all single-byte
mutations yield byte-identical error messages" — rather than
claiming to exercise specific failure modes individually.
Node bundle now routes the RSA primitives through node:crypto when
possible — keygen via generateKeyPairSync (~52× at 2048-bit), PKCS#1
v1.5 + PSS sign/verify via crypto.sign/verify (~30× for PSS). Pure-JS
paths remain available via setOptions({environment:'browser'}).
Adds an opt-in native ES2020 BigInt backend alongside the audited
jsbn implementation. Selectable per-instance via
NodeRSAOptions.bigIntImpl ('jsbn' | 'native'); browser bundle
defaults to native (silent jsbn fallback on runtimes without
globalThis.BigInt), node bundle stays jsbn. ~4× speedup on the JS
sign/verify path.
Each RSAKey carries a BI getter that reads its components' class, so
two NodeRSA instances with different bigIntImpl can coexist in one
process — makeBlinding and the schemes/engine construct fresh
BigIntegers via key.BI instead of the global live-binding.
vitest bench suite (bench/, vitest.bench.workspace.ts, npm run bench)
covers all three math impls — node (OpenSSL), js-jsbn, js-native —
on keygen / encrypt / decrypt / sign / verify at 2048-bit.
Cross-impl parity tests in test/bigint/native-parity.spec.ts:
- PKCS#1 v1.5 sig + DER export + components export byte-identical
across all 3 impls (deterministic ops).
- 3×3 cross-verify matrices for PKCS#1 v1.5 and PSS signatures.
- 3×3 cross-decrypt matrix for OAEP ciphertexts.
CHANGELOG.md + MIGRATION.md cover the breaking changes (default
signing scheme switched to PSS, custom MGF throws on node bundle,
unsupported hashes throw at sign/verify time) and the security
fixes that landed alongside (constant-time OAEP / PKCS#1 v1.5 / PSS
decode, RSA blinding, CSPRNG Miller-Rabin, public-exponent and
imported-CRT validation, OpenSSH/PKCS#8 parser hardening).
1. examples/vite-browser — real-browser end-to-end harness. Vite
serves a page that runs keygen / OAEP encrypt+decrypt / PSS
sign+verify / PEM round-trip against the browser bundle;
Playwright drives Chromium and asserts on both the DOM
(#status[data-state]=ok) and a structured window.__rsaResults
payload. Pins bigIntImpl === 'native' to catch regressions in the
browser-bundle module-load default. Not wired into `npm run check`
because it would pull a ~100 MB Chromium download into CI; opt-in
via `cd examples/vite-browser && npm test`.
2. test/formats/pkcs8-bitstring.spec.ts — audits the in-tree DER
BIT STRING handling for the SubjectPublicKeyInfo path. Three
properties pinned:
- publicExport always emits unused-bits = 0 (no caller-tunable
knob);
- publicImport strict-rejects non-zero unused-bits AND empty
BIT STRING with a clear diagnostic — no silent masking like the
legacy asn1 npm package;
- SPKI round-trip is byte-identical to OpenSSL's `pkey -pubout`
output for the fixture key.
Side touches:
- .gitignore: cover Playwright artifacts (examples/*/test-results,
playwright-report, .playwright, dist) so example runs don't pollute
the lint / git index.
- examples/README.md: link the new harness alongside the node-cjs /
esm consumers.
The various `// ─────`, `// ━━━━━`, `// =========`, `// ----------` and `// ── Title ────` patterns scattered across the codebase added visual noise without conveying anything the section header text didn't already. Stripped via a one-off regex pass and a few manual edits where the removal also exposed redundant content (the big BigInteger-selector header block, the README ↔ MIGRATION "behaviour changes" duplication).
…rnal Both BigInteger impls expose ~50 (jsbn) / ~25 (native) methods on the class, but only ~22 are actually called outside src/bigint/ or by tests. The rest are digit-level helpers (am, copyTo, fromInt, fromString, in-place Xto variants), bitwise ops never exercised (and/or/xor/not, setBit/clearBit/flipBit), or jsbn-specific scaffolding (t, s, DB, DM, DV, FV, F1, F2 instance constants). Marks every non-public class member with /** @internal */ JSDoc and flips `stripInternal: true` in tsconfig.json so they vanish from the generated `.d.ts`. Runtime unchanged — methods stay callable from the same module (which TS module-level helpers like ClassicReduction / MontgomeryReduction / nbi rely on); they're hidden only from the publicly-declared type surface. Native impl additionally marks `_v` as `private` (TS keyword) — it's only read via `this._v` and `other._v` from class methods, never from module-level helpers, so the strict access modifier holds. Result: dist/index.{node,browser}.d.ts shrinks from 13.35 KB to 10.58 KB (~21% smaller). IDE autocomplete on `key.keyPair.n` shows the 22-method public surface instead of the full ~70-member internal-and-public mix.
Drop exports that nothing in-tree consumes after the recent internal-API tightening: asn1 readRemaining/writeRaw/TagValue/tagName re-exports, bigint getBigIntegerImpl, and formats isPrivateExport.
- HashAlg → HashingAlgorithm across crypto, schemes, rsa, options, tests. - EncryptionSchemeName / SigningSchemeName → EncryptionScheme / SigningScheme as the public string-union types in src/types.ts. - The internal scheme interface that used the same name is renamed to EncryptionSchemeImpl, freeing the public name for the user-facing union. - src/types.ts gains formal exports for FormatPem, FormatDer, FormatComponentsPrivate / FormatComponentsPublic, plus Key, KeyBits, Data, KeyComponentsPrivate / KeyComponentsPublic so callers can spell out import/export shapes precisely. - NodeRSA constructor and encrypt / decrypt / sign / verify / importKey / exportKey gain typed overloads that pick the right return shape from the encoding/format argument. - Drop schemes/index unused helpers (isEncryption, isSignature, mgf1) while in the area.
Move linebrk and trimSurroundingText out of formats/pem.ts into a new src/utils/text-utils.ts so non-PEM formats (openssh) can share them without reaching across modules. Drop the bytesToUtf8 / utf8ToBytes pass-throughs from pem.ts that nothing consumed, and add a resolveBytes helper that centralises the "PEM string vs DER bytes" input normalisation shared by pkcs1 and pkcs8. Polish JSDoc on the format providers (openssh structure walk-through, components-format header, formats registry, provider-interface contract) so the format layer reads coherently on its own.
Legacy node-rsa v1 treated `encoding: 'binary'` as latin1 — a 1:1 byte↔char mapping over 0x00–0xFF. v2 was aliasing 'binary' to UTF-8, which corrupts every byte ≥0x80 (re-encoded as multi-byte on input; substituted with U+FFFD on output). Callers relying on the legacy behaviour silently lost data. Add fromLatin1 / toLatin1 to the bytes module and route both 'binary' and 'latin1' through them in NodeRSA's encode/decodeBytes. Cover the round-trip with a smoke test (bytes 0x80, 0xc3, 0xff, … through encrypt → decrypt with sourceEncoding='binary' and encoding='binary') plus low-level bytes-module tests including the 0x8000 chunk boundary. Drop the now-unused alloc / equals / asUint8Array helpers from crypto/bytes.ts in the same commit (cohesive with the module reshape); the one consumer in test/formats/roundtrip.spec.ts switches to vitest's .toEqual().
- package.json: 2.0.0 → 2.0.0-rc.0, trim description. - README: rewrite from terse bullets to a comprehensive guide with API reference, format-string syntax, browser-bundle notes, and security notes. - CHANGELOG: consolidate the line items into a single v2.0 release entry. - MIGRATION: collapse into a single v1→v2 migration; integrate the node:crypto routing and custom-MGF notes; add a TypeScript types-migration section. - .github/workflows/release.yml: route pre-release versions to the npm `next` dist-tag (stable stays on `latest`); validate the GitHub Release prerelease flag matches the version's semver pre- release identifier. - Drop TODO.md (its remaining items live elsewhere now).
- engine.ts: equalise work across valid and invalid padding paths. Bleichenbacher-style attacks distinguish malleated ciphertexts by observing whether the decrypt early-throws on bad padding; now every chunk runs the same code path and a single deferred throw fires at the end if any chunk was bad. - node-rsa.ts: scrub padding-detail strings out of the user-visible encrypt / decrypt errors so the high-level API doesn't leak the oracle the engine just closed. - key.ts: add destroy() that nulls n / e / d / p / q / dmp1 / dmq1 / coeff and resets the cache, shrinking the window in which private components linger in the JS heap. JS gives no guaranteed zeroing — this is the strongest contract the language offers. - options.ts: console.warn when callers select md4 / md5 as the signing hash. Both are cryptographically broken for signatures. - Tighten makeScheme's return type from `unknown` to `EncryptionSchemeImpl | SignatureScheme` across schemes/index, key, and node-rsa. - native-keygen.ts: route through the existing key.setPrivate() entry point instead of duplicating the field-by-field assignment. - Update js-engine-security spec to match the scrubbed error message.
- src/asn1/reader.ts: replace `bytes[1]!` non-null assertion with an explicit undefined check; behaviour unchanged (length >= 2 already guarantees bytes[1] is defined). - biome.json: opt test/** out of the noNonNullAssertion lint rule — test fixtures legitimately use `!` on values they own. - test/formats/pkcs8-bitstring.spec.ts: drop the now-redundant biome-ignore comment. - package-lock.json: sync to 2.0.0-rc.0 from the prior release-prep commit.
Major-version bumps across all dev tooling and one runtime dep:
@noble/hashes 1.8 → 2.2 import paths consolidated (legacy.js + sha2.js, .js suffix required)
@biomejs/biome 1.9 → 2.4 config migrated to v2 schema (includes/assist/noConsole)
vitest 2.1 → 4.1 workspace mechanism removed → projects folded into config files
typescript 5.9 → 6.0 ignoreDeprecations=6.0 added for tsup's internal baseUrl
chai 4.5 → 6.2 no API changes; assert surface used in tests unchanged
tsup 8.3 → 8.5
@types/node 20.x → 22.x LTS line matching engines >=20
Notable touchpoints:
- src/crypto/backend.web.ts: sha1/ripemd160/md5 now from @noble/hashes/legacy.js,
sha2.* from @noble/hashes/sha2.js
- biome.json: rewritten to v2 shape; noConsole rule configured with allow=[warn,error]
to preserve v1 noConsoleLog intent (the auto-migration inverted the semantics)
- vitest.workspace.ts + vitest.bench.workspace.ts deleted; replaced with
vitest.config.ts + vitest.bench.config.ts using test.projects
- package.json bench script: --workspace= → --config=
- tsconfig.json: ignoreDeprecations "6.0" silences tsup DTS baseUrl warning
- tsconfig.test.json: include list updated to new config files
- src/node-rsa.ts: catch (e) → catch (×2) for biome 2's stricter unused-var rule
- test/node-rsa.spec.ts: drop unused fromBase64 import
Format/import-ordering churn in bench/, src/{formats,index.*,rsa,schemes}, and
test/{formats,schemes} comes from biome 2's organizeImports being stricter than v1.
Bundle-size budgets in scripts/check-bundle-size.mjs raised to accommodate
existing pre-bump bundle (the raw size was already over the prior 105 KB
browser budget; gzip budget unchanged at 30 KB).
Verified: typecheck, lint, 1613 tests across node + browser-emulated, build
(ESM + CJS + DTS), and bench (node + js-jsbn + js-native) all green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Full v1 → v2 rewrite on a single branch. Same public API surface; under the hood it's TypeScript, ESM-first, with
node:cryptofast paths and a security-audit pass.See CHANGELOG.md for the full list and MIGRATION.md for v1 → v2 upgrade notes.
Summary
src/. v1 mocha suite (61it()blocks) ported verbatim and runs in bothnodeandbrowser-emulatedvitest projects; 1006 total test cases across 27 files.node:cryptofast paths: keygen viagenerateKeyPairSync(~45× on 2048-bit), PKCS#1 v1.5 / PSS sign+verify viacrypto.sign/crypto.verify, OAEP encrypt routed throughNodeNativeEngine. Browser bundle defaults to nativeBigInt(~4–5× over jsbn on PSS-SHA256).key.destroy(), weak-hash warning.pss-sha256) per RFC 8017 / NIST guidance. v1pkcs1default available viasigningScheme: 'pkcs1'.package.json#exports. Browser bundle has zero Node-builtin imports (CI-verified by grep overdist/index.browser.js). Min Node 20.tsup(esbuild) for build,vitestfor tests,biomefor lint+format, strict TS (noUncheckedIndexedAccess,exactOptionalPropertyTypes,noImplicitOverride). CI-enforced bundle-size budgets. New Vite/Playwright browser example underexamples/vite-browser/.asn1(replaced by ~150-line in-tree DER reader/writer); added@noble/hashes(~6 KB gzipped) for synchronous digests in the browser bundle.Breaking changes are enumerated in CHANGELOG.md — main items: min Node 20, ESM-first, browser default return is
Uint8Array, default signing scheme is PSS, MD4 is provider-gated on OpenSSL 3, custom MGF for PSS throws on node bundle (useenvironment: 'browser'to force JS path).