Skip to content

Feat/webcrypto migration - #46

Merged
leikind merged 16 commits into
masterfrom
feat/webcrypto-migration
Aug 25, 2026
Merged

leikind merged 16 commits into
masterfrom
feat/webcrypto-migration

Conversation

@leikind

@leikind leikind commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator
  • BREAKING: Replaced node-forge with native WebCrypto (globalThis.crypto.subtle) for every cryptographic primitive — AES-256-GCM, PBKDF2-HMAC-SHA256, RSA-OAEP, RSA-PKCS1v15/SHA-256 signing, HMAC-SHA256, and random byte generation. node-forge and @types/node-forge are no longer dependencies. Verified byte-for-byte compatible with the Ruby and Elixir cryppo ports against the full compat.json fixture suite.
  • As a consequence of using crypto.subtle directly, this now requires browser support for the Web Crypto API. In practice this is essentially every browser in current use — it only excludes browsers a decade or more old (and Internet Explorer, which was never fully supported). See the README's Requirements section for details.
    • Chrome/Edge ≥37 (Aug 2014)
    • Firefox ≥34 (Dec 2014)
    • Safari/iOS Safari ≥11 (Sept 2017)
    • Android ≥5.0 (Nov 2014)
  • On Node, crypto.subtle has been a stable, unflagged global since Node 19 (Oct 18, 2022) — comfortably below this package's existing >=22.0.0 requirement, so nothing changes there.
  • BREAKING: The following functions are now async (return a Promise) since WebCrypto's API is Promise-based, where they were previously synchronous:
    • signWithPrivateKey
    • verifyWithPublicKey
    • keyLengthFromPublicKeyPem
    • keyLengthFromPrivateKeyPem
    • hmacSha256Digest
    • encryptWithKeyUsingArtefacts
    • decryptWithKeyUsingArtefacts
  • BREAKING: Removed encryptPrivateKeyWithPassword and the password parameter from decryptWithPrivateKey/decryptSerializedWithPrivateKey. This password-protected an RSA private key PEM as a PKCS#8 EncryptedPrivateKeyInfo/PBES2 structure, which WebCrypto has no API to build or parse. Neither the Ruby (cryppo) nor Elixir (cryppo_ex) port has an equivalent feature, so this was cryppo-js-only with no cross-port interop to preserve.
  • Added a Claude Code skill (.claude/skills/cryppo-migrate-v3-to-v4/SKILL.md, shipped in the published package) that automates the mechanical parts of upgrading a consumer codebase from v3 to v4 — adding await at the newly-async call sites and flagging removed-password-feature usages for manual review. See the README's "Upgrading from v3 to v4" section.
  • Production bundle size (this package's dist/esm bundled and minified, as a consumer's bundler would) is ~57% smaller (~54% smaller gzipped).
  • Every cryptographic primitive is now substantially faster than the previous node-forge implementation (typically 5-150x, depending on the primitive and payload size) — see docs/webcrypto-vs-node-forge-benchmarks.md for detailed numbers.

Version 3 Versus Version 4 Benchmark Results

AES-256-GCM (symmetric encrypt/decrypt)

Size Encrypt (WebCrypto) Encrypt (node-forge) Encrypt speedup Decrypt (WebCrypto) Decrypt (node-forge) Decrypt speedup
1 KB 0.055 ms 0.234 ms 4.3x faster 0.044 ms 0.087 ms 2.0x faster
100 KB 0.712 ms 4.978 ms 7.0x faster 0.515 ms 4.368 ms 8.5x faster
1 MB 6.989 ms 72.358 ms 10.4x faster 4.932 ms 47.956 ms 9.7x faster
10 MB 74.123 ms 1445.230 ms 19.5x faster 45.368 ms 823.148 ms 18.1x faster

WebCrypto wins at every size, by roughly 2-20x, growing with payload size.

RSA-OAEP (asymmetric encrypt/decrypt, key generation)

Operation WebCrypto node-forge Speedup
Keygen 2048-bit 49.796 ms 39.280 ms 1.3x slower
Encrypt 2048-bit 0.122 ms 0.729 ms 6.0x faster
Decrypt 2048-bit 0.869 ms 17.933 ms 20.6x faster
Keygen 4096-bit 302.109 ms 406.765 ms 1.3x faster
Encrypt 4096-bit 0.165 ms 2.347 ms 14.2x faster
Decrypt 4096-bit 4.324 ms 138.027 ms 31.9x faster

Key generation is roughly a wash (a small 5-run sample, high variance either way); encrypt/decrypt are dominated by WebCrypto, especially decrypt (private-key operation) at larger key sizes.

PBKDF2-HMAC-SHA256 (key derivation)

Iterations WebCrypto node-forge Speedup
20,000 2.755 ms 375.059 ms 136.1x faster
100,000 12.441 ms 1865.054 ms 149.9x faster

HMAC-SHA256 (keyed digest)

Size WebCrypto node-forge Speedup
1 KB 0.045 ms 0.033 ms 1.4x slower
1 MB 2.023 ms 18.197 ms 9.0x faster
10 MB 19.509 ms 179.585 ms 9.2x faster

At 1 KB, per-call fixed overhead (e.g. WebCrypto's importKey call on every invocation) dominates and node-forge is marginally faster; WebCrypto pulls ahead sharply as payload size grows.

RSA signing (RSASSA-PKCS1-v1_5 / SHA-256)

Key size / payload Sign (WebCrypto) Sign (node-forge) Sign speedup Verify (WebCrypto) Verify (node-forge) Verify speedup
2048-bit, 1 KB 0.881 ms 18.230 ms 20.7x faster 0.084 ms 0.739 ms 8.8x faster
2048-bit, 1 MB 8.525 ms 96.962 ms 11.4x faster 0.620 ms 37.392 ms 60.3x faster
4096-bit, 1 KB 4.519 ms 141.607 ms 31.3x faster 0.134 ms 2.196 ms 16.4x faster

@derek-meeco

Copy link
Copy Markdown

The depreactions seems like will not impact Esafe usage, as encryptPrivateKeyWithPassword is not used, and the usage of decryptSerializedWithPrivateKey does not require a password.

@derek-meeco derek-meeco left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be tested in meeco-esafe app, and confirmed will run inside the app wapper.

@derek-meeco

Copy link
Copy Markdown

@leikind was this tested against the compatibility suite?
https://bitbucket.org/meeco/cryppo-ports-compatibility/

@leikind

leikind commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

@leikind was this tested against the compatibility suite? https://bitbucket.org/meeco/cryppo-ports-compatibility/

@derek-meeco
It's planned. It requires minor changes in the compatibility suite.

leikind added 11 commits August 21, 2026 13:19
… RSA DER helpers

Step 1 of the WebCrypto migration. Reimplements util.ts's base64/UTF-8/UTF-16/binary-string
helpers and random-byte generation without node-forge, preserving exact behavior. Adds
src/der.ts (PEM<->DER, PKCS#1<->PKCS#8 conversion) needed by later RSA steps, since
WebCrypto only imports/exports RSA private keys as PKCS#8. keyLengthFromPublicKeyPem/
PrivateKeyPem stay forge-based for now, migrating with RSA in later steps.

Adds test/der.spec.ts covering PEM<->DER and PKCS#1<->PKCS#8 round-trips, including the
long-form DER length encoding path exercised by real RSA key sizes.
Step 2 of the WebCrypto migration. Replaces forge's AES-GCM cipher/decipher in
encryption.ts/decryption.ts with crypto.subtle.encrypt/decrypt. Handles the key structural
difference from forge: WebCrypto returns ciphertext with the 16-byte tag appended, so it's
sliced off on encrypt and re-concatenated on decrypt to preserve the existing artifact
shape/serialization. Tag-mismatch failures (previously forge's `pass === false`) now surface
as a thrown OperationError, caught and rethrown as the existing 'Decryption failed' error.
encryptWithKeyUsingArtefacts/decryptWithKeyUsingArtefacts become async (WebCrypto is
Promise-based); existing tests already called them with `await`, so no test changes needed.

Also fixes an oversight from Step 1: EncryptionKey.generateRandom (encryption-key.ts) was
still using forge.random, now uses the shared generateRandomBytesString helper.

Adds a toBufferSource helper in util.ts to bridge a TS 5.7+/@types/node typing mismatch
between Uint8Array<ArrayBufferLike> and DOM lib's BufferSource, needed at every
crypto.subtle.* call site — will be reused by the PBKDF2/HMAC/RSA steps that follow.

Verified against all 227 Ruby-generated compat.json fixtures: AES-256-GCM ciphertext and
auth tags remain byte-for-byte identical.
Step 3 of the WebCrypto migration. Replaces forge.pkcs5.pbkdf2 in DerivedKeyOptions.deriveKey
with crypto.subtle.importKey('PBKDF2')/deriveBits, and forge.random.getBytesSync for salt
generation with the shared generateRandomBytesString helper. Hash name maps 'SHA256' ->
'SHA-256' (the only value used anywhere in the codebase/fixtures) via a simple regex rather
than a lookup table.

Verified against all Ruby-generated compat.json fixtures: derived keys remain byte-for-byte
identical. As a side effect, the PBKDF2-heavy parts of the test suite run roughly 4x faster
(WebCrypto's native PBKDF2 vs. forge's pure-JS implementation).
Step 4 of the WebCrypto migration. Replaces forge.hmac in hmacSha256Digest with
crypto.subtle.importKey('HMAC')/sign, hex-encoding the resulting ArrayBuffer manually
(WebCrypto has no built-in hex output like forge's digest().toHex()). Becomes async
(only test-file callers, no internal src/ usage) — updates the one test call site to await
it. Verified against the existing hard-coded test vector: output is byte-for-byte identical.
Step 5 of the WebCrypto migration. generateRSAKeyPair now uses crypto.subtle.generateKey,
converting the exported PKCS#8 private key to PKCS#1 (pkcs8ToPkcs1) to keep the public
"-----BEGIN RSA PRIVATE KEY-----" PEM format unchanged. encryptWithPublicKey and the
unencrypted-PEM path of decryptWithPrivateKey/decryptSerializedWithPrivateKey now use
crypto.subtle.importKey('spki'/'pkcs8')/encrypt/decrypt via the pemToDer/pkcs1ToPkcs8 helpers
from Step 1. RSA-OAEP hash stays SHA-1 (not a modern default, but required to match Ruby/
Elixir cryppo, which both use OpenSSL/Erlang's legacy SHA-1 OAEP default).

The password-protected private key path (password param on decrypt) stays forge-based for
now — dropped along with encryptPrivateKeyWithPassword in Step 7.

keyLengthFromPublicKeyPem (util.ts) becomes async, implemented via importKey +
key.algorithm.modulusLength instead of forge's undocumented pk.n.bitLength().
keyLengthFromPrivateKeyPem stays forge-based, migrating with RSA signing in Step 6.

Updates test/key-pairs/rsa.spec.ts: forge emits PEM with \r\n line endings, ours uses \n
(matching the Ruby-produced fixtures, which also use \n) — updates the hardcoded public key
PEM length (460 -> 451) and widens the private key length assertion from two exact forge-
specific values to a range, since DER integer-encoding variance differs slightly between
implementations.

Verified against all 227 Ruby-generated compat.json fixtures, including RSA-OAEP: ciphertext
remains byte-for-byte identical, and existing forge-based signing (Step 6 not yet done) still
successfully signs/verifies against our new WebCrypto-generated PKCS#1 keys, confirming
interop between the two. As a side effect, RSA operations run dramatically faster (WebCrypto's
native RSA vs. forge's pure-JS bignum implementation).
Step 6 of the WebCrypto migration. signWithPrivateKey and verifyWithPublicKey now use
crypto.subtle.sign/verify('RSASSA-PKCS1-v1_5', ...) via the pemToDer/pkcs1ToPkcs8 helpers
from Step 1, and become async (unavoidable — WebCrypto is Promise-based). Test/demo call
sites already awaited these calls in anticipation.

keyLengthFromPrivateKeyPem (util.ts) becomes async, mirroring Step 5's
keyLengthFromPublicKeyPem — implemented via importKey + key.algorithm.modulusLength.
util.ts is now fully forge-free.

Discovered and fixed a real interop gap not caught by the original migration plan: 31 of the
32 RSA signature fixtures in compat.json use PKCS#1 public key PEMs
("-----BEGIN RSA PUBLIC KEY-----", produced by Ruby/Elixir cryppo's public key export) rather
than SPKI ("-----BEGIN PUBLIC KEY-----") — only one fixture happened to be SPKI, which is why
this wasn't caught until running the full compatibility suite rather than just the isolated
round-trip unit tests. WebCrypto's importKey('spki', ...) can't parse PKCS#1 public keys
directly, same underlying issue as the private-key PKCS#1/PKCS#8 mismatch from Step 1 but on
the public side. Adds rsaPublicKeyToSpki and pemLabel to der.ts (wraps the PKCS#1
RSAPublicKey in an SPKI BIT STRING using the same fixed rsaEncryption AlgorithmIdentifier),
and verifyWithPublicKey now branches on the PEM label to convert only when needed.

Adds direct unit tests to der.spec.ts for pemLabel and rsaPublicKeyToSpki, using the actual
fixture that exposed the bug.

Verified against all 227 Ruby-generated compat.json fixtures, including all 32 RSA signature
fixtures (both SPKI and PKCS#1 public key formats): verification succeeds against every one.
… key feature

Step 7 (final) of the WebCrypto migration.

Removes encryptPrivateKeyWithPassword and the password param from decryptWithPrivateKey/
decryptSerializedWithPrivateKey (src/key-pairs/rsa.ts) — this feature password-protects an
RSA private key PEM as PKCS#8 EncryptedPrivateKeyInfo/PBES2, which WebCrypto's SubtleCrypto
has no API to build or parse. Confirmed via direct inspection of the sibling Ruby (cryppo) and
Elixir (cryppo_ex) ports that neither has any equivalent feature — no private-key password
protection at all — so this is cryppo-js-only with no cross-port interop constraint. Per
explicit user decision, the feature is dropped rather than kept via a residual forge
dependency or reimplemented with a custom format.

Removes node-forge and @types/node-forge from package.json/package-lock.json. Updates the two
remaining test files that imported forge directly (only for forge's util.encodeUtf8 and
util.createBuffer(...).data helpers, not for any crypto operation) to use this library's own
encodeUtf8/bytesToBinaryString instead. Updates README.md to drop the password-protection
example and simplify the asymmetric encryption walkthrough accordingly.

Every cryptographic primitive in cryppo-js (AES-256-GCM, PBKDF2-HMAC-SHA256, RSA-OAEP,
RSA-PKCS1v15/SHA-256 signing, HMAC-SHA256, random byte generation) now runs on native
WebCrypto (globalThis.crypto.subtle), working unpolyfilled in both Node (>=22) and the
browser. Verified: full test suite (285 tests, including all 227 Ruby/Elixir compat.json
fixtures) passes, both build targets (CJS/ESM) type-check, `npm run build` produces a working
dist/, and `node_modules/node-forge` no longer exists.
Bumps package.json/package-lock.json to 4.0.0 and adds the CHANGELOG.md entry documenting
the WebCrypto migration: node-forge removal, the functions that became async
(signWithPrivateKey, verifyWithPublicKey, keyLengthFromPublicKeyPem, keyLengthFromPrivateKeyPem,
hmacSha256Digest, encryptWithKeyUsingArtefacts, decryptWithKeyUsingArtefacts), the removed
password-protected private key feature, and measured before/after comparisons against the
last pre-migration commit:
- npm test: ~37s -> ~2.9s (~12.6x faster)
- production bundle (esbuild-bundled + minified dist/esm): 499,807 -> 215,707 bytes (~57%
  smaller); gzipped 139,927 -> 65,042 bytes (~54% smaller)
- dependencies: 4 -> 3 runtime, 10 -> 9 dev; 140 -> 138 total packages
Since crypto.subtle is a native platform API (unlike the pure-JS node-forge it replaced),
adds a Requirements section entry to README.md and two dedicated CHANGELOG notes (browser,
then Node) covering what's actually needed, each with release dates:
- Browsers: Chrome/Edge >=37 (Aug 2014), Firefox >=34 (Dec 2014), Safari/iOS Safari >=11
  (Sept 2017), Android >=5.0 (Nov 2014) — only excludes browsers a decade or more old, plus
  Internet Explorer (never fully supported).
- Node.js: crypto.subtle has been a stable, unflagged global since Node 19 (Oct 18, 2022) —
  well below this package's existing >=22.0.0 requirement (set previously, unrelated to
  WebCrypto), so nothing changes there.
Adds .claude/skills/cryppo-migrate-v3-to-v4/SKILL.md, which runs in a consumer repo to
automate the mechanical parts of the WebCrypto upgrade: await the 7 now-async call sites
(propagating async up via typecheck errors), flag removed password-feature usage for manual
review, bump the dependency, and verify. Ships automatically in the published package.
Documented in README's new "Upgrading from v3 to v4" section and a CHANGELOG bullet.
Node globals (Buffer, process, fs, __dirname) previously came in only
transitively via @types/node-forge's own `/// <reference types="node" />` -
TypeScript's automatic @types inclusion wasn't actually active for this
project. Removing node-forge removed that path, breaking the build.
Declare `types: ["node"]` explicitly so it doesn't depend on an
incidental transitive reference again.
@leikind
leikind force-pushed the feat/webcrypto-migration branch from ef2acf4 to b843249 Compare August 21, 2026 11:28
leikind and others added 5 commits August 21, 2026 17:59
binaryStringToBytes used Uint8Array.from with a per-character callback, and
bytesToBinaryString built the result via unchunked string concatenation in a
byte-by-byte loop. Both are much slower than a plain indexed loop /
chunked String.fromCharCode.apply respectively (~40x and ~10x on a 10MB
buffer). These helpers sit on the hot path for AES-GCM, HMAC-SHA256, and
RSA signing, so the fix cuts multi-MB HMAC time by >30x and AES-GCM
encrypt/decrypt time by 5-12x.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbSfAwDtW4ssSxz1Bj5MGL
@leikind
leikind merged commit 00ea9e5 into master Aug 25, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants