Research: paramiko ssh-rsa host-key compatibility — empirical findings (BK-198)¶
Date: 2026-05-12
Status: Complete; matrix extended to paramiko 5.0 and the BK-198 framing finalised in PR 613.
Scope: Verify the empirical premise of SFTPUtils.enable_ssh_rsa_compat() against a Dockerized legacy SFTP server across the full paramiko 2.x / 3.x / 4.x / 5.x range, before locking in the docstring / guide / hint-message wording.
Related: BK-198, trace, PR 613.
Context. The first cut of BK-198 was reasoned out from one data point (paramiko 4.0.0 import surface) and shipped a helper whose docstring claimed "Paramiko 3.x+ removed ssh-rsa from defaults at four levels." That claim turned out to be version-shifted by two majors: paramiko 2.x / 3.x / 4.x all ship ssh-rsa in defaults; paramiko 5.0 is where the removal happened. A first corrective sweep over-corrected (claimed the helper was a no-op on every shipping paramiko); this note records the final empirical state after a 5.0 probe re-confirmed that the helper is in fact required against modern paramiko + legacy SFTP servers.
1. Test rig¶
Two Docker images, both forcing the legacy-server shape:
| Image | Forcing | Port |
|---|---|---|
legacy-sftp (durable) |
Only ssh-rsa host-key + pubkey algorithms; modern KEX defaults; infra/legacy-sftp/Dockerfile |
2223 |
legacy-sftp:kex (research-only) |
Same as above PLUS KexAlgorithms diffie-hellman-group14-sha1,diffie-hellman-group1-sha1 |
2224 |
The second image is kept only for research-time reproductions of the user's originally-reported IncompatiblePeer: no acceptable kex algorithm shape; the durable e2e test (tests/e2e/test_sftp_legacy_recovery.py) drives the first.
Client matrix: five isolated uv venvs, all on Python 3.11/3.13, all sharing a recent cryptography, varying only paramiko:
paramiko==2.12.0(last 2.x release)paramiko==3.0.0(claimed cut-off in the original PR docstring)paramiko==3.5.0(mid-3.x sanity check)paramiko==4.0.0(previous[sftp]floor)paramiko==5.0.0(first major to actually clearssh-rsa)
Probe runs four scenarios per venv:
| ID | Scenario | What it tests |
|---|---|---|
| S1 | Bare connect on paramiko defaults | Does the legacy server connect out of the box? |
| S2 | Connect after enable_ssh_rsa_compat() on defaults |
Is the helper a no-op when defaults already contain ssh-rsa? |
| S3 | Strip ssh-rsa from all four sites, then connect |
Reproduces the cleared-defaults state |
| S4 | Run helper after S3, then connect | Does the helper actually recover? |
2. Findings¶
2.1 Default _preferred_keys and RSAKey.HASHES across versions¶
| Site | 2.12.0 | 3.0.0 | 3.5.0 | 4.0.0 | 5.0.0 |
|---|---|---|---|---|---|
Transport._preferred_keys |
present | present | present | present | absent |
Transport._preferred_pubkeys |
present | present | present | present | absent |
Transport._key_info["ssh-rsa"] |
present | present | present | present | absent |
RSAKey.HASHES["ssh-rsa"] |
present | present | present | present | absent |
Paramiko 5.0 is the version where the removal lands. The 2.x → 4.x range still ships ssh-rsa at every site (deprecated, but present). The only other relevant inter-version difference is that paramiko 4.0 dropped ssh-dss.
Defaults on paramiko 5.0 (post-removal): ssh-ed25519, ecdsa-sha2-nistp256, ecdsa-sha2-nistp384, ecdsa-sha2-nistp521, rsa-sha2-512, rsa-sha2-256.
2.2 Connection outcomes per scenario¶
Outcomes split cleanly along the 4.x / 5.x divide:
| Scenario | paramiko 2.x–4.x | paramiko 5.x |
|---|---|---|
| S1 (bare) | ✅ connect + open SFTP subsystem | ❌ IncompatiblePeer: Incompatible ssh peer (no acceptable host key) |
| S2 (helper on defaults) | ✅ succeeds; helper guards short-circuit (no-op) | ✅ succeeds; helper re-adds ssh-rsa and connection works |
| S3 (cleared, then bare) | ❌ same IncompatiblePeer as 5.x S1 |
❌ identical (defaults are already cleared) |
| S4 (cleared, then helper, then connect) | ✅ helper re-adds ssh-rsa; connection succeeds |
✅ same |
The S1-5.x and S3-2.x–4.x failures share a byte-identical error string — same handler in Transport._parse_kex_init.
3. Conclusions¶
- Paramiko 5.0 cleared
ssh-rsafrom all four sites. This is the version this PR addresses; the 2.x → 4.x range is unaffected by the BK-198 failure mode. - The helper is required on paramiko ≥ 5.0 to connect to an
ssh-rsa-only server. Without it, bare connect fails immediately during KEX in_parse_kex_initbefore authentication is attempted. - The helper is a no-op on paramiko < 5.0. All four guard clauses short-circuit because
ssh-rsais already present. Calling it eagerly is harmless and forward-compatible. - The
IncompatiblePeerhint is correctly scoped.IncompatiblePeeralso wrapsno acceptable {kex algorithm, cipher, MAC}failures, which the helper does not address. Gating the hint on"host key" in str(exc)keeps it accurate.
4. Changes derived from this verification¶
Tracked across PR 613:
enable_ssh_rsa_compatdocstring: names paramiko 5.0 explicitly as the version that clearedssh-rsa; describes the helper as required-on-5.0+, no-op-below.docs-src/guides/backends/sftp.mdLegacy Servers section: version-split framing (< 5vs>= 5); new "Diagnose first" subsection introducesscan_host_algorithmsas the triage step; the "Alternative: pin" tradeoff namesparamiko<5(the<3alternative previously listed was version-shifted and also conflicted with the>=3.0floor)._map_exceptionIncompatiblePeer branch: host-key variant carries theenable_ssh_rsa_compathint (gated on"host key"substring); KEX / cipher / MAC variants carry a hint pointing atscan_host_algorithms+connect_kwargs={"disabled_algorithms": ...}(BK-200).SFTPUtils.scan_host_algorithms(host, port)(BK-200): new raw-socket SSH KEXINIT probe; companion toscan_host_keys. Returns the server's full algorithm advertisement (kex / host-key / cipher / MAC / compression name-lists) so users can identify which negotiation list the server narrowed before reaching for a remedy. E2E-tested against the legacy-sftp Docker fixture (assertsserver_host_key_algorithms == ["ssh-rsa"]).tests/e2e/test_sftp_legacy_recovery.py::test_S1_bare_connect_*parametrised onparamiko.__version__: expects success on< 5,IncompatiblePeeron>= 5.sdd/BACKLOG-DONE.mdBK-198 entry +sdd/traces/BK-198-ssh-rsa-compat.ymltrigger: corrected to the version-conditional framing.
5. Decision: no library-side paramiko<5 cap¶
PR 613 deliberately keeps the [sftp] extra at paramiko>=3.0 with no
upper bound, after weighing the alternatives.
| Option | Pro | Con |
|---|---|---|
Add <5 cap to [sftp] extra |
Fresh installs do not hit the legacy-server failure on day one | Locks every consumer out of paramiko 5+ (security fixes, protocol improvements, the eventual paramiko 6 floor) regardless of whether they ever touch a legacy server. Cap becomes load-bearing and decays. |
Opt-in BackendConfig flag (allow_legacy_ssh_rsa: bool) |
Expresses intent in config | Adds public API surface that only matters to a narrow audience; still process-global underneath (mutates paramiko class attrs); per-backend solution would need a Transport subclass (the T40 follow-up). |
| No cap; helper + diagnostic + scoped hint (chosen) | Library evolves with paramiko; consumers who never touch legacy servers pay nothing; scan_host_algorithms + enable_ssh_rsa_compat + the IncompatiblePeer hint make the failure self-explanatory when it does occur |
First-time hit on a legacy server is a runtime failure, not an install-time guard |
The chosen option keeps the cost on the population that hits the
failure (a runtime IncompatiblePeer with a hint pointing at the
diagnostic) rather than on the population that doesn't (every other
consumer of [sftp]). A consumer-side pin (paramiko>=3.0,<5 in
requirements.txt) remains available for environments where calling
the helper at startup is not an option — documented in the SFTP
backend guide's "Alternative: pin paramiko<5" subsection.
The decision is recorded here rather than as an ADR because there is
no architecture-level commitment beyond this single dependency line;
the relevant context is the empirical evidence in §§ 1–4 above. If
the policy changes (e.g. paramiko 6 forces a re-evaluation), update
this section together with the pyproject.toml line.
6. Open questions / follow-ups¶
- Dependency policy. The
[sftp]extra currently pinsparamiko>=3.0with no upper bound, so fresh installs resolve to paramiko 5.x and need the helper. Whether to add a<5ceiling, an opt-inBackendConfigflag, or rely on the documented helper + hint is a separate decision tracked in PR 613 review. - Per-backend opt-in. The helper mutates paramiko's class attributes process-wide. For multi-backend processes that talk to a mix of modern and legacy SFTP servers, a per-Transport-subclass opt-in would scope the SHA-1 acceptance to one backend. Tracked as a non-blocking follow-up in the BK-198 trace's
discovery_followups. - CI drift guard. The 4.x → 5.x ssh-rsa removal is the second transitive-bump incident the PR addresses (BUG-204 = the 2.x → 3.x
channel_timeout=API surface). A scheduled CI job that re-resolves[<extra>]againstpip install --upgrade --preand runs the legacy-sftp e2e against each delta would catch the next one before users do. Tracked as a non-blocking follow-up in the BK-198 trace'sdiscovery_followups.
Appendix: probe script¶
The probe lives at tmp/paramiko5-probe/probe.py (not committed — Docker rig is regenerated as needed; see tests/e2e/test_sftp_legacy_recovery.py for the durable integration test).
Probe sketch:
def snapshot() -> dict:
t = paramiko.Transport
return {
"paramiko": paramiko.__version__,
"_preferred_keys": list(t._preferred_keys),
# ... and three more sites
}
def clear_ssh_rsa() -> None:
t = paramiko.Transport
t._preferred_keys = tuple(k for k in t._preferred_keys if k != "ssh-rsa")
# ... and three more sites
def main() -> int:
print(snapshot())
print(try_connect("S1_bare"))
enable_ssh_rsa_compat()
print(try_connect("S2_helper_on_defaults"))
clear_ssh_rsa()
print(try_connect("S3_after_clear")) # fails: no acceptable host key
enable_ssh_rsa_compat()
print(try_connect("S4_recovery")) # succeeds
Per-venv run: tmp/paramiko-test/v<ver>/Scripts/python.exe tmp/paramiko-test/probe.py.