Environment
ssh2: 1.17.0
- Runtime: Cloudflare Workers (
workerd) with compatibility_flags = ["nodejs_compat"], compatibility_date = 2025-04-01
- Bundler:
@opennextjs/cloudflare 1.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.com is selected, or (b) remove that cipher from CIPHER_INFO / DEFAULT_CIPHER at runtime.
- Guard
canUseCipher / canUseMAC so that a cipher/MAC that isn't in CIPHER_INFO / MAC_INFO is safely filtered instead of crashing with Cannot 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.
Environment
ssh2: 1.17.0workerd) withcompatibility_flags = ["nodejs_compat"],compatibility_date = 2025-04-01@opennextjs/cloudflare1.19.2 → Next.js 16 on WorkersSymptom
When
require('ssh2')is evaluated inside a Worker,workerdrefuses to compile the WASM module that ships withlib/protocol/crypto/poly1305.js:This happens because
lib/protocol/crypto.jsunconditionally callsas part of the top-level
initPromise. The Cloudflareworkerdembedder disallows dynamicWebAssembly.instantiateoutside of bundled wasm bindings, sossh2fails to load even when the user never plans to negotiatechacha20-poly1305@openssh.com.Why not just call
ssh2.init()on demand?The
initIIFE is evaluated at module-load time, so the failure is unavoidable for any consumer that importsssh2underworkerd. Wrapping our ownrequire('ssh2')in try/catch does not help because theWebAssembly.instantiatecall is what throws, and it happens inside the module initialiser.Impact
All
ssh2consumers on Cloudflare Workers (or any runtime that restricts dynamic WASM, e.g. some Electron sandboxing profiles, Deno with--no-dynamic-wasm) can't usessh2at 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
ssh2against 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 bychacha20-poly1305@openssh.com.Suggested fix
Make poly1305 loading lazy (and tolerant of failure):
require('./crypto/poly1305.js')at module load. Defer it until the first time a cipher that actually needs poly1305 is constructed.chacha20-poly1305@openssh.comis selected, or (b) remove that cipher fromCIPHER_INFO/DEFAULT_CIPHERat runtime.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:
Bonus:
workerdAES-GCM incompatUnrelated to this WASM issue,
workerd'snodejs_compatcurrently rejects thecreateDecipheriv('aes-*-gcm') → update → setAuthTag → finalsequence thatssh2uses, throwingError: No auth tag providedatfinal(). As a workaround we also forcealgorithms: { 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.