transports: assert an SSH algorithm floor on the SFTP hop (#178) - #317
transports: assert an SSH algorithm floor on the SFTP hop (#178)#317wshallwshall wants to merge 1 commit into
Conversation
… TLS one (#178) The SSH hop was the one transport hop with no cipher assertion. Measured 2026-08-10: harden_cipher_suites is called at 7 sites across 5 files (the API/UI listener, MLLP in both directions, both DICOM contexts, FTPS, and the SMTP alert sink), while disabled_algorithms appeared 0 times anywhere in the package. Driven against a live paramiko server, the SFTP client accepted hmac-md5, hmac-sha1 and 3des-cbc without complaint -- a partner, or an on-path attacker steering the negotiation, could pick any of them and nothing would say so. config/ssh_policy.py is the missing assertion, and it is the TLS floor re-expressed rather than a second opinion. The shipped TLS contexts resolve to 17 suites whose measured properties are Kx in {ECDH, DH, any}, Enc in {AES, AESGCM, CHACHA20} and Mac in {AEAD, SHA256, SHA384}; each cell maps to one SSH rule (forward-secret key exchange, no 64-bit-block or broken cipher, no MD5/SHA-1 MAC). Two of those three are properties OpenSSL's default cipher string gives the TLS side for free and paramiko's defaults do not, which is why the SSH side has to name them. Two operations, deliberately separated. BELOW-floor names are disabled -- SSH negotiates from the offer, so pruning the offer IS the enforcement, unlike TLS where the context already resolves forward-secret and harden_cipher_suites only has to check it. Everything surviving is asserted recognisable, so an unclassified algorithm raises at connect naming itself rather than being quietly dropped and narrowing reachability with no signal. Against paramiko 5.0.0 the floor prunes 5 of 24 offered names: 3des-cbc, hmac-md5, hmac-md5-96, hmac-sha1, hmac-sha1-96. Every algorithm a current OpenSSH negotiates by default survives, AES-CBC included -- dropping it would re-introduce on this hop exactly the interop regression harden_cipher_suites measured and declined to take on TLS. Fails closed without failing silently: a partner offering nothing above the floor is refused with a permanent error naming the category, the algorithms refused, why they are refused, and what to enable instead -- not a timeout and not a bare "Incompatible ssh server (no acceptable macs)", which reads like a partner defect. paramiko stays lazily imported behind the [sftp] extra: ssh_policy imports it never, the caller passes the offer in, and an install without paramiko behaves exactly as it did. No operator override, deliberately, so nothing is added to security_loosenings. Nothing plausible needs loosening from this floor, and a switch nobody asked for is a second posture by the back door. If a partner ever forces the question it belongs there as a REQUIRED parameter, the way the TLS deviations do. The operator-configurable cipher/KEX/MAC allow-list that is #178's other half stays DEMAND-GATED and unbuilt -- this module has no configuration surface at all. Verified against a real key exchange rather than by attribute, because a floor asserted only on a constructed object passes identically when disabled_algorithms never reaches the connect call. tests/test_ssh_algorithm_floor.py stands up a live paramiko SSH server on loopback: the three algorithms that connected before are each refused after, with the message asserted, and a modern partner plus a paramiko-defaults partner both still negotiate. The pure-policy half imports no SSH library and runs without the extra.
|
Closing this PR, not abandoning the work. The branch Why it cannot go green. It cites Why it should not merge yet. Adversarial verification found the kex screen tests Where the record now lives. #320 has landed and amends the item(s) in the ledger -- partial, not closed, with the residuals named. The ledger tells the truth about this work whether or not the code lands, which is what makes closing this PR safe. To resume: fix the finding on |
BACKLOG #178
The owner ruled #178 BUILD, scoped narrowly: the SSH/SFTP hop was the single transport hop without a
forward-secrecy floor assertion. The configurable allow-list stays deferred.
Handed over by the authoring session at a usage cutoff. Reported COMPLETE and verified there against a
live paramiko server.
NOT INDEPENDENTLY VERIFIED BY THE COORDINATOR -- pushed and routed to preserve the work off a local
ref, not merged. Run the standard battery before landing.
Leak-scanned before pushing: zero hits, negative control firing.
Deliberately NOT armed for auto-merge.