Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
40f142a
feat: port node-rsa v1 to TypeScript
rzcoder May 11, 2026
4691ee7
test: restore 146 missing regression cases vs v1
rzcoder May 11, 2026
559b45b
fix(bigint): restore sign normalization in modInverse
rzcoder May 11, 2026
c142291
fix: 4 implementation deviations vs legacy v1
rzcoder May 11, 2026
c782c2d
fix(security): CSPRNG witnesses + proper Miller-Rabin round count
rzcoder May 11, 2026
81468fc
fix(security): validate public exponent and RSA primitive input bounds
rzcoder May 11, 2026
cb2601c
fix(security): base blinding in $doPrivate
rzcoder May 11, 2026
a9a52b3
fix(security): OAEP decode constant-time, EM[0]==0, length bound
rzcoder May 11, 2026
7817555
fix(security): PKCS#1 v1.5 decode constant-time + verify guards
rzcoder May 11, 2026
39ed321
fix(security): unconditional CRT recombination
rzcoder May 11, 2026
e382b6b
fix(security): OpenSSH SshReader bounds-check + checkint validation
rzcoder May 11, 2026
2872416
fix(security): PKCS#8 version + algorithm OID validation
rzcoder May 11, 2026
5218c16
fix(security): minimum key size guard + Fermat distance check
rzcoder May 11, 2026
2086056
fix(security): private key CRT parameter consistency check on import
rzcoder May 11, 2026
47dcb7c
chore: bump browser bundle-size budget to 105 KB
rzcoder May 11, 2026
275046c
chore: trim audit-tracking IDs from inline comments
rzcoder May 11, 2026
b5dfc7a
fix(security): PSS verify constant-time accumulator
rzcoder May 12, 2026
385a35b
feat(security)!: switch default signing scheme from pkcs1 to pss
rzcoder May 12, 2026
c1913b1
fix(asn1): strict-DER canonical INTEGER and length encoding
rzcoder May 12, 2026
92fb94b
fix(pem): reject multi-block PEM input
rzcoder May 12, 2026
9293d0d
chore: constantTimeEqual in PKCS#1 verify + readable hex error
rzcoder May 12, 2026
43c55ef
test(asn1): negative cases for TLV bounds, non-canonical INTEGER and …
rzcoder May 12, 2026
fd03c6c
test(pem): multi-block rejection + malformed-input robustness
rzcoder May 12, 2026
310067e
test(rsa): public exponent, primitive bounds, CRT consistency, key-si…
rzcoder May 12, 2026
4230fc8
test(openssh): SshReader bounds and checkint validation
rzcoder May 12, 2026
966019d
test(pkcs8): version and algorithm OID validation
rzcoder May 12, 2026
791706f
test(schemes): cross-validation with node:crypto + tamper-rejection
rzcoder May 12, 2026
da68dfb
test(security): close JS-engine coverage gap in cross-validation suite
rzcoder May 12, 2026
b2dbdda
ci: update github actions
rzcoder May 17, 2026
ce7f5fb
perf+feat: node:crypto fast paths, native BigInt backend, 3-impl parity
rzcoder May 17, 2026
90c38f9
example+test: Vite/Playwright browser harness + PKCS#8 BIT STRING audit
rzcoder May 17, 2026
8d94085
chore: strip horizontal-divider lines from comments
rzcoder May 17, 2026
617ad6d
refactor(bigint): hide internal API surface via @internal + stripInte…
rzcoder May 17, 2026
f19fcbe
chore: remove unused internal helpers
rzcoder May 17, 2026
d0b4d30
refactor(api): rename scheme/hash types, expand public type surface
rzcoder May 17, 2026
81eb128
refactor(formats): extract text-utils module, tighten format-layer JSDoc
rzcoder May 17, 2026
1906e6d
fix: preserve bytes ≥0x80 through 'binary'/'latin1' encoding
rzcoder May 17, 2026
b96c25d
chore(release): prep 2.0.0-rc.0
rzcoder May 17, 2026
7e74461
feat(security): timing-safe JS decrypt, key.destroy(), weak-hash warning
rzcoder May 17, 2026
c08e6d8
chore: drop non-null assertions — narrow in src, allow in test specs
rzcoder May 17, 2026
d8cc10f
chore(deps): upgrade biome 2, vitest 4, ts 6, chai 6, @noble/hashes 2
rzcoder May 18, 2026
0eade68
doc: update readme
rzcoder May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
check:
name: Lint, typecheck, test, build (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v6

- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: npm

- name: Install dependencies
run: npm ci

- name: Typecheck
run: npm run typecheck

- name: Lint
run: npm run lint

- name: Test
run: npm test

- name: Build
run: npm run build

- name: Browser bundle hygiene check
run: |
# Only flag actual imports/requires of node: modules — not string
# literals or comments that happen to contain "node:".
if grep -E "require\(['\"](node:|crypto|buffer|fs)['\"]|import.*from.*['\"]node:|import\s+['\"]node:" dist/index.browser.js; then
echo "::error::Browser bundle contains forbidden Node imports"
exit 1
fi

- name: Bundle size budget
run: npm run check:bundle-size

- name: Smoke test ESM example
run: cd examples/node-esm && npm install --no-package-lock && npm start

- name: Smoke test CJS example
run: cd examples/node-cjs && npm install --no-package-lock && npm start
110 changes: 110 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
name: Release

on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to publish (e.g. v2.0.0). Must already exist as a git tag."
required: true
type: string
dry-run:
description: "Skip the actual npm publish (build + verify only)."
required: false
type: boolean
default: false

permissions:
contents: read

jobs:
publish:
name: Publish to npm
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.inputs.tag || github.ref }}

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
registry-url: https://registry.npmjs.org

