Skip to content

Fix native ChaCha nonce corruption during nested writes - #1523

Open
lzfxxx wants to merge 1 commit into
mscdex:masterfrom
lzfxxx:fix/chacha-native-reentrant-nonce
Open

lzfxxx wants to merge 1 commit into
mscdex:masterfrom
lzfxxx:fix/chacha-native-reentrant-nonce

Conversation

@lzfxxx

@lzfxxx lzfxxx commented Oct 13, 2026

Copy link
Copy Markdown

Fix native ChaCha20-Poly1305 nonce corruption during nested writes

Target repository: mscdex/ssh2. Draft for upstream review.
Baseline tested: ssh2 1.17.0, Node 22.20.0, macOS, optional native binding absent.
Updated 2026-10-12: master HEAD a3a39d7
still contains the pattern. GitHub connector reads verified the SHA and searched
open/closed issues and PRs. No equivalent fix was found within the documented
search coverage; see upstream-search.md for limits. Highest tag is v1.17.0;
GitHub Releases is empty, and npm registry publication state was not checked.

Summary

 ChaChaPolyCipherNative.encrypt(packet)
+  allocate a nonce buffer scoped to this invocation
   encrypt packet length
   invoke onWrite(length)
   encrypt payload using the same invocation's nonce

The native implementation uses a module-scoped mutable nonce buffer.
The synchronous onWrite callback can encrypt through another cipher, as
in nested SSH forwarding. That nested call changes the shared sequence
number before the original cipher encrypts its payload. The packet length
is valid, but the peer decrypts an invalid payload.

Use a zero-initialized 16-byte buffer scoped to encrypt(). This isolates
the nonce across different cipher instances in nested callbacks without
changing cipher negotiation or SSH access policy.

Evidence

  • Before: unmodified 1.17.0 passes standalone payload validation but fails
    nested service-request payload validation; regression exits 1.
  • After: the two-line patch passes standalone and nested payload validation;
    regression exits 0.
  • Fixtures use synthetic keys and sequence numbers. No network or credentials.
  • The original diagnostic fixture validates payload and packet length.
    The integrated test/test-protocol-chacha-reentrancy.js forces the native
    cipher and decipher, validates authenticated decryption and exact payload,
    and covers standalone/nested writes at sequence numbers 0, 3 and 0xFFFFFFFF.
    It fails on unpatched HEAD and passes all six cases with the patch.
  • The complete upstream node test/test.js entry point was rerun on
    2026-10-13 using Node 22.20.0 on macOS with normal approval for localhost
    and test sockets. It exited 1: 12 test files passed, including the native
    six-case regression and upstream crypto; three failed. The OpenSSH 10.3 RSA
    integration test reported client exit 255; server-hostkeys and SFTP tests
    reported localhost AggregateError ETIMEDOUT. These failures have not been
    diagnosed or confirmed against an unpatched baseline in this execution.
    One unsupported OpenSSL cipher case was skipped. This PR does not claim
    the full suite passes.
  • Runtime and test subtrees were verified by Git tree hash against HEAD before
    patching. Existing local dependencies were copied; no software was installed.
    Compiled optional binding and Windows/Linux were not tested. Lint was not run
    because ESLint and the upstream config package are unavailable locally.

Merge Danger

Door: two-way. Revert the invocation-local allocation to undo the change.

Blast Radius: cryptography.

One small buffer allocation per packet on this native path. Benchmarking and
upstream review are appropriate. Optional compiled binding and other ciphers
are unchanged. This does not claim to make same-instance recursive encryption
or all module-scoped scratch storage generally reentrant.

@lzfxxx
lzfxxx marked this pull request as ready for review October 13, 2026 01:00
tufantunc added a commit to tufantunc/ssh-mcp that referenced this pull request Nov 6, 2026
)

* fix: connect through a bastion on ssh2's JavaScript ciphers (#315)

A `via` profile never connected when both hops negotiated
chacha20-poly1305 and ssh2's optional native binding was absent. That
covers the published Docker image, which installs with
--ignore-scripts, and installs without build tools. The inner handshake
hung after its first encrypted packet.

The cause is ssh2's JavaScript chacha20 cipher (mscdex/ssh2#1523,
open). It keeps the nonce in one module-level buffer and writes the
encrypted packet length before encrypting the payload. When the socket
is another connection's channel, that write runs the outer cipher's
encrypt() in the middle of the inner one's, and the outer cipher leaves
its own sequence number in the shared buffer.

Connections over a bastion now offer chacha20-poly1305 last
(FORWARDED_ALGORITHMS). It is moved, not removed, so a chacha20-only
server is still reachable.

Reproduced on the repo's bastion with ssh2's binding hidden: chacha20 on
both hops hangs; any other pairing works; reordering ssh2's encrypt()
fixes it. The new integration test runs the hops in a child that cannot
load the binding. CI builds the binding, which is why proxy-jump.test.ts
never saw this. With the binding hidden, proxy-jump.test.ts fails 3 of 4
without this change ("SSH connection ended during handshake") and passes
with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: review round on the #315 workaround

- The control test accepted any stall as the ssh2 bug, and its message
  said the workaround could go for any result but `hang`. The child now
  reports the last stage it completed. The control requires a stall
  after the forward, and only `ok` carries the "can go" message.
- The unit test reached the private connect(). ensureConnected() is the
  public handshake-only call.
- CI never ran the real via stack on ssh2's JavaScript ciphers, because
  `npm ci` builds the native binding. A step now reruns proxy-jump with
  the binding hidden, after checking that the hook hides it. Measured:
  without the fix that run fails 3 of 4, and with it all pass.
- The changeset and the comment claimed a chacha20-only server stays
  reachable. On the JavaScript path it is still offered chacha20 and
  still hangs, as before. The corruption also needs chacha20 on any
  carrying hop, not only the adjacent one. Both now say this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: mark the inner handshake's completion in the via-chacha child

So hang@forwarded means a stall in the inner handshake and nothing after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant