Workers / workerd: ssh2 fails to load because poly1305.js WASM is instantiated at module init
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 5.8k
- Forks
- 734
- PR merge metrics
- No merged PRs in 30d
Description
Environment
ssh2: 1.17.0- Runtime: Cloudflare Workers (
workerd) withcompatibility_flags = ["nodejs_compat"],compatibility_date = 2025-04-01 - Bundler:
@opennextjs/cloudflare1.19.2 → Next.js 16 on Workers - Node runtime used for local dev: 22.16
Symptom
When require('ssh2') is evaluated inside a Worker, workerd refuses to compile the WASM module that ships with lib/protocol/crypto/poly1305.js:
failed to asynchronously prepare wasm: CompileError: WebAssembly.instantiate(): Wasm code generation disallowed by embedder
RuntimeError: abort(CompileError: WebAssembly.instantiate(): Wasm code generation disallowed by embedder).
at module `ssh2/lib/protocol/crypto.js` module evaluation
This happens because lib/protocol/crypto.js unconditionally calls
POLY1305_WASM_MODULE = await require('./crypto/poly1305.js')();
as part of the top-level init Promise. The Cloudflare workerd embedder disallows dynamic WebAssembly.instantiate outside of bundled wasm bindings, so ssh2 fails to load even when the user never plans to negotiate chacha20-poly1305@openssh.com.
Why not just call ssh2.init() on demand?
The init IIFE is evaluated at module-load time, so the failure is unavoidable for any consumer that imports ssh2 under workerd. Wrapping our own require('ssh2') in try/catch does not help because the WebAssembly.instantiate call is what throws, and it happens inside the module initialiser.
Impact
All ssh2 consumers on Cloudflare Workers (or any runtime that restricts dynamic WASM, e.g. some Electron sandboxing profiles, Deno with --no-dynamic-wasm) can't use ssh2 at all, even for AES-GCM / AES-CTR sessions that would never touch the poly1305 code path.
Removing the offline patch is the only way for us to run ssh2 against real SSH servers from Workers. With the patch below all other algorithms (aes*-gcm, aes*-ctr, aes*-cbc, hmac-sha2-*-etm@openssh.com, hmac-sha2-*, etc.) keep working, because the poly1305 WASM module is only required by chacha20-poly1305@openssh.com.
Suggested fix
Make poly1305 loading lazy (and tolerant of failure):
- Don't evaluate
require('./crypto/poly1305.js')at module load. Defer it until the first time a cipher that actually needs poly1305 is constructed. - If WASM loading fails, the module should either (a) surface a clear error only when
chacha20-poly1305@openssh.comis selected, or (b) remove that cipher fromCIPHER_INFO/DEFAULT_CIPHERat runtime. - Guard
canUseCipher/canUseMACso that a cipher/MAC that isn't inCIPHER_INFO/MAC_INFOis safely filtered instead of crashing withCannot read properties of undefined (reading 'sslName').
Happy to open a PR — this is the downstream patch we are running today to unblock our Workers deployment:
diff --git a/lib/protocol/constants.js b/lib/protocol/constants.js
@@ const canUseCipher = (() => {
- return (name) => ciphers.includes(CIPHER_INFO[name].sslName);
+ return (name) => {
+ const info = CIPHER_INFO[name];
+ return !!info && ciphers.includes(info.sslName);
+ };
})();
@@ const canUseMAC = (() => {
- return (name) => hashes.includes(MAC_INFO[name].sslName);
+ return (name) => {
+ const info = MAC_INFO[name];
+ return !!info && hashes.includes(info.sslName);
+ };
})();
diff --git a/lib/protocol/crypto.js b/lib/protocol/crypto.js
@@ module.exports = {
init: (() => {
- return new Promise(async (resolve, reject) => {
- try {
- POLY1305_WASM_MODULE = await require('./crypto/poly1305.js')();
- POLY1305_RESULT_MALLOC = POLY1305_WASM_MODULE._malloc(16);
- poly1305_auth = POLY1305_WASM_MODULE.cwrap(
- 'poly1305_auth',
- null,
- ['number', 'array', 'number', 'array', 'number', 'array']
- );
- } catch (ex) {
- return reject(ex);
- }
- resolve();
- });
+ // Defer poly1305 WASM loading until something actually needs it, so
+ // ssh2 can be imported on runtimes that restrict dynamic WASM
+ // (e.g. Cloudflare Workers / workerd).
+ return Promise.resolve();
})(),
Bonus: workerd AES-GCM incompat
Unrelated to this WASM issue, workerd's nodejs_compat currently rejects the createDecipheriv('aes-*-gcm') → update → setAuthTag → final sequence that ssh2 uses, throwing Error: No auth tag provided at final(). As a workaround we also force algorithms: { cipher: ['aes128-ctr','aes192-ctr','aes256-ctr'], hmac: ['hmac-sha2-256-etm@openssh.com', ...] }. Filing this separately with Cloudflare — just noting it here because anyone hitting the WASM error is likely to hit the GCM one next.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in lib/protocol/crypto.js, lib/protocol/constants.js, and lib/protocol/crypto/poly1305.js; trace the module-load init path and the cipher capability checks. Verify behavior when poly1305 WASM cannot load: ssh2 should still import for other algorithms, while chacha20-poly1305@openssh.com should be filtered or report a clear error instead of crashing.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, wasm
- Domain
- backend, cryptography
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100