- name: Install dependencies
run: npm ci

- name: Verify tag matches package.json version and determine npm dist-tag
env:
RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}
run: |
PKG_VERSION="$(node -p "require('./package.json').version")"
RAW_REF="${{ github.event.inputs.tag || github.ref_name }}"
TAG_VERSION="${RAW_REF#v}"
echo "package.json version: $PKG_VERSION"
echo "git tag version: $TAG_VERSION"
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
echo "::error::package.json version ($PKG_VERSION) does not match release tag ($TAG_VERSION)"
exit 1
fi

# Pre-release semver (contains a hyphen, e.g. 2.0.0-rc.0) → publish under "next".
# Stable (e.g. 2.0.0) → publish under "latest", which is npm's default install target.
if [[ "$PKG_VERSION" == *-* ]]; then
NPM_TAG=next
else
NPM_TAG=latest
fi
echo "npm dist-tag: $NPM_TAG"
echo "NPM_TAG=$NPM_TAG" >> "$GITHUB_ENV"

# If this run was triggered by a GitHub Release, the "prerelease" flag
# on the release must agree with the version string. This catches the
# easy mistake of forgetting to tick "Set as a pre-release" (or vice
# versa) before publishing the release.
if [ "${{ github.event_name }}" = "release" ]; then
if [ "$NPM_TAG" = "next" ] && [ "$RELEASE_PRERELEASE" != "true" ]; then
echo "::error::Version $PKG_VERSION looks like a pre-release but the GitHub Release is not marked as prerelease"
exit 1
fi
if [ "$NPM_TAG" = "latest" ] && [ "$RELEASE_PRERELEASE" = "true" ]; then
echo "::error::Version $PKG_VERSION is stable but the GitHub Release is marked as prerelease"
exit 1
fi
fi

- name: Typecheck
run: npm run typecheck

- name: Lint
run: npm run lint

- name: Test
run: npm test

- name: Build
run: npm run build

- name: Browser bundle hygiene check
run: |
if grep -E "require\(['\"](node:|crypto|buffer|fs)['\"]|import.*from.*['\"]node:|import\s+['\"]node:" dist/index.browser.js; then
echo "::error::Browser bundle contains forbidden Node imports"
exit 1
fi

- name: Bundle size budget
run: npm run check:bundle-size

- name: Publish to npm (dry-run)
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry-run == 'true' }}
run: npm publish --provenance --access public --tag "$NPM_TAG" --dry-run

- name: Publish to npm
if: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.dry-run != 'true' }}
run: npm publish --provenance --access public --tag "$NPM_TAG"
15 changes: 13 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
.DS_Store
.idea
.tmp
node_modules/
.nyc_output
nbproject/
nbproject/
node_modules/
examples/*/node_modules/
examples/*/package-lock.json
examples/*/dist/
examples/*/test-results/
examples/*/playwright-report/
examples/*/.playwright/
dist/
coverage/
*.log
.env
.env.local
19 changes: 15 additions & 4 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
src
src.legacy
test
.travis.yml
.nyc_output
.tmp
examples
.github
.idea
.DS_Store
.tmp
.nyc_output
.DS_Store
.nvmrc
.gitignore
biome.json
tsconfig.json
tsconfig.test.json
tsup.config.ts
vitest.workspace.ts
vitest.config.ts
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
12 changes: 0 additions & 12 deletions .travis.yml

This file was deleted.

162 changes: 162 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# Changelog

## 2.0.0 — TypeScript rewrite, native `node:crypto` fast paths, security audit fixes

Full rewrite of the v1 library in TypeScript with the same public API. The
node bundle now routes RSA primitives through `node:crypto` whenever
possible, and the browser bundle defaults to native `BigInt`.

### Performance — node bundle uses `node:crypto` natively

- **Keygen** uses `crypto.generateKeyPairSync`. 2048-bit drops from ~2.3 s
to ~50 ms (~45× faster) on modern hardware; 1024-bit from ~240 ms to
~10 ms.
- **PKCS#1 v1.5 and PSS sign/verify** use `crypto.sign` / `crypto.verify`.
PSS-SHA256 sign on 2048-bit drops from ~17 ms to sub-millisecond.
- OAEP encrypt / PKCS#1 v1.5 encrypt route through `NodeNativeEngine` —
also `node:crypto`-backed.

### Performance — browser bundle defaults to native `BigInt`

A drop-in BigInteger implementation lives at
[src/bigint/big-integer-native.ts](src/bigint/big-integer-native.ts) and
uses ES2020's native `BigInt`. The browser bundle picks it at load time;
the node bundle stays on the audited jsbn implementation. Round-trips
identically through every API; switch back to jsbn with
`new NodeRSA(key, { bigIntImpl: 'jsbn' })` if you ever need to.

| 2048-bit, JS path | jsbn | native | speedup |
|---|---|---|---|
| PSS-SHA256 sign | ~16 ms | ~4 ms | **~4×** |
| PSS-SHA256 verify | ~0.4 ms | ~0.08 ms | **~5×** |

The `bigIntImpl` option (also accepted by `setOptions`) must be set
BEFORE the key is imported or generated; switching it on an instance
that already has key components throws, since the two implementations
produce incompatible BigInteger instances.

The browser bundle silently falls back to jsbn on runtimes without
`globalThis.BigInt` (i.e. pre-2020 environments). No user action needed.

### Breaking changes

- **Min Node.js is now 20**. v1 worked back to Node 8.11; v2 requires Node 20+
for `node:crypto`, `globalThis.crypto`, and modern ESM features.
- **Module shape**: ESM-first. `package.json#exports` provides a dual ESM/CJS
layout — `import NodeRSA from 'node-rsa'` for ESM,
`require('node-rsa').default` for CommonJS.
- **Browser default return type is `Uint8Array`** (was `Buffer` via polyfill).
Node return type stays `Buffer` (which extends `Uint8Array`, so most
existing consumers continue to work). Internal byte handling is `Uint8Array`
end-to-end; the Node entry wraps results as `Buffer` at the API boundary.
- **No more `Buffer` or `crypto` shims for browsers**. The browser bundle
contains zero Node-builtin imports — verified in CI by a `grep` over
`dist/index.browser.js`. Bundlers (Vite, Webpack 5, Rollup, esbuild, Parcel)
resolve the browser entry via package.json conditional exports.
- **`setOptions({environment})` is a deprecated no-op**. Build-time platform
conditions decide the runtime now. The option still forces the pure-JS
engine path when set to `'browser'`, preserving the v1 semantic that the
61-case test suite relies on. A one-time `console.warn` is emitted on use.
- **MD4 is Node-only and provider-gated**. OpenSSL 3 (Node 17+) doesn't load
the legacy provider by default, so `crypto.createHash('md4')` throws. v2
probes at module load and reports md4 as unsupported when the provider is
absent. The browser bundle never supports MD4.
- **`asn1` npm dependency removed**. PKCS#1, PKCS#8, and OpenSSH formats now
use a small in-tree DER reader/writer (~150 lines, under
[`src/asn1/`](src/asn1)). Byte-identical to v1 output for every fixture key.
- **Native PKCS#1 v1.5 `privateDecrypt` is routed through the JS engine on
modern Node**. Node has security-deprecated raw PKCS#1 v1.5 decryption (CVE
response); v2 transparently falls back to the pure-JS implementation so the
call still succeeds. The byte-for-byte plaintext is identical.
- **Default signing scheme switched from `pkcs1` (PKCS#1 v1.5) to `pss`
(RSASSA-PSS).** PSS is the modern best-practice signing scheme — it has
a tighter security reduction and is preferred by RFC 8017 / NIST for new
code. Existing signatures produced under the v1 default remain verifiable
by passing `signingScheme: 'pkcs1'` explicitly.

```ts
// To keep v1's PKCS#1 v1.5 default explicit:
const key = new NodeRSA(null, { signingScheme: 'pkcs1' });
const sig = key.sign('msg');
```

The bare-hash shorthand `setOptions({ signingScheme: 'sha256' })`
also resolves to `pss-sha256` (was `pkcs1-sha256` in v1). Set
`signingScheme: 'pkcs1-sha256'` explicitly to keep v1 behaviour.
- **Custom MGF for PSS now throws on the node bundle.** `node:crypto`
only supports MGF1 with hash equal to the signing hash. If you need a
non-default MGF, force the pure-JS path with
`setOptions({ environment: 'browser' })`.
- **Hash algorithms unsupported by the local OpenSSL build now throw at
sign/verify time on the node bundle.** Functionally equivalent to v1
(the JS scheme delegated to `nodeBackend.digest` which also threw) —
only the error wording and call-site changed.

### Security fixes (no API change)

- **OAEP decode is now constant-time** (RFC 8017 §7.1.2). Closes a Manger-
style padding-oracle (~10⁵ queries to recover plaintext given a timing
oracle). Includes a missing `Y == 0x00` check on the leading byte and a
post-decode message-length bound.
- **PKCS#1 v1.5 decode is now constant-time** internally (RFC 8017 §7.2.2,
Bleichenbacher / ROBOT). Closes the internal differential timing oracle;
the valid/invalid binary oracle inherent to PKCS#1 v1.5 remains — use
OAEP for untrusted ciphertexts (the README has a security note).
- **PSS verify is now constant-time** (RFC 8017 §9.1.2 step 11).
- **Private-key operations are blinded** (Kocher 1996 / Brumley-Boneh
2003 defence). Fresh `r ← random coprime to n` masks the variable-time
`modPow` from any timing leak on `d`, `dmp1`, or `dmq1`.
- **Miller-Rabin uses CSPRNG witnesses** in [2, n-2] (was `Math.random()`
over a 168-element fixed table — adversarial-pseudoprime risk) and now
honours the caller's full round count (was silently halved). Keygen
picks adaptive rounds by bit length per FIPS 186-4 Table C.3.
- **Public exponent validated on import**: `1 < e` with e odd
(RFC 8017 §3.1).
- **RSA primitive bounds-check**: `0 ≤ x < n` enforced in both
`$doPrivate` and `$doPublic` (RFC 8017 §3.2). `verify()` translates
the resulting out-of-range error to "invalid signature" per §8.x.
- **Imported private keys are CRT-consistency-checked**: `n = p·q`,
`dp ≡ d mod (p−1)`, `dq ≡ d mod (q−1)`, `q·coeff ≡ 1 mod p`,
`e·dp ≡ 1 mod (p−1)`, `e·dq ≡ 1 mod (q−1)`. Closes a Boneh-DeMillo-
Lipton fault-injection vector on crafted PEM/PKCS#8/OpenSSH files.
- **`generate(B)` refuses `B < 512`** (cryptographically broken) and
emits a one-shot `console.warn` for `B < 2048` (below NIST SP 800-56B
§6.1.6.2 minimum).
- **Fermat-distance defence**: keygen rejects p, q pairs with
`|p − q| < 2^(B/2 − 100)` (FIPS 186-4 §B.3.6).
- **CRT recombination is branch-free**: removed the data-dependent
`while (xp < xq) xp += p` loop.
- **OpenSSH parser hardening**: `SshReader.readString` bounds-checks
before `subarray`; the two private-section checkints (`checkint1`,
`checkint2`) are now validated for equality.
- **PKCS#8 parser hardening**: outer version validated against
{0, 1} (RFC 5958 §2); inner PKCS#1 version restricted to two-prime
(RFC 8017 §A.1.2); algorithm OID whitelist with clear diagnostics for
PSS-only (1.2.840.113549.1.1.10) and OAEP-only (.1.1.7) misuse.

### Added

- TypeScript types for every public surface (`NodeRSAOptions`,
`EncryptionSchemeOptions`, `SigningSchemeOptions`, `HashAlg`, format
string union types).
- `@noble/hashes` runtime dependency for synchronous SHA/MD/RIPEMD digests
in the browser bundle. ~6 KB gzipped, audited, zero-dep.
- Bundle size budget (CI-enforced):
- `dist/index.browser.js`: <100 KB raw / <30 KB gzipped (currently 90/21)
- `dist/index.node.{js,cjs}`: <120 KB raw / <35 KB gzipped (currently 94/22)

### Internal

- Modern tooling: `tsup` for build (esbuild), `vitest` for tests (with a
workspace running every spec in two projects — `node` and
`browser-emulated`), `biome` for lint+format, strict TypeScript with
`noUncheckedIndexedAccess` / `exactOptionalPropertyTypes` /
`noImplicitOverride` etc.
- 1006 test cases across 27 files. The v1 mocha suite of 61 `it()` blocks is
ported verbatim and runs in both vitest projects.
- The legacy v1 source is preserved in `src.legacy/` during the port and
deleted on the v2.0.0 release commit.

## 1.1.1 and earlier

See git history.
Loading