Feat/webcrypto migration - #46
Merged
Merged
Conversation
leikind
requested review from
Matthew-Meeco,
derek-meeco,
javereec,
linasi,
ragnika and
vijayshiyani
August 19, 2026 21:58
|
The depreactions seems like will not impact Esafe usage, as |
derek-meeco
approved these changes
Aug 20, 2026
derek-meeco
left a comment
There was a problem hiding this comment.
should be tested in meeco-esafe app, and confirmed will run inside the app wapper.
|
@leikind was this tested against the compatibility suite? |
Collaborator
Author
@derek-meeco |
… 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
force-pushed
the
feat/webcrypto-migration
branch
from
August 21, 2026 11:28
ef2acf4 to
b843249
Compare
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
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.
node-forgewith 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-forgeand@types/node-forgeare no longer dependencies. Verified byte-for-byte compatible with the Ruby and Elixir cryppo ports against the fullcompat.jsonfixture suite.crypto.subtledirectly, 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.crypto.subtlehas been a stable, unflagged global since Node 19 (Oct 18, 2022) — comfortably below this package's existing>=22.0.0requirement, so nothing changes there.async(return aPromise) since WebCrypto's API is Promise-based, where they were previously synchronous:signWithPrivateKeyverifyWithPublicKeykeyLengthFromPublicKeyPemkeyLengthFromPrivateKeyPemhmacSha256DigestencryptWithKeyUsingArtefactsdecryptWithKeyUsingArtefactsencryptPrivateKeyWithPasswordand thepasswordparameter fromdecryptWithPrivateKey/decryptSerializedWithPrivateKey. This password-protected an RSA private key PEM as a PKCS#8EncryptedPrivateKeyInfo/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..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 — addingawaitat the newly-async call sites and flagging removed-password-feature usages for manual review. See the README's "Upgrading from v3 to v4" section.dist/esmbundled and minified, as a consumer's bundler would) is ~57% smaller (~54% smaller gzipped).docs/webcrypto-vs-node-forge-benchmarks.mdfor detailed numbers.Version 3 Versus Version 4 Benchmark Results
AES-256-GCM (symmetric encrypt/decrypt)
WebCrypto wins at every size, by roughly 2-20x, growing with payload size.
RSA-OAEP (asymmetric encrypt/decrypt, key generation)
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)
HMAC-SHA256 (keyed digest)
At 1 KB, per-call fixed overhead (e.g. WebCrypto's
importKeycall 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)