Skip to content

Workers / workerd: ssh2 fails to load because poly1305.js WASM is instantiated at module init #1494

@mushan0x0

Description

@mushan0x0

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):

  1. Don't evaluate require('./crypto/poly1305.js') at module load. Defer it until the first time a cipher that actually needs poly1305 is constructed.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions