Skip to content

Feat: parse SSH_MSG_EXT_INFO server-sig-algs (RFC 8308) - #1062

Merged
hierynomus merged 3 commits into
masterfrom
feat/rfc-8308
Sep 16, 2026
Merged

hierynomus merged 3 commits into
masterfrom
feat/rfc-8308

Conversation

@hierynomus

Copy link
Copy Markdown
Owner

Summary

Addresses the gap identified in #1015: sshj advertises ext-info-c in its kex proposal (PR #622) purely to unlock rsa-sha2-* signatures on picky servers, but when the server replies with SSH_MSG_EXT_INFO, the message was logged and discarded entirely — none of its extensions were ever parsed or used.

This PR implements the one extension that's practically useful for a client: server-sig-algs.

  • TransportImpl.gotExtInfo() parses SSH_MSG_EXT_INFO (uint32 nr-extensions + nr-extensions * (string name, string value)), extracting server-sig-algs and storing it. Unknown extensions (delay-compression, no-flow-control, elevation, ...) are safely skipped — their value is always consumed so buffer position stays correct for subsequent pairs. Defaults to an empty list until (if ever) received.
  • Transport.getServerSignatureAlgorithms() exposes the parsed list to the userauth layer, following the same pattern as the existing getHostKeyAlgorithm()/getClientKeyAlgorithms().
  • KeyedAuthMethod.getPublicKeyAlgorithm() uses it to reorder (never filter/drop) the client's own configured candidate algorithms: matches move to the front in the client's own relative order, non-matches remain as fallback. This means:
    • Server absent/no extension → zero behavior change (today's trial-and-error order).
    • No overlap with client's configured algorithms → zero behavior change (true no-op, doesn't even reallocate).
    • Overlap → the first publickey attempt is now more likely to succeed, saving the round-trip(s) that today only get resolved via retrying on SSH_MSG_USERAUTH_FAILURE.

Filtering-to-intersection was considered and rejected: it could remove the client's only remaining fallback if the server's list is incomplete or references an algorithm name this client doesn't map identically, turning a working (if slower) auth into a hard failure. Reordering can only ever match or improve on today's behavior.

Out of scope (noted in code/README): delay-compression, no-flow-control, elevation, and any client-to-server EXT_INFO sending.

Also updates the README's feature list to document this (RFC 8308 support was previously only implied by a changelog line for #622, which is what prompted #1015's question in the first place).

Test plan

  • TransportImplExtInfoTest (new): parses server-sig-algs correctly, skips unknown extensions on either side without losing buffer position, defaults to empty before/without receipt, last-wins on repeated extension entries, throws TransportException on a truncated/malformed message.
  • KeyedAuthMethodTest (new): empty server list is a no-op, a matching subset is moved to front, no-overlap is a no-op, reordering preserves the client's relative preference among matches (not the server's list order), a single configured candidate is always a no-op, and the transport is only queried once per auth attempt (lazy caching preserved).
  • Verified both new unit test suites fail deterministically against the pre-fix code and pass against the fix (confirmed via temporarily stashing each change).
  • AuthPublickeyExtInfoTest (new): end-to-end RSA publickey auth against a real embedded Apache MINA SSHD server (which sends real server-sig-algs), asserting successful authentication — a regression guard that the new parse/reorder path doesn't break real auth.
  • Full unit suite passes locally (590 tests).
  • Docker isn't available in my sandbox, so I couldn't run ./gradlew integrationTest locally; CI's integration job (which has Docker) will cover that.

🤖 Generated with Claude Code

sshj already advertised ext-info-c to unlock rsa-sha2-* on picky
servers, but discarded the server's SSH_MSG_EXT_INFO reply entirely
instead of acting on it. Parse it and use the server-sig-algs
extension to reorder (never filter/drop) the client's candidate
pubkey signature algorithms, so the first publickey auth attempt is
more likely to match what the server accepts, cutting a round-trip
that today only gets resolved via trial-and-error retries. Addresses
the gap identified in #1015.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the documentation Documentation changes label Sep 16, 2026
The previous version of this test only proved auth still succeeds
end-to-end, which it would even without the fix (AcceptAllPublickeyAuthenticator
accepts any algorithm eventually). It gave no signal about whether MINA
actually sent server-sig-algs or whether sshj acted on it.

Now the embedded server's signature factories are restricted to exclude
rsa-sha2-512 (sshj's own default top preference for an RSA key), and a
Proxy-based spy on the real Transport records the algorithm name of
every publickey feeler request in order. Verified this fails
deterministically on the pre-fix code (first attempt is rsa-sha2-512,
rejected, then retries with rsa-sha2-256) and passes on the fix (first
attempt is rsa-sha2-256 directly) - closing the gap identified in review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@hierynomus

Copy link
Copy Markdown
Owner Author

Follow-up: the original integration test only proved auth still succeeds end-to-end, which it would have even without the fix — AcceptAllPublickeyAuthenticator accepts any algorithm eventually, so it gave no real signal about whether MINA actually sent server-sig-algs or whether sshj acted on it.

Strengthened it in b5aa3fb: the embedded server's signature factories are now restricted to exclude rsa-sha2-512 (sshj's own default top preference for an RSA key), and a Proxy-based spy on the real Transport records the algorithm name of every publickey "feeler" request in order.

Verified directly:

  • Pre-fix: first attempt is rsa-sha2-512 (rejected by the server), then retries with rsa-sha2-256 (accepted) — an extra round-trip, and the test fails.
  • With the fix: first attempt is rsa-sha2-256 directly — matches what the server's real server-sig-algs (independently asserted via Transport.getServerSignatureAlgorithms()) says it accepts, and the test passes.

This closes the gap and gives a decisive, non-tautological end-to-end proof of the feature.

Unrelated to the server-sig-algs feature in this PR (this file, and
the Decoder/Encoder/KeyExchanger loggers it watches, are untouched by
that change) - just a pre-existing race surfaced by CI: getLogs()
streamed Logback's ListAppender.list (a plain ArrayList) directly,
racing against the [2] parameterized case's hot-loop heartbeater
thread, which keeps writing/logging immediately after client.close()
returns. AppenderBase.doAppend() synchronizes concurrent writers on
the appender itself, but the read here never took that same lock.
Snapshot the list under it before streaming.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@hierynomus

Copy link
Copy Markdown
Owner Author

CI flake unrelated to this PR: `StrictKeyExchangeTest > strictKeyExchange(Config) > [2] ... FAILED` with a `ConcurrentModificationException` inside `getLogs()`.

Confirmed unrelated — this branch touches zero files under `src/itest/`, and doesn't touch the `Decoder`/`Encoder`/`KeyExchanger` loggers this test watches (the new EXT_INFO logging goes through `TransportImpl`'s own logger).

Root cause: `getLogs()` streamed Logback's `ListAppender.list` (a plain `ArrayList`) directly. Logback's `AppenderBase.doAppend()` is `synchronized` on the appender for concurrent writers, but this read never took that same lock — so it raced against the `[2]` case's hot-loop heartbeater thread (busy-loops with `sleep(0)`), which can still be writing/logging right after `client.close()` returns.

Fixed in f986c90 by snapshotting the list under the same lock before streaming. Since it's a pre-existing flake in shared test infra, not part of the feature, happy to split it into its own PR instead if preferred — let me know.

@hierynomus
hierynomus merged commit 243f339 into master Sep 16, 2026
7 checks passed
@hierynomus
hierynomus deleted the feat/rfc-8308 branch September 16, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Documentation changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant