Summary
chacha20-poly1305@openssh.com cannot complete a handshake against a real OpenSSH server. Forcing it produces:
SSHAuthAbortError(Connection closed before authentication. No auth method tried. No attempts made)
and sshd logs:
padding error: need 28 block 8 mod 4 [preauth]
ssh_dispatch_run_fatal: Connection from ...: message authentication code incorrect [preauth]
The default SSHAlgorithms.cipher puts CTR modes first, so this stays hidden on a typical server. It becomes the whole story on a server configured to accept only AEAD ciphers — the Mozilla "modern" and CIS hardening recommendations both do exactly that.
Impact
Measured against OpenSSH 9.2 on Debian 12, over a local network so the transfer is not network-bound, 64 MiB payload:
| Server cipher policy |
dartssh2 negotiated |
system ssh |
| Stock (CTR permitted) |
47.5 MiB/s |
71.5 MiB/s |
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com |
1.9 MiB/s |
48.9 MiB/s |
On the AEAD-only server, ChaCha20-Poly1305 fails to connect, so negotiation falls through to aes256-gcm@openssh.com. pointycastle's GHASH is the bottleneck there — an isolated microbenchmark over 32 KiB packets gives:
| Algorithm |
MiB/s |
aes256-ctr |
104.7 |
chacha20-poly1305 primitives |
84.5 |
aes256-gcm |
1.9 |
The end-to-end 1.9 MiB/s matches the isolated GCM figure exactly, so nothing else in the stack contributes.
Root causes
Five defects, each independently fatal.
-
splitOpenSSHChaChaKeys returns the halves swapped. PROTOCOL.chacha20poly1305: "The first 256 bits constitute K_2 and the second 256 bits become K_1", where K_1 encrypts the length field and K_2 the payload. The code assigns the first half to the length key.
-
Wrong ChaCha variant. _encryptChaChaOpenSSH and _consumeChaChaOpenSSHPacket build the keystream with ChaCha7539Engine — RFC 7539, 96-bit nonce and 32-bit counter. This cipher is specified over the original DJB ChaCha20 with a 64-bit nonce and 64-bit counter. The state layouts differ, so the keystreams cannot match. pointycastle already ships the correct primitive as ChaCha20Engine in stream/chacha20.dart.
-
Wrong nonce encoding. _composeChaChaNonce builds 12 bytes with the sequence number little-endian at offset 4. OpenSSH uses an 8-byte big-endian uint64 (POKE_U64(seqbuf, seqnr) in cipher-chachapoly.c).
-
Wrong Poly1305 construction. The MAC is computed with the RFC 8439 AEAD framing — 16-byte padding after each part plus trailing little-endian length blocks. OpenSSH authenticates the raw enc_len || enc_body concatenation with no padding and no length suffix.
-
Wrong packet padding alignment. The send path uses SSHPacket.pack, whose paddingLength folds the 4-byte length field into the alignment via headerLength. For this cipher the length field is encrypted separately under K_1 and must be excluded — which is what the AES-GCM path already does via _alignedPaddingLength(..., includePacketLength: false). This is the direct source of the padding error: need 28 block 8 mod 4 above. The receive path has the same omission: _verifyPacketPadding is called without includePacketLength: false, and with the cipher's nominal blockSize of 16 rather than the 8 OpenSSH aligns on.
Note on the existing test
test/src/utils/chacha_test.dart asserts the swapped key order from defect 1:
expect(lenKey, equals(Uint8List.fromList(List<int>.generate(32, (i) => i))));
expect(encKey, equals(Uint8List.fromList(List<int>.generate(32, (i) => i + 32))));
so the suite was locking the bug in place rather than catching it.
Reproduction
final client = SSHClient(
await SSHSocket.connect(host, 22),
username: user,
algorithms: SSHAlgorithms(cipher: [SSHCipherType.chacha20poly1305]),
identities: SSHKeyPair.fromPem(File(keyPath).readAsStringSync()),
onVerifyHostKey: (_, __) => true,
);
await client.authenticated; // throws
Follow-up worth considering
Nothing in the suite exercises this cipher against a real OpenSSH peer, which is why five separate protocol-level mistakes went unnoticed. An opt-in interop test — skipped unless a host is configured, in the style of a *_E2E_SSH_HOST environment variable — would close that gap for all AEAD ciphers, not just this one.
Separately, aes256-gcm@openssh.com at 1.9 MiB/s is worth its own issue: it is a correctness-independent performance problem in pointycastle's GHASH, and it will remain the fallback whenever a peer offers nothing better.
Summary
chacha20-poly1305@openssh.comcannot complete a handshake against a real OpenSSH server. Forcing it produces:and sshd logs:
The default
SSHAlgorithms.cipherputs CTR modes first, so this stays hidden on a typical server. It becomes the whole story on a server configured to accept only AEAD ciphers — the Mozilla "modern" and CIS hardening recommendations both do exactly that.Impact
Measured against OpenSSH 9.2 on Debian 12, over a local network so the transfer is not network-bound, 64 MiB payload:
sshCiphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.comOn the AEAD-only server, ChaCha20-Poly1305 fails to connect, so negotiation falls through to
aes256-gcm@openssh.com. pointycastle's GHASH is the bottleneck there — an isolated microbenchmark over 32 KiB packets gives:aes256-ctrchacha20-poly1305primitivesaes256-gcmThe end-to-end 1.9 MiB/s matches the isolated GCM figure exactly, so nothing else in the stack contributes.
Root causes
Five defects, each independently fatal.
splitOpenSSHChaChaKeysreturns the halves swapped.PROTOCOL.chacha20poly1305: "The first 256 bits constitute K_2 and the second 256 bits become K_1", where K_1 encrypts the length field and K_2 the payload. The code assigns the first half to the length key.Wrong ChaCha variant.
_encryptChaChaOpenSSHand_consumeChaChaOpenSSHPacketbuild the keystream withChaCha7539Engine— RFC 7539, 96-bit nonce and 32-bit counter. This cipher is specified over the original DJB ChaCha20 with a 64-bit nonce and 64-bit counter. The state layouts differ, so the keystreams cannot match. pointycastle already ships the correct primitive asChaCha20Engineinstream/chacha20.dart.Wrong nonce encoding.
_composeChaChaNoncebuilds 12 bytes with the sequence number little-endian at offset 4. OpenSSH uses an 8-byte big-endian uint64 (POKE_U64(seqbuf, seqnr)incipher-chachapoly.c).Wrong Poly1305 construction. The MAC is computed with the RFC 8439 AEAD framing — 16-byte padding after each part plus trailing little-endian length blocks. OpenSSH authenticates the raw
enc_len || enc_bodyconcatenation with no padding and no length suffix.Wrong packet padding alignment. The send path uses
SSHPacket.pack, whosepaddingLengthfolds the 4-byte length field into the alignment viaheaderLength. For this cipher the length field is encrypted separately under K_1 and must be excluded — which is what the AES-GCM path already does via_alignedPaddingLength(..., includePacketLength: false). This is the direct source of thepadding error: need 28 block 8 mod 4above. The receive path has the same omission:_verifyPacketPaddingis called withoutincludePacketLength: false, and with the cipher's nominalblockSizeof 16 rather than the 8 OpenSSH aligns on.Note on the existing test
test/src/utils/chacha_test.dartasserts the swapped key order from defect 1:so the suite was locking the bug in place rather than catching it.
Reproduction
Follow-up worth considering
Nothing in the suite exercises this cipher against a real OpenSSH peer, which is why five separate protocol-level mistakes went unnoticed. An opt-in interop test — skipped unless a host is configured, in the style of a
*_E2E_SSH_HOSTenvironment variable — would close that gap for all AEAD ciphers, not just this one.Separately,
aes256-gcm@openssh.comat 1.9 MiB/s is worth its own issue: it is a correctness-independent performance problem in pointycastle's GHASH, and it will remain the fallback whenever a peer offers nothing better.