Skip to content

chacha20-poly1305@openssh.com cannot connect to OpenSSH; AEAD-only servers fall back to GCM at 1.9 MiB/s #17

Description

@lollipopkit

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.

  1. 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.

  2. 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.

  3. 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).

  4. 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.

  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions