diff --git a/Cargo.lock b/Cargo.lock index aa2398d6..7ab9369c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,74 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array 0.14.7", -] - [[package]] name = "aead" version = "0.6.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b657e772794c6b04730ea897b66a058ccd866c16d1967da05eeeecec39043fe" dependencies = [ - "crypto-common 0.2.1", + "crypto-common 0.2.2", "inout 0.2.2", ] -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", -] - [[package]] name = "aes" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" dependencies = [ - "cipher 0.5.1", + "cipher", "cpubits", "cpufeatures 0.3.0", + "zeroize", ] [[package]] name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead 0.5.2", - "aes 0.8.4", - "cipher 0.4.4", - "ctr 0.9.2", - "ghash 0.5.1", - "subtle", -] - -[[package]] -name = "aes-gcm" -version = "0.11.0-rc.3" +version = "0.11.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22c0c90bbe8d4f77c3ca9ddabe41a1f8382d6fc1f7cea89459d0f320371f972" +checksum = "da8c919c118108f144adecad74b425b804ad075580d605d9b33c2d6d1c62a2f8" dependencies = [ - "aead 0.6.0-rc.10", - "aes 0.9.0", - "cipher 0.5.1", - "ctr 0.10.0", - "ghash 0.6.0", + "aead", + "aes", + "cipher", + "ctr", + "ghash", "subtle", + "zeroize", ] [[package]] @@ -180,9 +147,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", - "blake2", + "blake2 0.10.6", "cpufeatures 0.2.17", - "password-hash", + "password-hash 0.5.0", +] + +[[package]] +name = "argon2" +version = "0.6.0-rc.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5" +dependencies = [ + "base64ct", + "blake2 0.11.0-rc.6", + "cpufeatures 0.3.0", + "password-hash 0.6.1", ] [[package]] @@ -235,9 +214,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" @@ -262,12 +241,6 @@ dependencies = [ "fs_extra", ] -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - [[package]] name = "base16ct" version = "1.0.0" @@ -293,7 +266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905" dependencies = [ "base64", - "blowfish 0.10.0", + "blowfish", "getrandom 0.4.2", "subtle", "zeroize", @@ -301,13 +274,13 @@ dependencies = [ [[package]] name = "bcrypt-pbkdf" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" dependencies = [ - "blowfish 0.9.1", - "pbkdf2 0.12.2", - "sha2 0.10.9", + "blowfish", + "pbkdf2", + "sha2 0.11.0", ] [[package]] @@ -349,6 +322,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "blake2" +version = "0.11.0-rc.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -365,6 +347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -385,16 +368,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "blowfish" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" -dependencies = [ - "byteorder", - "cipher 0.4.4", -] - [[package]] name = "blowfish" version = "0.10.0" @@ -402,7 +375,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" dependencies = [ "byteorder", - "cipher 0.5.1", + "cipher", ] [[package]] @@ -410,7 +383,7 @@ name = "bssh" version = "2.2.2" dependencies = [ "anyhow", - "argon2", + "argon2 0.5.3", "async-compression", "async-trait", "bcrypt", @@ -471,76 +444,84 @@ dependencies = [ [[package]] name = "bssh-russh" -version = "0.60.3" +version = "0.61.1" dependencies = [ - "aes 0.8.4", + "aes", "async-trait", "aws-lc-rs", "bitflags 2.11.1", - "block-padding 0.3.3", + "block-padding 0.4.2", "byteorder", "bytes", - "cbc 0.1.2", - "cipher 0.5.1", - "crypto-bigint 0.7.3", - "ctr 0.9.2", + "cbc", + "cipher", + "crypto-bigint", + "ctr", "curve25519-dalek", "data-encoding", "delegate", - "der 0.8.0", + "der", "des", - "digest 0.10.7", - "ecdsa 0.17.0-rc.17", + "digest 0.11.3", + "ecdsa", "ed25519-dalek", - "elliptic-curve 0.14.0-rc.31", + "elliptic-curve", "enum_dispatch", "env_logger", "flate2", "futures", "generic-array 1.4.1", "getrandom 0.2.17", + "ghash", "hex-literal", - "hmac 0.12.1", + "hkdf", + "hmac", "inout 0.1.4", - "internal-russh-forked-ssh-key", "internal-russh-num-bigint", + "keccak", "log", "md5", "ml-kem", "module-lattice", - "p256 0.14.0-rc.9", - "p384 0.14.0-rc.9", - "p521 0.14.0-rc.9", - "pbkdf2 0.12.2", - "pkcs1 0.8.0-rc.4", + "num-bigint", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", "pkcs5", - "pkcs8 0.11.0-rc.11", - "polyval 0.7.1", + "pkcs8", + "polyval", "rand 0.10.1", "rand_core 0.10.1", "ring", - "rsa 0.10.0-rc.17", + "rsa", "russh-cryptovec", "russh-util", - "sec1 0.8.1", - "sha1 0.10.6", - "sha2 0.10.9", - "signature 3.0.0", - "spki 0.8.0", + "salsa20", + "scrypt", + "sec1", + "sha1", + "sha2 0.11.0", + "sha3", + "signature", + "spki", "ssh-encoding", + "ssh-key", "subtle", "tempfile", "thiserror 2.0.18", "tokio", "typenum", - "universal-hash 0.6.1", + "universal-hash", "yasna", "zeroize", ] [[package]] name = "bssh-russh-sftp" -version = "2.1.2" +version = "2.3.0" dependencies = [ "async-trait", "bitflags 2.11.1", @@ -548,19 +529,21 @@ dependencies = [ "chrono", "dashmap", "futures", + "gloo-timers", "log", "serde", "serde_bytes", "thiserror 2.0.18", "tokio", "tokio-util", + "wasm-bindgen-futures", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" @@ -597,20 +580,11 @@ dependencies = [ [[package]] name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher 0.4.4", -] - -[[package]] -name = "cbc" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98db6aeaef0eeef2c1e3ce9a27b739218825dae116076352ac3777076aa22225" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ - "cipher 0.5.1", + "cipher", ] [[package]] @@ -637,17 +611,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", -] - [[package]] name = "chacha20" version = "0.10.0" @@ -655,8 +618,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", + "cipher", "cpufeatures 0.3.0", "rand_core 0.10.1", + "zeroize", ] [[package]] @@ -702,23 +667,14 @@ dependencies = [ [[package]] name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout 0.1.4", -] - -[[package]] -name = "cipher" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.0", - "crypto-common 0.2.1", + "crypto-common 0.2.2", "inout 0.2.2", + "zeroize", ] [[package]] @@ -843,12 +799,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-oid" version = "0.10.2" @@ -1006,18 +956,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array 0.14.7", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - [[package]] name = "crypto-bigint" version = "0.7.3" @@ -1047,9 +985,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "getrandom 0.4.2", "hybrid-array", @@ -1062,7 +1000,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21f41f23de7d24cdbda7f0c4d9c0351f99a4ceb258ef30e5c1927af8987ffe5a" dependencies = [ - "crypto-bigint 0.7.3", + "crypto-bigint", "libm", "rand_core 0.10.1", ] @@ -1079,20 +1017,11 @@ dependencies = [ [[package]] name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher 0.4.4", -] - -[[package]] -name = "ctr" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17469f8eb9bdbfad10f71f4cfddfd38b01143520c0e717d8796ccb4d44d44e42" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher 0.5.1", + "cipher", ] [[package]] @@ -1134,9 +1063,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ "darling_core", "darling_macro", @@ -1144,10 +1073,11 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ + "fnv", "ident_case", "proc-macro2", "quote", @@ -1157,9 +1087,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", @@ -1203,24 +1133,14 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "zeroize", -] - [[package]] name = "der" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ - "const-oid 0.10.2", - "pem-rfc7468 1.0.0", + "const-oid", + "pem-rfc7468", "zeroize", ] @@ -1257,11 +1177,11 @@ dependencies = [ [[package]] name = "des" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" dependencies = [ - "cipher 0.4.4", + "cipher", ] [[package]] @@ -1271,7 +1191,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -1283,8 +1202,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", - "const-oid 0.10.2", - "crypto-common 0.2.1", + "const-oid", + "crypto-common 0.2.2", "ctutils", ] @@ -1341,13 +1260,13 @@ version = "0.7.0-rc.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a914d1d3e30ea021331fc4c4b3558aba5b1a26d91cddb920322d209a3a43cb99" dependencies = [ - "crypto-bigint 0.7.3", + "crypto-bigint", "crypto-primes", - "der 0.8.0", + "der", "digest 0.11.3", - "rfc6979 0.5.0", + "rfc6979", "sha2 0.11.0", - "signature 3.0.0", + "signature", "zeroize", ] @@ -1359,103 +1278,70 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der 0.7.10", - "digest 0.10.7", - "elliptic-curve 0.13.8", - "rfc6979 0.4.0", - "signature 2.2.0", - "spki 0.7.3", -] - -[[package]] -name = "ecdsa" -version = "0.17.0-rc.17" +version = "0.17.0-rc.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc4bf51f0534ed6e59a0f2f26272b64ba55c470133f8424c2adfd1c4d59d9988" +checksum = "54fb064faabbee66e1fc8e5c5a9458d4269dc2d8b638fe86a425adb2510d1a96" dependencies = [ - "der 0.8.0", + "der", "digest 0.11.3", - "elliptic-curve 0.14.0-rc.31", - "rfc6979 0.5.0", - "signature 3.0.0", - "spki 0.8.0", + "elliptic-curve", + "rfc6979", + "signature", + "spki", "zeroize", ] [[package]] name = "ed25519" -version = "3.0.0-rc.4" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8 0.11.0-rc.11", - "signature 3.0.0", + "pkcs8", + "signature", ] [[package]] name = "ed25519-dalek" -version = "3.0.0-pre.6" +version = "3.0.0-pre.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b" +checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6" dependencies = [ "curve25519-dalek", "ed25519", "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature 3.0.0", + "signature", "subtle", "zeroize", ] [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct 0.2.0", - "crypto-bigint 0.5.5", - "digest 0.10.7", - "ff", - "generic-array 0.14.7", - "group", - "pkcs8 0.10.2", - "rand_core 0.6.4", - "sec1 0.7.3", - "subtle", - "zeroize", -] +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" -version = "0.14.0-rc.31" +version = "0.14.0-rc.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b148a81cede8f4023248f980cffdf7611c46f2add469c6980e815b7c5b764ba5" +checksum = "cda94f31325c4275e9706adecbb6f0650dee2f904c915a98e3d81adaaaa757aa" dependencies = [ - "base16ct 1.0.0", - "crypto-bigint 0.7.3", - "crypto-common 0.2.1", + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", "digest 0.11.3", "hkdf", "hybrid-array", "once_cell", - "pem-rfc7468 1.0.0", - "pkcs8 0.11.0-rc.11", + "pem-rfc7468", + "pkcs8", "rand_core 0.10.1", "rustcrypto-ff", "rustcrypto-group", - "sec1 0.8.1", + "sec1", "subtle", "zeroize", ] @@ -1554,16 +1440,6 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "fiat-crypto" version = "0.3.0" @@ -1747,7 +1623,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -1800,23 +1675,13 @@ dependencies = [ "wasip3", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval 0.6.2", -] - [[package]] name = "ghash" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ - "polyval 0.7.1", + "polyval", ] [[package]] @@ -1826,14 +1691,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] -name = "group" -version = "0.13.0" +name = "gloo-timers" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -1927,16 +1793,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac 0.13.0", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", + "hmac", ] [[package]] @@ -2098,13 +1955,12 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", - "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -2112,9 +1968,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -2125,9 +1981,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2139,15 +1995,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -2159,15 +2015,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", @@ -2203,9 +2059,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -2279,9 +2135,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.12" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" dependencies = [ "darling", "indoc", @@ -2290,37 +2146,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "internal-russh-forked-ssh-key" -version = "0.6.18+upstream-0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f8a978272e3cbdf4768f7363eb1c8e1e6ba63c52a3ed05e29e222da4aec7cb" -dependencies = [ - "argon2", - "bcrypt-pbkdf", - "crypto-bigint 0.7.3", - "dsa", - "ecdsa 0.17.0-rc.17", - "ed25519-dalek", - "hex", - "hmac 0.13.0", - "num-bigint-dig", - "p256 0.14.0-rc.9", - "p384 0.14.0-rc.9", - "p521 0.14.0-rc.9", - "rand_core 0.10.1", - "rsa 0.10.0-rc.17", - "sec1 0.8.1", - "serde", - "sha1 0.11.0", - "sha2 0.11.0", - "signature 3.0.0", - "ssh-cipher", - "ssh-encoding", - "subtle", - "zeroize", -] - [[package]] name = "internal-russh-num-bigint" version = "0.5.0" @@ -2377,9 +2202,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.24" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "6835eea34fb6321b9b3aa7b685c2b433948c09447e389dc017fdf687d5d11e65" dependencies = [ "jiff-static", "log", @@ -2390,9 +2215,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.24" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "3c22e04db9c58f5136eb1757f3d5c49a7b187f49e52185228cbd2f5acdfcc08c" dependencies = [ "proc-macro2", "quote", @@ -2411,9 +2236,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -2448,7 +2273,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" dependencies = [ - "crypto-common 0.2.1", + "crypto-common 0.2.2", "rand_core 0.10.1", ] @@ -2463,9 +2288,6 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] [[package]] name = "leb128fmt" @@ -2532,9 +2354,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lru" @@ -2630,13 +2452,14 @@ dependencies = [ [[package]] name = "ml-kem" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c77d5ff6d755d09a0ef4d4d28c2b7e83658fe83e8c736d55e93d43e380d1cd" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" dependencies = [ "hybrid-array", "kem", "module-lattice", + "pkcs8", "rand_core 0.10.1", "sha3", ] @@ -2766,28 +2589,11 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "serde", - "smallvec", - "zeroize", -] - [[package]] name = "num-conv" -version = "0.2.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-derive" @@ -2809,17 +2615,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2827,7 +2622,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -2875,12 +2669,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -2985,82 +2773,44 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", - "primeorder 0.13.6", - "sha2 0.10.9", -] - [[package]] name = "p256" version = "0.14.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b97e3bf0465157ae90975ff52dbeb1362ba618924878c9f74c25baa27a65f9a" dependencies = [ - "ecdsa 0.17.0-rc.17", - "elliptic-curve 0.14.0-rc.31", + "ecdsa", + "elliptic-curve", "primefield", - "primeorder 0.14.0-rc.9", + "primeorder", "sha2 0.11.0", ] -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", - "primeorder 0.13.6", - "sha2 0.10.9", -] - [[package]] name = "p384" version = "0.14.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "437f30ebcb1e16ff48acead5f08bd69fbcdbc82421687bb48af5c315a0bfab03" dependencies = [ - "ecdsa 0.17.0-rc.17", - "elliptic-curve 0.14.0-rc.31", + "ecdsa", + "elliptic-curve", "fiat-crypto", "primefield", - "primeorder 0.14.0-rc.9", + "primeorder", "sha2 0.11.0", ] -[[package]] -name = "p521" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" -dependencies = [ - "base16ct 0.2.0", - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", - "primeorder 0.13.6", - "rand_core 0.6.4", - "sha2 0.10.9", -] - [[package]] name = "p521" version = "0.14.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e9fd792bab86ecf6249561752fb5a413511f999887107dd054bbda5143743d7" dependencies = [ - "base16ct 1.0.0", - "ecdsa 0.17.0-rc.17", - "elliptic-curve 0.14.0-rc.31", + "base16ct", + "ecdsa", + "elliptic-curve", "primefield", - "primeorder 0.14.0-rc.9", + "primeorder", "sha2 0.11.0", ] @@ -3074,6 +2824,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "pageant" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5" +dependencies = [ + "base16ct", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.1", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "windows", + "windows-strings", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -3109,13 +2879,12 @@ dependencies = [ ] [[package]] -name = "pbkdf2" -version = "0.12.2" +name = "password-hash" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", + "phc", ] [[package]] @@ -3125,16 +2894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ "digest 0.11.3", - "hmac 0.13.0", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", + "hmac", ] [[package]] @@ -3195,6 +2955,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + [[package]] name = "phf" version = "0.11.3" @@ -3273,64 +3043,42 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der 0.7.10", - "pkcs8 0.10.2", - "spki 0.7.3", -] - [[package]] name = "pkcs1" version = "0.8.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" dependencies = [ - "der 0.8.0", - "spki 0.8.0", + "der", + "spki", ] [[package]] name = "pkcs5" -version = "0.8.0-rc.13" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a777c6e26664bc9504b3ce3f6133f8f20d9071f130a4f9fcbd3186959d8dd6" +checksum = "279a91971a1d8eb1260a30938eae3be9cb67b472dffecb222fbbbe2fd2dc1453" dependencies = [ - "aes 0.9.0", - "aes-gcm 0.11.0-rc.3", - "cbc 0.2.0", - "der 0.8.0", - "pbkdf2 0.13.0", + "aes", + "cbc", + "der", + "pbkdf2", "rand_core 0.10.1", "scrypt", "sha2 0.11.0", - "spki 0.8.0", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der 0.7.10", - "spki 0.7.3", + "spki", ] [[package]] name = "pkcs8" -version = "0.11.0-rc.11" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.0", + "der", "pkcs5", "rand_core 0.10.1", - "spki 0.8.0", + "spki", ] [[package]] @@ -3363,25 +3111,13 @@ dependencies = [ [[package]] name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash 0.5.1", -] - -[[package]] -name = "polyval" -version = "0.6.2" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +checksum = "a00baa632505d05512f48a963e16051c54fda9a95cc9acea1a4e3c90991c4a2e" dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash 0.5.1", + "cpufeatures 0.3.0", + "universal-hash", + "zeroize", ] [[package]] @@ -3392,7 +3128,7 @@ checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" dependencies = [ "cpubits", "cpufeatures 0.3.0", - "universal-hash 0.6.1", + "universal-hash", ] [[package]] @@ -3476,30 +3212,21 @@ version = "0.14.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b52e6ee42db392378a95622b463c9740631171d1efce43fa445a569c1600cb6" dependencies = [ - "crypto-bigint 0.7.3", - "crypto-common 0.2.1", + "crypto-bigint", + "crypto-common 0.2.2", "rand_core 0.10.1", "rustcrypto-ff", "subtle", "zeroize", ] -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve 0.13.8", -] - [[package]] name = "primeorder" version = "0.14.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0556580e42c19833f5d232aca11a7687a503ee41f937b54f5ae1d50fc2a6a36a" dependencies = [ - "elliptic-curve 0.14.0-rc.31", + "elliptic-curve", ] [[package]] @@ -3580,8 +3307,6 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ - "libc", - "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -3591,7 +3316,7 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] @@ -3601,21 +3326,11 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "chacha20 0.10.0", + "chacha20", "getrandom 0.4.2", "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - [[package]] name = "rand_chacha" version = "0.9.0" @@ -3835,23 +3550,13 @@ dependencies = [ "web-sys", ] -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac 0.12.1", - "subtle", -] - [[package]] name = "rfc6979" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5236ce872cac07e0fb3969b0cbf468c7d2f37d432f1b627dcb7b8d34563fb0c3" dependencies = [ - "hmac 0.13.0", + "hmac", "subtle", ] @@ -3871,9 +3576,9 @@ dependencies = [ [[package]] name = "rpassword" -version = "7.5.2" +version = "7.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64" +checksum = "835a57a69104632d64deb0df2e09a69945cd7a6eab4070fc9b1d7e50cf6c3edc" dependencies = [ "libc", "rtoolbox", @@ -3882,41 +3587,20 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid 0.9.6", - "digest 0.10.7", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1 0.7.5", - "pkcs8 0.10.2", - "rand_core 0.6.4", - "sha2 0.10.9", - "signature 2.2.0", - "spki 0.7.3", - "subtle", - "zeroize", -] - -[[package]] -name = "rsa" -version = "0.10.0-rc.17" +version = "0.10.0-rc.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ed3e93fc7e473e464b9726f4759659e72bc8665e4b8ea227547024f416d905" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" dependencies = [ - "const-oid 0.10.2", - "crypto-bigint 0.7.3", + "const-oid", + "crypto-bigint", "crypto-primes", "digest 0.11.3", - "pkcs1 0.8.0-rc.4", - "pkcs8 0.11.0-rc.11", + "pkcs1", + "pkcs8", "rand_core 0.10.1", "sha2 0.11.0", - "signature 3.0.0", - "spki 0.8.0", + "signature", + "spki", "zeroize", ] @@ -3932,9 +3616,9 @@ dependencies = [ [[package]] name = "russh-cryptovec" -version = "0.60.3" +version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37cb4d0360bdd8935392a306d8b5edb539cc455b30e8bf13dd213a0cf7879b40" +checksum = "443f6bbcfacb34a1aab2b12b99bf08e0c63abdc5a0db261901365df9d57fff51" dependencies = [ "log", "nix 0.31.3", @@ -4085,7 +3769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" dependencies = [ "cfg-if", - "cipher 0.5.1", + "cipher", ] [[package]] @@ -4128,7 +3812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" dependencies = [ "cfg-if", - "pbkdf2 0.13.0", + "pbkdf2", "salsa20", "sha2 0.11.0", ] @@ -4139,29 +3823,15 @@ version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct 0.2.0", - "der 0.7.10", - "generic-array 0.14.7", - "pkcs8 0.10.2", - "subtle", - "zeroize", -] - [[package]] name = "sec1" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ - "base16ct 1.0.0", + "base16ct", "ctutils", - "der 0.8.0", + "der", "hybrid-array", "subtle", "zeroize", @@ -4248,9 +3918,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -4290,7 +3960,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ - "base16ct 1.0.0", + "base16ct", "serde", ] @@ -4320,17 +3990,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha1" version = "0.11.0" @@ -4436,16 +4095,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - [[package]] name = "signature" version = "3.0.0" @@ -4496,22 +4145,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der 0.7.10", -] - [[package]] name = "spki" version = "0.8.0" @@ -4519,55 +4152,69 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der 0.8.0", + "der", ] [[package]] name = "ssh-cipher" -version = "0.2.0" +version = "0.3.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +checksum = "10db6f219196a8528f9ec904d9d45cdad692d65b0e57e72be4dedd1c5fddce36" dependencies = [ - "aes 0.8.4", - "aes-gcm 0.10.3", - "cbc 0.1.2", - "chacha20 0.9.1", - "cipher 0.4.4", - "ctr 0.9.2", + "aead", + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "ctutils", + "des", "poly1305", "ssh-encoding", - "subtle", + "zeroize", ] [[package]] name = "ssh-encoding" -version = "0.2.0" +version = "0.3.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +checksum = "7abf34aa716da5d5b4c496936d042ea282ab392092cd68a72ef6a8863ff8c96a" dependencies = [ "base64ct", "bytes", - "pem-rfc7468 0.7.0", - "sha2 0.10.9", + "crypto-bigint", + "ctutils", + "digest 0.11.3", + "pem-rfc7468", + "zeroize", ] [[package]] name = "ssh-key" -version = "0.6.7" +version = "0.7.0-rc.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +checksum = "45735ce3dea95690e4a9e414c4cfde7f79835063c3dcd35881df85a84118e74b" dependencies = [ - "p256 0.13.2", - "p384 0.13.1", - "p521 0.13.3", - "rand_core 0.6.4", - "rsa 0.9.10", - "sec1 0.7.3", - "sha2 0.10.9", - "signature 2.2.0", + "argon2 0.6.0-rc.8", + "bcrypt-pbkdf", + "ctutils", + "dsa", + "ed25519-dalek", + "hex", + "hmac", + "p256", + "p384", + "p521", + "rand_core 0.10.1", + "rsa", + "sec1", + "serde", + "sha1", + "sha2 0.11.0", + "signature", "ssh-cipher", "ssh-encoding", - "subtle", "zeroize", ] @@ -4801,9 +4448,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "libc", @@ -4816,9 +4463,9 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "tinystr" @@ -4909,15 +4556,16 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] [[package]] name = "tonic" -version = "0.14.6" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", "base64", @@ -4941,9 +4589,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.6" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" dependencies = [ "bytes", "prost", @@ -4952,9 +4600,9 @@ dependencies = [ [[package]] name = "tonic-types" -version = "0.14.6" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +checksum = "2a875a902255423d34c1f20838ab374126db8eb41625b7947a1d54113b0b7399" dependencies = [ "prost", "prost-types", @@ -5130,23 +4778,13 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - [[package]] name = "universal-hash" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common 0.2.1", + "crypto-common 0.2.2", "ctutils", ] @@ -5261,11 +4899,11 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen 0.57.1", + "wit-bindgen 0.46.0", ] [[package]] @@ -5288,9 +4926,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -5301,9 +4939,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -5311,9 +4949,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5321,9 +4959,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -5334,9 +4972,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -5377,9 +5015,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -5511,6 +5149,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -5524,6 +5183,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5552,6 +5222,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -5613,6 +5293,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5661,6 +5350,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5670,12 +5365,6 @@ dependencies = [ "wit-bindgen-rust-macro", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "wit-bindgen-core" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 4ba65592..9e54b726 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,14 +19,14 @@ edition = "2024" [dependencies] bytes = "1.11.1" -tokio = { version = "1.52.1", features = ["full"] } +tokio = { version = "1.52.3", features = ["full"] } # Use our internal russh fork with session loop fixes # - Development: uses local path (crates/bssh-russh) # - Publishing: uses crates.io version (path ignored) -russh = { package = "bssh-russh", version = "0.60.3", path = "crates/bssh-russh" } -# Use our internal russh-sftp fork tracking upstream 2.1.2 +russh = { package = "bssh-russh", version = "0.61.1", path = "crates/bssh-russh" } +# Use our internal russh-sftp fork tracking upstream 2.3.0 # (adds pipelined File I/O; serde_bytes perf fix is now upstreamed) -russh-sftp = { package = "bssh-russh-sftp", version = "2.1.2", path = "crates/bssh-russh-sftp" } +russh-sftp = { package = "bssh-russh-sftp", version = "2.3.0", path = "crates/bssh-russh-sftp" } clap = { version = "4.6.1", features = ["derive", "env"] } anyhow = "1.0.102" thiserror = "2.0.18" @@ -62,9 +62,11 @@ shell-words = "1.1.1" libc = "0.2" ipnetwork = "0.21" bcrypt = "0.19" -argon2 = "0.5" +argon2 = { version = "0.5", features = ["std"] } rand = "0.10" -ssh-key = { version = "0.6", features = ["std"] } +# Pinned to match russh's ssh-key (0.61.x uses =0.7.0-rc.10) so the workspace +# resolves a single ssh-key version instead of 0.6 + 0.7-rc side by side. +ssh-key = { version = "=0.7.0-rc.10", features = ["std"] } async-compression = { version = "0.4", features = ["tokio", "gzip"] } serde_json = "1.0" opentelemetry = "0.32" diff --git a/crates/bssh-russh-sftp/Cargo.toml b/crates/bssh-russh-sftp/Cargo.toml index 418cf809..394df061 100644 --- a/crates/bssh-russh-sftp/Cargo.toml +++ b/crates/bssh-russh-sftp/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "bssh-russh-sftp" -version = "2.1.2" +version = "2.3.0" authors = ["Jeongkyu Shin "] -description = "Temporary fork of russh-sftp 2.1.2 adding pipelined SFTP File I/O (write_all_pipelined / read_to_writer_pipelined). Note: the serde_bytes perf fix that originally motivated this fork is now upstreamed in russh-sftp 2.1.2; only the pipelined helpers remain as fork value-add." +description = "Temporary fork of russh-sftp 2.3.0 adding pipelined SFTP File I/O (write_all_pipelined / read_to_writer_pipelined). These helpers hide per-request RTT for fast bulk transfers and are the only value-add over upstream russh-sftp." documentation = "https://docs.rs/bssh-russh-sftp" edition = "2021" homepage = "https://github.com/lablup/bssh" @@ -11,23 +11,26 @@ license = "Apache-2.0" readme = "README.md" repository = "https://github.com/lablup/bssh" +# Dependency versions mirror upstream russh-sftp 2.3.0 (AspectUnk/russh-sftp). +# Update via ./sync-upstream.sh; the only fork addition is the `futures` dep, +# needed by the forward-ported pipelined helpers in src/client/fs/file.rs. [dependencies] -tokio = { version = "1", default-features = false, features = [ +tokio = { version = "1.52.3", default-features = false, features = [ "io-util", "rt", "sync", "time", "macros", ] } -tokio-util = "0.7.18" +tokio-util = { version = "0.7.18", default-features = false, features = ["rt"] } serde = { version = "1.0.228", features = ["derive"] } -serde_bytes = "0.11" +serde_bytes = "0.11.19" bitflags = { version = "2.11.1", features = ["serde"] } async-trait = { version = "0.1.89", optional = true } # futures: required by our forward-ported pipelined helpers # (write_all_pipelined / read_to_writer_pipelined use FuturesUnordered). -# Upstream russh-sftp 2.1.2 does not need this dependency. +# Upstream russh-sftp only needs this as a dev-dependency. futures = { version = "0.3.32", default-features = false, features = ["std", "async-await"] } thiserror = "2.0.18" @@ -36,5 +39,9 @@ bytes = "1.11.1" log = "0.4.29" dashmap = "6.1.0" +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen-futures = "0.4.71" +gloo-timers = { version = "0.4.0", features = ["futures"] } + [features] async-trait = ["dep:async-trait"] diff --git a/crates/bssh-russh-sftp/README.md b/crates/bssh-russh-sftp/README.md index f94ae445..2d97b713 100644 --- a/crates/bssh-russh-sftp/README.md +++ b/crates/bssh-russh-sftp/README.md @@ -1,25 +1,35 @@ # bssh-russh-sftp -Temporary fork of [russh-sftp](https://crates.io/crates/russh-sftp) with a `serde_bytes` performance fix for SFTP `Write` and `Data` packets. +**Temporary fork of [russh-sftp](https://crates.io/crates/russh-sftp) (tracking upstream `2.3.0`) adding pipelined SFTP file I/O.** -This crate exists so bssh can ship the packet serialization fix independently while keeping the public crate name usable through Cargo's `package = "bssh-russh-sftp"` dependency alias. +This crate exists so bssh can ship faster bulk SFTP transfers independently, while keeping the public crate name usable through Cargo's `package = "bssh-russh-sftp"` dependency alias. -## The Problem +## The Value-Add -`russh-sftp` 2.1.1 derives serde for `Vec` fields in `SSH_FXP_WRITE` and `SSH_FXP_DATA`. With the crate's custom deserializer, that routes through `deserialize_seq` and reads payload bytes one at a time. Large transfers spend substantial CPU in serde's generic `VecVisitor` path. +The fork adds two helpers to `client::fs::File` that keep many SFTP requests in flight at once, hiding per-request round-trip latency (mirroring how OpenSSH's `sftp` client keeps ~64 requests outstanding): -## The Fix +- `File::write_all_pipelined(reader, max_inflight)` — streams a reader to the remote file with up to `max_inflight` concurrent `SSH_FXP_WRITE`s. +- `File::read_to_writer_pipelined(writer, max_inflight)` — streams the remote file to a writer with up to `max_inflight` concurrent `SSH_FXP_READ`s, reassembling chunks in offset order so the output matches a sequential read. -The fork annotates the binary payload fields with `#[serde(with = "serde_bytes")]` and implements compatible `serialize_bytes` framing in the SFTP serializer. The wire format remains `u32 length + bytes`, but deserialization uses the existing bulk byte-buffer path. +These are the only additions over upstream. They live in `src/client/fs/file.rs` and are re-applied on each sync from `patches/pipelined-file-io.patch`. + +> The `serde_bytes` packet-serialization performance fix that originally motivated this fork was upstreamed in russh-sftp 2.1.2; it is kept for reference under `patches/historical/`. + +## Usage + +```toml +[dependencies] +russh-sftp = { package = "bssh-russh-sftp", version = "2.3.0" } +``` ## Sync with Upstream ```bash cd crates/bssh-russh-sftp -./sync-upstream.sh 2.1.1 +./sync-upstream.sh 2.3.0 # omit the version to use upstream's default branch ``` -Local changes are kept as patch files under `patches/`. +`sync-upstream.sh` copies upstream `src` verbatim and re-applies every patch directly under `patches/` (anything under `patches/historical/` is excluded). Patches already merged upstream are detected via reverse-apply and skipped. ## License diff --git a/crates/bssh-russh-sftp/create-patch.sh b/crates/bssh-russh-sftp/create-patch.sh index 9011ae52..5df155ea 100755 --- a/crates/bssh-russh-sftp/create-patch.sh +++ b/crates/bssh-russh-sftp/create-patch.sh @@ -1,44 +1,66 @@ #!/bin/bash # create-patch.sh -# Creates a patch file from the current bssh-russh-sftp changes compared to upstream russh-sftp. +# Regenerates patches/pipelined-file-io.patch by diffing the current vendored +# source against a fresh checkout of upstream russh-sftp. # -# Usage: ./create-patch.sh +# Self-contained: clones upstream into a temp dir (no manually-maintained +# references/ directory needed), so it always diffs against the exact version. +# +# Usage: ./create-patch.sh [version] +# version: optional, e.g. "2.3.0" (default: upstream's default branch, since +# russh-sftp does not publish git tags) set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BSSH_ROOT="$SCRIPT_DIR/../.." -UPSTREAM_DIR="$BSSH_ROOT/references/russh-sftp/src" -CURRENT_DIR="$SCRIPT_DIR/src" +UPSTREAM_URL="https://github.com/AspectUnk/russh-sftp.git" +TEMP_DIR="/tmp/russh-sftp-createpatch-$$" PATCH_DIR="$SCRIPT_DIR/patches" -PATCH_FILE="$PATCH_DIR/sftp-serde-bytes-perf.patch" +PATCH_FILE="$PATCH_DIR/pipelined-file-io.patch" GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' - log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } -if [ ! -d "$UPSTREAM_DIR" ]; then - echo "Error: Upstream russh-sftp not found at $UPSTREAM_DIR" - echo "Please ensure references/russh-sftp exists with the upstream source." - exit 1 +cleanup() { [ -d "$TEMP_DIR" ] && rm -rf "$TEMP_DIR"; } +trap cleanup EXIT + +VERSION="${1:-}" + +log_info "Cloning upstream russh-sftp..." +git clone --quiet "$UPSTREAM_URL" "$TEMP_DIR" +cd "$TEMP_DIR" + +if [ -z "$VERSION" ]; then + VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "master") +fi +if [ "$VERSION" != "master" ]; then + # russh-sftp publishes no git tags, so a version string may not be a ref. + if ! { git checkout --quiet "v$VERSION" 2>/dev/null || git checkout --quiet "$VERSION" 2>/dev/null; }; then + log_warn "No git ref '$VERSION' (russh-sftp publishes no tags); diffing against the default branch." + VERSION="master" + fi fi +log_info "Diffing against upstream $VERSION ($(git rev-parse --short HEAD))" +UPSTREAM_SRC="$TEMP_DIR/src" +CURRENT_SRC="$SCRIPT_DIR/src" mkdir -p "$PATCH_DIR" -log_info "Creating patch from differences..." - -/usr/bin/diff -urN "$UPSTREAM_DIR" "$CURRENT_DIR" \ - | sed "s|$UPSTREAM_DIR|a/src|g" \ - | sed "s|$CURRENT_DIR|b/src|g" \ +# The only fork change is the pipelined File I/O in client/fs/file.rs +# (write_all_pipelined / read_to_writer_pipelined). Emit a -p1 patch. +diff -u \ + --label a/src/client/fs/file.rs \ + --label b/src/client/fs/file.rs \ + "$UPSTREAM_SRC/client/fs/file.rs" \ + "$CURRENT_SRC/client/fs/file.rs" \ > "$PATCH_FILE" || true if [ -s "$PATCH_FILE" ]; then LINES=$(wc -l < "$PATCH_FILE" | tr -d ' ') log_info "Patch created: $PATCH_FILE ($LINES lines)" - echo "" echo "Patch summary:" echo "==============" diff --git a/crates/bssh-russh-sftp/patches/pipelined-file-io.patch b/crates/bssh-russh-sftp/patches/pipelined-file-io.patch new file mode 100644 index 00000000..f263d299 --- /dev/null +++ b/crates/bssh-russh-sftp/patches/pipelined-file-io.patch @@ -0,0 +1,208 @@ +--- a/src/client/fs/file.rs ++++ b/src/client/fs/file.rs +@@ -91,6 +91,205 @@ + + self.session.fsync(self.handle.as_str()).await.map(|_| ()) + } ++ ++ /// Streams `reader` to this remote file with up to `max_inflight` concurrent ++ /// SFTP `WRITE` requests in flight. Each request carries up to the negotiated ++ /// `write_len` (or the per-handle packet ceiling when no limit is advertised). ++ /// ++ /// The high-level [`AsyncWrite`] impl can pipeline writes via the file's ++ /// `write_acks` ring, but that path requires the caller to feed bytes via ++ /// repeated `poll_write` calls. This helper hides the per-request RTT by ++ /// driving the reader and dispatching WRITEs in lockstep, mirroring how ++ /// OpenSSH's `sftp` client behaves (~64 outstanding requests by default). ++ /// ++ /// On success returns the number of bytes streamed. Updates `self.pos` to ++ /// the new write offset. Reading from `reader` and dispatching writes are ++ /// interleaved, so memory usage is bounded by `max_inflight * chunk_size`. ++ pub async fn write_all_pipelined( ++ &mut self, ++ reader: &mut R, ++ max_inflight: usize, ++ ) -> SftpResult ++ where ++ R: tokio::io::AsyncRead + Unpin, ++ { ++ use futures::stream::{FuturesUnordered, StreamExt}; ++ use tokio::io::AsyncReadExt; ++ ++ if max_inflight == 0 { ++ return Err(Error::UnexpectedBehavior( ++ "max_inflight must be at least 1".to_owned(), ++ )); ++ } ++ ++ let chunk_size = self ++ .features ++ .limits ++ .and_then(|l| l.write_len) ++ .unwrap_or_else(|| { ++ let overhead = WRITE_OVERHEAD_LENGTH + self.handle.len() as u32; ++ self.features.max_packet_len.saturating_sub(overhead) as u64 ++ }) as usize; ++ ++ let mut total: u64 = 0; ++ let mut offset = self.pos; ++ let mut in_flight = FuturesUnordered::new(); ++ let mut eof = false; ++ ++ loop { ++ while !eof && in_flight.len() < max_inflight { ++ let mut buf = vec![0u8; chunk_size]; ++ let n = reader.read(&mut buf).await?; ++ if n == 0 { ++ eof = true; ++ break; ++ } ++ buf.truncate(n); ++ ++ let session = self.session.clone(); ++ let handle = self.handle.clone(); ++ let off = offset; ++ ++ in_flight.push(async move { ++ session.write(handle, off, buf).await?; ++ SftpResult::Ok(n as u64) ++ }); ++ ++ offset += n as u64; ++ total += n as u64; ++ } ++ ++ match in_flight.next().await { ++ Some(Ok(_)) => {} ++ Some(Err(e)) => return Err(e), ++ None => break, ++ } ++ } ++ ++ self.pos = offset; ++ Ok(total) ++ } ++ ++ /// Streams the remote file from the current position to `writer` using up to ++ /// `max_inflight` concurrent SFTP `READ` requests. Each request asks for up ++ /// to the negotiated `read_len`, capped at the packet ceiling. ++ /// ++ /// Like [`Self::write_all_pipelined`], this hides per-request RTT. Chunks ++ /// are reassembled in offset order before being written to `writer`, so the ++ /// output is identical to a sequential read. For regular files, the current ++ /// file size is used to avoid speculative reads beyond EOF; if the size is ++ /// unavailable, the transfer stops on EOF or the first short read. ++ /// ++ /// Returns the number of bytes streamed. Updates `self.pos`. ++ pub async fn read_to_writer_pipelined( ++ &mut self, ++ writer: &mut W, ++ max_inflight: usize, ++ ) -> SftpResult ++ where ++ W: tokio::io::AsyncWrite + Unpin, ++ { ++ use futures::stream::{FuturesUnordered, StreamExt}; ++ use std::collections::BTreeMap; ++ use tokio::io::AsyncWriteExt; ++ ++ if max_inflight == 0 { ++ return Err(Error::UnexpectedBehavior( ++ "max_inflight must be at least 1".to_owned(), ++ )); ++ } ++ ++ let chunk_size = self ++ .features ++ .limits ++ .and_then(|l| l.read_len) ++ .unwrap_or_else(|| { ++ self.features ++ .max_packet_len ++ .saturating_sub(READ_OVERHEAD_LENGTH) as u64 ++ }) as usize; ++ let file_end = self ++ .metadata() ++ .await ++ .ok() ++ .and_then(|m| m.size) ++ .filter(|&size| size >= self.pos); ++ ++ let mut total: u64 = 0; ++ let mut next_offset = self.pos; ++ let mut next_to_write = self.pos; ++ let mut pending: BTreeMap> = BTreeMap::new(); ++ let mut in_flight = FuturesUnordered::new(); ++ let mut eof = false; ++ ++ loop { ++ while !eof ++ && in_flight.len() + pending.len() < max_inflight ++ && file_end.is_none_or(|end| next_offset < end) ++ { ++ let session = self.session.clone(); ++ let handle = self.handle.clone(); ++ let off = next_offset; ++ let len = file_end.map_or(chunk_size as u64, |end| { ++ (end - next_offset).min(chunk_size as u64) ++ }) as u32; ++ ++ in_flight.push(async move { ++ match session.read(handle, off, len).await { ++ Ok(data) => SftpResult::Ok((off, len, Some(data.data))), ++ Err(Error::Status(s)) if s.status_code == StatusCode::Eof => { ++ SftpResult::Ok((off, len, None)) ++ } ++ Err(e) => Err(e), ++ } ++ }); ++ ++ next_offset += u64::from(len); ++ } ++ ++ match in_flight.next().await { ++ Some(Ok((off, len, Some(data)))) => { ++ if data.is_empty() { ++ eof = true; ++ } else { ++ if let Some(end) = file_end { ++ let got_end = off.saturating_add(data.len() as u64); ++ if data.len() != len as usize || got_end > end { ++ return Err(Error::UnexpectedBehavior(format!( ++ "short read before EOF at offset {off}: requested {len} bytes, received {} bytes", ++ data.len() ++ ))); ++ } ++ } else if data.len() < len as usize { ++ eof = true; ++ } ++ ++ pending.insert(off, data); ++ } ++ } ++ Some(Ok((off, _, None))) => { ++ if file_end.is_some_and(|end| off < end) { ++ return Err(Error::UnexpectedBehavior(format!( ++ "unexpected EOF before file size at offset {off}" ++ ))); ++ } ++ eof = true; ++ } ++ Some(Err(e)) => return Err(e), ++ None => break, ++ } ++ ++ while let Some(chunk) = pending.remove(&next_to_write) { ++ let n = chunk.len() as u64; ++ writer.write_all(&chunk).await?; ++ next_to_write += n; ++ total += n; ++ } ++ } ++ ++ self.pos = next_to_write; ++ Ok(total) ++ } + } + + fn check_write_result( diff --git a/crates/bssh-russh-sftp/src/client/error.rs b/crates/bssh-russh-sftp/src/client/error.rs index 09e1c10a..7d32aa5d 100644 --- a/crates/bssh-russh-sftp/src/client/error.rs +++ b/crates/bssh-russh-sftp/src/client/error.rs @@ -2,10 +2,8 @@ use std::io; use thiserror::Error; use tokio::sync::mpsc::error::SendError as MpscSendError; use tokio::sync::oneshot::error::RecvError as OneshotRecvError; -use tokio::time::error::Elapsed as TimeElapsed; -use crate::error; -use crate::protocol::Status; +use crate::{error, protocol::Status}; /// Enum for client errors #[derive(Debug, Clone, Error)] @@ -54,12 +52,6 @@ impl From for Error { } } -impl From for Error { - fn from(_: TimeElapsed) -> Self { - Self::Timeout - } -} - impl From for Error { fn from(error: error::Error) -> Self { Self::UnexpectedBehavior(error.to_string()) diff --git a/crates/bssh-russh-sftp/src/client/fs/dir.rs b/crates/bssh-russh-sftp/src/client/fs/dir.rs index 7d71de1c..f61f12d9 100644 --- a/crates/bssh-russh-sftp/src/client/fs/dir.rs +++ b/crates/bssh-russh-sftp/src/client/fs/dir.rs @@ -1,4 +1,4 @@ -use std::collections::VecDeque; +use std::{collections::VecDeque, sync::Arc}; use super::Metadata; use crate::protocol::FileType; @@ -6,6 +6,7 @@ use crate::protocol::FileType; /// Entries returned by the [`ReadDir`] iterator. #[derive(Debug)] pub struct DirEntry { + parent: Arc, file: String, metadata: Metadata, } @@ -25,10 +26,29 @@ impl DirEntry { pub fn metadata(&self) -> Metadata { self.metadata.to_owned() } + + /// Returns the full path of the file that this entry points at. + /// + /// The returned path is built by joining the path originally passed to + /// [`SftpSession::read_dir`](crate::client::SftpSession::read_dir) with + /// [`DirEntry::file_name`] using `/` as the separator (SFTP always uses + /// POSIX-style paths on the wire). No canonicalization is performed, so a + /// relative input yields a relative result — mirroring the behaviour of + /// [`std::fs::DirEntry::path`]. + pub fn path(&self) -> String { + if self.parent.is_empty() { + self.file.clone() + } else if self.parent.ends_with('/') { + format!("{}{}", self.parent, self.file) + } else { + format!("{}/{}", self.parent, self.file) + } + } } /// Iterator over the entries in a remote directory. pub struct ReadDir { + pub(crate) parent: Arc, pub(crate) entries: VecDeque<(String, Metadata)>, } @@ -40,6 +60,7 @@ impl Iterator for ReadDir { None => None, Some(entry) if entry.0 == "." || entry.0 == ".." => self.next(), Some(entry) => Some(DirEntry { + parent: self.parent.clone(), file: entry.0, metadata: entry.1, }), diff --git a/crates/bssh-russh-sftp/src/client/fs/file.rs b/crates/bssh-russh-sftp/src/client/fs/file.rs index d143695f..cac51598 100644 --- a/crates/bssh-russh-sftp/src/client/fs/file.rs +++ b/crates/bssh-russh-sftp/src/client/fs/file.rs @@ -8,7 +8,6 @@ use std::{ }; use tokio::{ io::{AsyncRead, AsyncSeek, AsyncWrite, ReadBuf}, - runtime::Handle, sync::oneshot, }; @@ -338,14 +337,7 @@ impl Drop for File { return; } - if let Ok(handle) = Handle::try_current() { - let session = self.session.clone(); - let file_handle = self.handle.clone(); - - handle.spawn(async move { - let _ = session.close(file_handle).await; - }); - } + let _ = self.session.close_nowait(std::mem::take(&mut self.handle)); } } diff --git a/crates/bssh-russh-sftp/src/client/mod.rs b/crates/bssh-russh-sftp/src/client/mod.rs index 7c4ae52e..06cc8ca1 100644 --- a/crates/bssh-russh-sftp/src/client/mod.rs +++ b/crates/bssh-russh-sftp/src/client/mod.rs @@ -2,6 +2,7 @@ pub mod error; pub mod fs; mod handler; pub mod rawsession; +pub(crate) mod runtime; mod session; pub use handler::Handler; @@ -87,7 +88,7 @@ where let rc = CancellationToken::new(); let wc = rc.clone(); { - tokio::spawn(async move { + runtime::spawn(async move { loop { select! { result = process_handler(&mut rd, &mut handler) => { @@ -106,7 +107,7 @@ where }); } - tokio::spawn(async move { + runtime::spawn(async move { loop { select! { Some(data) = rx.recv() => { diff --git a/crates/bssh-russh-sftp/src/client/rawsession.rs b/crates/bssh-russh-sftp/src/client/rawsession.rs index 6e8174d2..1ea5a40a 100644 --- a/crates/bssh-russh-sftp/src/client/rawsession.rs +++ b/crates/bssh-russh-sftp/src/client/rawsession.rs @@ -10,10 +10,9 @@ use std::{ use tokio::{ io::{AsyncRead, AsyncWrite}, sync::{mpsc, oneshot}, - time, }; -use super::{error::Error, Handler}; +use super::{error::Error, runtime, Handler}; use crate::{ client::{run, Config}, de, @@ -213,12 +212,12 @@ impl RawSftpSession { let rx = self.send(id, packet)?; let timeout = self.timeout.load(Ordering::Relaxed); - match time::timeout(Duration::from_secs(timeout), rx).await { + match runtime::timeout(Duration::from_secs(timeout), rx).await { Ok(Ok(result)) => result, Ok(Err(_)) => Err(Error::UnexpectedBehavior("sender dropped".into())), Err(error) => { self.requests.remove(&id); - Err(error.into()) + Err(error) } } } @@ -313,6 +312,15 @@ impl RawSftpSession { into_status!(result) } + /// Sends a close packet without awaiting the server's acknowledgement. + pub(crate) fn close_nowait( + &self, + handle: String, + ) -> SftpResult>> { + let id = self.use_next_id(); + self.send(Some(id), Close { id, handle }.into()) + } + pub async fn read>( &self, handle: H, diff --git a/crates/bssh-russh-sftp/src/client/runtime.rs b/crates/bssh-russh-sftp/src/client/runtime.rs new file mode 100644 index 00000000..6a18c356 --- /dev/null +++ b/crates/bssh-russh-sftp/src/client/runtime.rs @@ -0,0 +1,102 @@ +//! Runtime abstraction over tokio (native) and wasm-bindgen-futures / gloo-timers (wasm32). + +use std::{ + fmt, + future::Future, + pin::Pin, + task::{Context, Poll}, + time::Duration, +}; + +use super::error::Error; + +#[derive(Debug)] +pub struct JoinError; + +impl fmt::Display for JoinError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("JoinError") + } +} + +impl std::error::Error for JoinError {} + +pub struct JoinHandle { + rx: tokio::sync::oneshot::Receiver, +} + +impl Future for JoinHandle { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match Pin::new(&mut self.rx).poll(cx) { + Poll::Ready(Ok(v)) => Poll::Ready(Ok(v)), + Poll::Ready(Err(_)) => Poll::Ready(Err(JoinError)), + Poll::Pending => Poll::Pending, + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +macro_rules! spawn_impl { + ($fut:expr) => {{ + tokio::spawn($fut); + }}; +} + +#[cfg(target_arch = "wasm32")] +macro_rules! spawn_impl { + ($fut:expr) => { + wasm_bindgen_futures::spawn_local($fut) + }; +} + +pub fn spawn(future: F) -> JoinHandle +where + F: Future + Send + 'static, + T: Send + 'static, +{ + let (tx, rx) = tokio::sync::oneshot::channel(); + spawn_impl!(async move { + let _ = tx.send(future.await); + }); + JoinHandle { rx } +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn timeout(duration: Duration, future: F) -> Result { + tokio::time::timeout(duration, future) + .await + .map_err(|_| Error::Timeout) +} + +// wasm32-unknown-unknown is single-threaded, so Send is trivially safe +#[cfg(target_arch = "wasm32")] +struct SendWrapper(F); + +#[cfg(target_arch = "wasm32")] +unsafe impl Send for SendWrapper {} + +#[cfg(target_arch = "wasm32")] +unsafe impl Sync for SendWrapper {} + +#[cfg(target_arch = "wasm32")] +impl Future for SendWrapper { + type Output = F::Output; + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + unsafe { self.map_unchecked_mut(|w| &mut w.0) }.poll(cx) + } +} + +#[cfg(target_arch = "wasm32")] +pub async fn timeout(duration: Duration, future: F) -> Result { + let timer = SendWrapper(gloo_timers::future::TimeoutFuture::new( + duration.as_millis() as u32, + )); + tokio::pin!(future); + tokio::pin!(timer); + tokio::select! { + v = &mut future => Ok(v), + _ = &mut timer => Err(Error::Timeout), + } +} diff --git a/crates/bssh-russh-sftp/src/client/session.rs b/crates/bssh-russh-sftp/src/client/session.rs index b6a9c00e..d393d6ec 100644 --- a/crates/bssh-russh-sftp/src/client/session.rs +++ b/crates/bssh-russh-sftp/src/client/session.rs @@ -39,19 +39,6 @@ impl SftpSession { Self::new_with_config(stream, Config::default()).await } - /// Creates a new session with timeout opt before the first request - #[deprecated(note = "use SftpSession::new_with_config with Config::req_timeout_secs instead")] - pub async fn new_opts(stream: S, timeout: Option) -> SftpResult - where - S: AsyncRead + AsyncWrite + Unpin + Send + 'static, - { - let mut cfg = Config::default(); - if let Some(secs) = timeout { - cfg.request_timeout_secs = secs; - } - Self::new_with_config(stream, cfg).await - } - /// Creates a new session with custom configuration pub async fn new_with_config(stream: S, cfg: Config) -> SftpResult where @@ -181,8 +168,11 @@ impl SftpSession { /// Returns an iterator over the entries within a directory. pub async fn read_dir>(&self, path: P) -> SftpResult { - let mut files = vec![]; + let path: String = path.into(); + let parent = Arc::from(path.as_str()); + let handle = self.session.opendir(path).await?.handle; + let mut files = vec![]; loop { match self.session.readdir(handle.as_str()).await { @@ -202,6 +192,7 @@ impl SftpSession { self.session.close(handle).await?; Ok(ReadDir { + parent, entries: files.into(), }) } diff --git a/crates/bssh-russh-sftp/src/lib.rs b/crates/bssh-russh-sftp/src/lib.rs index 80a1d489..91a7e974 100644 --- a/crates/bssh-russh-sftp/src/lib.rs +++ b/crates/bssh-russh-sftp/src/lib.rs @@ -31,5 +31,6 @@ pub mod extensions; pub mod protocol; pub mod ser; /// Server side +#[cfg(not(target_arch = "wasm32"))] pub mod server; mod utils; diff --git a/crates/bssh-russh-sftp/src/server/handler.rs b/crates/bssh-russh-sftp/src/server/handler.rs index d8a7d483..65f035d1 100644 --- a/crates/bssh-russh-sftp/src/server/handler.rs +++ b/crates/bssh-russh-sftp/src/server/handler.rs @@ -1,16 +1,17 @@ use std::{collections::HashMap, future::Future}; -use crate::protocol::{ - Attrs, Data, FileAttributes, Handle, Name, OpenFlags, Packet, Status, StatusCode, Version, +use crate::{ + protocol::{Attrs, Data, FileAttributes, Handle, Name, OpenFlags, Packet, Status, Version}, + server::StatusReply, }; /// Server handler for each client. This is `async_trait` #[cfg_attr(feature = "async-trait", async_trait::async_trait)] pub trait Handler: Sized { - /// The type must have an `Into` + /// The type must have an `Into` /// implementation because a response must be sent /// to any request, even if completed by error. - type Error: Into + Send; + type Error: Into + Send; /// Called by the handler when the packet is not implemented fn unimplemented(&self) -> Self::Error; diff --git a/crates/bssh-russh-sftp/src/server/mod.rs b/crates/bssh-russh-sftp/src/server/mod.rs index 27a626cb..a3e5a9ba 100644 --- a/crates/bssh-russh-sftp/src/server/mod.rs +++ b/crates/bssh-russh-sftp/src/server/mod.rs @@ -1,20 +1,30 @@ mod handler; +mod reply; use bytes::Bytes; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; pub use self::handler::Handler; +pub use self::reply::StatusReply; use crate::{ error::Error, - protocol::{Packet, StatusCode}, + protocol::{Packet, Status, StatusCode}, utils::read_packet, }; macro_rules! into_wrap { ($id:expr, $handler:expr, $var:ident; $($arg:ident),*) => { match $handler.$var($($var.$arg),*).await { - Err(err) => Packet::error($id, err.into()), + Err(err) => { + let StatusReply { status_code, error_message, language_tag } = err.into(); + Packet::Status(Status { + id: $id, + status_code, + error_message: error_message.unwrap_or_else(|| status_code.to_string()), + language_tag: language_tag.unwrap_or_else(|| "en-US".to_string()), + }) + }, Ok(packet) => packet.into(), } }; diff --git a/crates/bssh-russh-sftp/src/server/reply.rs b/crates/bssh-russh-sftp/src/server/reply.rs new file mode 100644 index 00000000..228d7d41 --- /dev/null +++ b/crates/bssh-russh-sftp/src/server/reply.rs @@ -0,0 +1,51 @@ +use crate::protocol::StatusCode; + +/// Response sent by [`Handler`](super::Handler) for any request that completes +/// via the `Err` arm. Mapped to an `SSH_FXP_STATUS` packet. +/// +/// `error_message` and `language_tag` fall back to `status_code.to_string()` +/// and `"en-US"` respectively when left as `None`, so simple cases that only +/// carry a [`StatusCode`] stay allocation-free. +#[derive(Debug, Clone, PartialEq)] +pub struct StatusReply { + pub status_code: StatusCode, + pub error_message: Option, + pub language_tag: Option, +} + +impl StatusReply { + pub fn new(status_code: StatusCode) -> Self { + Self { + status_code, + error_message: None, + language_tag: None, + } + } + + pub fn with_message(mut self, message: impl Into) -> Self { + self.error_message = Some(message.into()); + self + } + + pub fn with_language_tag(mut self, tag: impl Into) -> Self { + self.language_tag = Some(tag.into()); + self + } +} + +impl From for StatusReply { + fn from(status_code: StatusCode) -> Self { + Self::new(status_code) + } +} + +// Lives here, not in `protocol/status.rs`, to keep `protocol` free of a +// dependency on `server`-side types. +impl StatusCode { + /// Attach a custom message to this status code and produce a [`StatusReply`]. + /// + /// Shorthand for `StatusReply::new(code).with_message(msg)`. + pub fn with_message(self, message: impl Into) -> StatusReply { + StatusReply::new(self).with_message(message) + } +} diff --git a/crates/bssh-russh-sftp/sync-upstream.sh b/crates/bssh-russh-sftp/sync-upstream.sh index 96741fc7..565c1ee5 100755 --- a/crates/bssh-russh-sftp/sync-upstream.sh +++ b/crates/bssh-russh-sftp/sync-upstream.sh @@ -10,7 +10,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" UPSTREAM_URL="https://github.com/AspectUnk/russh-sftp.git" TEMP_DIR="/tmp/russh-sftp-sync-$$" -PATCH_FILE="$SCRIPT_DIR/patches/sftp-serde-bytes-perf.patch" +PATCH_DIR="$SCRIPT_DIR/patches" RED='\033[0;31m' GREEN='\033[0;32m' @@ -74,27 +74,45 @@ if [ "$VERSION" != "master" ]; then log_info "Updated version to $CLEAN_VERSION" fi +# Apply every *.patch directly under patches/ (patches/historical/ is excluded: +# those are forward-ports already merged upstream, kept only for reference). +# If a patch reverse-applies cleanly the change is already upstream, so we skip +# it and flag it as obsolete. log_info "Applying patches..." -if [ -f "$PATCH_FILE" ]; then - if patch -p1 --dry-run < "$PATCH_FILE" > /dev/null 2>&1; then - patch -p1 < "$PATCH_FILE" - log_info "Applied sftp-serde-bytes-perf.patch" +shopt -s nullglob +PATCH_FILES=("$PATCH_DIR"/*.patch) +shopt -u nullglob + +if [ ${#PATCH_FILES[@]} -eq 0 ]; then + log_warn "No patch files found in $PATCH_DIR/" +fi + +OBSOLETE_PATCHES=() + +for PATCH_FILE in "${PATCH_FILES[@]}"; do + PATCH_NAME=$(basename "$PATCH_FILE") + + if patch -p1 -R --dry-run --silent < "$PATCH_FILE" > /dev/null 2>&1; then + log_info "Skipping $PATCH_NAME — already present in upstream (consider moving to patches/historical/)" + OBSOLETE_PATCHES+=("$PATCH_NAME") + continue + fi + + if patch -p1 --dry-run --silent < "$PATCH_FILE" > /dev/null 2>&1; then + patch -p1 --silent < "$PATCH_FILE" + log_info "Applied $PATCH_NAME" else - log_warn "Patch may not apply cleanly, attempting with fuzz..." + log_warn "$PATCH_NAME may not apply cleanly, attempting with fuzz..." if patch -p1 --fuzz=3 < "$PATCH_FILE"; then - log_warn "Patch applied with fuzz - please verify manually" + log_warn "$PATCH_NAME applied with fuzz - please verify manually" else - log_error "Failed to apply patch. Manual intervention required." + log_error "Failed to apply $PATCH_NAME. Manual intervention required." log_error "Patch file: $PATCH_FILE" exit 1 fi fi -else - log_error "Patch file not found: $PATCH_FILE" - log_error "Please create the patch file first using: ./create-patch.sh" - exit 1 -fi +done log_info "Verifying build..." cd "$SCRIPT_DIR/../.." diff --git a/crates/bssh-russh/Cargo.toml b/crates/bssh-russh/Cargo.toml index 9e72a202..183c8960 100644 --- a/crates/bssh-russh/Cargo.toml +++ b/crates/bssh-russh/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "bssh-russh" -version = "0.60.3" +version = "0.61.1" authors = ["Jeongkyu Shin "] -description = "Temporary fork of russh with high-frequency PTY output fix (Handle::data from spawned tasks)" +description = "Temporary fork of russh 0.61.1 with high-frequency PTY output fix (Handle::data from spawned tasks)" documentation = "https://docs.rs/bssh-russh" edition = "2024" homepage = "https://github.com/lablup/bssh" @@ -10,6 +10,24 @@ keywords = ["ssh"] license = "Apache-2.0" readme = "README.md" repository = "https://github.com/lablup/bssh" +rust-version = "1.85" + +# Internal lib name matches upstream (`russh`) so vendored source and the +# regression test in tests/ reference it as `russh::...`; the crate is still +# consumed as `bssh-russh` via the package alias in the workspace root manifest. +# doctest = false: the vendored upstream doctests need russh's repo test fixtures +# and extra dev-deps we don't carry (src/ only), so we skip them — the 143 unit +# tests + tests/pty_handle_data.rs cover this fork. +[lib] +name = "russh" +doctest = false + +# This crate vendors upstream russh source verbatim (+ the PTY fix). Don't hold +# that code to bssh's workspace `-D warnings` for purely stylistic clippy lints +# upstream itself doesn't fix. `[lints]` lives in Cargo.toml, which +# ./sync-upstream.sh preserves, so this survives future syncs. +[lints.clippy] +manual_saturating_arithmetic = "allow" [features] default = ["flate2", "aws-lc-rs", "rsa"] @@ -20,87 +38,98 @@ legacy-ed25519-pkcs8-parser = ["yasna"] des = ["dep:des"] dsa = ["ssh-key/dsa"] ring = ["dep:ring"] -rsa = ["dep:rsa", "dep:pkcs1", "ssh-key/rsa", "ssh-key/rsa-sha1"] +rsa = ["dep:rsa", "dep:pkcs1", "ssh-key/rsa"] serde = ["ssh-key/serde"] +# Dependency versions mirror upstream russh 0.61.1 (warp-tech/russh, tag v0.61.1) +# so this fork stays a clean "upstream + PTY fix". Update via ./sync-upstream.sh. [dependencies] -aes = "0.8" +aes = "0.9" async-trait = { version = "0.1.50", optional = true } -aws-lc-rs = { version = "1.17.0", optional = true } +aws-lc-rs = { version = "1.16.2", optional = true } bitflags = "2.0" -block-padding = { version = "0.3", features = ["std"] } +block-padding = { version = "0.4" } byteorder = "1.4" bytes = "1.7" -cbc = { version = "0.1" } -cipher = "0.5.1" -ctr = "0.9" -curve25519-dalek = "5.0.0-pre.6" -crypto-bigint = { version = "0.7.0-rc.28", features = ["alloc"] } +cbc = { version = "0.2" } +cipher = "0.5.1" # only pinned due to a cargo-minimal-versions failure in 0.5.0 +ctr = "0.10" +curve25519-dalek = "=5.0.0-pre.6" +crypto-bigint = { version = "0.7.3", features = ["alloc"] } data-encoding = "2.3" delegate = "0.13" -digest = "0.10" +digest = "0.11.0-rc.5" der = "0.8" -des = { version = "0.8.1", optional = true } -ecdsa = "0.17.0-rc.17" -ed25519-dalek = { version = "3.0.0-pre.6", features = ["alloc", "rand_core", "pkcs8"] } -elliptic-curve = { version = "0.14.0-rc.31", features = ["ecdh"] } +des = { version = "0.9", optional = true } +ecdsa = "=0.17.0-rc.18" +ed25519-dalek = { version = "=3.0.0-pre.7", features = ["alloc", "rand_core", "pkcs8"] } +elliptic-curve = { version = "=0.14.0-rc.32", features = ["ecdh"] } enum_dispatch = "0.3.13" flate2 = { version = "1.0.15", optional = true } futures = "0.3" generic-array = { version = "1.3.3", features = ["compat-0_14"] } getrandom = { version = "0.2.15", features = ["js"] } +ghash = "0.6.0" # only pinned due to a cargo-minimal-versions failure in 0.6.0-rc.3 hex-literal = "1" -hmac = "0.12" +hkdf = "0.13.0" +hmac = "0.13" inout = { version = "0.1", features = ["std"] } -log = "0.4" +keccak = "0.2.0" +log = "0.4.11" md5 = "0.7" -ml-kem = "0.3.0-rc.2" +ml-kem = "0.3" module-lattice = "0.2" -# num-bigint 0.4.x only supports rand 0.8; upstream russh ships a fork that -# adds rand 0.10 support via the `rand_0_10` feature flag. +# num-bigint 0.4.x only supports rand 0.8. The internal-russh-num-bigint fork +# adds rand 0.10 support via the `rand_0_10` feature flag (matches upstream russh). num-bigint = { package = "internal-russh-num-bigint", version = "=0.5.0", features = ["rand_0_10"] } -p256 = { version = "0.14.0-rc.9", features = ["ecdh"] } -p384 = { version = "0.14.0-rc.9", features = ["ecdh"] } -p521 = { version = "0.14.0-rc.9", features = ["ecdh"] } -pbkdf2 = "0.12" -pkcs1 = { version = "0.8.0-rc.4", optional = true } -# Pinned: pkcs8 0.11.0-rc.11 still calls the rc.13-era `Parameters::recommended` API. -# Stable 0.8.0 renamed it to `generate_recommended` and breaks pkcs8. -pkcs5 = "=0.8.0-rc.13" -pkcs8 = { version = "0.11.0-rc.11", features = ["encryption", "std"] } -polyval = "0.7.1" +num_bigint_0_4 = { package = "num-bigint", version = "0.4.6" } +p256 = { version = "=0.14.0-rc.9", features = ["ecdh"] } +p384 = { version = "=0.14.0-rc.9", features = ["ecdh"] } +p521 = { version = "=0.14.0-rc.9", features = ["ecdh"] } +pbkdf2 = "0.13" +pkcs1 = { version = "=0.8.0-rc.4", optional = true } +pkcs5 = "0.8" +pkcs8 = { version = "0.11", features = ["encryption", "std"] } +polyval = "0.7.1" # only pinned due to a cargo-minimal-versions failure in 0.7.0 rand_core = { version = "0.10.0" } rand = { version = "0.10", features = ["thread_rng"] } ring = { version = "0.17.14", optional = true } -rsa = { version = "0.10.0-rc.17", optional = true } +rsa = { version = "=0.10.0-rc.18", optional = true } +salsa20 = "0.11.0" +scrypt = "0.12.0" sec1 = { version = "0.8", features = ["der"] } -sha1 = { version = "0.10.5", features = ["oid"] } -sha2 = { version = "0.10.6", features = ["oid"] } +sha1 = { version = "0.11", features = ["oid"] } +sha2 = { version = "0.11", features = ["oid"] } +sha3 = "0.11.0" signature = "3.0.0-rc.10" -spki = "0.8.0" -ssh-encoding = { version = "0.2", features = ["bytes"] } +spki = "0.8" +ssh-encoding = { version = "=0.3.0-rc.9", features = ["bytes"] } subtle = "2.4" thiserror = "2.0.18" -tokio = { version = "1.52.1", features = ["io-util", "sync", "time", "rt-multi-thread", "net"] } +tokio = { version = "1.52.3", features = ["io-util", "sync", "time", "rt-multi-thread", "net"] } typenum = "1.17" -universal-hash = "0.6.1" +universal-hash = "0.6.1" # only pinned due to a cargo-minimal-versions failure in 0.6.0 yasna = { version = "0.5.0", features = ["bit-vec", "num-bigint"], optional = true } zeroize = "1.7" -# Public russh crates (no modifications needed) -russh-cryptovec = { version = "0.60.3", features = ["ssh-encoding"] } +# Public russh crates (no modifications needed) — pinned to the 0.61.x line. +russh-cryptovec = { version = "0.61.0", features = ["ssh-encoding"] } russh-util = "0.52.0" -# Use the forked ssh-key from russh -ssh-key = { version = "=0.6.18", features = [ +# ssh-key: upstream russh 0.61.x switched from the internal-russh-forked-ssh-key +# fork back to the real crate at 0.7.0-rc.10 (the fork's changes are upstreamed). +ssh-key = { version = "=0.7.0-rc.10", features = [ "ed25519", "p256", "p384", "p521", "encryption", "ppk", - "hazmat-allow-insecure-rsa-keys", -], package = "internal-russh-forked-ssh-key" } + "sha1", +] } + +[target.'cfg(windows)'.dependencies] +pageant = "0.2" [dev-dependencies] # Dev-deps required by the inline test modules imported from upstream russh @@ -110,4 +139,7 @@ env_logger = "0.11" tempfile = "3" # Additive: merges with the main tokio dep to add the `process` and `macros` # features needed by the spawn-ssh-agent helpers and #[tokio::test]. -tokio = { version = "1.52.1", features = ["process", "macros"] } +tokio = { version = "1.52.3", features = ["process", "macros"] } +# Needed by the PTY-fix regression test (tests/pty_handle_data.rs) to generate +# ephemeral host/client keys; the lib's own `rand` dep isn't visible to test crates. +rand = { version = "0.10", features = ["thread_rng"] } diff --git a/crates/bssh-russh/README.md b/crates/bssh-russh/README.md index 613fed26..8b3caaf5 100644 --- a/crates/bssh-russh/README.md +++ b/crates/bssh-russh/README.md @@ -1,6 +1,6 @@ # bssh-russh -**Temporary fork of [russh](https://crates.io/crates/russh) with high-frequency PTY output fix.** +**Temporary fork of [russh](https://crates.io/crates/russh) (tracking upstream `0.61.1`) with a high-frequency PTY output fix.** This crate exists solely to address a specific issue where `Handle::data()` messages from spawned tasks may not be delivered to SSH clients during high-throughput PTY sessions. @@ -10,13 +10,13 @@ When implementing SSH servers with interactive PTY support, shell output sent vi ## The Fix -Added a `try_recv()` batch processing loop before `select!` to drain pending messages, with a limit of 64 messages per batch to maintain input responsiveness (e.g., Ctrl+C). +Added a `try_recv()` batch processing loop before `select!` to drain pending messages, with a limit of 64 messages per batch to maintain input responsiveness (e.g., Ctrl+C). The change lives in `src/server/session.rs` and is re-applied on each sync from `patches/handle-data-fix.patch`. A regression test lives in `tests/pty_handle_data.rs`. ## Usage ```toml [dependencies] -russh = { package = "bssh-russh", version = "0.56" } +russh = { package = "bssh-russh", version = "0.61.1" } ``` ## Sync with Upstream @@ -25,12 +25,14 @@ This fork tracks upstream russh releases. To sync with a new version: ```bash cd crates/bssh-russh -./sync-upstream.sh 0.57.0 # specify version +./sync-upstream.sh 0.61.1 # specify version ``` +`sync-upstream.sh` copies upstream `russh/src` verbatim and re-applies every patch under `patches/`. Patches that have already landed upstream are detected (their reverse-apply succeeds) and skipped. As of the `0.61.1` sync, only `handle-data-fix.patch` remains — the previous `channel-write-ordering`, `agent-frame-length-cap`, and `sha1-mac-exclude` cherry-picks are now upstream and were removed. + ## Upstream Status -- Issue: High-frequency PTY output not delivered when using Handle::data() from spawned tasks +- Issue: High-frequency PTY output not delivered when using `Handle::data()` from spawned tasks - PR: https://github.com/inureyes/russh/tree/fix/handle-data-from-spawned-tasks - When merged upstream, this fork will be deprecated diff --git a/crates/bssh-russh/create-patch.sh b/crates/bssh-russh/create-patch.sh index ec53a14f..6ed613e8 100755 --- a/crates/bssh-russh/create-patch.sh +++ b/crates/bssh-russh/create-patch.sh @@ -1,46 +1,61 @@ #!/bin/bash # create-patch.sh -# Creates a patch file from the current bssh-russh changes compared to upstream russh +# Regenerates patches/handle-data-fix.patch by diffing the current vendored +# source against a fresh checkout of upstream russh. # -# Usage: ./create-patch.sh +# Self-contained: clones upstream into a temp dir (no manually-maintained +# references/ directory needed), so it always diffs against the exact version. +# +# Usage: ./create-patch.sh [version] +# version: optional, e.g. "0.61.1" (default: latest tag) set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BSSH_ROOT="$SCRIPT_DIR/../.." -UPSTREAM_DIR="$BSSH_ROOT/references/russh/russh/src" -CURRENT_DIR="$SCRIPT_DIR/src" +UPSTREAM_URL="https://github.com/warp-tech/russh.git" +TEMP_DIR="/tmp/russh-createpatch-$$" PATCH_DIR="$SCRIPT_DIR/patches" PATCH_FILE="$PATCH_DIR/handle-data-fix.patch" -# Colors for output GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' - log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } -if [ ! -d "$UPSTREAM_DIR" ]; then - echo "Error: Upstream russh not found at $UPSTREAM_DIR" - echo "Please ensure references/russh exists with the upstream source." - exit 1 +cleanup() { [ -d "$TEMP_DIR" ] && rm -rf "$TEMP_DIR"; } +trap cleanup EXIT + +VERSION="${1:-}" + +log_info "Cloning upstream russh..." +git clone --quiet --depth 100 "$UPSTREAM_URL" "$TEMP_DIR" +cd "$TEMP_DIR" + +if [ -z "$VERSION" ]; then + VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "main") +fi +if [ "$VERSION" != "main" ]; then + git checkout --quiet "v$VERSION" 2>/dev/null || git checkout --quiet "$VERSION" fi +log_info "Diffing against upstream $VERSION ($(git rev-parse --short HEAD))" +UPSTREAM_SRC="$TEMP_DIR/russh/src" +CURRENT_SRC="$SCRIPT_DIR/src" mkdir -p "$PATCH_DIR" -log_info "Creating patch from differences..." - -# Create patch for server/session.rs (the main change) -diff -u "$UPSTREAM_DIR/server/session.rs" "$CURRENT_DIR/server/session.rs" \ - | sed 's|'"$UPSTREAM_DIR"'|a/src|g' \ - | sed 's|'"$CURRENT_DIR"'|b/src|g' \ +# The only fork change is the PTY fix in server/session.rs (the batch try_recv +# drain before select!). Emit a -p1 patch with a/ b/ prefixes. +diff -u \ + --label a/src/server/session.rs \ + --label b/src/server/session.rs \ + "$UPSTREAM_SRC/server/session.rs" \ + "$CURRENT_SRC/server/session.rs" \ > "$PATCH_FILE" || true if [ -s "$PATCH_FILE" ]; then LINES=$(wc -l < "$PATCH_FILE" | tr -d ' ') log_info "Patch created: $PATCH_FILE ($LINES lines)" - echo "" echo "Patch summary:" echo "==============" diff --git a/crates/bssh-russh/patches/agent-frame-length-cap.patch b/crates/bssh-russh/patches/agent-frame-length-cap.patch deleted file mode 100644 index 8ebd2716..00000000 --- a/crates/bssh-russh/patches/agent-frame-length-cap.patch +++ /dev/null @@ -1,96 +0,0 @@ ---- a/src/keys/agent/client.rs -+++ b/src/keys/agent/client.rs -@@ -16,6 +16,8 @@ - - impl AgentStream for S {} - -+const MAX_AGENT_FRAME_LEN: usize = 256 * 1024; -+ - /// SSH agent client. - pub struct AgentClient { - stream: S, -@@ -112,25 +114,29 @@ - } - - impl AgentClient { -- async fn read_response(&mut self) -> Result<(), Error> { -- // Writing the message -- self.stream.write_all(&self.buf).await?; -- self.stream.flush().await?; -- -- // Reading the length -+ async fn read_frame(&mut self) -> Result<(), Error> { - self.buf.clear(); - self.buf.resize(4, 0); - self.stream.read_exact(&mut self.buf).await?; - -- // Reading the rest of the buffer - let len = BigEndian::read_u32(&self.buf) as usize; -+ if len > MAX_AGENT_FRAME_LEN { -+ return Err(Error::AgentProtocolError); -+ } -+ - self.buf.clear(); - self.buf.resize(len, 0); - self.stream.read_exact(&mut self.buf).await?; -- - Ok(()) - } - -+ async fn read_response(&mut self) -> Result<(), Error> { -+ // Writing the message -+ self.stream.write_all(&self.buf).await?; -+ self.stream.flush().await?; -+ self.read_frame().await -+ } -+ - async fn read_success(&mut self) -> Result<(), Error> { - self.read_response().await?; - if self.buf.first() == Some(&msg::SUCCESS) { ---- a/src/keys/agent/server.rs -+++ b/src/keys/agent/server.rs -@@ -19,6 +19,8 @@ - use crate::keys::Error; - use crate::CryptoVec; - -+const MAX_AGENT_FRAME_LEN: usize = 256 * 1024; -+ - #[derive(Clone)] - #[allow(clippy::type_complexity)] - struct KeyStore(Arc, (Arc, SystemTime, Vec)>>>); -@@ -97,18 +99,26 @@ - impl - Connection - { -+ async fn read_frame(&mut self) -> Result<(), Error> { -+ self.buf.clear(); -+ self.buf.resize(4, 0); -+ self.s.read_exact(&mut self.buf).await?; -+ -+ let len = BigEndian::read_u32(&self.buf) as usize; -+ if len > MAX_AGENT_FRAME_LEN { -+ return Err(Error::AgentProtocolError); -+ } -+ -+ self.buf.clear(); -+ self.buf.resize(len, 0); -+ self.s.read_exact(&mut self.buf).await?; -+ Ok(()) -+ } -+ - async fn run(mut self) -> Result<(), Error> { - let mut writebuf = Vec::new(); - loop { -- // Reading the length -- self.buf.clear(); -- self.buf.resize(4, 0); -- self.s.read_exact(&mut self.buf).await?; -- // Reading the rest of the buffer -- let len = BigEndian::read_u32(&self.buf) as usize; -- self.buf.clear(); -- self.buf.resize(len, 0); -- self.s.read_exact(&mut self.buf).await?; -+ self.read_frame().await?; - // respond - writebuf.clear(); - self.respond(&mut writebuf).await?; diff --git a/crates/bssh-russh/patches/channel-write-ordering.patch b/crates/bssh-russh/patches/channel-write-ordering.patch deleted file mode 100644 index 12185700..00000000 --- a/crates/bssh-russh/patches/channel-write-ordering.patch +++ /dev/null @@ -1,88 +0,0 @@ ---- a/src/session.rs -+++ b/src/session.rs -@@ -449,7 +449,7 @@ - let buf0 = buf0.into(); - if let Some(channel) = self.channels.get_mut(&channel) { - assert!(channel.confirmed); -- if !channel.pending_data.is_empty() && is_rekeying { -+ if !channel.pending_data.is_empty() || is_rekeying { - channel.pending_data.push_back((buf0, None, 0)); - return Ok(()); - } -@@ -473,7 +473,7 @@ - let buf0 = buf0.into(); - if let Some(channel) = self.channels.get_mut(&channel) { - assert!(channel.confirmed); -- if !channel.pending_data.is_empty() && is_rekeying { -+ if !channel.pending_data.is_empty() || is_rekeying { - channel.pending_data.push_back((buf0, Some(ext), 0)); - return Ok(()); - } -@@ -836,4 +836,67 @@ - 1 - ); - } -+ -+ #[test] -+ fn data_queues_behind_existing_pending_data_when_not_rekeying() { -+ let channel_id = ChannelId(5); -+ let mut encrypted = test_encrypted(); -+ encrypted -+ .channels -+ .insert(channel_id, test_channel(channel_id, 42, false, false)); -+ -+ let channel = &encrypted.channels[&channel_id]; -+ let initial_pending = channel.pending_data.len(); -+ assert!(initial_pending > 0); -+ let initial_front = channel.pending_data.front().unwrap(); -+ let initial_front_data = initial_front.0.to_vec(); -+ let initial_front_ext = initial_front.1; -+ -+ encrypted -+ .data(channel_id, Bytes::from_static(b"new"), false) -+ .unwrap(); -+ -+ let channel = &encrypted.channels[&channel_id]; -+ assert_eq!(channel.pending_data.len(), initial_pending + 1); -+ assert_eq!( -+ channel.pending_data.front().unwrap().0.as_ref(), -+ initial_front_data.as_slice() -+ ); -+ assert_eq!(channel.pending_data.front().unwrap().1, initial_front_ext); -+ assert_eq!(channel.pending_data.back().unwrap().0.as_ref(), b"new"); -+ assert_eq!(channel.pending_data.back().unwrap().1, None); -+ assert!(encrypted.write.is_empty()); -+ } -+ -+ #[test] -+ fn extended_data_queues_behind_existing_pending_data_when_not_rekeying() { -+ let channel_id = ChannelId(6); -+ let ext = 1; -+ let mut encrypted = test_encrypted(); -+ encrypted -+ .channels -+ .insert(channel_id, test_channel(channel_id, 42, false, false)); -+ -+ let channel = &encrypted.channels[&channel_id]; -+ let initial_pending = channel.pending_data.len(); -+ assert!(initial_pending > 0); -+ let initial_front = channel.pending_data.front().unwrap(); -+ let initial_front_data = initial_front.0.to_vec(); -+ let initial_front_ext = initial_front.1; -+ -+ encrypted -+ .extended_data(channel_id, ext, Bytes::from_static(b"new"), false) -+ .unwrap(); -+ -+ let channel = &encrypted.channels[&channel_id]; -+ assert_eq!(channel.pending_data.len(), initial_pending + 1); -+ assert_eq!( -+ channel.pending_data.front().unwrap().0.as_ref(), -+ initial_front_data.as_slice() -+ ); -+ assert_eq!(channel.pending_data.front().unwrap().1, initial_front_ext); -+ assert_eq!(channel.pending_data.back().unwrap().0.as_ref(), b"new"); -+ assert_eq!(channel.pending_data.back().unwrap().1, Some(ext)); -+ assert!(encrypted.write.is_empty()); -+ } - } diff --git a/crates/bssh-russh/patches/handle-data-fix.patch b/crates/bssh-russh/patches/handle-data-fix.patch index 49a28976..1f3663a6 100644 --- a/crates/bssh-russh/patches/handle-data-fix.patch +++ b/crates/bssh-russh/patches/handle-data-fix.patch @@ -1,15 +1,15 @@ --- a/src/server/session.rs +++ b/src/server/session.rs -@@ -7,7 +7,7 @@ +@@ -6,7 +6,7 @@ + use kex::ServerKex; use log::debug; - use negotiation::parse_kex_algo_list; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; --use tokio::sync::mpsc::{channel, Receiver, Sender}; -+use tokio::sync::mpsc::{channel, error::TryRecvError, Receiver, Sender}; +-use tokio::sync::mpsc::{Receiver, Sender, channel}; ++use tokio::sync::mpsc::{Receiver, Sender, channel, error::TryRecvError}; use tokio::sync::oneshot; use super::*; -@@ -513,6 +513,136 @@ +@@ -511,6 +511,136 @@ while !self.common.disconnected { self.common.received_data = false; let mut sent_keepalive = false; @@ -146,193 +146,3 @@ tokio::select! { r = &mut reading => { let (stream_read, mut buffer, mut opening_cipher) = match r { -@@ -659,22 +789,20 @@ - // data from it. - self.common.alive_timeouts = 0; - } -- if self.common.received_data || sent_keepalive { -- if let (futures::future::Either::Right(ref mut sleep), Some(d)) = ( -+ if (self.common.received_data || sent_keepalive) -+ && let (futures::future::Either::Right(ref mut sleep), Some(d)) = ( - keepalive_timer.as_mut().as_pin_mut(), - self.common.config.keepalive_interval, - ) { - sleep.as_mut().reset(tokio::time::Instant::now() + d); - } -- } -- if !sent_keepalive { -- if let (futures::future::Either::Right(ref mut sleep), Some(d)) = ( -+ if !sent_keepalive -+ && let (futures::future::Either::Right(ref mut sleep), Some(d)) = ( - inactivity_timer.as_mut().as_pin_mut(), - self.common.config.inactivity_timeout, - ) { - sleep.as_mut().reset(tokio::time::Instant::now() + d); - } -- } - } - debug!("disconnected"); - // Shutdown -@@ -703,38 +831,35 @@ - } - - pub fn writable_packet_size(&self, channel: &ChannelId) -> u32 { -- if let Some(ref enc) = self.common.encrypted { -- if let Some(channel) = enc.channels.get(channel) { -+ if let Some(ref enc) = self.common.encrypted -+ && let Some(channel) = enc.channels.get(channel) { - return channel - .sender_window_size - .min(channel.sender_maximum_packet_size); - } -- } - 0 - } - - pub fn window_size(&self, channel: &ChannelId) -> u32 { -- if let Some(ref enc) = self.common.encrypted { -- if let Some(channel) = enc.channels.get(channel) { -+ if let Some(ref enc) = self.common.encrypted -+ && let Some(channel) = enc.channels.get(channel) { - return channel.sender_window_size; - } -- } - 0 - } - - pub fn max_packet_size(&self, channel: &ChannelId) -> u32 { -- if let Some(ref enc) = self.common.encrypted { -- if let Some(channel) = enc.channels.get(channel) { -+ if let Some(ref enc) = self.common.encrypted -+ && let Some(channel) = enc.channels.get(channel) { - return channel.sender_maximum_packet_size; - } -- } - 0 - } - - /// Flush the session, i.e. encrypt the pending buffer. - pub fn flush(&mut self) -> Result<(), Error> { -- if let Some(ref mut enc) = self.common.encrypted { -- if enc.flush( -+ if let Some(ref mut enc) = self.common.encrypted -+ && enc.flush( - &self.common.config.as_ref().limits, - &mut self.common.packet_writer, - )? && self.kex == SessionKexState::Idle -@@ -744,7 +869,6 @@ - self.begin_rekey()?; - } - } -- } - Ok(()) - } - -@@ -819,12 +943,11 @@ - /// cancelling). Always call this function if the request was - /// successful (it checks whether the client expects an answer). - pub fn request_success(&mut self) { -- if self.common.wants_reply { -- if let Some(ref mut enc) = self.common.encrypted { -+ if self.common.wants_reply -+ && let Some(ref mut enc) = self.common.encrypted { - self.common.wants_reply = false; - push_packet!(enc.write, enc.write.push(msg::REQUEST_SUCCESS)) - } -- } - } - - /// Send a "failure" reply to a global request. -@@ -839,8 +962,8 @@ - /// function if the request was successful (it checks whether the - /// client expects an answer). - pub fn channel_success(&mut self, channel: ChannelId) -> Result<(), crate::Error> { -- if let Some(ref mut enc) = self.common.encrypted { -- if let Some(channel) = enc.channels.get_mut(&channel) { -+ if let Some(ref mut enc) = self.common.encrypted -+ && let Some(channel) = enc.channels.get_mut(&channel) { - assert!(channel.confirmed); - if channel.wants_reply { - channel.wants_reply = false; -@@ -851,14 +974,13 @@ - }) - } - } -- } - Ok(()) - } - - /// Send a "failure" reply to a global request. - pub fn channel_failure(&mut self, channel: ChannelId) -> Result<(), crate::Error> { -- if let Some(ref mut enc) = self.common.encrypted { -- if let Some(channel) = enc.channels.get_mut(&channel) { -+ if let Some(ref mut enc) = self.common.encrypted -+ && let Some(channel) = enc.channels.get_mut(&channel) { - assert!(channel.confirmed); - if channel.wants_reply { - channel.wants_reply = false; -@@ -868,7 +990,6 @@ - }) - } - } -- } - Ok(()) - } - -@@ -951,8 +1072,8 @@ - channel: ChannelId, - client_can_do: bool, - ) -> Result<(), Error> { -- if let Some(ref mut enc) = self.common.encrypted { -- if let Some(channel) = enc.channels.get(&channel) { -+ if let Some(ref mut enc) = self.common.encrypted -+ && let Some(channel) = enc.channels.get(&channel) { - assert!(channel.confirmed); - push_packet!(enc.write, { - msg::CHANNEL_REQUEST.encode(&mut enc.write)?; -@@ -963,7 +1084,6 @@ - (client_can_do as u8).encode(&mut enc.write)?; - }) - } -- } - Ok(()) - } - -@@ -1003,8 +1123,8 @@ - channel: ChannelId, - exit_status: u32, - ) -> Result<(), Error> { -- if let Some(ref mut enc) = self.common.encrypted { -- if let Some(channel) = enc.channels.get(&channel) { -+ if let Some(ref mut enc) = self.common.encrypted -+ && let Some(channel) = enc.channels.get(&channel) { - assert!(channel.confirmed); - push_packet!(enc.write, { - msg::CHANNEL_REQUEST.encode(&mut enc.write)?; -@@ -1015,7 +1135,6 @@ - exit_status.encode(&mut enc.write)?; - }) - } -- } - Ok(()) - } - -@@ -1028,8 +1147,8 @@ - error_message: &str, - language_tag: &str, - ) -> Result<(), Error> { -- if let Some(ref mut enc) = self.common.encrypted { -- if let Some(channel) = enc.channels.get(&channel) { -+ if let Some(ref mut enc) = self.common.encrypted -+ && let Some(channel) = enc.channels.get(&channel) { - assert!(channel.confirmed); - push_packet!(enc.write, { - msg::CHANNEL_REQUEST.encode(&mut enc.write)?; -@@ -1043,7 +1162,6 @@ - language_tag.encode(&mut enc.write)?; - }) - } -- } - Ok(()) - } - diff --git a/crates/bssh-russh/patches/sha1-mac-exclude.patch b/crates/bssh-russh/patches/sha1-mac-exclude.patch deleted file mode 100644 index a2bb1532..00000000 --- a/crates/bssh-russh/patches/sha1-mac-exclude.patch +++ /dev/null @@ -1,36 +0,0 @@ ---- a/src/negotiation.rs -+++ b/src/negotiation.rs -@@ -131,13 +131,12 @@ - cipher::AES_128_CTR, - ]; - --const HMAC_ORDER: &[mac::Name] = &[ -+// SHA-1 MAC variants are excluded from defaults. -+const SAFE_HMAC_ORDER: &[mac::Name] = &[ - mac::HMAC_SHA512_ETM, - mac::HMAC_SHA256_ETM, - mac::HMAC_SHA512, - mac::HMAC_SHA256, -- mac::HMAC_SHA1_ETM, -- mac::HMAC_SHA1, - ]; - - const COMPRESSION_ORDER: &[compression::Name] = &[ -@@ -171,7 +170,7 @@ - Algorithm::Rsa { hash: None }, - ]), - cipher: Cow::Borrowed(CIPHER_ORDER), -- mac: Cow::Borrowed(HMAC_ORDER), -+ mac: Cow::Borrowed(SAFE_HMAC_ORDER), - compression: Cow::Borrowed(COMPRESSION_ORDER), - }; - -@@ -179,7 +178,7 @@ - kex: Cow::Borrowed(SAFE_KEX_ORDER), - key: Preferred::DEFAULT.key, - cipher: Cow::Borrowed(CIPHER_ORDER), -- mac: Cow::Borrowed(HMAC_ORDER), -+ mac: Cow::Borrowed(SAFE_HMAC_ORDER), - compression: Cow::Borrowed(COMPRESSION_ORDER), - }; - } diff --git a/crates/bssh-russh/src/auth.rs b/crates/bssh-russh/src/auth.rs index e96861ca..c1e3e443 100644 --- a/crates/bssh-russh/src/auth.rs +++ b/crates/bssh-russh/src/auth.rs @@ -100,7 +100,6 @@ impl From<&NameList> for MethodSet { fn from(value: &NameList) -> Self { Self( value - .0 .iter() .filter_map(|x| MethodKind::from_str(x).ok()) .collect(), @@ -227,14 +226,23 @@ pub enum Method { #[doc(hidden)] #[derive(Debug)] pub struct AuthRequest { + initial_methods: MethodSet, pub methods: MethodSet, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub partial_success: bool, pub current: Option, + pub(crate) principal: Option, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub rejection_count: usize, } +#[doc(hidden)] +#[derive(Debug)] +pub(crate) struct AuthPrincipal { + user: String, + service: String, +} + #[doc(hidden)] #[derive(Debug)] pub enum CurrentRequest { @@ -253,22 +261,53 @@ pub enum CurrentRequest { } impl AuthRequest { + pub(crate) fn server(methods: MethodSet) -> Self { + Self { + initial_methods: methods.clone(), + methods, + partial_success: false, + current: None, + principal: None, + rejection_count: 0, + } + } + pub(crate) fn new(method: &Method) -> Self { match method { Method::KeyboardInteractive { submethods } => Self { + initial_methods: MethodSet::all(), methods: MethodSet::all(), partial_success: false, current: Some(CurrentRequest::KeyboardInteractive { submethods: submethods.to_string(), }), + principal: None, rejection_count: 0, }, _ => Self { + initial_methods: MethodSet::all(), methods: MethodSet::all(), partial_success: false, current: None, + principal: None, rejection_count: 0, }, } } + + pub(crate) fn bind_or_reset_principal(&mut self, user: &str, service: &str) -> bool { + match &self.principal { + Some(bound) if bound.user == user && bound.service == service => false, + _ => { + self.principal = Some(AuthPrincipal { + user: user.to_owned(), + service: service.to_owned(), + }); + self.methods = self.initial_methods.clone(); + self.partial_success = false; + self.current = None; + true + } + } + } } diff --git a/crates/bssh-russh/src/channels/mod.rs b/crates/bssh-russh/src/channels/mod.rs index f16406c5..2573e238 100644 --- a/crates/bssh-russh/src/channels/mod.rs +++ b/crates/bssh-russh/src/channels/mod.rs @@ -322,6 +322,11 @@ impl + Send + Sync + 'static> ChannelWriteHalf< self.send_data(None, data).await } + /// Send owned bytes to a channel without copying them into the `AsyncWrite` path. + pub async fn data_bytes(&self, data: impl Into) -> Result<(), Error> { + self.send_bytes(None, data.into()).await + } + /// Send data to a channel. The number of bytes added to the /// "sending pipeline" (to be processed by the event loop) is /// returned. @@ -333,6 +338,15 @@ impl + Send + Sync + 'static> ChannelWriteHalf< self.send_data(Some(ext), data).await } + /// Send owned extended data to a channel without copying it into the `AsyncWrite` path. + pub async fn extended_data_bytes( + &self, + ext: u32, + data: impl Into, + ) -> Result<(), Error> { + self.send_bytes(Some(ext), data.into()).await + } + async fn send_data( &self, ext: Option, @@ -345,6 +359,49 @@ impl + Send + Sync + 'static> ChannelWriteHalf< Ok(()) } + async fn reserve_writable_chunk(&self, remaining: usize) -> Result { + if self.max_packet_size == 0 { + return Err(Error::Inconsistent); + } + loop { + let mut window_size = self.window_size.value.lock().await; + let writable = (self.max_packet_size as usize) + .min(*window_size as usize) + .min(remaining); + if writable > 0 { + *window_size -= writable as u32; + if *window_size > 0 { + self.window_size.notifier.notify_one(); + } + return Ok(writable); + } + let notified = self.window_size.notifier.notified(); + drop(window_size); + notified.await; + } + } + + async fn send_bytes(&self, ext: Option, data: Bytes) -> Result<(), Error> { + if data.is_empty() { + return Ok(()); + } + + let mut offset = 0; + while offset < data.len() { + let writable = self.reserve_writable_chunk(data.len() - offset).await?; + let end = offset + writable; + let chunk = data.slice(offset..end); + let msg = match ext { + None => ChannelMsg::Data { data: chunk }, + Some(ext) => ChannelMsg::ExtendedData { data: chunk, ext }, + }; + self.send_msg(msg).await?; + offset = end; + } + + Ok(()) + } + pub async fn eof(&self) -> Result<(), Error> { self.send_msg(ChannelMsg::Eof).await } @@ -556,6 +613,11 @@ impl + Send + Sync + 'static> Channel { self.write_half.data(data).await } + /// Send owned bytes to a channel without copying them into the `AsyncWrite` path. + pub async fn data_bytes(&self, data: impl Into) -> Result<(), Error> { + self.write_half.data_bytes(data).await + } + /// Send data to a channel. The number of bytes added to the /// "sending pipeline" (to be processed by the event loop) is /// returned. @@ -567,6 +629,15 @@ impl + Send + Sync + 'static> Channel { self.write_half.extended_data(ext, data).await } + /// Send owned extended data to a channel without copying it into the `AsyncWrite` path. + pub async fn extended_data_bytes( + &self, + ext: u32, + data: impl Into, + ) -> Result<(), Error> { + self.write_half.extended_data_bytes(ext, data).await + } + pub async fn eof(&self) -> Result<(), Error> { self.write_half.eof().await } @@ -625,3 +696,148 @@ impl + Send + Sync + 'static> Channel { self.write_half.make_writer_ext(ext) } } + +#[cfg(test)] +mod tests { + use tokio::sync::mpsc; + + use super::*; + + fn test_write_half( + window_size: WindowSizeRef, + max_packet_size: u32, + ) -> ( + ChannelWriteHalf<(ChannelId, ChannelMsg)>, + mpsc::Receiver<(ChannelId, ChannelMsg)>, + ) { + let (sender, receiver) = mpsc::channel(8); + ( + ChannelWriteHalf { + id: ChannelId(7), + sender, + max_packet_size, + window_size, + }, + receiver, + ) + } + + #[tokio::test] + async fn data_bytes_sends_one_owned_message_when_window_permits() { + let payload = Bytes::from_static(b"owned data"); + let (write_half, mut receiver) = test_write_half(WindowSizeRef::new(1024), 1024); + + write_half.data_bytes(payload.clone()).await.unwrap(); + + match receiver.recv().await.unwrap() { + (ChannelId(7), ChannelMsg::Data { data }) => { + assert_eq!(data, payload); + assert_eq!(data.as_ptr(), payload.as_ptr()); + } + msg => panic!("unexpected message: {msg:?}"), + } + } + + #[tokio::test] + async fn data_bytes_splits_by_max_packet_size_without_copying() { + let payload = Bytes::from_static(b"abcdefghij"); + let (write_half, mut receiver) = test_write_half(WindowSizeRef::new(1024), 4); + + write_half.data_bytes(payload.clone()).await.unwrap(); + + for (range, expected) in [ + (0..4, &b"abcd"[..]), + (4..8, &b"efgh"[..]), + (8..10, &b"ij"[..]), + ] { + match receiver.recv().await.unwrap() { + (ChannelId(7), ChannelMsg::Data { data }) => { + assert_eq!(data.as_ref(), expected); + assert_eq!(data.as_ptr(), payload.slice(range).as_ptr()); + } + msg => panic!("unexpected message: {msg:?}"), + } + } + assert!(receiver.try_recv().is_err()); + } + + #[tokio::test] + async fn extended_data_bytes_preserves_extension_code() { + let payload = Bytes::from_static(b"stderr"); + let (write_half, mut receiver) = test_write_half(WindowSizeRef::new(1024), 1024); + + write_half + .extended_data_bytes(1, payload.clone()) + .await + .unwrap(); + + match receiver.recv().await.unwrap() { + (ChannelId(7), ChannelMsg::ExtendedData { data, ext }) => { + assert_eq!(ext, 1); + assert_eq!(data, payload); + assert_eq!(data.as_ptr(), payload.as_ptr()); + } + msg => panic!("unexpected message: {msg:?}"), + } + } + + #[tokio::test] + async fn data_bytes_empty_payload_sends_nothing() { + let (write_half, mut receiver) = test_write_half(WindowSizeRef::new(1024), 1024); + + write_half.data_bytes(Bytes::new()).await.unwrap(); + + assert!(receiver.try_recv().is_err()); + } + + #[tokio::test] + async fn data_bytes_waits_for_window_update() { + let window_size = WindowSizeRef::new(0); + let (write_half, mut receiver) = test_write_half(window_size.clone(), 1024); + let send = tokio::spawn(async move { + write_half + .data_bytes(Bytes::from_static(b"after-window")) + .await + .unwrap(); + }); + + tokio::task::yield_now().await; + assert!(!send.is_finished()); + + window_size.update(1024).await; + send.await.unwrap(); + + match receiver.recv().await.unwrap() { + (ChannelId(7), ChannelMsg::Data { data }) => { + assert_eq!(data.as_ref(), b"after-window"); + } + msg => panic!("unexpected message: {msg:?}"), + } + } + + #[tokio::test] + async fn data_bytes_rejects_zero_max_packet_size() { + let (write_half, mut receiver) = test_write_half(WindowSizeRef::new(1024), 0); + + let result = write_half.data_bytes(Bytes::from_static(b"owned")).await; + + assert!(matches!(result, Err(Error::Inconsistent))); + assert!(receiver.try_recv().is_err()); + } + + #[tokio::test] + async fn channel_data_bytes_forwards_to_write_half() { + let (sender, mut receiver) = mpsc::channel(8); + let (channel, _reference) = + Channel::<(ChannelId, ChannelMsg)>::new(ChannelId(9), sender, 1024, 1024, 8); + + channel.data_bytes(Bytes::from_static(b"channel")).await.unwrap(); + + match receiver.recv().await.unwrap() { + (ChannelId(9), ChannelMsg::Data { data }) => { + assert_eq!(data.as_ref(), b"channel"); + } + msg => panic!("unexpected message: {msg:?}"), + } + } +} diff --git a/crates/bssh-russh/src/cipher/block.rs b/crates/bssh-russh/src/cipher/block.rs index 523f411c..8618978d 100644 --- a/crates/bssh-russh/src/cipher/block.rs +++ b/crates/bssh-russh/src/cipher/block.rs @@ -14,9 +14,11 @@ use std::convert::TryInto; use std::marker::PhantomData; -use aes::cipher::{IvSizeUser, KeyIvInit, KeySizeUser, StreamCipher}; +use aes::cipher::{ + InOutBuf, Iv, IvSizeUser, Key, KeyIvInit, KeySizeUser, StreamCipher, StreamCipherError, + StreamCipherSeek, +}; #[allow(deprecated)] -use digest::generic_array::GenericArray as GenericArray_0_14; use rand_core::Rng; use super::super::Error; @@ -24,20 +26,79 @@ use super::PACKET_LENGTH_LEN; use crate::keys::key::safe_rng; use crate::mac::{Mac, MacAlgorithm}; -// Allow deprecated generic-array 0.14 usage until RustCrypto crates (cipher, digest, etc.) -// upgrade to generic-array 1.x. Remove this when dependencies no longer use 0.14. -#[allow(deprecated)] fn new_cipher_from_slices(k: &[u8], n: &[u8]) -> C { + #[allow(clippy::expect_used)] C::new( - GenericArray_0_14::from_slice(k), - GenericArray_0_14::from_slice(n), + <&Key>::try_from(k).expect("key length matches"), + <&Iv>::try_from(n).expect("iv length matches"), ) } -pub struct SshBlockCipher(pub PhantomData); +/// Cloneable wrapper for `Ctr128BE<>` +pub struct CtrWrapper +where + C: KeyIvInit, +{ + key: Key, + initial_iv: Iv, + pos: u64, +} + +impl Clone for CtrWrapper { + fn clone(&self) -> Self { + Self { + key: self.key.clone(), + initial_iv: self.initial_iv.clone(), + pos: self.pos, + } + } +} + +impl KeySizeUser for CtrWrapper { + type KeySize = ::KeySize; +} + +impl IvSizeUser for CtrWrapper { + type IvSize = ::IvSize; +} + +impl KeyIvInit for CtrWrapper { + fn new(key: &Key, iv: &Iv) -> Self { + Self { + key: key.clone(), + initial_iv: iv.clone(), + pos: 0, + } + } +} + +impl StreamCipher for CtrWrapper { + fn check_remaining(&self, _data_len: usize) -> Result<(), StreamCipherError> { + Ok(()) + } + + fn unchecked_apply_keystream_inout(&mut self, buf: InOutBuf<'_, '_, u8>) { + let mut cipher = C::new(&self.key, &self.initial_iv); + cipher.seek(self.pos); + cipher.unchecked_apply_keystream_inout(buf); + self.pos = cipher.current_pos(); + } + + fn unchecked_write_keystream(&mut self, buf: &mut [u8]) { + let mut cipher = C::new(&self.key, &self.initial_iv); + cipher.seek(self.pos); + cipher.unchecked_write_keystream(buf); + self.pos = cipher.current_pos(); + } +} + +pub struct SshBlockCipher( + pub PhantomData, +); -impl super::Cipher - for SshBlockCipher +impl< + C: BlockStreamCipher + PacketLengthProbe + KeySizeUser + IvSizeUser + KeyIvInit + Send + 'static, +> super::Cipher for SshBlockCipher { fn key_len(&self) -> usize { C::key_size() @@ -78,7 +139,7 @@ impl { +pub struct OpeningKey { pub(crate) cipher: C, pub(crate) mac: Box, } @@ -88,7 +149,9 @@ pub struct SealingKey { pub(crate) mac: Box, } -impl super::OpeningKey for OpeningKey { +impl super::OpeningKey + for OpeningKey +{ fn packet_length_to_read_for_block_length(&self) -> usize { 16 } @@ -108,10 +171,7 @@ impl super::OpeningKey for Open #[allow(clippy::unwrap_used, clippy::indexing_slicing)] encrypted_packet_length[..4].try_into().unwrap() } else { - // Work around uncloneable Aes<> - let mut cipher: C = unsafe { std::ptr::read(&self.cipher as *const C) }; - - cipher.decrypt_data(&mut first_block); + self.cipher.decrypt_packet_length_block(&mut first_block); // Fine because of self.packet_length_to_read_for_block_length() #[allow(clippy::unwrap_used, clippy::indexing_slicing)] @@ -213,6 +273,10 @@ pub trait BlockStreamCipher { fn decrypt_data(&mut self, data: &mut [u8]); } +pub(crate) trait PacketLengthProbe { + fn decrypt_packet_length_block(&self, first_block: &mut [u8; 16]); +} + impl BlockStreamCipher for T { fn encrypt_data(&mut self, data: &mut [u8]) { self.apply_keystream(data); @@ -222,3 +286,124 @@ impl BlockStreamCipher for T { self.apply_keystream(data); } } + +impl PacketLengthProbe for T { + fn decrypt_packet_length_block(&self, first_block: &mut [u8; 16]) { + let mut cipher = self.clone(); + cipher.apply_keystream(first_block); + } +} + +#[cfg(test)] +mod tests { + use aes::Aes128; + use aes::cipher::KeyIvInit; + use aes::cipher::StreamCipher; + use aes::cipher::{IvSizeUser, KeySizeUser}; + use ctr::Ctr128BE; + use digest::typenum::U16; + use tokio::io::AsyncWriteExt; + + use super::{BlockStreamCipher, CtrWrapper, OpeningKey, PacketLengthProbe}; + use crate::mac::MacAlgorithm; + use crate::sshbuffer::SSHBuffer; + + #[test] + fn stream_cipher_probe_does_not_advance_cipher_state() { + let plaintext = *b"0123456789ABCDEF"; + let key = fixture_bytes::<16>(7); + let iv = fixture_bytes::<16>(3); + + let mut encryptor = CtrWrapper::>::new(&key.into(), &iv.into()); + let mut ciphertext = plaintext; + encryptor.apply_keystream(&mut ciphertext); + + let cipher = CtrWrapper::>::new(&key.into(), &iv.into()); + let mut probed_block = ciphertext; + cipher.decrypt_packet_length_block(&mut probed_block); + assert_eq!(probed_block, plaintext); + + let mut decrypted = ciphertext; + let mut cipher_after_probe = cipher; + cipher_after_probe.decrypt_data(&mut decrypted); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn decrypt_packet_length_uses_independent_cipher_state() -> std::io::Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let opening = OpeningKey { + cipher: OwnedStateCipher::new(), + mac: crate::mac::_NONE.make_mac(&[]), + }; + let mut opening = opening; + let mut buffer = SSHBuffer::new(); + let bytes_read = runtime + .block_on(async { + let (mut writer, mut reader) = tokio::io::duplex(64); + writer.write_all(&[0; 17]).await?; + drop(writer); + crate::cipher::read(&mut reader, &mut buffer, &mut opening).await + }) + .map_err(std::io::Error::other)?; + + assert_eq!(bytes_read, 16); + Ok(()) + } + + struct OwnedStateCipher { + packet_length: Box<[u8; 4]>, + } + + impl OwnedStateCipher { + fn new() -> Self { + Self { + packet_length: Box::new([0, 0, 0, 13]), + } + } + } + + impl Clone for OwnedStateCipher { + fn clone(&self) -> Self { + Self { + packet_length: Box::new([0, 0, 0, 12]), + } + } + } + + impl KeySizeUser for OwnedStateCipher { + type KeySize = U16; + } + + impl IvSizeUser for OwnedStateCipher { + type IvSize = U16; + } + + impl BlockStreamCipher for OwnedStateCipher { + fn encrypt_data(&mut self, _data: &mut [u8]) {} + + fn decrypt_data(&mut self, data: &mut [u8]) { + if let Some(prefix) = data.get_mut(..4) { + prefix.copy_from_slice(&self.packet_length[..]); + } + } + } + + impl PacketLengthProbe for OwnedStateCipher { + fn decrypt_packet_length_block(&self, first_block: &mut [u8; 16]) { + if let Some(prefix) = first_block.get_mut(..4) { + prefix.copy_from_slice(&[0, 0, 0, 12]); + } + } + } + + fn fixture_bytes(seed: u8) -> [u8; N] { + let mut bytes = [0; N]; + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = seed.wrapping_add(i as u8); + } + bytes + } +} diff --git a/crates/bssh-russh/src/cipher/cbc.rs b/crates/bssh-russh/src/cipher/cbc.rs index bcc9c8c4..100c93bd 100644 --- a/crates/bssh-russh/src/cipher/cbc.rs +++ b/crates/bssh-russh/src/cipher/cbc.rs @@ -1,64 +1,137 @@ -use aes::cipher::{ - BlockCipher, BlockDecrypt, BlockDecryptMut, BlockEncrypt, BlockEncryptMut, InnerIvInit, Iv, - IvSizeUser, -}; +use cbc::cipher::{InnerIvInit, Iv, IvSizeUser}; use cbc::{Decryptor, Encryptor}; -use digest::crypto_common::InnerUser; -#[allow(deprecated)] -use digest::generic_array::GenericArray; +use cipher::common::InnerUser; +use cipher::{ + Block, BlockCipherDecrypt, BlockCipherEncrypt, BlockModeDecrypt, BlockModeEncrypt, IvState, +}; -use super::block::BlockStreamCipher; +use super::block::{BlockStreamCipher, PacketLengthProbe}; -// Allow deprecated generic-array 0.14 usage until RustCrypto crates (cipher, cbc, etc.) -// upgrade to generic-array 1.x. Remove this when dependencies no longer use 0.14. -#[allow(deprecated)] -fn generic_array_from_slice(chunk: &[u8]) -> GenericArray +/// CBC wrapper that stores the decryption cipher and IV separately rather than +/// a `cbc::Decryptor`, because `Decryptor` is no longer `Clone` in cbc 0.2. +/// This allows stateless peeking at the packet length block without cloning. +pub struct CbcWrapper where - N: digest::generic_array::ArrayLength, + C: BlockCipherEncrypt + BlockCipherDecrypt, { - GenericArray::from_slice(chunk).clone() + encryptor: Encryptor, + /// Raw cipher used for decryption. `BlockCipherDecrypt::decrypt_block` takes + /// `&self`, so this can be used without mutation for packet-length probing. + dec_cipher: C, + /// Current CBC decryption IV (i.e. the last ciphertext block consumed). + dec_iv: Block, } -pub struct CbcWrapper { - encryptor: Encryptor, - decryptor: Decryptor, +impl CbcWrapper +where + C: BlockCipherEncrypt + BlockCipherDecrypt + Clone, +{ + #[must_use] + fn decrypt_inner(&self, data: &mut [u8]) -> Iv { + let mut dec = Decryptor::<&C>::inner_iv_init(&self.dec_cipher, &self.dec_iv); + + for chunk in data.chunks_exact_mut(C::block_size()) { + #[allow(clippy::expect_used)] + let block = <&mut Block>::try_from(chunk).expect("chunk length matches block size"); + + dec.decrypt_block(block); + } + + dec.iv_state() + } } -impl InnerUser for CbcWrapper { +impl InnerUser for CbcWrapper { type Inner = C; } -impl IvSizeUser for CbcWrapper { +impl IvSizeUser for CbcWrapper { type IvSize = C::BlockSize; } -impl BlockStreamCipher for CbcWrapper { +impl BlockStreamCipher for CbcWrapper { fn encrypt_data(&mut self, data: &mut [u8]) { for chunk in data.chunks_exact_mut(C::block_size()) { - let mut block = generic_array_from_slice(chunk); - self.encryptor.encrypt_block_mut(&mut block); - chunk.copy_from_slice(&block); + #[allow(clippy::expect_used)] + let block = <&mut Block>::try_from(chunk).expect("chunk length matches block size"); + self.encryptor.encrypt_block(block); } } fn decrypt_data(&mut self, data: &mut [u8]) { - for chunk in data.chunks_exact_mut(C::block_size()) { - let mut block = generic_array_from_slice(chunk); - self.decryptor.decrypt_block_mut(&mut block); - chunk.copy_from_slice(&block); - } + self.dec_iv = self.decrypt_inner(data) } } -impl InnerIvInit for CbcWrapper -where - C: BlockEncryptMut + BlockCipher, -{ +impl PacketLengthProbe for CbcWrapper { + fn decrypt_packet_length_block(&self, first_block: &mut [u8; 16]) { + let _ = self.decrypt_inner(first_block); + } +} + +impl InnerIvInit for CbcWrapper { #[inline] fn inner_iv_init(cipher: C, iv: &Iv) -> Self { Self { encryptor: Encryptor::inner_iv_init(cipher.clone(), iv), - decryptor: Decryptor::inner_iv_init(cipher, iv), + dec_cipher: cipher, + dec_iv: iv.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use aes::Aes128; + use cbc::cipher::KeyIvInit; + #[cfg(feature = "des")] + use des::TdesEde3; + + use super::{BlockStreamCipher, CbcWrapper, PacketLengthProbe}; + + #[test] + fn packet_length_probe_does_not_advance_cbc_decryptor_state() { + let plaintext = *b"0123456789ABCDEF"; + let key = fixture_bytes::<16>(11); + let iv = fixture_bytes::<16>(5); + + let mut encryptor = CbcWrapper::::new(&key.into(), &iv.into()); + let mut ciphertext = plaintext; + encryptor.encrypt_data(&mut ciphertext); + + let cipher = CbcWrapper::::new(&key.into(), &iv.into()); + let mut probed_block = ciphertext; + cipher.decrypt_packet_length_block(&mut probed_block); + assert_eq!(probed_block, plaintext); + + let mut decrypted = ciphertext; + let mut cipher_after_probe = cipher; + cipher_after_probe.decrypt_data(&mut decrypted); + assert_eq!(decrypted, plaintext); + } + + #[cfg(feature = "des")] + #[test] + fn packet_length_probe_respects_3des_block_size() { + let plaintext = *b"0123456789ABCDEF"; + let key = fixture_bytes::<24>(11); + let iv = fixture_bytes::<8>(5); + + let mut encryptor = CbcWrapper::::new(&key.into(), &iv.into()); + let mut ciphertext = plaintext; + encryptor.encrypt_data(&mut ciphertext); + + let cipher = CbcWrapper::::new(&key.into(), &iv.into()); + let mut probed_block = ciphertext; + cipher.decrypt_packet_length_block(&mut probed_block); + assert_eq!(probed_block, plaintext); + } + + fn fixture_bytes(seed: u8) -> [u8; N] { + let mut bytes = [0; N]; + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = seed.wrapping_add(i as u8); } + bytes } } diff --git a/crates/bssh-russh/src/cipher/mod.rs b/crates/bssh-russh/src/cipher/mod.rs index 1b10055b..7250d608 100644 --- a/crates/bssh-russh/src/cipher/mod.rs +++ b/crates/bssh-russh/src/cipher/mod.rs @@ -26,6 +26,7 @@ use aes::{Aes128, Aes192, Aes256}; #[cfg(feature = "aws-lc-rs")] use aws_lc_rs::aead::{AES_128_GCM as ALGORITHM_AES_128_GCM, AES_256_GCM as ALGORITHM_AES_256_GCM}; use byteorder::{BigEndian, ByteOrder}; +use block::CtrWrapper; use ctr::Ctr128BE; use delegate::delegate; use log::trace; @@ -103,9 +104,9 @@ pub const NONE: Name = Name("none"); pub(crate) static _CLEAR: Clear = Clear {}; #[cfg(feature = "des")] static _3DES_CBC: SshBlockCipher> = SshBlockCipher(PhantomData); -static _AES_128_CTR: SshBlockCipher> = SshBlockCipher(PhantomData); -static _AES_192_CTR: SshBlockCipher> = SshBlockCipher(PhantomData); -static _AES_256_CTR: SshBlockCipher> = SshBlockCipher(PhantomData); +static _AES_128_CTR: SshBlockCipher>> = SshBlockCipher(PhantomData); +static _AES_192_CTR: SshBlockCipher>> = SshBlockCipher(PhantomData); +static _AES_256_CTR: SshBlockCipher>> = SshBlockCipher(PhantomData); static _AES_128_GCM: GcmCipher = GcmCipher(&ALGORITHM_AES_128_GCM); static _AES_256_GCM: GcmCipher = GcmCipher(&ALGORITHM_AES_256_GCM); static _AES_128_CBC: SshBlockCipher> = SshBlockCipher(PhantomData); @@ -209,6 +210,45 @@ pub(crate) trait SealingKey { fn seal(&mut self, seqn: u32, plaintext_in_ciphertext_out: &mut [u8], tag_out: &mut [u8]); + #[allow(clippy::indexing_slicing)] // PacketWriter reserves and sizes the packet buffer first + fn finish_packet(&mut self, offset: usize, payload_len: usize, buffer: &mut SSHBuffer) { + let payload_start = offset + PACKET_LENGTH_LEN + PADDING_LENGTH_LEN; + let payload_end = payload_start + payload_len; + + trace!("writing, seqn = {:?}", buffer.seqn.0); + let padding_length = self.padding_length(&buffer.buffer[payload_start..payload_end]); + trace!("padding length {padding_length:?}"); + let packet_length = PADDING_LENGTH_LEN + payload_len + padding_length; + trace!("packet_length {packet_length:?}"); + + // Maximum packet length: + // https://tools.ietf.org/html/rfc4253#section-6.1 + assert!(packet_length <= u32::MAX as usize); + BigEndian::write_u32( + &mut buffer.buffer[offset..offset + PACKET_LENGTH_LEN], + packet_length as u32, + ); + + assert!(padding_length <= u8::MAX as usize); + buffer.buffer[offset + PACKET_LENGTH_LEN] = padding_length as u8; + buffer.buffer.resize(payload_end + padding_length, 0); + #[allow(clippy::indexing_slicing)] // length checked + self.fill_padding(&mut buffer.buffer[payload_end..]); + let tag_offset = buffer.buffer.len(); + buffer.buffer.resize(tag_offset + self.tag_len(), 0); + + #[allow(clippy::indexing_slicing)] // length checked + let (plaintext, tag) = + buffer.buffer[offset..].split_at_mut(PACKET_LENGTH_LEN + packet_length); + + self.seal(buffer.seqn.0, plaintext, tag); + + buffer.bytes += payload_len; + // Sequence numbers are on 32 bits and wrap. + // https://tools.ietf.org/html/rfc4253#section-6.4 + buffer.seqn += Wrapping(1); + } + fn write(&mut self, payload: &[u8], buffer: &mut SSHBuffer) { // https://tools.ietf.org/html/rfc4253#section-6 // @@ -225,8 +265,9 @@ pub(crate) trait SealingKey { // Maximum packet length: // https://tools.ietf.org/html/rfc4253#section-6.1 assert!(packet_length <= u32::MAX as usize); - #[allow(clippy::unwrap_used)] // length checked - (packet_length as u32).encode(&mut buffer.buffer).unwrap(); + buffer + .buffer + .extend_from_slice(&(packet_length as u32).to_be_bytes()); assert!(padding_length <= u8::MAX as usize); buffer.buffer.push(padding_length as u8); @@ -322,6 +363,9 @@ const MAXIMUM_PADDING_LEN: usize = 19; const MAXIMUM_PACKET_LEN_HEADROOM: usize = PADDING_LENGTH_LEN + CHANNEL_EXTENDED_DATA_PACKET_OVERHEAD + MAXIMUM_PADDING_LEN; const MAXIMUM_PACKET_LEN: usize = MAXIMUM_PACKET_LEN_BASELINE + MAXIMUM_PACKET_LEN_HEADROOM; +// Keep post-decompression growth within the same packet-acceptance model as +// the transport read path. +pub(crate) const MAXIMUM_DECOMPRESSED_PACKET_LEN: usize = MAXIMUM_PACKET_LEN; #[cfg(feature = "_bench")] pub mod benchmark; diff --git a/crates/bssh-russh/src/client/encrypted.rs b/crates/bssh-russh/src/client/encrypted.rs index 34d22821..86ca494c 100644 --- a/crates/bssh-russh/src/client/encrypted.rs +++ b/crates/bssh-russh/src/client/encrypted.rs @@ -27,7 +27,7 @@ use crate::cert::PublicKeyOrCertificate; use crate::client::{Handler, Msg, Prompt, Reply, Session}; use crate::helpers::{AlgorithmExt, EncodedExt, NameList, sign_with_hash_alg}; use crate::keys::key::parse_public_key; -use crate::parsing::{ChannelOpenConfirmation, ChannelType, OpenChannelMessage}; +use crate::parsing::{ChannelOpenConfirmation, ChannelType, OpenChannelMessage, ensure_end}; use crate::session::{Encrypted, EncryptedState, GlobalRequestResponse}; use crate::{ Channel, ChannelId, ChannelMsg, ChannelOpenFailure, ChannelParams, Error, MethodSet, Sig, auth, @@ -72,6 +72,7 @@ impl Session { match buf.split_first() { Some((&msg::SERVICE_ACCEPT, mut r)) => { if map_err!(Bytes::decode(&mut r))?.as_ref() == b"ssh-userauth" { + map_err!(ensure_end(&r))?; *accepted = true; if let Some(ref meth) = self.common.auth_method { let len = enc.write.len(); @@ -98,7 +99,8 @@ impl Session { EncryptedState::WaitingAuthRequest(ref mut auth_request) => { trace!("waiting auth request, {:?}", buf.first(),); match buf.split_first() { - Some((&msg::USERAUTH_SUCCESS, _)) => { + Some((&msg::USERAUTH_SUCCESS, r)) => { + map_err!(ensure_end(&r))?; debug!("userauth_success"); self.sender .send(Reply::AuthSuccess) @@ -111,6 +113,8 @@ impl Session { } Some((&msg::USERAUTH_BANNER, mut r)) => { let banner = map_err!(String::decode(&mut r))?; + let _language_tag = map_err!(String::decode(&mut r))?; + map_err!(ensure_end(&r))?; client.auth_banner(&banner, self).await?; return Ok(()); } @@ -120,6 +124,7 @@ impl Session { let remaining_methods: MethodSet = (&map_err!(NameList::decode(&mut r))?).into(); let partial_success = map_err!(u8::decode(&mut r))? != 0; + map_err!(ensure_end(&r))?; debug!( "remaining methods {remaining_methods:?}, partial success {partial_success:?}" ); @@ -146,6 +151,9 @@ impl Session { }) = auth_request.current { debug!("userauth_pk_ok"); + let _algo = map_err!(String::decode(&mut r))?; + let _key = map_err!(Bytes::decode(&mut r))?; + map_err!(ensure_end(&r))?; *sent_pk_ok = true; } else if let Some(auth::CurrentRequest::KeyboardInteractive { .. @@ -162,8 +170,13 @@ impl Session { let n_prompts = map_err!(u32::decode(&mut r))?; // read prompts - let mut prompts = - Vec::with_capacity(n_prompts.try_into().unwrap_or(0)); + // Each prompt needs at least a 4-byte length plus a 1-byte echo flag. + let max_prompts = r.remaining_len() / 5; + let n_prompts = n_prompts as usize; + if n_prompts > max_prompts { + return Err(crate::Error::Inconsistent.into()); + } + let mut prompts = Vec::with_capacity(n_prompts); for _i in 0..n_prompts { let prompt = map_err!(String::decode(&mut r))?; @@ -173,6 +186,7 @@ impl Session { echo, }); } + map_err!(ensure_end(&r))?; // send challenges to caller self.sender @@ -324,6 +338,7 @@ impl Session { } } } + ensure_end(r)?; Ok(()) } @@ -331,8 +346,7 @@ impl Session { let algs = NameList::decode(r)?; debug!("* server-sig-algs"); self.server_sig_algs = Some( - algs.0 - .iter() + algs.iter() .filter_map(|x| Algorithm::from_str(x).ok()) .inspect(|x| { debug!(" * {x:?}"); @@ -351,6 +365,7 @@ impl Session { Some((&msg::CHANNEL_OPEN_CONFIRMATION, mut reader)) => { debug!("channel_open_confirmation"); let msg = map_err!(ChannelOpenConfirmation::decode(&mut reader))?; + map_err!(ensure_end(&reader))?; let local_id = ChannelId(msg.recipient_channel); if let Some(ref mut enc) = self.common.encrypted { @@ -389,6 +404,7 @@ impl Session { Some((&msg::CHANNEL_CLOSE, mut r)) => { debug!("channel_close"); let channel_num = map_err!(ChannelId::decode(&mut r))?; + map_err!(ensure_end(&r))?; if let Some(ref mut enc) = self.common.encrypted { // The CHANNEL_CLOSE message must be sent to the server at this point or the session // will not be released. @@ -406,6 +422,7 @@ impl Session { Some((&msg::CHANNEL_EOF, mut r)) => { debug!("channel_eof"); let channel_num = map_err!(ChannelId::decode(&mut r))?; + map_err!(ensure_end(&r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan.send(ChannelMsg::Eof).await; } @@ -418,6 +435,7 @@ impl Session { .unwrap_or(ChannelOpenFailure::Unknown); let descr = map_err!(String::decode(&mut r))?; let language = map_err!(String::decode(&mut r))?; + map_err!(ensure_end(&r))?; if let Some(ref mut enc) = self.common.encrypted { enc.channels.remove(&channel_num); } @@ -436,15 +454,17 @@ impl Session { trace!("channel_data"); let channel_num = map_err!(ChannelId::decode(&mut r))?; let data = map_err!(Bytes::decode(&mut r))?; + map_err!(ensure_end(&r))?; let target = self.common.config.window_size; - if let Some(ref mut enc) = self.common.encrypted - && enc.adjust_window_size(channel_num, &data, target)? { + if let Some(ref mut enc) = self.common.encrypted { + if enc.adjust_window_size(channel_num, &data, target)? { let next_window = client.adjust_window(channel_num, self.target_window_size); if next_window > 0 { self.target_window_size = next_window } } + } if let Some(chan) = self.channels.get(&channel_num) { let _ = chan.send(ChannelMsg::Data { data: data.clone() }).await; @@ -457,15 +477,17 @@ impl Session { let channel_num = map_err!(ChannelId::decode(&mut r))?; let extended_code = map_err!(u32::decode(&mut r))?; let data = map_err!(Bytes::decode(&mut r))?; + map_err!(ensure_end(&r))?; let target = self.common.config.window_size; - if let Some(ref mut enc) = self.common.encrypted - && enc.adjust_window_size(channel_num, &data, target)? { + if let Some(ref mut enc) = self.common.encrypted { + if enc.adjust_window_size(channel_num, &data, target)? { let next_window = client.adjust_window(channel_num, self.target_window_size); if next_window > 0 { self.target_window_size = next_window } } + } if let Some(chan) = self.channels.get(&channel_num) { let _ = chan @@ -488,6 +510,7 @@ impl Session { "xon-xoff" => { map_err!(u8::decode(&mut r))?; // should be 0. let client_can_do = map_err!(u8::decode(&mut r))? != 0; + map_err!(ensure_end(&r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan.send(ChannelMsg::XonXoff { client_can_do }).await; } @@ -496,6 +519,7 @@ impl Session { "exit-status" => { map_err!(u8::decode(&mut r))?; // should be 0. let exit_status = map_err!(u32::decode(&mut r))?; + map_err!(ensure_end(&r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan.send(ChannelMsg::ExitStatus { exit_status }).await; } @@ -508,6 +532,7 @@ impl Session { let core_dumped = map_err!(u8::decode(&mut r))? != 0; let error_message = map_err!(String::decode(&mut r))?; let lang_tag = map_err!(String::decode(&mut r))?; + map_err!(ensure_end(&r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan .send(ChannelMsg::ExitSignal { @@ -531,6 +556,7 @@ impl Session { } "keepalive@openssh.com" => { let wants_reply = map_err!(u8::decode(&mut r))?; + map_err!(ensure_end(&r))?; if wants_reply == 1 { if let Some(ref mut enc) = self.common.encrypted { trace!("Received channel keep alive message: {req:?}",); @@ -549,8 +575,8 @@ impl Session { } _ => { let wants_reply = map_err!(u8::decode(&mut r))?; - if wants_reply == 1 - && let Some(ref mut enc) = self.common.encrypted { + if wants_reply == 1 { + if let Some(ref mut enc) = self.common.encrypted { self.common.wants_reply = false; if let Some(ch) = enc.channels.get(&channel_num) { push_packet!(enc.write, { @@ -559,6 +585,7 @@ impl Session { }) } } + } info!("Unknown channel request {req:?} {wants_reply:?}",); Ok(()) } @@ -567,6 +594,7 @@ impl Session { Some((&msg::CHANNEL_WINDOW_ADJUST, mut r)) => { let channel_num = map_err!(ChannelId::decode(&mut r))?; let amount = map_err!(u32::decode(&mut r))?; + map_err!(ensure_end(&r))?; let mut new_size = 0; debug!("channel_window_adjust amount: {amount:?}"); if let Some(ref mut enc) = self.common.encrypted { @@ -578,8 +606,11 @@ impl Session { } } - if let Some(ref mut enc) = self.common.encrypted { - new_size -= enc.flush_pending(channel_num)? as u32; + let common = &mut self.common; + if let Some(enc) = common.encrypted.as_mut() { + new_size -= enc + .flush_pending_with_writer(&mut common.packet_writer, channel_num)? + as u32; } if let Some(chan) = self.channels.get(&channel_num) { chan.window_size().update(new_size).await; @@ -595,6 +626,7 @@ impl Session { let wants_reply = map_err!(u8::decode(&mut r))?; if let Some(ref mut enc) = self.common.encrypted { if req.starts_with("keepalive") { + map_err!(ensure_end(&r))?; if wants_reply == 1 { trace!("Received keep alive message: {req:?}",); self.common.wants_reply = false; @@ -604,25 +636,18 @@ impl Session { } } else if req == "hostkeys-00@openssh.com" { let mut keys = vec![]; - loop { - match Bytes::decode(&mut r) { - Ok(key) => { - let key = map_err!(parse_public_key(&key)); - match key { - Ok(key) => keys.push(key), - Err(ref err) => { - debug!( - "failed to parse announced host key {key:?}: {err:?}", - ) - } - } - } - Err(ssh_encoding::Error::Length) => break, - x => { - map_err!(x)?; + while !r.is_empty() { + let key_blob = map_err!(Bytes::decode(&mut r))?; + match parse_public_key(&key_blob) { + Ok(key) => keys.push(key), + Err(ref err) => { + debug!( + "failed to parse announced host key {key_blob:?}: {err:?}", + ) } } } + map_err!(ensure_end(&r))?; return client.openssh_ext_host_keys_announced(keys, self).await; } else { warn!("Unhandled global request: {req:?} {wants_reply:?}",); @@ -635,6 +660,7 @@ impl Session { } Some((&msg::CHANNEL_SUCCESS, mut r)) => { let channel_num = map_err!(ChannelId::decode(&mut r))?; + map_err!(ensure_end(&r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan.send(ChannelMsg::Success).await; } @@ -642,6 +668,7 @@ impl Session { } Some((&msg::CHANNEL_FAILURE, mut r)) => { let channel_num = map_err!(ChannelId::decode(&mut r))?; + map_err!(ensure_end(&r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan.send(ChannelMsg::Failure).await; } @@ -776,12 +803,15 @@ impl Session { trace!("Global Request Success"); match self.open_global_requests.pop_front() { Some(GlobalRequestResponse::Keepalive) => { + map_err!(ensure_end(&r))?; // ignore keepalives } Some(GlobalRequestResponse::Ping(return_channel)) => { + map_err!(ensure_end(&r))?; let _ = return_channel.send(()); } Some(GlobalRequestResponse::NoMoreSessions) => { + map_err!(ensure_end(&r))?; debug!("no-more-sessions@openssh.com requests success"); } Some(GlobalRequestResponse::TcpIpForward(return_channel)) => { @@ -790,7 +820,16 @@ impl Session { Some(0) } else { match u32::decode(&mut r) { - Ok(port) => Some(port), + Ok(port) => { + if let Err(e) = ensure_end(&r) { + error!( + "Error parsing port for TcpIpForward request: {e:?}" + ); + None + } else { + Some(port) + } + } Err(e) => { error!("Error parsing port for TcpIpForward request: {e:?}"); None @@ -800,12 +839,15 @@ impl Session { let _ = return_channel.send(result); } Some(GlobalRequestResponse::CancelTcpIpForward(return_channel)) => { + map_err!(ensure_end(&r))?; let _ = return_channel.send(true); } Some(GlobalRequestResponse::StreamLocalForward(return_channel)) => { + map_err!(ensure_end(&r))?; let _ = return_channel.send(true); } Some(GlobalRequestResponse::CancelStreamLocalForward(return_channel)) => { + map_err!(ensure_end(&r))?; let _ = return_channel.send(true); } None => { @@ -814,8 +856,9 @@ impl Session { } Ok(()) } - Some((&msg::REQUEST_FAILURE, _)) => { + Some((&msg::REQUEST_FAILURE, r)) => { trace!("global request failure"); + map_err!(ensure_end(&r))?; match self.open_global_requests.pop_front() { Some(GlobalRequestResponse::Keepalive) => { // ignore keepalives @@ -884,7 +927,7 @@ impl Session { } => { debug!("sending ssh-userauth service requset"); if !*sent { - self.common.packet_writer.packet(|w| { + self.common.packet_writer.write_packet(|w| { msg::SERVICE_REQUEST.encode(w)?; "ssh-userauth".encode(w)?; Ok(()) diff --git a/crates/bssh-russh/src/client/kex.rs b/crates/bssh-russh/src/client/kex.rs index d6523318..11217c16 100644 --- a/crates/bssh-russh/src/client/kex.rs +++ b/crates/bssh-russh/src/client/kex.rs @@ -14,6 +14,7 @@ use crate::kex::dh::groups::DhGroup; use crate::kex::{KEXES, KexAlgorithm, KexAlgorithmImplementor, KexCause, KexProgress}; use crate::keys::key::parse_public_key; use crate::negotiation::{Names, Select}; +use crate::parsing::ensure_end; use crate::session::Exchange; use crate::sshbuffer::PacketWriter; use crate::{CryptoVec, Error, SshId, msg, negotiation, strict_kex_violation}; @@ -115,9 +116,7 @@ impl ClientKex { let names = { // read algorithms from packet. - self.exchange - .server_kex_init - .extend_from_slice(&input.buffer); + self.exchange.server_kex_init = input.buffer.clone().into(); negotiation::Client::read_kex( &input.buffer, &self.config.preferred, @@ -147,7 +146,7 @@ impl ClientKex { self.cause.session_id(), )?; - output.packet(|w| { + output.write_packet(|w| { msg::NEWKEYS.encode(w)?; Ok(()) })?; @@ -159,14 +158,14 @@ impl ClientKex { } if kex.is_dh_gex() { - output.packet(|w| { + output.write_packet(|w| { kex.client_dh_gex_init(&self.config.gex, w)?; Ok(()) })?; self.state = ClientKexState::WaitingForGexReply { names, kex }; } else { - output.packet(|w| { + output.write_packet(|w| { kex.client_dh(&mut self.exchange.client_ephemeral, w)?; Ok(()) })?; @@ -197,6 +196,7 @@ impl ClientKex { let prime = Mpint::decode(&mut r)?; let generator = Mpint::decode(&mut r)?; + ensure_end(&r)?; debug!("received gex group: prime={prime}, generator={generator}"); let group = DhGroup { @@ -217,7 +217,7 @@ impl ClientKex { let exchange = &mut self.exchange; exchange.gex = Some((self.config.gex.clone(), group.clone())); kex.dh_gex_set_group(group)?; - output.packet(|w| { + output.write_packet(|w| { kex.client_dh(&mut exchange.client_ephemeral, w)?; Ok(()) })?; @@ -289,7 +289,10 @@ impl ClientKex { })?; let signature = Bytes::decode(r)?; - let signature = Signature::decode(&mut &signature[..])?; + let mut signature_reader = &signature[..]; + let signature = Signature::decode(&mut signature_reader)?; + ensure_end(&signature_reader)?; + ensure_end(r)?; if let Err(e) = signature::Verifier::verify(&server_host_key, hash.as_ref(), &signature) @@ -306,7 +309,7 @@ impl ClientKex { self.cause.session_id(), )?; - output.packet(|w| { + output.write_packet(|w| { msg::NEWKEYS.encode(w)?; Ok(()) })?; @@ -341,6 +344,10 @@ impl ClientKex { return Err(Error::Kex); } + #[allow(clippy::indexing_slicing, reason = "length checked")] + let r = &input.buffer[1..]; + ensure_end(&r)?; + Ok(KexProgress::Done { newkeys, server_host_key: Some(server_host_key), diff --git a/crates/bssh-russh/src/client/mod.rs b/crates/bssh-russh/src/client/mod.rs index 60df7453..6730858c 100644 --- a/crates/bssh-russh/src/client/mod.rs +++ b/crates/bssh-russh/src/client/mod.rs @@ -890,7 +890,7 @@ impl Handle { ) -> Result<(), bytes::Bytes> { let data = data.into(); self.sender - .send(Msg::Channel(id, ChannelMsg::Data { data: data.clone() })) + .send(Msg::Channel(id, ChannelMsg::Data { data })) .await .map_err(|e| match e.0 { Msg::Channel(_, ChannelMsg::Data { data, .. }) => data, @@ -966,10 +966,11 @@ pub async fn connect( handler: H, ) -> Result, H::Error> { let socket = map_err!(tokio::net::TcpStream::connect(addrs).await)?; - if config.as_ref().nodelay - && let Err(e) = socket.set_nodelay(true) { + if config.as_ref().nodelay { + if let Err(e) = socket.set_nodelay(true) { warn!("set_nodelay() failed: {e:?}"); } + } connect_stream(config, socket, handler).await } @@ -1134,6 +1135,7 @@ impl Session { // The kex signal has not been consumed yet, // so we can send return the concrete error to be propagated // into the JoinHandle and returned from `connect_stream` + debug!("disconnected during handshake {e:?}"); Err(e) } else { // The kex signal has been consumed, so no one is @@ -1210,8 +1212,8 @@ impl Session { reading.set(start_reading(stream_read, buffer, opening_cipher)); } () = &mut keepalive_timer => { - if let Some(ref mut enc) = self.common.encrypted - && matches!(enc.state, EncryptedState::Authenticated) { + if let Some(ref mut enc) = self.common.encrypted { + if matches!(enc.state, EncryptedState::Authenticated) { self.common.alive_timeouts = self.common.alive_timeouts.saturating_add(1); if self.common.config.keepalive_max != 0 && self.common.alive_timeouts > self.common.config.keepalive_max { debug!("Timeout, server not responding to keepalives"); @@ -1220,6 +1222,7 @@ impl Session { sent_keepalive = true; self.send_keepalive(true)?; } + } } () = &mut inactivity_timer => { debug!("timeout"); @@ -1261,14 +1264,15 @@ impl Session { self.flush()?; map_err!(self.common.packet_writer.flush_into(stream_write).await)?; - if let Some(ref mut enc) = self.common.encrypted - && let EncryptedState::InitCompression = enc.state { + if let Some(ref mut enc) = self.common.encrypted { + if let EncryptedState::InitCompression = enc.state { if enc.client_compression.is_deferred() { enc.client_compression .init_compress(self.common.packet_writer.compress()); } enc.state = EncryptedState::Authenticated; } + } if self.common.received_data { // Reset the number of failed keepalive attempts. We don't @@ -1278,20 +1282,22 @@ impl Session { // data from it. self.common.alive_timeouts = 0; } - if (self.common.received_data || sent_keepalive) - && let (futures::future::Either::Right(ref mut sleep), Some(d)) = ( + if self.common.received_data || sent_keepalive { + if let (futures::future::Either::Right(ref mut sleep), Some(d)) = ( keepalive_timer.as_mut().as_pin_mut(), self.common.config.keepalive_interval, ) { sleep.as_mut().reset(tokio::time::Instant::now() + d); } - if !sent_keepalive - && let (futures::future::Either::Right(ref mut sleep), Some(d)) = ( + } + if !sent_keepalive { + if let (futures::future::Either::Right(ref mut sleep), Some(d)) = ( inactivity_timer.as_mut().as_pin_mut(), self.common.config.inactivity_timeout, ) { sleep.as_mut().reset(tokio::time::Instant::now() + d); } + } } result @@ -1523,14 +1529,15 @@ impl Session { /// Flush the temporary cleartext buffer into the encryption /// buffer. This does *not* flush to the socket. fn flush(&mut self) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted - && enc.flush( + if let Some(ref mut enc) = self.common.encrypted { + if enc.flush( &self.common.config.as_ref().limits, &mut self.common.packet_writer, )? && !self.kex.active() { self.begin_rekey()?; } + } Ok(()) } @@ -1575,8 +1582,8 @@ async fn reply( let is_kex_msg = pkt.buffer.first().cloned().map(is_kex_msg).unwrap_or(false); - if is_kex_msg - && let SessionKexState::InProgress(kex) = session.kex.take() { + if is_kex_msg { + if let SessionKexState::InProgress(kex) = session.kex.take() { let progress = kex.step(Some(pkt), &mut session.common.packet_writer)?; match progress { @@ -1602,18 +1609,24 @@ async fn reply( .kex_done(shared_secret, &newkeys.names, session) .await?; - if let Some(ref mut enc) = session.common.encrypted { + if session.common.encrypted.is_some() { // This is a rekey - enc.last_rekey = Instant::now(); - session.common.packet_writer.buffer().bytes = 0; - enc.flush_all_pending()?; + { + let common = &mut session.common; + common.newkeys(newkeys); + common.packet_writer.buffer().bytes = 0; + if let Some(enc) = common.encrypted.as_mut() { + enc.last_rekey = Instant::now(); + enc.flush_all_pending_with_writer(&mut common.packet_writer)?; + } + } + let mut pending = std::mem::take(&mut session.pending_reads); for p in pending.drain(..) { session.process_packet(handler, &p).await?; } session.pending_reads = pending; session.pending_len = 0; - session.common.newkeys(newkeys); } else { // This is the initial kex if let Some(server_host_key) = &server_host_key { @@ -1646,10 +1659,228 @@ async fn reply( return Ok(()); } + } session.client_read_encrypted(handler, pkt).await } +#[cfg(test)] +mod tests { + use std::collections::{HashMap, VecDeque}; + #[cfg(feature = "flate2")] + use std::io::Write; + use std::num::Wrapping; + use std::sync::Arc; + + use ssh_encoding::Encode; + use tokio::sync::mpsc::channel; + use tokio::sync::mpsc::unbounded_channel; + + use super::*; + use crate::auth::{AuthRequest, Method}; + use crate::compression::{Compression, Decompress}; + use crate::kex::{KEXES, NONE}; + use crate::session::{CommonSession, Encrypted, EncryptedState, Exchange}; + use crate::sshbuffer::{IncomingSshPacket, PacketWriter, SSHBuffer}; + use crate::{CryptoVec, cipher, mac}; + + struct TestHandler; + + impl Handler for TestHandler { + type Error = crate::Error; + + async fn check_server_key( + &mut self, + _: &ssh_key::PublicKey, + ) -> Result { + Ok(true) + } + } + + fn keyboard_interactive_session() -> ( + Session, + tokio::sync::mpsc::Sender, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let config = Arc::new(Config::default()); + let (sender, receiver) = channel(config.channel_buffer_size); + let (reply_sender, reply_receiver) = unbounded_channel(); + let auth_request = AuthRequest::new(&Method::KeyboardInteractive { + submethods: String::new(), + }); + let session = Session::new( + config.window_size, + CommonSession { + auth_user: "user".to_owned(), + auth_attempts: 0, + auth_method: Some(Method::KeyboardInteractive { + submethods: String::new(), + }), + remote_to_local: Box::new(cipher::clear::Key), + encrypted: Some(Encrypted { + state: EncryptedState::WaitingAuthRequest(auth_request), + exchange: Some(Exchange::default()), + kex: KEXES.get(&NONE).unwrap().make(), + key: 0, + client_mac: mac::NONE, + server_mac: mac::NONE, + session_id: CryptoVec::new(), + channels: HashMap::new(), + last_channel_id: Wrapping(0), + write: Vec::new(), + write_cursor: 0, + last_rekey: russh_util::time::Instant::now(), + server_compression: Compression::None, + client_compression: Compression::None, + decompress: Decompress::None, + rekey_wanted: false, + received_extensions: Vec::new(), + extension_info_awaiters: HashMap::new(), + }), + config, + wants_reply: false, + disconnected: false, + buffer: Vec::new(), + strict_kex: false, + alive_timeouts: 0, + received_data: false, + remote_sshid: b"SSH-2.0-test".to_vec(), + packet_writer: PacketWriter::clear(), + }, + receiver, + reply_sender, + ); + (session, sender, reply_receiver) + } + + #[cfg(feature = "flate2")] + fn authenticated_session() -> Session { + let config = Arc::new(Config::default()); + let (receiver_sender, receiver) = channel(config.channel_buffer_size); + let (reply_sender, _) = unbounded_channel(); + let mut session = Session::new( + config.window_size, + CommonSession { + auth_user: String::new(), + auth_attempts: 0, + auth_method: None, + remote_to_local: Box::new(cipher::clear::Key), + encrypted: Some(Encrypted { + state: EncryptedState::Authenticated, + exchange: Some(Exchange::default()), + kex: KEXES.get(&NONE).unwrap().make(), + key: 0, + client_mac: mac::NONE, + server_mac: mac::NONE, + session_id: CryptoVec::new(), + channels: HashMap::new(), + last_channel_id: Wrapping(0), + write: Vec::new(), + write_cursor: 0, + last_rekey: russh_util::time::Instant::now(), + server_compression: Compression::None, + client_compression: Compression::None, + decompress: Decompress::Zlib(flate2::Decompress::new(true)), + rekey_wanted: false, + received_extensions: Vec::new(), + extension_info_awaiters: HashMap::new(), + }), + config, + wants_reply: false, + disconnected: false, + buffer: Vec::new(), + strict_kex: false, + alive_timeouts: 0, + received_data: false, + remote_sshid: b"SSH-2.0-test".to_vec(), + packet_writer: PacketWriter::clear(), + }, + receiver, + reply_sender, + ); + session.open_global_requests = VecDeque::new(); + let _ = receiver_sender; + session + } + + fn oversized_prompt_count_packet() -> Vec { + let mut packet = Vec::new(); + msg::USERAUTH_INFO_REQUEST_OR_USERAUTH_PK_OK + .encode(&mut packet) + .unwrap(); + "name".encode(&mut packet).unwrap(); + "instructions".encode(&mut packet).unwrap(); + "".encode(&mut packet).unwrap(); + u32::MAX.encode(&mut packet).unwrap(); + packet + } + + #[tokio::test] + async fn oversized_keyboard_interactive_prompt_count_is_rejected() { + let (mut session, _sender, mut replies) = keyboard_interactive_session(); + let mut handler = TestHandler; + let err = session + .process_packet(&mut handler, &oversized_prompt_count_packet()) + .await + .expect_err("malformed prompt count must fail"); + + assert!(matches!(err, crate::Error::Inconsistent)); + assert!( + replies.try_recv().is_err(), + "malformed packet must not emit a reply" + ); + } + + #[cfg(feature = "flate2")] + fn compressed_debug_payload(payload_len: usize) -> Vec { + let mut payload = vec![b'A'; payload_len]; + payload[0] = crate::msg::DEBUG; + + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + encoder.write_all(&payload).unwrap(); + let compressed = encoder.finish().unwrap(); + assert!(compressed.len() < 256 * 1024); + compressed + } + + #[cfg(feature = "flate2")] + fn incoming_packet(compressed: Vec) -> SSHBuffer { + let mut buffer = SSHBuffer::new(); + buffer.buffer.extend_from_slice(&[0; 5]); + buffer.buffer.extend_from_slice(&compressed); + buffer + } + + #[cfg(feature = "flate2")] + #[tokio::test] + async fn compressed_debug_is_ignored_after_client_parses_it() { + let mut session = authenticated_session(); + let mut handler = TestHandler; + let mut kex_done_signal = None; + let buffer = incoming_packet(compressed_debug_payload(200 * 1024)); + let mut pkt: IncomingSshPacket = session.maybe_decompress(&buffer).unwrap(); + + super::reply(&mut session, &mut handler, &mut kex_done_signal, &mut pkt) + .await + .unwrap(); + + assert!(!session.common.disconnected); + } + + #[cfg(feature = "flate2")] + #[test] + fn oversized_compressed_debug_is_rejected_before_client_ignores_it() { + let mut session = authenticated_session(); + let oversized = crate::cipher::MAXIMUM_DECOMPRESSED_PACKET_LEN + 1024; + let buffer = incoming_packet(compressed_debug_payload(oversized)); + + let err = session.maybe_decompress(&buffer).unwrap_err(); + assert!( + matches!(err, crate::Error::PacketSize(len) if len > crate::cipher::MAXIMUM_DECOMPRESSED_PACKET_LEN) + ); + } +} + fn initial_encrypted_state(session: &Session) -> EncryptedState { if session.common.config.anonymous { EncryptedState::Authenticated diff --git a/crates/bssh-russh/src/client/session.rs b/crates/bssh-russh/src/client/session.rs index 4cdd2e5c..81597e07 100644 --- a/crates/bssh-russh/src/client/session.rs +++ b/crates/bssh-russh/src/client/session.rs @@ -111,8 +111,8 @@ impl Session { pix_height: u32, terminal_modes: &[(Pty, u32)], ) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(&channel) { push_packet!(enc.write, { map_err!(msg::CHANNEL_REQUEST.encode(&mut enc.write))?; @@ -137,6 +137,7 @@ impl Session { (Pty::TTY_OP_END as u8).encode(&mut enc.write)?; }); } + } Ok(()) } @@ -149,8 +150,8 @@ impl Session { x11_authentication_cookie: &str, x11_screen_number: u32, ) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(&channel) { push_packet!(enc.write, { msg::CHANNEL_REQUEST.encode(&mut enc.write)?; @@ -163,6 +164,7 @@ impl Session { x11_screen_number.encode(&mut enc.write)?; }); } + } Ok(()) } @@ -173,8 +175,8 @@ impl Session { variable_name: &str, variable_value: &str, ) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(&channel) { push_packet!(enc.write, { msg::CHANNEL_REQUEST.encode(&mut enc.write)?; @@ -185,6 +187,7 @@ impl Session { variable_value.encode(&mut enc.write)?; }); } + } Ok(()) } @@ -193,8 +196,8 @@ impl Session { want_reply: bool, channel: ChannelId, ) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(&channel) { push_packet!(enc.write, { msg::CHANNEL_REQUEST.encode(&mut enc.write)?; @@ -203,6 +206,7 @@ impl Session { (want_reply as u8).encode(&mut enc.write)?; }); } + } Ok(()) } @@ -212,8 +216,8 @@ impl Session { want_reply: bool, command: &[u8], ) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(&channel) { push_packet!(enc.write, { msg::CHANNEL_REQUEST.encode(&mut enc.write)?; @@ -224,13 +228,14 @@ impl Session { }); return Ok(()); } + } error!("exec"); Ok(()) } pub fn signal(&mut self, channel: ChannelId, signal: Sig) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(&channel) { push_packet!(enc.write, { msg::CHANNEL_REQUEST.encode(&mut enc.write)?; channel.recipient_channel.encode(&mut enc.write)?; @@ -239,6 +244,7 @@ impl Session { signal.name().encode(&mut enc.write)?; }); } + } Ok(()) } @@ -248,8 +254,8 @@ impl Session { channel: ChannelId, name: &str, ) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(&channel) { push_packet!(enc.write, { msg::CHANNEL_REQUEST.encode(&mut enc.write)?; @@ -259,6 +265,7 @@ impl Session { name.encode(&mut enc.write)?; }); } + } Ok(()) } @@ -270,8 +277,8 @@ impl Session { pix_width: u32, pix_height: u32, ) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(&channel) { push_packet!(enc.write, { msg::CHANNEL_REQUEST.encode(&mut enc.write)?; @@ -284,6 +291,7 @@ impl Session { pix_height.encode(&mut enc.write)?; }); } + } Ok(()) } @@ -435,8 +443,10 @@ impl Session { } pub fn data(&mut self, channel: ChannelId, data: impl Into) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted { - enc.data(channel, data, self.kex.active()) + let is_rekeying = self.kex.active(); + let common = &mut self.common; + if let Some(enc) = common.encrypted.as_mut() { + enc.data_with_writer(&mut common.packet_writer, channel, data, is_rekeying) } else { unreachable!() } @@ -464,8 +474,10 @@ impl Session { ext: u32, data: impl Into, ) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted { - enc.extended_data(channel, ext, data, self.kex.active()) + let is_rekeying = self.kex.active(); + let common = &mut self.common; + if let Some(enc) = common.encrypted.as_mut() { + enc.extended_data_with_writer(&mut common.packet_writer, channel, ext, data, is_rekeying) } else { unreachable!() } @@ -476,8 +488,8 @@ impl Session { channel: ChannelId, want_reply: bool, ) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(&channel) { push_packet!(enc.write, { msg::CHANNEL_REQUEST.encode(&mut enc.write)?; channel.recipient_channel.encode(&mut enc.write)?; @@ -485,6 +497,7 @@ impl Session { (want_reply as u8).encode(&mut enc.write)?; }); } + } Ok(()) } diff --git a/crates/bssh-russh/src/compression.rs b/crates/bssh-russh/src/compression.rs index ec3eff60..1500623d 100644 --- a/crates/bssh-russh/src/compression.rs +++ b/crates/bssh-russh/src/compression.rs @@ -3,6 +3,9 @@ use std::convert::TryFrom; use delegate::delegate; use ssh_encoding::Encode; +#[cfg(feature = "flate2")] +use crate::cipher::MAXIMUM_DECOMPRESSED_PACKET_LEN; + #[derive(Debug, Clone)] pub enum Compression { None, @@ -142,6 +145,17 @@ impl Compress { ) -> Result<&'a [u8], crate::Error> { Ok(input) } + + pub fn compress_into( + &mut self, + input: &[u8], + output: &mut Vec, + start_len: usize, + ) -> Result { + output.truncate(start_len); + output.extend_from_slice(input); + Ok(input.len()) + } } #[cfg(not(feature = "flate2"))] @@ -155,8 +169,61 @@ impl Decompress { } } +#[cfg(all(test, feature = "flate2"))] +mod tests { + use std::io::Write; + + use flate2::write::ZlibEncoder; + + use super::*; + + #[test] + fn decompressed_packet_at_limit_is_accepted() { + let payload = vec![b'A'; MAXIMUM_DECOMPRESSED_PACKET_LEN]; + let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + encoder.write_all(&payload).unwrap(); + let compressed = encoder.finish().unwrap(); + + let mut decompressor = Decompress::Zlib(flate2::Decompress::new(true)); + let mut output = Vec::new(); + + let decompressed = decompressor.decompress(&compressed, &mut output).unwrap(); + assert_eq!(decompressed.len(), MAXIMUM_DECOMPRESSED_PACKET_LEN); + } + + #[test] + fn oversized_decompressed_packet_is_rejected() { + let payload = vec![b'A'; MAXIMUM_DECOMPRESSED_PACKET_LEN + 1024]; + let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + encoder.write_all(&payload).unwrap(); + let compressed = encoder.finish().unwrap(); + + let mut decompressor = Decompress::Zlib(flate2::Decompress::new(true)); + let mut output = Vec::new(); + + let err = decompressor.decompress(&compressed, &mut output).unwrap_err(); + assert!( + matches!(err, crate::Error::PacketSize(len) if len > MAXIMUM_DECOMPRESSED_PACKET_LEN) + ); + } + + #[test] + fn empty_compressed_packet_does_not_spin() { + let compressed = Vec::new(); + let mut decompressor = Decompress::Zlib(flate2::Decompress::new(true)); + let mut output = Vec::new(); + + let decompressed = decompressor.decompress(&compressed, &mut output).unwrap(); + assert!(decompressed.is_empty()); + } +} + #[cfg(feature = "flate2")] impl Compress { + fn zlib_output_reserve_bound(input_len: usize) -> usize { + input_len.saturating_add(10) + } + pub fn compress<'a>( &mut self, input: &'a [u8], @@ -188,6 +255,45 @@ impl Compress { } } } + + pub fn compress_into( + &mut self, + input: &[u8], + output: &mut Vec, + start_len: usize, + ) -> Result { + match *self { + Compress::None => { + output.truncate(start_len); + output.extend_from_slice(input); + Ok(input.len()) + } + Compress::Zlib(ref mut z) => { + output.truncate(start_len); + let n_in = z.total_in() as usize; + let n_out = z.total_out() as usize; + let reserve = Self::zlib_output_reserve_bound(input.len()); + output.resize(start_len + reserve, 0); + let flush = flate2::FlushCompress::Partial; + loop { + let n_in_ = z.total_in() as usize - n_in; + let n_out_ = z.total_out() as usize - n_out; + #[allow(clippy::indexing_slicing)] // length checked + let c = z.compress(&input[n_in_..], &mut output[start_len + n_out_..], flush)?; + match c { + flate2::Status::BufError => { + let growth = output.len().saturating_sub(start_len).max(1); + output.resize(output.len() + growth, 0); + } + _ => break, + } + } + let n_out_ = z.total_out() as usize - n_out; + output.truncate(start_len + n_out_); + Ok(n_out_) + } + } + } } #[cfg(feature = "flate2")] @@ -203,7 +309,10 @@ impl Decompress { output.clear(); let n_in = z.total_in() as usize; let n_out = z.total_out() as usize; - output.resize(input.len(), 0); + let max_output_len = MAXIMUM_DECOMPRESSED_PACKET_LEN + .checked_add(1) + .ok_or(crate::Error::PacketSize(usize::MAX))?; + output.resize(input.len().clamp(1, max_output_len), 0); let flush = flate2::FlushDecompress::None; loop { let n_in_ = z.total_in() as usize - n_in; @@ -211,13 +320,33 @@ impl Decompress { #[allow(clippy::indexing_slicing)] // length checked let d = z.decompress(&input[n_in_..], &mut output[n_out_..], flush); match d? { - flate2::Status::Ok => { - output.resize(output.len() * 2, 0); + flate2::Status::Ok | flate2::Status::BufError => { + let consumed_all_input = n_in_ == input.len(); + let output_full = n_out_ == output.len(); + + if !output_full && consumed_all_input { + break; + } + + if output_full { + if output.len() == max_output_len { + break; + } + let next_len = output + .len() + .checked_mul(2) + .map(|len| len.min(max_output_len)) + .ok_or(crate::Error::PacketSize(usize::MAX))?; + output.resize(next_len, 0); + } } _ => break, } } let n_out_ = z.total_out() as usize - n_out; + if n_out_ > MAXIMUM_DECOMPRESSED_PACKET_LEN { + return Err(crate::Error::PacketSize(n_out_)); + } #[allow(clippy::indexing_slicing)] // length checked Ok(&output[..n_out_]) } diff --git a/crates/bssh-russh/src/helpers.rs b/crates/bssh-russh/src/helpers.rs index 208d2cfe..d961183e 100644 --- a/crates/bssh-russh/src/helpers.rs +++ b/crates/bssh-russh/src/helpers.rs @@ -15,43 +15,148 @@ impl EncodedExt for E { } } -pub struct NameList(pub Vec); +mod limited_string { + use super::*; + use std::ops::Deref; -impl Debug for NameList { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) + pub struct LimitedString(String); + + impl Deref for LimitedString { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl Decode for LimitedString { + type Error = ssh_encoding::Error; + + fn decode(reader: &mut impl ssh_encoding::Reader) -> Result { + reader.read_prefixed(|reader| { + let len = reader.remaining_len(); + if len > N { + return Err(ssh_encoding::Error::Length); + } + + // Allocate only after the SSH string length has been bounded. + let mut buf = vec![0; len]; + reader.read(&mut buf)?; + let value = + String::from_utf8(buf).map_err(|_| ssh_encoding::Error::CharacterEncoding)?; + reader.ensure_finished()?; + + Ok(Self(value)) + }) + } } } -impl NameList { - pub fn as_encoded_string(&self) -> String { - self.0.join(",") +mod name_list { + use std::ops::Deref; + + use super::*; + const MAX_NAME_LIST_ENTRIES: usize = 1024; + const MAX_NAME_LIST_BYTES: usize = 16 * 1024; + + pub struct NameList(pub Vec); + + impl Debug for NameList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } } - pub fn from_encoded_string(value: &str) -> Self { - Self(value.split(',').map(|x| x.to_string()).collect()) + impl Deref for NameList { + type Target = [String]; + + fn deref(&self) -> &Self::Target { + &self.0 + } } -} -impl Encode for NameList { - fn encoded_len(&self) -> Result { - self.as_encoded_string().encoded_len() + impl NameList { + pub fn as_encoded_string(&self) -> String { + self.0.join(",") + } + + pub fn from_encoded_string(value: &str) -> Result { + // RFC 4251 §5: a name-list may have zero names (a string of + // zero length). `"".split(',')` yields one empty element, + // which the per-name validation below would reject. + if value.is_empty() { + return Ok(Self(Vec::new())); + } + Ok(Self(value.split(',').try_fold( + Vec::new(), + |mut list, name| { + if name.is_empty() || !name.is_ascii() { + return Err(ssh_encoding::Error::CharacterEncoding); + } + if list.len() > MAX_NAME_LIST_ENTRIES { + Err(ssh_encoding::Error::Length) + } else { + list.push(name.into()); + Ok(list) + } + }, + )?)) + } } - fn encode(&self, writer: &mut impl ssh_encoding::Writer) -> Result<(), ssh_encoding::Error> { - self.as_encoded_string().encode(writer) + impl Encode for NameList { + fn encoded_len(&self) -> Result { + self.as_encoded_string().encoded_len() + } + + fn encode( + &self, + writer: &mut impl ssh_encoding::Writer, + ) -> Result<(), ssh_encoding::Error> { + self.as_encoded_string().encode(writer) + } } -} -impl Decode for NameList { - fn decode(reader: &mut impl ssh_encoding::Reader) -> Result { - let s = String::decode(reader)?; - Ok(Self::from_encoded_string(&s)) + impl Decode for NameList { + fn decode(reader: &mut impl ssh_encoding::Reader) -> Result { + let s = LimitedString::::decode(reader)?; + Self::from_encoded_string(&s) + } + + type Error = ssh_encoding::Error; } - type Error = ssh_encoding::Error; + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn empty_name_list_is_valid() { + // RFC 4251 §5 permits a zero-name list. Servers that only + // offer AEAD ciphers (e.g. hssh) send empty MAC name-lists. + let nl = NameList::from_encoded_string("").unwrap(); + assert!(nl.0.is_empty()); + } + + #[test] + fn name_list_round_trip() { + let nl = NameList::from_encoded_string("a,b,c").unwrap(); + assert_eq!(nl.0, vec!["a", "b", "c"]); + assert_eq!(nl.as_encoded_string(), "a,b,c"); + } + + #[test] + fn name_list_rejects_empty_entry() { + // An empty entry mid-list (",,") is still invalid — only + // the zero-length whole-list case is allowed. + assert!(NameList::from_encoded_string("a,,b").is_err()); + } + } } +pub use limited_string::LimitedString; +pub use name_list::NameList; + pub(crate) mod macros { #[allow(clippy::crate_in_macro_def)] macro_rules! map_err { diff --git a/crates/bssh-russh/src/kex/curve25519.rs b/crates/bssh-russh/src/kex/curve25519.rs index ed43e8ed..7d888cad 100644 --- a/crates/bssh-russh/src/kex/curve25519.rs +++ b/crates/bssh-russh/src/kex/curve25519.rs @@ -7,11 +7,11 @@ use sha2::Digest; use ssh_encoding::{Encode, Writer}; use super::{ - compute_keys, encode_mpint, KexAlgorithm, KexAlgorithmImplementor, KexType, SharedSecret, + KexAlgorithm, KexAlgorithmImplementor, KexType, SharedSecret, compute_keys, encode_mpint, }; use crate::mac::{self}; use crate::session::Exchange; -use crate::{cipher, msg, CryptoVec}; +use crate::{CryptoVec, cipher, msg}; pub struct Curve25519KexType {} @@ -79,7 +79,9 @@ impl KexAlgorithmImplementor for Curve25519Kex { // fill exchange. exchange.server_ephemeral.clear(); - exchange.server_ephemeral.extend_from_slice(&server_pubkey.0); + exchange + .server_ephemeral + .extend_from_slice(&server_pubkey.0); let shared = server_secret * client_pubkey; self.shared_secret = Some(shared); Ok(()) @@ -99,7 +101,7 @@ impl KexAlgorithmImplementor for Curve25519Kex { client_ephemeral.extend_from_slice(&client_pubkey.0); msg::KEX_ECDH_INIT.encode(writer)?; - client_pubkey.0.encode(writer)?; + (client_pubkey.0[..]).encode(writer)?; self.local_secret = Some(client_secret); Ok(()) diff --git a/crates/bssh-russh/src/kex/dh/mod.rs b/crates/bssh-russh/src/kex/dh/mod.rs index 723a3740..730a2f2e 100644 --- a/crates/bssh-russh/src/kex/dh/mod.rs +++ b/crates/bssh-russh/src/kex/dh/mod.rs @@ -121,10 +121,11 @@ impl std::fmt::Debug for DhGroupKex { pub(crate) fn biguint_to_mpint(biguint: &BigUint) -> Vec { let mut mpint = Vec::new(); let bytes = biguint.to_bytes_be(); - if let Some(b) = bytes.first() - && b > &0x7f { + if let Some(b) = bytes.first() { + if b > &0x7f { mpint.push(0); } + } mpint.extend(&bytes); mpint } diff --git a/crates/bssh-russh/src/kex/hybrid_mlkem.rs b/crates/bssh-russh/src/kex/hybrid_mlkem.rs index 421f1205..fd3fd2ad 100644 --- a/crates/bssh-russh/src/kex/hybrid_mlkem.rs +++ b/crates/bssh-russh/src/kex/hybrid_mlkem.rs @@ -335,8 +335,8 @@ mod tests { let mut exchange = Exchange { client_id: b"SSH-2.0-Test_Client".to_vec(), server_id: b"SSH-2.0-Test_Server".to_vec(), - client_kex_init: b"client_kex_init".to_vec(), - server_kex_init: b"server_kex_init".to_vec(), + client_kex_init: bytes::Bytes::from_static(b"client_kex_init"), + server_kex_init: bytes::Bytes::from_static(b"server_kex_init"), client_ephemeral: client_ephemeral.clone(), server_ephemeral: Vec::new(), gex: None, diff --git a/crates/bssh-russh/src/keys/agent/client.rs b/crates/bssh-russh/src/keys/agent/client.rs index ec2e7c17..b2a485e5 100644 --- a/crates/bssh-russh/src/keys/agent/client.rs +++ b/crates/bssh-russh/src/keys/agent/client.rs @@ -448,9 +448,16 @@ impl AgentClient { let t = String::decode(&mut resp)?; if (hash == 2 && t == "rsa-sha2-256") || (hash == 4 && t == "rsa-sha2-512") || hash == 0 { let sig = Bytes::decode(&mut resp)?; - (t.len() + sig.len() + 8).encode(data)?; + let is_sk_signature = t.starts_with("sk-"); + (t.len() + sig.len() + 8 + if is_sk_signature { 5 } else { 0 }).encode(data)?; t.encode(data)?; sig.encode(data)?; + if is_sk_signature { + let flags = u8::decode(&mut resp)?; + let counter = u32::decode(&mut resp)?; + flags.encode(data)?; + counter.encode(data)?; + } Ok(()) } else { error!("unexpected agent signature type: {t:?}"); @@ -576,3 +583,42 @@ impl AgentClient { } } } + +#[cfg(test)] +mod tests { + use byteorder::{BigEndian, ByteOrder}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use super::{AgentClient, MAX_AGENT_FRAME_LEN}; + use crate::keys::Error; + + #[test] + fn oversized_agent_response_is_rejected_before_allocation() -> std::io::Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + runtime.block_on(async { + let (mut writer, reader) = tokio::io::duplex(64); + let server = tokio::spawn(async move { + let mut frame = [0u8; 4]; + writer.read_exact(&mut frame).await?; + let len = BigEndian::read_u32(&frame) as usize; + let mut body = vec![0; len]; + writer.read_exact(&mut body).await?; + + BigEndian::write_u32(&mut frame, (MAX_AGENT_FRAME_LEN + 1) as u32); + writer.write_all(&frame).await?; + Ok::<(), std::io::Error>(()) + }); + + let mut client = AgentClient::connect(reader); + let err = client.request_identities().await.unwrap_err(); + assert!(matches!(err, Error::AgentProtocolError)); + server.await.expect("server task")?; + Ok::<(), std::io::Error>(()) + })?; + + Ok(()) + } +} diff --git a/crates/bssh-russh/src/keys/agent/server.rs b/crates/bssh-russh/src/keys/agent/server.rs index 9832826a..48a4149b 100644 --- a/crates/bssh-russh/src/keys/agent/server.rs +++ b/crates/bssh-russh/src/keys/agent/server.rs @@ -360,3 +360,44 @@ impl std::io::Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + runtime.block_on(async { + let (server, mut client) = tokio::io::duplex(64); + let connection = Connection { + lock: Lock(std::sync::Arc::new(std::sync::RwLock::new(crate::CryptoVec::new()))), + keys: KeyStore(std::sync::Arc::new(std::sync::RwLock::new( + std::collections::HashMap::new(), + ))), + agent: Some(()), + s: server, + buf: Vec::new(), + }; + let server = tokio::spawn(async move { connection.run().await }); + + let mut frame = [0u8; 4]; + BigEndian::write_u32(&mut frame, (MAX_AGENT_FRAME_LEN + 1) as u32); + client.write_all(&frame).await?; + drop(client); + + let err = server.await.expect("server task").unwrap_err(); + assert!(matches!(err, Error::AgentProtocolError)); + Ok::<(), std::io::Error>(()) + })?; + + Ok(()) + } +} diff --git a/crates/bssh-russh/src/keys/format/pkcs5.rs b/crates/bssh-russh/src/keys/format/pkcs5.rs index 6d5e5b83..c5bfe861 100644 --- a/crates/bssh-russh/src/keys/format/pkcs5.rs +++ b/crates/bssh-russh/src/keys/format/pkcs5.rs @@ -11,8 +11,7 @@ pub fn decode_pkcs5( password: Option<&str>, enc: Encryption, ) -> Result { - use aes::cipher::{BlockDecryptMut, KeyIvInit}; - use block_padding::Pkcs7; + use aes::cipher::{block_padding::Pkcs7, BlockModeDecrypt, KeyIvInit}; if let Some(pass) = password { let sec = match enc { @@ -25,7 +24,7 @@ pub fn decode_pkcs5( #[allow(clippy::unwrap_used)] // AES parameters are static let c = cbc::Decryptor::::new_from_slices(&md5.0, &iv[..]).unwrap(); let mut dec = secret.to_vec(); - c.decrypt_padded_mut::(&mut dec)?.to_vec() + c.decrypt_padded::(&mut dec)?.to_vec() } Encryption::Aes256Cbc(_) => unimplemented!(), }; diff --git a/crates/bssh-russh/src/keys/format/pkcs8.rs b/crates/bssh-russh/src/keys/format/pkcs8.rs index 3eff91fc..6cd10593 100644 --- a/crates/bssh-russh/src/keys/format/pkcs8.rs +++ b/crates/bssh-russh/src/keys/format/pkcs8.rs @@ -123,13 +123,13 @@ pub fn encode_pkcs8_encrypted( use rand_core::Rng; let mut rng = safe_rng(); - let mut salt = [0; 64]; + let mut salt = [0; 32]; rng.fill_bytes(&mut salt); let mut iv = [0; 16]; rng.fill_bytes(&mut iv); let doc = pvi.encrypt_with_params( - pkcs5::pbes2::Parameters::pbkdf2_sha256_aes256cbc(rounds, &salt, iv) + pkcs5::pbes2::Parameters::generate_pbkdf2_sha256_aes256cbc(rounds, &salt, iv) .map_err(|_| Error::InvalidParameters)?, pass, )?; diff --git a/crates/bssh-russh/src/keys/format/pkcs8_legacy.rs b/crates/bssh-russh/src/keys/format/pkcs8_legacy.rs index 3c8e40b2..099e5d2d 100644 --- a/crates/bssh-russh/src/keys/format/pkcs8_legacy.rs +++ b/crates/bssh-russh/src/keys/format/pkcs8_legacy.rs @@ -1,11 +1,12 @@ use std::borrow::Cow; use std::convert::TryFrom; -use aes::cipher::{BlockDecryptMut, KeyIvInit}; +use aes::cipher::KeyIvInit; use aes::*; use block_padding::Pkcs7; -use ssh_key::private::{Ed25519Keypair, Ed25519PrivateKey, KeypairData}; +use cipher::BlockModeDecrypt; use ssh_key::PrivateKey; +use ssh_key::private::{Ed25519Keypair, Ed25519PrivateKey, KeypairData}; use yasna::BERReaderSeq; use super::Encryption; @@ -139,13 +140,13 @@ impl Encryption { #[allow(clippy::unwrap_used)] // parameters are static let c = cbc::Decryptor::::new_from_slices(key, iv).unwrap(); let mut dec = ciphertext.to_vec(); - Ok(c.decrypt_padded_mut::(&mut dec)?.into()) + Ok(c.decrypt_padded::(&mut dec)?.into()) } Encryption::Aes256Cbc(ref iv) => { #[allow(clippy::unwrap_used)] // parameters are static let c = cbc::Decryptor::::new_from_slices(key, iv).unwrap(); let mut dec = ciphertext.to_vec(); - Ok(c.decrypt_padded_mut::(&mut dec)?.into()) + Ok(c.decrypt_padded::(&mut dec)?.into()) } } } diff --git a/crates/bssh-russh/src/keys/format/tests.rs b/crates/bssh-russh/src/keys/format/tests.rs index 54574025..6d48a83e 100644 --- a/crates/bssh-russh/src/keys/format/tests.rs +++ b/crates/bssh-russh/src/keys/format/tests.rs @@ -1,3 +1,7 @@ +use ssh_key::{Algorithm, PrivateKey}; + +use crate::keys::pkcs8::{decode_pkcs8, encode_pkcs8_encrypted}; + use super::decode_secret_key; #[test] @@ -10,3 +14,13 @@ lpQg5vf23Fc9fFrQ9AnQKrb1dgTkoxQ= -----END EC PRIVATE KEY-----"#; decode_secret_key(key, None).unwrap(); } + +#[test] +fn test_pkcs8_roundtrip() { + let password = b"SomePassword"; + let original_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + + let encrypted = encode_pkcs8_encrypted(password, 10, &original_key).unwrap(); + let decrypted = decode_pkcs8(&encrypted, Some(password)).unwrap(); + assert_eq!(decrypted, original_key); +} diff --git a/crates/bssh-russh/src/keys/known_hosts.rs b/crates/bssh-russh/src/keys/known_hosts.rs index e356e6bb..00d69638 100644 --- a/crates/bssh-russh/src/keys/known_hosts.rs +++ b/crates/bssh-russh/src/keys/known_hosts.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::env; use data_encoding::BASE64_MIME; -use hmac::{Hmac, Mac}; +use hmac::{Hmac, KeyInit, Mac}; use log::debug; use sha1::Sha1; @@ -116,10 +116,11 @@ fn match_hostname(host: &str, pattern: &str) -> bool { let Some(Ok(hash)) = parts.next().map(|p| BASE64_MIME.decode(p.as_bytes())) else { continue; }; - if let Ok(hmac) = Hmac::::new_from_slice(&salt) - && hmac.chain_update(host).verify_slice(&hash).is_ok() { + if let Ok(hmac) = Hmac::::new_from_slice(&salt) { + if hmac.chain_update(host).verify_slice(&hash).is_ok() { return true; } + } } else if host == entry { return true; } diff --git a/crates/bssh-russh/src/keys/mod.rs b/crates/bssh-russh/src/keys/mod.rs index 44c539c7..01344dc7 100644 --- a/crates/bssh-russh/src/keys/mod.rs +++ b/crates/bssh-russh/src/keys/mod.rs @@ -61,7 +61,6 @@ use std::io::Read; use std::path::Path; use std::string::FromUtf8Error; -use aes::cipher::block_padding::UnpadError; use aes::cipher::inout::PadError; use data_encoding::BASE64_MIME; use thiserror::Error; @@ -145,7 +144,7 @@ pub enum Error { Pad(#[from] PadError), #[error(transparent)] - Unpad(#[from] UnpadError), + Unpad(#[from] aes::cipher::block_padding::Error), #[error("Base64 decoding error: {0}")] Decode(#[from] data_encoding::DecodeError), @@ -1251,7 +1250,7 @@ Cog3JMeTrb3LiPHgN6gU2P30MRp6L1j1J/MtlOAr5rux #[cfg(unix)] async fn test_sign_request_cert_missing_key_returns_agent_failure() { use crate::keys::agent::client::AgentClient; - + env_logger::try_init().unwrap_or(()); let (mut agent, agent_path, _dir) = spawn_agent().await.unwrap(); @@ -1303,7 +1302,7 @@ Cog3JMeTrb3LiPHgN6gU2P30MRp6L1j1J/MtlOAr5rux #[cfg(unix)] async fn test_sign_request_missing_key_returns_agent_failure() { use crate::keys::agent::client::AgentClient; - + env_logger::try_init().unwrap_or(()); let (mut agent, agent_path, _dir) = spawn_agent().await.unwrap(); diff --git a/crates/bssh-russh/src/lib_inner.rs b/crates/bssh-russh/src/lib_inner.rs index 81370d0d..d2ced8f2 100644 --- a/crates/bssh-russh/src/lib_inner.rs +++ b/crates/bssh-russh/src/lib_inner.rs @@ -3,7 +3,7 @@ use std::fmt::{Debug, Display, Formatter}; use std::future::{Future, Pending}; use futures::future::Either as EitherFuture; -use log::{debug, warn}; +use log::warn; use parsing::ChannelOpenConfirmation; pub use russh_cryptovec::CryptoVec; use ssh_encoding::{Decode, Encode}; diff --git a/crates/bssh-russh/src/mac/crypto.rs b/crates/bssh-russh/src/mac/crypto.rs index a1af4a12..af44add9 100644 --- a/crates/bssh-russh/src/mac/crypto.rs +++ b/crates/bssh-russh/src/mac/crypto.rs @@ -47,7 +47,7 @@ where fn compute(&self, sequence_number: u32, payload: &[u8], output: &mut [u8]) { #[allow(clippy::unwrap_used)] - let mut hmac = ::new_from_slice(&self.key).unwrap(); + let mut hmac = ::new_from_slice(&self.key).unwrap(); let mut seqno_buf = [0; 4]; BigEndian::write_u32(&mut seqno_buf, sequence_number); hmac.update(&seqno_buf); diff --git a/crates/bssh-russh/src/negotiation.rs b/crates/bssh-russh/src/negotiation.rs index c1d97105..df0ea058 100644 --- a/crates/bssh-russh/src/negotiation.rs +++ b/crates/bssh-russh/src/negotiation.rs @@ -14,6 +14,7 @@ // use std::borrow::Cow; +use bytes::Bytes; use log::debug; use rand_core::Rng; use ssh_encoding::{Decode, Encode}; @@ -25,6 +26,7 @@ use crate::kex::{ EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, KexCause, }; use crate::keys::key::safe_rng; +use crate::parsing::ensure_end; #[cfg(not(target_arch = "wasm32"))] use crate::server::Config; use crate::sshbuffer::PacketWriter; @@ -189,18 +191,14 @@ impl Default for Preferred { } } -pub(crate) fn parse_kex_algo_list(list: &str) -> Vec<&str> { - list.split(',').collect() -} - pub(crate) trait Select { fn is_server() -> bool; - fn select + Clone>( - a: &[S], - b: &[&str], + fn select + Clone, B: AsRef + Clone>( + a: &[A], + b: &[B], kind: AlgorithmKind, - ) -> Result<(bool, S), Error>; + ) -> Result<(bool, A), Error>; /// `available_host_keys`, if present, is used to limit the host key algorithms to the ones we have keys for. fn read_kex( @@ -215,7 +213,7 @@ pub(crate) trait Select { // Key exchange - let kex_string = String::decode(&mut r)?; + let kex_list = NameList::decode(&mut r)?; // Filter out extension kex names from both lists before selecting let _local_kexes_no_ext = pref .kex @@ -223,10 +221,10 @@ pub(crate) trait Select { .filter(|k| !KEX_EXTENSION_NAMES.contains(k)) .cloned() .collect::>(); - let _remote_kexes_no_ext = parse_kex_algo_list(&kex_string) - .into_iter() + let _remote_kexes_no_ext = kex_list + .iter() .filter(|k| { - kex::Name::try_from(*k) + kex::Name::try_from(k.as_str()) .ok() .map(|k| !KEX_EXTENSION_NAMES.contains(&k)) .unwrap_or(false) @@ -251,7 +249,7 @@ pub(crate) trait Select { } else { EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER }], - &parse_kex_algo_list(&kex_string), + &kex_list, AlgorithmKind::Kex, ) .is_ok(); @@ -262,60 +260,48 @@ pub(crate) trait Select { // Host key - let key_string = String::decode(&mut r)?; + let key_list = NameList::decode(&mut r)?; let possible_host_key_algos = match available_host_keys { Some(available_host_keys) => pref.possible_host_key_algos_for_keys(available_host_keys), None => pref.key.iter().map(ToOwned::to_owned).collect::>(), }; - let (key_both_first, key_algorithm) = Self::select( - &possible_host_key_algos[..], - &parse_kex_algo_list(&key_string), - AlgorithmKind::Key, - )?; + let (key_both_first, key_algorithm) = + Self::select(&possible_host_key_algos[..], &key_list, AlgorithmKind::Key)?; // Cipher - let cipher_string = String::decode(&mut r)?; - let (_cipher_both_first, cipher) = Self::select( - &pref.cipher, - &parse_kex_algo_list(&cipher_string), - AlgorithmKind::Cipher, - )?; + let cipher_list = NameList::decode(&mut r)?; + let (_cipher_both_first, cipher) = + Self::select(&pref.cipher, &cipher_list, AlgorithmKind::Cipher)?; String::decode(&mut r)?; // cipher server-to-client. // MAC let need_mac = CIPHERS.get(&cipher).map(|x| x.needs_mac()).unwrap_or(false); - let client_mac = match Self::select( - &pref.mac, - &parse_kex_algo_list(&String::decode(&mut r)?), - AlgorithmKind::Mac, - ) { - Ok((_, m)) => m, - Err(e) => { - if need_mac { - return Err(e); - } else { - mac::NONE + let client_mac = + match Self::select(&pref.mac, &NameList::decode(&mut r)?, AlgorithmKind::Mac) { + Ok((_, m)) => m, + Err(e) => { + if need_mac { + return Err(e); + } else { + mac::NONE + } } - } - }; - let server_mac = match Self::select( - &pref.mac, - &parse_kex_algo_list(&String::decode(&mut r)?), - AlgorithmKind::Mac, - ) { - Ok((_, m)) => m, - Err(e) => { - if need_mac { - return Err(e); - } else { - mac::NONE + }; + let server_mac = + match Self::select(&pref.mac, &NameList::decode(&mut r)?, AlgorithmKind::Mac) { + Ok((_, m)) => m, + Err(e) => { + if need_mac { + return Err(e); + } else { + mac::NONE + } } - } - }; + }; // Compression @@ -323,7 +309,7 @@ pub(crate) trait Select { let client_compression = compression::Compression::new( &Self::select( &pref.compression, - &parse_kex_algo_list(&String::decode(&mut r)?), + &NameList::decode(&mut r)?, AlgorithmKind::Compression, )? .1, @@ -333,7 +319,7 @@ pub(crate) trait Select { let server_compression = compression::Compression::new( &Self::select( &pref.compression, - &parse_kex_algo_list(&String::decode(&mut r)?), + &NameList::decode(&mut r)?, AlgorithmKind::Compression, )? .1, @@ -342,6 +328,8 @@ pub(crate) trait Select { String::decode(&mut r)?; // languages server-to-client let follows = u8::decode(&mut r)? != 0; + u32::decode(&mut r)?; + ensure_end(&r)?; Ok(Names { kex: kex_algorithm, key: key_algorithm, @@ -365,15 +353,15 @@ impl Select for Server { true } - fn select + Clone>( - server_list: &[S], - client_list: &[&str], + fn select + Clone, B: AsRef + Clone>( + server_list: &[A], + client_list: &[B], kind: AlgorithmKind, - ) -> Result<(bool, S), Error> { + ) -> Result<(bool, A), Error> { let mut both_first_choice = true; for c in client_list { for s in server_list { - if c == &s.as_ref() { + if c.as_ref() == s.as_ref() { return Ok((both_first_choice, s.clone())); } both_first_choice = false @@ -382,7 +370,7 @@ impl Select for Server { Err(Error::NoCommonAlgo { kind, ours: server_list.iter().map(|x| x.as_ref().to_owned()).collect(), - theirs: client_list.iter().map(|x| (*x).to_owned()).collect(), + theirs: client_list.iter().map(|x| x.as_ref().to_owned()).collect(), }) } } @@ -392,15 +380,15 @@ impl Select for Client { false } - fn select + Clone>( - client_list: &[S], - server_list: &[&str], + fn select + Clone, B: AsRef + Clone>( + client_list: &[A], + server_list: &[B], kind: AlgorithmKind, - ) -> Result<(bool, S), Error> { + ) -> Result<(bool, A), Error> { let mut both_first_choice = true; for c in client_list { for s in server_list { - if s == &c.as_ref() { + if s.as_ref() == c.as_ref() { return Ok((both_first_choice, c.clone())); } both_first_choice = false @@ -409,7 +397,7 @@ impl Select for Client { Err(Error::NoCommonAlgo { kind, ours: client_list.iter().map(|x| x.as_ref().to_owned()).collect(), - theirs: server_list.iter().map(|x| (*x).to_owned()).collect(), + theirs: server_list.iter().map(|x| x.as_ref().to_owned()).collect(), }) } } @@ -418,9 +406,8 @@ pub(crate) fn write_kex( prefs: &Preferred, writer: &mut PacketWriter, server_config: Option<&Config>, -) -> Result, Error> { - writer.packet(|w| { - // buf.clear(); +) -> Result { + writer.packet_bytes(|w| { msg::KEXINIT.encode(w)?; let mut cookie = [0; 16]; diff --git a/crates/bssh-russh/src/parsing.rs b/crates/bssh-russh/src/parsing.rs index 361b94c8..8b7ee867 100644 --- a/crates/bssh-russh/src/parsing.rs +++ b/crates/bssh-russh/src/parsing.rs @@ -4,6 +4,23 @@ use crate::msg; use crate::map_err; +/// Require a decoded known-message payload to be fully consumed. +/// +/// SSH RFCs and implemented OpenSSH extensions define exact field layouts for +/// known message types. Callers use this after decoding those fields so +/// malformed packets with trailing payload bytes are rejected instead of being +/// treated as canonical messages. +pub(crate) fn ensure_end(reader: &impl Reader) -> Result<(), crate::Error> { + if reader.is_finished() { + Ok(()) + } else { + Err(ssh_encoding::Error::TrailingData { + remaining: reader.remaining_len(), + } + .into()) + } +} + #[derive(Debug)] pub struct OpenChannelMessage { pub typ: ChannelType, @@ -13,6 +30,12 @@ pub struct OpenChannelMessage { } impl OpenChannelMessage { + /// Parse an SSH `CHANNEL_OPEN` payload. + /// + /// Known channel types are parsed according to their fixed layouts and must + /// not contain trailing bytes. Unknown extension channel types remain + /// intentionally opaque so applications can implement extension-specific + /// parsing and compatibility behavior. pub fn parse(r: &mut R) -> Result { // https://tools.ietf.org/html/rfc4254#section-5.1 let typ = map_err!(String::decode(r))?; @@ -21,24 +44,46 @@ impl OpenChannelMessage { let maxpacket = map_err!(u32::decode(r))?; let typ = match typ.as_str() { - "session" => ChannelType::Session, + "session" => { + ensure_end(r)?; + ChannelType::Session + } "x11" => { let originator_address = map_err!(String::decode(r))?; let originator_port = map_err!(u32::decode(r))?; + ensure_end(r)?; ChannelType::X11 { originator_address, originator_port, } } - "direct-tcpip" => ChannelType::DirectTcpip(TcpChannelInfo::decode(r)?), + "direct-tcpip" => { + let info = TcpChannelInfo::decode(r)?; + ensure_end(r)?; + ChannelType::DirectTcpip(info) + } "direct-streamlocal@openssh.com" => { - ChannelType::DirectStreamLocal(StreamLocalChannelInfo::decode(r)?) + let info = StreamLocalChannelInfo::decode(r)?; + String::decode(r)?; // originator address/reserved + u32::decode(r)?; // originator port/reserved + ensure_end(r)?; + ChannelType::DirectStreamLocal(info) + } + "forwarded-tcpip" => { + let info = TcpChannelInfo::decode(r)?; + ensure_end(r)?; + ChannelType::ForwardedTcpIp(info) } - "forwarded-tcpip" => ChannelType::ForwardedTcpIp(TcpChannelInfo::decode(r)?), "forwarded-streamlocal@openssh.com" => { - ChannelType::ForwardedStreamLocal(StreamLocalChannelInfo::decode(r)?) + let info = StreamLocalChannelInfo::decode(r)?; + String::decode(r)?; // reserved + ensure_end(r)?; + ChannelType::ForwardedStreamLocal(info) + } + "auth-agent@openssh.com" => { + ensure_end(r)?; + ChannelType::AgentForward } - "auth-agent@openssh.com" => ChannelType::AgentForward, _ => ChannelType::Unknown { typ }, }; @@ -177,3 +222,51 @@ impl Decode for ChannelOpenConfirmation { }) } } + +#[cfg(test)] +mod tests { + use super::{ChannelType, OpenChannelMessage}; + use crate::tests::raw_no_crypto::{channel_open_payload, encode_string, push_u32}; + + #[test] + fn known_channel_open_with_trailing_bytes_is_rejected() { + let mut payload = channel_open_payload(b"session"); + payload.push(0); + + assert!( + OpenChannelMessage::parse(&mut payload.as_slice()).is_err(), + "known channel-open type accepted trailing bytes" + ); + } + + #[test] + fn unknown_channel_open_with_extra_payload_stays_permissive() { + let mut payload = channel_open_payload(b"unknown@example.com"); + payload.extend_from_slice(b"opaque"); + + let parsed = OpenChannelMessage::parse(&mut payload.as_slice()) + .expect("unknown channel-open payload should remain opaque"); + + assert!(matches!(parsed.typ, ChannelType::Unknown { .. })); + } + + #[test] + fn openssh_streamlocal_channel_open_reserved_fields_are_consumed() { + let mut direct = channel_open_payload(b"direct-streamlocal@openssh.com"); + encode_string(&mut direct, b"/tmp/socket"); + encode_string(&mut direct, b""); + push_u32(&mut direct, 0); + + let parsed = OpenChannelMessage::parse(&mut direct.as_slice()) + .expect("direct streamlocal reserved fields should be consumed"); + assert!(matches!(parsed.typ, ChannelType::DirectStreamLocal(_))); + + let mut forwarded = channel_open_payload(b"forwarded-streamlocal@openssh.com"); + encode_string(&mut forwarded, b"/tmp/socket"); + encode_string(&mut forwarded, b""); + + let parsed = OpenChannelMessage::parse(&mut forwarded.as_slice()) + .expect("forwarded streamlocal reserved field should be consumed"); + assert!(matches!(parsed.typ, ChannelType::ForwardedStreamLocal(_))); + } +} diff --git a/crates/bssh-russh/src/server/encrypted.rs b/crates/bssh-russh/src/server/encrypted.rs index 2efe9d01..96bb2e10 100644 --- a/crates/bssh-russh/src/server/encrypted.rs +++ b/crates/bssh-russh/src/server/encrypted.rs @@ -32,7 +32,7 @@ use super::*; use crate::helpers::NameList; use crate::map_err; use crate::msg::SSH_OPEN_ADMINISTRATIVELY_PROHIBITED; -use crate::parsing::{ChannelOpenConfirmation, ChannelType, OpenChannelMessage}; +use crate::parsing::{ChannelOpenConfirmation, ChannelType, OpenChannelMessage, ensure_end}; impl Session { /// Returns false iff a request was rejected. @@ -73,6 +73,7 @@ impl Session { Some((&msg::SERVICE_REQUEST, mut r)), ) => { let request = map_err!(String::decode(&mut r))?; + map_err!(ensure_end(&r))?; debug!("request: {request:?}"); if request == "ssh-userauth" { let auth_request = server_accept_service( @@ -143,6 +144,177 @@ impl Session { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::raw_no_crypto::{ + MSG_SERVICE_REQUEST, MSG_USERAUTH_FAILURE, MSG_USERAUTH_REQUEST, RawSession, + assert_rejected, capture_panics, channel_request_payload, encode_string, pty_req_payload, + raw_auth_request_signal, raw_channel_request_signal, raw_service_request_signal, + read_packet, timeout, + }; + + #[tokio::test] + async fn malformed_pty_req_truncated_modes_rejected_by_server() { + let (result, panicked) = capture_panics(async { + timeout(raw_channel_request_signal(|server_channel| { + pty_req_payload(server_channel, &[Pty::VINTR as u8, 0, 0, 0]) + })) + .await + }) + .await; + + assert!(!panicked, "truncated pty terminal modes caused a panic"); + assert_rejected(result, "truncated pty terminal modes crashed or survived"); + } + + #[tokio::test] + async fn malformed_pty_req_rejects_bytes_after_mode_end() { + let result = timeout(raw_channel_request_signal(|server_channel| { + pty_req_payload(server_channel, &[Pty::TTY_OP_END as u8, 0]) + })) + .await; + + assert_rejected( + result, + "server accepted trailing bytes inside pty terminal modes", + ); + } + + #[tokio::test] + async fn malformed_pty_req_trailing_bytes_rejected_by_server() { + let result = timeout(raw_channel_request_signal(|server_channel| { + let mut payload = pty_req_payload(server_channel, &[Pty::TTY_OP_END as u8]); + payload.push(0); + payload + })) + .await; + + assert_rejected(result, "server accepted a pty request with trailing bytes"); + } + + #[tokio::test] + async fn env_request_with_trailing_bytes_rejected_by_server() { + let result = timeout(raw_channel_request_signal(|server_channel| { + let mut payload = channel_request_payload(server_channel, b"env"); + encode_string(&mut payload, b"LANG"); + encode_string(&mut payload, b"C"); + payload.push(0); + payload + })) + .await; + + assert_rejected(result, "server accepted an env request with trailing bytes"); + } + + #[tokio::test] + async fn exec_request_with_trailing_bytes_rejected_by_server() { + let result = timeout(raw_channel_request_signal(|server_channel| { + let mut payload = channel_request_payload(server_channel, b"exec"); + encode_string(&mut payload, b"true"); + payload.push(0); + payload + })) + .await; + + assert_rejected( + result, + "server accepted an exec request with trailing bytes", + ); + } + + #[tokio::test] + async fn signal_request_with_trailing_bytes_rejected_by_server() { + let result = timeout(raw_channel_request_signal(|server_channel| { + let mut payload = channel_request_payload(server_channel, b"signal"); + encode_string(&mut payload, b"TERM"); + payload.push(0); + payload + })) + .await; + + assert_rejected( + result, + "server accepted a signal request with trailing bytes", + ); + } + + #[tokio::test] + async fn service_request_with_trailing_bytes_rejected_by_server() { + let result = timeout(raw_service_request_signal(|payload| { + payload.push(MSG_SERVICE_REQUEST); + encode_string(payload, b"ssh-userauth"); + payload.push(0); + })) + .await; + + assert_rejected( + result, + "server accepted a service request with trailing bytes", + ); + } + + #[tokio::test] + async fn auth_none_with_trailing_bytes_rejected_by_server() { + let result = timeout(raw_auth_request_signal(|payload| { + payload.push(MSG_USERAUTH_REQUEST); + encode_string(payload, b"test"); + encode_string(payload, b"ssh-connection"); + encode_string(payload, b"none"); + payload.push(0); + })) + .await; + + assert_rejected( + result, + "server accepted a none auth request with trailing bytes", + ); + } + + #[tokio::test] + async fn auth_password_with_trailing_bytes_rejected_by_server() { + let result = timeout(raw_auth_request_signal(|payload| { + payload.push(MSG_USERAUTH_REQUEST); + encode_string(payload, b"test"); + encode_string(payload, b"ssh-connection"); + encode_string(payload, b"password"); + payload.push(0); + encode_string(payload, b"secret"); + payload.push(0); + })) + .await; + + assert_rejected( + result, + "server accepted a password auth request with trailing bytes", + ); + } + + #[tokio::test] + async fn password_change_request_is_parsed_and_rejected_by_server() { + let mut stream = RawSession::connect().await; + stream.service_request().await.unwrap(); + + let mut payload = Vec::new(); + payload.push(MSG_USERAUTH_REQUEST); + encode_string(&mut payload, b"test"); + encode_string(&mut payload, b"ssh-connection"); + encode_string(&mut payload, b"password"); + payload.push(1); + encode_string(&mut payload, b"old-password"); + encode_string(&mut payload, b"new-password"); + stream.send_packet(&payload).await.unwrap(); + + let failure = read_packet(&mut stream.stream).await.unwrap(); + assert_eq!(failure.first(), Some(&MSG_USERAUTH_FAILURE)); + assert!( + !stream.events.lock().unwrap().contains(&"auth_password"), + "password-change requests should not call normal password auth" + ); + stream.server_task.abort(); + } +} + fn server_accept_service( banner: Option, methods: MethodSet, @@ -161,12 +333,7 @@ fn server_accept_service( }) } - Ok(AuthRequest { - methods, - partial_success: false, // not used immediately anway. - current: None, - rejection_count: 0, - }) + Ok(AuthRequest::server(methods)) } impl Encrypted { @@ -187,6 +354,18 @@ impl Encrypted { debug!("name: {user:?} {service_name:?} {method:?}",); if service_name == "ssh-connection" { + { + let auth_request = if let EncryptedState::WaitingAuthRequest(ref mut a) = self.state + { + a + } else { + unreachable!() + }; + if auth_request.bind_or_reset_principal(&user, &service_name) { + auth_user.clear(); + } + } + if method == "password" { let auth_request = if let EncryptedState::WaitingAuthRequest(ref mut a) = self.state { @@ -196,9 +375,20 @@ impl Encrypted { }; auth_user.clear(); auth_user.push_str(&user); - map_err!(u8::decode(r))?; + let change = map_err!(u8::decode(r))? != 0; let password = map_err!(String::decode(r))?; - let auth = handler.auth_password(&user, &password).await?; + if change { + let _new_password = map_err!(String::decode(r))?; + } + map_err!(ensure_end(r))?; + let auth = if change { + Auth::Reject { + proceed_with_methods: None, + partial_success: false, + } + } else { + handler.auth_password(&user, &password).await? + }; if let Auth::Accept = auth { server_auth_request_success(&mut self.write); self.state = EncryptedState::InitCompression; @@ -237,6 +427,7 @@ impl Encrypted { }; until = initial_auth_until; + map_err!(ensure_end(r))?; let auth = handler.auth_none(&user).await?; if let Auth::Accept = auth { @@ -269,6 +460,7 @@ impl Encrypted { auth_user.push_str(&user); let _ = map_err!(String::decode(r))?; // language_tag, deprecated. let submethods = map_err!(String::decode(r))?; + map_err!(ensure_end(r))?; debug!("{submethods:?}"); auth_request.current = Some(CurrentRequest::KeyboardInteractive { submethods: submethods.to_string(), @@ -335,7 +527,12 @@ impl Encrypted { PublicKeyOrCertificate::Certificate(ref cert) => { // Validate certificate expiration let now = SystemTime::now(); - if now < cert.valid_after_time() || now > cert.valid_before_time() { + if cert.valid_after_time().map(|t| now < t).unwrap_or_default() + || cert + .valid_before_time() + .map(|t| now > t) + .unwrap_or_default() + { warn!("Certificate is expired or not yet valid"); reject_auth_request(until, &mut self.write, auth_request).await?; return Ok(()); @@ -374,7 +571,10 @@ impl Encrypted { let encoded_signature = map_err!(Vec::::decode(r))?; - let sig = map_err!(Signature::decode(&mut encoded_signature.as_slice()))?; + let mut signature_reader = encoded_signature.as_slice(); + let sig = map_err!(Signature::decode(&mut signature_reader))?; + map_err!(ensure_end(&signature_reader))?; + map_err!(ensure_end(r))?; let is_valid = if sent_pk_ok && user == auth_user { true @@ -433,6 +633,7 @@ impl Encrypted { } Ok(()) } else { + map_err!(ensure_end(r))?; auth_user.clear(); auth_user.push_str(user); let auth = handler.auth_publickey_offered(user, &pubkey).await?; @@ -522,10 +723,17 @@ async fn read_userauth_info_response( if let Some(CurrentRequest::KeyboardInteractive { ref submethods }) = auth_request.current { let n = map_err!(u32::decode(r))?; - let mut responses = Vec::with_capacity(n as usize); + // Bound both allocation and iteration by remaining packet data to + // prevent a malicious client from causing a multi-GB allocation or + // billions of loop iterations with a crafted count. + // Each response needs at least 4 bytes (length prefix). + let max_responses = r.remaining_len().saturating_add(3) / 4; + let n = (n as usize).min(max_responses); + let mut responses = Vec::with_capacity(n); for _ in 0..n { responses.push(Bytes::decode(r).ok()) } + map_err!(ensure_end(r))?; let auth = handler .auth_keyboard_interactive(user, submethods, Some(Response(&mut responses.into_iter()))) @@ -599,6 +807,7 @@ impl Session { .map(|_| ()), msg::CHANNEL_CLOSE => { let channel_num = map_err!(ChannelId::decode(r))?; + map_err!(ensure_end(r))?; if let Some(ref mut enc) = self.common.encrypted { enc.channels.remove(&channel_num); } @@ -614,6 +823,7 @@ impl Session { } msg::CHANNEL_EOF => { let channel_num = map_err!(ChannelId::decode(r))?; + map_err!(ensure_end(r))?; if let Some(chan) = self.channels.get(&channel_num) { chan.send(ChannelMsg::Eof).await.unwrap_or(()) } @@ -630,15 +840,17 @@ impl Session { }; trace!("handler.data {ext:?} {channel_num:?}"); let data = map_err!(Bytes::decode(r))?; + map_err!(ensure_end(r))?; let target = self.target_window_size; - if let Some(ref mut enc) = self.common.encrypted - && enc.adjust_window_size(channel_num, &data, target)? { + if let Some(ref mut enc) = self.common.encrypted { + if enc.adjust_window_size(channel_num, &data, target)? { let window = handler.adjust_window(channel_num, self.target_window_size); if window > 0 { self.target_window_size = window } } + } self.flush()?; if let Some(ext) = ext { if let Some(chan) = self.channels.get(&channel_num) { @@ -652,11 +864,9 @@ impl Session { handler.extended_data(channel_num, ext, &data, self).await } else { if let Some(chan) = self.channels.get(&channel_num) { - chan.send(ChannelMsg::Data { - data: data.clone(), - }) - .await - .unwrap_or(()) + chan.send(ChannelMsg::Data { data: data.clone() }) + .await + .unwrap_or(()) } handler.data(channel_num, &data, self).await } @@ -665,6 +875,7 @@ impl Session { msg::CHANNEL_WINDOW_ADJUST => { let channel_num = map_err!(ChannelId::decode(r))?; let amount = map_err!(u32::decode(r))?; + map_err!(ensure_end(r))?; let mut new_size = 0; if let Some(ref mut enc) = self.common.encrypted { if let Some(channel) = enc.channels.get_mut(&channel_num) { @@ -674,8 +885,11 @@ impl Session { return Ok(()); } } - if let Some(ref mut enc) = self.common.encrypted { - enc.flush_pending(channel_num)?; + let common = &mut self.common; + if let Some(enc) = common.encrypted.as_mut() { + new_size -= enc + .flush_pending_with_writer(&mut common.packet_writer, channel_num)? + as u32; } if let Some(chan) = self.channels.get(&channel_num) { chan.window_size().update(new_size).await; @@ -691,6 +905,7 @@ impl Session { msg::CHANNEL_OPEN_CONFIRMATION => { debug!("channel_open_confirmation"); let msg = map_err!(ChannelOpenConfirmation::decode(r))?; + map_err!(ensure_end(r))?; let local_id = ChannelId(msg.recipient_channel); if let Some(ref mut enc) = self.common.encrypted { @@ -730,10 +945,11 @@ impl Session { let channel_num = map_err!(ChannelId::decode(r))?; let req_type = map_err!(String::decode(r))?; let wants_reply = map_err!(u8::decode(r))?; - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get_mut(&channel_num) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get_mut(&channel_num) { channel.wants_reply = wants_reply != 0; } + } match req_type.as_str() { "pty-req" => { let term = map_err!(String::decode(r))?; @@ -745,14 +961,21 @@ impl Session { let mut i = 0; { let mode_string = map_err!(Bytes::decode(r))?; - while 5 * i < mode_string.len() { + let mut mode_bytes = mode_string.as_ref(); + while !mode_bytes.is_empty() { #[allow(clippy::indexing_slicing)] // length checked - let code = mode_string[5 * i]; + let code = mode_bytes[0]; if code == 0 { + if mode_bytes.len() != 1 { + return Err(Error::Inconsistent.into()); + } break; } + if mode_bytes.len() < 5 { + return Err(Error::Inconsistent.into()); + } #[allow(clippy::indexing_slicing)] // length checked - let num = BigEndian::read_u32(&mode_string[5 * i + 1..]); + let num = BigEndian::read_u32(&mode_bytes[1..5]); debug!("code = {code:?}"); if let Some(code) = Pty::from_u8(code) { #[allow(clippy::indexing_slicing)] // length checked @@ -764,9 +987,15 @@ impl Session { } else { info!("pty-req: unknown pty code {code:?}"); } - i += 1 + i += 1; + + #[allow(clippy::indexing_slicing, reason = "length checked")] + { + mode_bytes = &mode_bytes[5..]; + } } } + map_err!(ensure_end(r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan @@ -802,6 +1031,7 @@ impl Session { let x11_auth_protocol = map_err!(String::decode(r))?; let x11_auth_cookie = map_err!(String::decode(r))?; let x11_screen_number = map_err!(u32::decode(r))?; + map_err!(ensure_end(r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan @@ -829,6 +1059,7 @@ impl Session { "env" => { let env_variable = map_err!(String::decode(r))?; let env_value = map_err!(String::decode(r))?; + map_err!(ensure_end(r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan @@ -846,6 +1077,7 @@ impl Session { .await } "shell" => { + map_err!(ensure_end(r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan .send(ChannelMsg::RequestShell { want_reply: true }) @@ -855,6 +1087,7 @@ impl Session { handler.shell_request(channel_num, self).await } "auth-agent-req@openssh.com" => { + map_err!(ensure_end(r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan .send(ChannelMsg::AgentForward { want_reply: true }) @@ -872,6 +1105,7 @@ impl Session { } "exec" => { let req = map_err!(Bytes::decode(r))?; + map_err!(ensure_end(r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan .send(ChannelMsg::Exec { @@ -885,6 +1119,7 @@ impl Session { } "subsystem" => { let name = map_err!(String::decode(r))?; + map_err!(ensure_end(r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan @@ -902,6 +1137,7 @@ impl Session { let row_height = map_err!(u32::decode(r))?; let pix_width = map_err!(u32::decode(r))?; let pix_height = map_err!(u32::decode(r))?; + map_err!(ensure_end(r))?; if let Some(chan) = self.channels.get(&channel_num) { let _ = chan @@ -928,6 +1164,7 @@ impl Session { } "signal" => { let signal = Sig::from_name(&map_err!(String::decode(r))?); + map_err!(ensure_end(r))?; if let Some(chan) = self.channels.get(&channel_num) { chan.send(ChannelMsg::Signal { signal: signal.clone(), @@ -952,6 +1189,7 @@ impl Session { "tcpip-forward" => { let address = map_err!(String::decode(r))?; let port = map_err!(u32::decode(r))?; + map_err!(ensure_end(r))?; debug!("handler.tcpip_forward {address:?} {port:?}"); let mut returned_port = port; let result = handler @@ -974,6 +1212,7 @@ impl Session { "cancel-tcpip-forward" => { let address = map_err!(String::decode(r))?; let port = map_err!(u32::decode(r))?; + map_err!(ensure_end(r))?; debug!("handler.cancel_tcpip_forward {address:?} {port:?}"); let result = handler.cancel_tcpip_forward(&address, port, self).await?; if let Some(ref mut enc) = self.common.encrypted { @@ -987,6 +1226,7 @@ impl Session { } "streamlocal-forward@openssh.com" => { let server_socket_path = map_err!(String::decode(r))?; + map_err!(ensure_end(r))?; debug!("handler.streamlocal_forward {server_socket_path:?}"); let result = handler .streamlocal_forward(&server_socket_path, self) @@ -1002,6 +1242,7 @@ impl Session { } "cancel-streamlocal-forward@openssh.com" => { let socket_path = map_err!(String::decode(r))?; + map_err!(ensure_end(r))?; debug!("handler.cancel_streamlocal_forward {socket_path:?}"); let result = handler .cancel_streamlocal_forward(&socket_path, self) @@ -1032,6 +1273,7 @@ impl Session { .unwrap_or(ChannelOpenFailure::Unknown); let description = map_err!(String::decode(r))?; let language_tag = map_err!(String::decode(r))?; + map_err!(ensure_end(r))?; trace!("Channel open failure description: {description}"); trace!("Channel open failure language tag: {language_tag}"); @@ -1053,9 +1295,11 @@ impl Session { trace!("Global Request Success"); match self.open_global_requests.pop_front() { Some(GlobalRequestResponse::Keepalive) => { + map_err!(ensure_end(r))?; // ignore keepalives } Some(GlobalRequestResponse::Ping(return_channel)) => { + map_err!(ensure_end(r))?; let _ = return_channel.send(()); } Some(GlobalRequestResponse::TcpIpForward(return_channel)) => { @@ -1064,7 +1308,16 @@ impl Session { Some(0) } else { match u32::decode(r) { - Ok(port) => Some(port), + Ok(port) => { + if let Err(e) = ensure_end(r) { + error!( + "Error parsing port for TcpIpForward request: {e:?}" + ); + None + } else { + Some(port) + } + } Err(e) => { error!("Error parsing port for TcpIpForward request: {e:?}"); None @@ -1074,6 +1327,7 @@ impl Session { let _ = return_channel.send(result); } Some(GlobalRequestResponse::CancelTcpIpForward(return_channel)) => { + map_err!(ensure_end(r))?; let _ = return_channel.send(true); } _ => { @@ -1084,6 +1338,7 @@ impl Session { } msg::REQUEST_FAILURE => { trace!("global request failure"); + map_err!(ensure_end(r))?; match self.open_global_requests.pop_front() { Some(GlobalRequestResponse::Keepalive) => { // ignore keepalives diff --git a/crates/bssh-russh/src/server/kex.rs b/crates/bssh-russh/src/server/kex.rs index 236235d6..6dc52e50 100644 --- a/crates/bssh-russh/src/server/kex.rs +++ b/crates/bssh-russh/src/server/kex.rs @@ -10,9 +10,10 @@ use ssh_key::Algorithm; use super::*; use crate::helpers::sign_with_hash_alg; use crate::kex::dh::biguint_to_mpint; -use crate::kex::{KexAlgorithm, KexAlgorithmImplementor, KexCause, KEXES}; +use crate::kex::{KEXES, KexAlgorithm, KexAlgorithmImplementor, KexCause}; use crate::keys::key::PrivateKeyWithHashAlg; -use crate::negotiation::{is_key_compatible_with_algo, Names, Select}; +use crate::negotiation::{Names, Select, is_key_compatible_with_algo}; +use crate::parsing::ensure_end; use crate::{msg, negotiation}; thread_local! { @@ -109,7 +110,7 @@ impl ServerKex { } let names = { - self.exchange.client_kex_init.extend_from_slice(&input.buffer); + self.exchange.client_kex_init = input.buffer.clone().into(); negotiation::Server::read_kex( &input.buffer, &self.config.preferred, @@ -138,7 +139,7 @@ impl ServerKex { self.cause.session_id(), )?; - output.packet(|w| { + output.write_packet(|w| { msg::NEWKEYS.encode(w)?; Ok(()) })?; @@ -173,11 +174,15 @@ impl ServerKex { } #[allow(clippy::indexing_slicing)] // length checked - let gex_params = GexParams::decode(&mut &input.buffer[1..])?; + let mut r = &input.buffer[1..]; + let gex_params = GexParams::decode(&mut r)?; + ensure_end(&r)?; debug!("client requests a gex group: {gex_params:?}"); let Some(dh_group) = handler.lookup_dh_gex_group(&gex_params).await? else { - debug!("server::Handler impl did not find a matching DH group (is lookup_dh_gex_group implemented?)"); + debug!( + "server::Handler impl did not find a matching DH group (is lookup_dh_gex_group implemented?)" + ); return Err(Error::Kex)?; }; @@ -187,7 +192,7 @@ impl ServerKex { self.exchange.gex = Some((gex_params, dh_group.clone())); kex.dh_gex_set_group(dh_group)?; - output.packet(|w| { + output.write_packet(|w| { msg::KEX_DH_GEX_GROUP.encode(w)?; prime.encode(w)?; generator.encode(w)?; @@ -236,6 +241,7 @@ impl ServerKex { self.exchange .client_ephemeral .extend_from_slice(&Bytes::decode(&mut r).map_err(Into::into)?); + ensure_end(&r)?; let exchange = &mut self.exchange; kex.server_dh(exchange, &input.buffer)?; @@ -278,7 +284,7 @@ impl ServerKex { ) .map_err(Into::into)?; - output.packet(|w| { + output.write_packet(|w| { match kex.is_dh_gex() { true => &msg::KEX_DH_GEX_REPLY, false => &msg::KEX_ECDH_REPLY, @@ -290,7 +296,7 @@ impl ServerKex { Ok(()) })?; - output.packet(|w| { + output.write_packet(|w| { msg::NEWKEYS.encode(w)?; Ok(()) })?; @@ -324,6 +330,9 @@ impl ServerKex { ); return Err(Error::Kex.into()); } + #[allow(clippy::indexing_slicing, reason = "checked")] + let r = &input.buffer[1..]; + ensure_end(&r)?; debug!("new keys received"); Ok(KexProgress::Done { @@ -372,3 +381,19 @@ fn compute_keys( session_id: session_id_cv, }) } + +#[cfg(test)] +mod tests { + use crate::tests::raw_no_crypto::{assert_rejected, kexinit_payload, raw_kex_signal, timeout}; + + #[tokio::test] + async fn kexinit_with_trailing_bytes_rejected_by_server() { + let result = timeout(raw_kex_signal(|payload| { + payload.extend_from_slice(&kexinit_payload("none")); + payload.push(0); + })) + .await; + + assert_rejected(result, "server accepted a kexinit with trailing bytes"); + } +} diff --git a/crates/bssh-russh/src/server/mod.rs b/crates/bssh-russh/src/server/mod.rs index ee6f66e2..7cbbfc51 100644 --- a/crates/bssh-russh/src/server/mod.rs +++ b/crates/bssh-russh/src/server/mod.rs @@ -879,10 +879,11 @@ pub trait Server { let error_tx = error_tx.clone(); russh_util::runtime::spawn(async move { - if config.nodelay - && let Err(e) = socket.set_nodelay(true) { + if config.nodelay { + if let Err(e) = socket.set_nodelay(true) { warn!("set_nodelay() failed: {e:?}"); } + } let session = match run_stream(config, socket, handler).await { Ok(s) => s, @@ -1040,9 +1041,9 @@ async fn read_ssh_id( read: &mut SshRead, ) -> Result>, Error> { let sshid = if let Some(t) = config.inactivity_timeout { - tokio::time::timeout(t, read.read_ssh_id()).await?? + tokio::time::timeout(t, read.read_client_ssh_id()).await?? } else { - read.read_ssh_id().await? + read.read_client_ssh_id().await? }; let session = CommonSession { @@ -1095,8 +1096,8 @@ async fn reply( let is_kex_msg = pkt.buffer.first().cloned().map(is_kex_msg).unwrap_or(false); - if is_kex_msg - && let SessionKexState::InProgress(kex) = session.kex.take() { + if is_kex_msg { + if let SessionKexState::InProgress(kex) = session.kex.take() { let progress = kex .step(Some(pkt), &mut session.common.packet_writer, handler) .await?; @@ -1115,11 +1116,17 @@ async fn reply( session.common.strict_kex = session.common.strict_kex || newkeys.names.strict_kex(); - if let Some(ref mut enc) = session.common.encrypted { + if session.common.encrypted.is_some() { // This is a rekey - enc.last_rekey = Instant::now(); - session.common.packet_writer.buffer().bytes = 0; - enc.flush_all_pending()?; + { + let common = &mut session.common; + common.newkeys(newkeys); + common.packet_writer.buffer().bytes = 0; + if let Some(enc) = common.encrypted.as_mut() { + enc.last_rekey = Instant::now(); + enc.flush_all_pending_with_writer(&mut common.packet_writer)?; + } + } let mut pending = std::mem::take(&mut session.pending_reads); for p in pending.drain(..) { @@ -1127,7 +1134,6 @@ async fn reply( } session.pending_reads = pending; session.pending_len = 0; - session.common.newkeys(newkeys); session.flush()?; } else { // This is the initial kex @@ -1157,6 +1163,7 @@ async fn reply( return Ok(()); } + } // Handle key exchange/re-exchange. session.server_read_encrypted(handler, pkt).await diff --git a/crates/bssh-russh/src/server/session.rs b/crates/bssh-russh/src/server/session.rs index 9a35c495..6434ead1 100644 --- a/crates/bssh-russh/src/server/session.rs +++ b/crates/bssh-russh/src/server/session.rs @@ -5,15 +5,14 @@ use std::sync::Arc; use channels::WindowSizeRef; use kex::ServerKex; use log::debug; -use negotiation::parse_kex_algo_list; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; -use tokio::sync::mpsc::{channel, error::TryRecvError, Receiver, Sender}; +use tokio::sync::mpsc::{Receiver, Sender, channel, error::TryRecvError}; use tokio::sync::oneshot; use super::*; use crate::channels::{Channel, ChannelMsg, ChannelReadHalf, ChannelRef, ChannelWriteHalf}; use crate::helpers::NameList; -use crate::kex::{KexCause, SessionKexState, EXTENSION_SUPPORT_AS_CLIENT}; +use crate::kex::{EXTENSION_SUPPORT_AS_CLIENT, KexCause, SessionKexState}; use crate::{map_err, msg}; /// A connected server session. This type is unique to a client. @@ -101,12 +100,14 @@ pub struct Handle { impl Handle { /// Send data to the session referenced by this handler. - pub async fn data(&self, id: ChannelId, data: impl Into) -> Result<(), bytes::Bytes> { + pub async fn data( + &self, + id: ChannelId, + data: impl Into, + ) -> Result<(), bytes::Bytes> { let data = data.into(); self.sender - .send(Msg::Channel(id, ChannelMsg::Data { - data: data.clone(), - })) + .send(Msg::Channel(id, ChannelMsg::Data { data })) .await .map_err(|e| match e.0 { Msg::Channel(_, ChannelMsg::Data { data }) => data, @@ -123,10 +124,7 @@ impl Handle { ) -> Result<(), bytes::Bytes> { let data = data.into(); self.sender - .send(Msg::Channel(id, ChannelMsg::ExtendedData { - ext, - data: data.clone(), - })) + .send(Msg::Channel(id, ChannelMsg::ExtendedData { ext, data })) .await .map_err(|e| match e.0 { Msg::Channel(_, ChannelMsg::ExtendedData { data, .. }) => data, @@ -403,7 +401,7 @@ impl Handle { }); } Some(ChannelMsg::OpenFailure(reason)) => { - return Err(Error::ChannelOpenFailure(reason)) + return Err(Error::ChannelOpenFailure(reason)); } None => { return Err(Error::Disconnect); @@ -789,20 +787,22 @@ impl Session { // data from it. self.common.alive_timeouts = 0; } - if (self.common.received_data || sent_keepalive) - && let (futures::future::Either::Right(ref mut sleep), Some(d)) = ( + if self.common.received_data || sent_keepalive { + if let (futures::future::Either::Right(ref mut sleep), Some(d)) = ( keepalive_timer.as_mut().as_pin_mut(), self.common.config.keepalive_interval, ) { sleep.as_mut().reset(tokio::time::Instant::now() + d); } - if !sent_keepalive - && let (futures::future::Either::Right(ref mut sleep), Some(d)) = ( + } + if !sent_keepalive { + if let (futures::future::Either::Right(ref mut sleep), Some(d)) = ( inactivity_timer.as_mut().as_pin_mut(), self.common.config.inactivity_timeout, ) { sleep.as_mut().reset(tokio::time::Instant::now() + d); } + } } debug!("disconnected"); // Shutdown @@ -831,35 +831,38 @@ impl Session { } pub fn writable_packet_size(&self, channel: &ChannelId) -> u32 { - if let Some(ref enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(channel) { + if let Some(ref enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(channel) { return channel .sender_window_size .min(channel.sender_maximum_packet_size); } + } 0 } pub fn window_size(&self, channel: &ChannelId) -> u32 { - if let Some(ref enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(channel) { + if let Some(ref enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(channel) { return channel.sender_window_size; } + } 0 } pub fn max_packet_size(&self, channel: &ChannelId) -> u32 { - if let Some(ref enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(channel) { + if let Some(ref enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(channel) { return channel.sender_maximum_packet_size; } + } 0 } /// Flush the session, i.e. encrypt the pending buffer. pub fn flush(&mut self) -> Result<(), Error> { - if let Some(ref mut enc) = self.common.encrypted - && enc.flush( + if let Some(ref mut enc) = self.common.encrypted { + if enc.flush( &self.common.config.as_ref().limits, &mut self.common.packet_writer, )? && self.kex == SessionKexState::Idle @@ -869,6 +872,7 @@ impl Session { self.begin_rekey()?; } } + } Ok(()) } @@ -943,11 +947,12 @@ impl Session { /// cancelling). Always call this function if the request was /// successful (it checks whether the client expects an answer). pub fn request_success(&mut self) { - if self.common.wants_reply - && let Some(ref mut enc) = self.common.encrypted { + if self.common.wants_reply { + if let Some(ref mut enc) = self.common.encrypted { self.common.wants_reply = false; push_packet!(enc.write, enc.write.push(msg::REQUEST_SUCCESS)) } + } } /// Send a "failure" reply to a global request. @@ -962,8 +967,8 @@ impl Session { /// function if the request was successful (it checks whether the /// client expects an answer). pub fn channel_success(&mut self, channel: ChannelId) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get_mut(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get_mut(&channel) { assert!(channel.confirmed); if channel.wants_reply { channel.wants_reply = false; @@ -974,13 +979,14 @@ impl Session { }) } } + } Ok(()) } /// Send a "failure" reply to a global request. pub fn channel_failure(&mut self, channel: ChannelId) -> Result<(), crate::Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get_mut(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get_mut(&channel) { assert!(channel.confirmed); if channel.wants_reply { channel.wants_reply = false; @@ -990,6 +996,7 @@ impl Session { }) } } + } Ok(()) } @@ -1038,8 +1045,10 @@ impl Session { /// The number of bytes added to the "sending pipeline" (to be /// processed by the event loop) is returned. pub fn data(&mut self, channel: ChannelId, data: impl Into) -> Result<(), Error> { - if let Some(ref mut enc) = self.common.encrypted { - enc.data(channel, data, self.kex.active()) + let is_rekeying = self.kex.active(); + let common = &mut self.common; + if let Some(enc) = common.encrypted.as_mut() { + enc.data_with_writer(&mut common.packet_writer, channel, data, is_rekeying) } else { unreachable!() } @@ -1057,8 +1066,16 @@ impl Session { extended: u32, data: impl Into, ) -> Result<(), Error> { - if let Some(ref mut enc) = self.common.encrypted { - enc.extended_data(channel, extended, data, self.kex.active()) + let is_rekeying = self.kex.active(); + let common = &mut self.common; + if let Some(enc) = common.encrypted.as_mut() { + enc.extended_data_with_writer( + &mut common.packet_writer, + channel, + extended, + data, + is_rekeying, + ) } else { unreachable!() } @@ -1072,8 +1089,8 @@ impl Session { channel: ChannelId, client_can_do: bool, ) -> Result<(), Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(&channel) { assert!(channel.confirmed); push_packet!(enc.write, { msg::CHANNEL_REQUEST.encode(&mut enc.write)?; @@ -1084,6 +1101,7 @@ impl Session { (client_can_do as u8).encode(&mut enc.write)?; }) } + } Ok(()) } @@ -1123,8 +1141,8 @@ impl Session { channel: ChannelId, exit_status: u32, ) -> Result<(), Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(&channel) { assert!(channel.confirmed); push_packet!(enc.write, { msg::CHANNEL_REQUEST.encode(&mut enc.write)?; @@ -1135,6 +1153,7 @@ impl Session { exit_status.encode(&mut enc.write)?; }) } + } Ok(()) } @@ -1147,8 +1166,8 @@ impl Session { error_message: &str, language_tag: &str, ) -> Result<(), Error> { - if let Some(ref mut enc) = self.common.encrypted - && let Some(channel) = enc.channels.get(&channel) { + if let Some(ref mut enc) = self.common.encrypted { + if let Some(channel) = enc.channels.get(&channel) { assert!(channel.confirmed); push_packet!(enc.write, { msg::CHANNEL_REQUEST.encode(&mut enc.write)?; @@ -1162,6 +1181,7 @@ impl Session { language_tag.encode(&mut enc.write)?; }) } + } Ok(()) } @@ -1371,11 +1391,11 @@ impl Session { let Some(mut r) = e.client_kex_init.get(17..) else { return Ok(()); }; - if let Ok(kex_string) = String::decode(&mut r) { + if let Ok(kex_list) = NameList::decode(&mut r) { use super::negotiation::Select; key_extension_client = super::negotiation::Server::select( &[EXTENSION_SUPPORT_AS_CLIENT], - &parse_kex_algo_list(&kex_string), + &kex_list, AlgorithmKind::Kex, ) .is_ok(); @@ -1427,3 +1447,124 @@ impl Session { Ok(()) } } + +#[cfg(all(test, feature = "flate2"))] +mod tests { + use std::collections::{HashMap, VecDeque}; + use std::io::Write; + use std::num::Wrapping; + use std::sync::Arc; + + use super::*; + use crate::compression::{Compression, Decompress}; + use crate::kex::{KEXES, NONE, SessionKexState}; + use crate::session::{CommonSession, Encrypted, EncryptedState, Exchange}; + use crate::sshbuffer::{IncomingSshPacket, PacketWriter, SSHBuffer}; + use crate::{CryptoVec, cipher, mac}; + + struct TestHandler; + + impl crate::server::Handler for TestHandler { + type Error = crate::Error; + } + + fn authenticated_session() -> Session { + let config = Arc::new(crate::server::Config::default()); + let (sender, receiver) = tokio::sync::mpsc::channel(config.event_buffer_size); + let handle = Handle { + sender, + channel_buffer_size: config.channel_buffer_size, + }; + + Session { + common: CommonSession { + auth_user: String::new(), + remote_sshid: b"SSH-2.0-test".to_vec(), + config: config.clone(), + encrypted: Some(Encrypted { + state: EncryptedState::Authenticated, + exchange: Some(Exchange::default()), + kex: KEXES.get(&NONE).unwrap().make(), + key: 0, + client_mac: mac::NONE, + server_mac: mac::NONE, + session_id: CryptoVec::new(), + channels: HashMap::new(), + last_channel_id: Wrapping(0), + write: Vec::new(), + write_cursor: 0, + last_rekey: russh_util::time::Instant::now(), + server_compression: Compression::None, + client_compression: Compression::None, + decompress: Decompress::Zlib(flate2::Decompress::new(true)), + rekey_wanted: false, + received_extensions: Vec::new(), + extension_info_awaiters: HashMap::new(), + }), + auth_method: None, + auth_attempts: 0, + packet_writer: PacketWriter::clear(), + remote_to_local: Box::new(cipher::clear::Key), + wants_reply: false, + disconnected: false, + buffer: Vec::new(), + strict_kex: false, + alive_timeouts: 0, + received_data: false, + }, + sender: handle, + receiver, + target_window_size: config.window_size, + pending_reads: Vec::new(), + pending_len: 0, + channels: HashMap::new(), + open_global_requests: VecDeque::new(), + kex: SessionKexState::Idle, + } + } + + fn compressed_debug_payload(payload_len: usize) -> Vec { + let mut payload = vec![b'A'; payload_len]; + payload[0] = crate::msg::DEBUG; + + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + encoder.write_all(&payload).unwrap(); + let compressed = encoder.finish().unwrap(); + assert!(compressed.len() < 256 * 1024); + compressed + } + + fn incoming_packet(compressed: Vec) -> SSHBuffer { + let mut buffer = SSHBuffer::new(); + buffer.buffer.extend_from_slice(&[0; 5]); + buffer.buffer.extend_from_slice(&compressed); + buffer + } + + #[tokio::test] + async fn compressed_debug_is_ignored_after_server_parses_it() { + let mut session = authenticated_session(); + let mut handler = TestHandler; + let buffer = incoming_packet(compressed_debug_payload(200 * 1024)); + let mut pkt: IncomingSshPacket = session.maybe_decompress(&buffer).unwrap(); + + super::super::reply(&mut session, &mut handler, &mut pkt) + .await + .unwrap(); + + assert!(!session.common.disconnected); + } + + #[test] + fn oversized_compressed_debug_is_rejected_before_server_ignores_it() { + let mut session = authenticated_session(); + let buffer = incoming_packet(compressed_debug_payload( + crate::cipher::MAXIMUM_DECOMPRESSED_PACKET_LEN + 1024, + )); + + let err = session.maybe_decompress(&buffer).unwrap_err(); + assert!( + matches!(err, crate::Error::PacketSize(len) if len > crate::cipher::MAXIMUM_DECOMPRESSED_PACKET_LEN) + ); + } +} diff --git a/crates/bssh-russh/src/session.rs b/crates/bssh-russh/src/session.rs index d9cf65ff..b1eae0be 100644 --- a/crates/bssh-russh/src/session.rs +++ b/crates/bssh-russh/src/session.rs @@ -18,6 +18,7 @@ use std::fmt::{Debug, Formatter}; use std::mem::replace; use std::num::Wrapping; +use bytes::Bytes; use byteorder::{BigEndian, ByteOrder}; use log::{debug, trace}; use ssh_encoding::Encode; @@ -332,6 +333,29 @@ impl Encrypted { Ok(ChannelFlushResult::complete(pending_size, channel)) } + fn flush_channel_with_writer( + write: &mut Vec, + writer: &mut PacketWriter, + channel: &mut ChannelParams, + ) -> Result { + let mut pending_size = 0; + while let Some((buf, a, from)) = channel.pending_data.pop_front() { + let size = if write.is_empty() { + Self::data_noqueue_direct(writer, channel, &buf, a, from)? + } else { + Self::data_noqueue(write, channel, &buf, a, from)? + }; + pending_size += size; + if from + size < buf.len() { + channel.pending_data.push_front((buf, a, from + size)); + return Ok(ChannelFlushResult::Incomplete { + wrote: pending_size, + }); + } + } + Ok(ChannelFlushResult::complete(pending_size, channel)) + } + fn handle_flushed_channel( &mut self, channel: ChannelId, @@ -363,6 +387,21 @@ impl Encrypted { Ok(wrote) } + pub fn flush_pending_with_writer( + &mut self, + writer: &mut PacketWriter, + channel: ChannelId, + ) -> Result { + let flush_result = match self.channels.get_mut(&channel) { + Some(ch) => Self::flush_channel_with_writer(&mut self.write, writer, ch)?, + None => return Ok(0), + }; + let wrote = flush_result.wrote(); + self.handle_flushed_channel(channel, flush_result)?; + Ok(wrote) + } + + #[allow(dead_code)] pub fn flush_all_pending(&mut self) -> Result<(), crate::Error> { let channel_ids: Vec = self.channels.keys().copied().collect(); for channel_id in channel_ids { @@ -371,6 +410,17 @@ impl Encrypted { Ok(()) } + pub fn flush_all_pending_with_writer( + &mut self, + writer: &mut PacketWriter, + ) -> Result<(), crate::Error> { + let channel_ids: Vec = self.channels.keys().copied().collect(); + for channel_id in channel_ids { + self.flush_pending_with_writer(writer, channel_id)?; + } + Ok(()) + } + fn has_pending_data_mut(&mut self, channel: ChannelId) -> Option<&mut ChannelParams> { self.channels .get_mut(&channel) @@ -398,18 +448,27 @@ impl Encrypted { if from >= buf0.len() { return Ok(0); } - let mut buf = if buf0.len() as u32 > from as u32 + channel.recipient_window_size { - #[allow(clippy::indexing_slicing)] // length checked - &buf0[from..from + channel.recipient_window_size as usize] - } else { - #[allow(clippy::indexing_slicing)] // length checked - &buf0[from..] - }; + let window_end = from + .checked_add(channel.recipient_window_size as usize) + .unwrap_or(usize::MAX); + let end = std::cmp::min(buf0.len(), window_end); + #[allow(clippy::indexing_slicing)] // length checked + let mut buf = &buf0[from..end]; let buf_len = buf.len(); + let max_packet_size = channel.recipient_maximum_packet_size as usize; + if max_packet_size == 0 { + return Err(crate::Error::Inconsistent); + } + let packet_count = buf_len.div_ceil(max_packet_size); + let packet_overhead = match a { + None => 4 + 1 + 4 + 4, + Some(_) => 4 + 1 + 4 + 4 + 4, + }; + write.reserve(buf_len.saturating_add(packet_count.saturating_mul(packet_overhead))); while !buf.is_empty() { // Compute the length we're allowed to send. - let off = std::cmp::min(buf.len(), channel.recipient_maximum_packet_size as usize); + let off = std::cmp::min(buf.len(), max_packet_size); match a { None => push_packet!(write, { write.push(msg::CHANNEL_DATA); @@ -440,10 +499,71 @@ impl Encrypted { Ok(buf_len) } + fn data_noqueue_direct( + writer: &mut PacketWriter, + channel: &mut ChannelParams, + buf0: &Bytes, + a: Option, + from: usize, + ) -> Result { + if from >= buf0.len() { + return Ok(0); + } + let buf0 = buf0.as_ref(); + let window_end = from + .checked_add(channel.recipient_window_size as usize) + .unwrap_or(usize::MAX); + let end = std::cmp::min(buf0.len(), window_end); + #[allow(clippy::indexing_slicing)] // length checked + let mut buf = &buf0[from..end]; + let buf_len = buf.len(); + let max_packet_size = channel.recipient_maximum_packet_size as usize; + if max_packet_size == 0 { + return Err(crate::Error::Inconsistent); + } + let packet_count = buf_len.div_ceil(max_packet_size); + let channel_payload_overhead = match a { + None => 1 + 4 + 4, + Some(_) => 1 + 4 + 4 + 4, + }; + writer.reserve_cleartext_packet_output( + buf_len.saturating_add(packet_count.saturating_mul(channel_payload_overhead)), + packet_count, + ); + + while !buf.is_empty() { + let off = std::cmp::min(buf.len(), max_packet_size); + match a { + None => writer.write_packet(|packet| { + packet.push(msg::CHANNEL_DATA); + channel.recipient_channel.encode(packet)?; + #[allow(clippy::indexing_slicing)] // length checked + buf[..off].encode(packet)?; + Ok(()) + })?, + Some(ext) => writer.write_packet(|packet| { + packet.push(msg::CHANNEL_EXTENDED_DATA); + channel.recipient_channel.encode(packet)?; + ext.encode(packet)?; + #[allow(clippy::indexing_slicing)] // length checked + buf[..off].encode(packet)?; + Ok(()) + })?, + } + channel.recipient_window_size -= off as u32; + #[allow(clippy::indexing_slicing)] // length checked + { + buf = &buf[off..] + } + } + Ok(buf_len) + } + + #[allow(dead_code)] pub fn data( &mut self, channel: ChannelId, - buf0: impl Into, + buf0: impl Into, is_rekeying: bool, ) -> Result<(), crate::Error> { let buf0 = buf0.into(); @@ -463,11 +583,40 @@ impl Encrypted { Ok(()) } + pub fn data_with_writer( + &mut self, + writer: &mut PacketWriter, + channel: ChannelId, + buf0: impl Into, + is_rekeying: bool, + ) -> Result<(), crate::Error> { + let buf0 = buf0.into(); + if let Some(channel) = self.channels.get_mut(&channel) { + assert!(channel.confirmed); + if !channel.pending_data.is_empty() || is_rekeying { + channel.pending_data.push_back((buf0, None, 0)); + return Ok(()); + } + let buf_len = if self.write.is_empty() { + Self::data_noqueue_direct(writer, channel, &buf0, None, 0)? + } else { + Self::data_noqueue(&mut self.write, channel, &buf0, None, 0)? + }; + if buf_len < buf0.len() { + channel.pending_data.push_back((buf0, None, buf_len)) + } + } else { + debug!("{channel:?} not saved for this session"); + } + Ok(()) + } + + #[allow(dead_code)] pub fn extended_data( &mut self, channel: ChannelId, ext: u32, - buf0: impl Into, + buf0: impl Into, is_rekeying: bool, ) -> Result<(), crate::Error> { let buf0 = buf0.into(); @@ -485,6 +634,33 @@ impl Encrypted { Ok(()) } + pub fn extended_data_with_writer( + &mut self, + writer: &mut PacketWriter, + channel: ChannelId, + ext: u32, + buf0: impl Into, + is_rekeying: bool, + ) -> Result<(), crate::Error> { + let buf0 = buf0.into(); + if let Some(channel) = self.channels.get_mut(&channel) { + assert!(channel.confirmed); + if !channel.pending_data.is_empty() || is_rekeying { + channel.pending_data.push_back((buf0, Some(ext), 0)); + return Ok(()); + } + let buf_len = if self.write.is_empty() { + Self::data_noqueue_direct(writer, channel, &buf0, Some(ext), 0)? + } else { + Self::data_noqueue(&mut self.write, channel, &buf0, Some(ext), 0)? + }; + if buf_len < buf0.len() { + channel.pending_data.push_back((buf0, Some(ext), buf_len)) + } + } + Ok(()) + } + pub fn flush( &mut self, limits: &Limits, @@ -571,8 +747,8 @@ pub struct Exchange { // They carry no secret material and do not require mlock. pub client_id: Vec, pub server_id: Vec, - pub client_kex_init: Vec, - pub server_kex_init: Vec, + pub client_kex_init: Bytes, + pub server_kex_init: Bytes, pub client_ephemeral: Vec, pub server_ephemeral: Vec, pub gex: Option<(GexParams, DhGroup)>, @@ -626,7 +802,8 @@ mod tests { use super::{Encrypted, EncryptedState, Exchange}; use crate::compression::{Compression, Decompress}; use crate::kex::{KEXES, NONE}; - use crate::{ChannelId, ChannelParams, CryptoVec, mac, msg}; + use crate::sshbuffer::PacketWriter; + use crate::{ChannelId, ChannelParams, CryptoVec, Limits, mac, msg}; fn test_encrypted() -> Encrypted { Encrypted { @@ -685,6 +862,25 @@ mod tests { packet_types } + fn clear_packet_types(buf: &[u8]) -> Vec { + let mut packet_types = Vec::new(); + let mut cursor = 0; + + while cursor < buf.len() { + let packet_len = BigEndian::read_u32(&buf[cursor..cursor + 4]) as usize; + packet_types.push(buf[cursor + 5]); + cursor += 4 + packet_len; + } + + packet_types + } + + fn test_ready_channel(sender_channel: ChannelId, recipient_channel: u32) -> ChannelParams { + let mut channel = test_channel(sender_channel, recipient_channel, false, false); + channel.pending_data.clear(); + channel + } + fn test_channel_windowed( sender_channel: ChannelId, recipient_channel: u32, @@ -807,34 +1003,43 @@ mod tests { } #[test] - fn flush_all_pending_handles_multiple_channels_independently() { - let eof_only = ChannelId(3); - let close_too = ChannelId(4); + fn flush_all_pending_replays_deferred_controls_across_channels() { + let channel_a = ChannelId(3); + let channel_b = ChannelId(4); let mut encrypted = test_encrypted(); encrypted .channels - .insert(eof_only, test_channel(eof_only, 50, true, false)); + .insert(channel_a, test_channel(channel_a, 44, true, false)); encrypted .channels - .insert(close_too, test_channel(close_too, 51, true, true)); + .insert(channel_b, test_channel(channel_b, 45, false, true)); encrypted.flush_all_pending().unwrap(); - // eof_only: data + EOF, channel still present - assert!(encrypted.channels.contains_key(&eof_only)); - assert!(!encrypted.channels[&eof_only].pending_eof); - - // close_too: data + EOF + CLOSE, channel removed - assert!(!encrypted.channels.contains_key(&close_too)); - - // Combined wire output contains both sets of packets (order may vary by map iteration). - let types = packet_types(&encrypted.write); - assert_eq!(types.iter().filter(|&&t| t == msg::CHANNEL_DATA).count(), 2); - assert_eq!(types.iter().filter(|&&t| t == msg::CHANNEL_EOF).count(), 2); + let packet_types = packet_types(&encrypted.write); assert_eq!( - types.iter().filter(|&&t| t == msg::CHANNEL_CLOSE).count(), + packet_types + .iter() + .filter(|&&msg_type| msg_type == msg::CHANNEL_DATA) + .count(), + 2 + ); + assert_eq!( + packet_types + .iter() + .filter(|&&msg_type| msg_type == msg::CHANNEL_EOF) + .count(), + 1 + ); + assert_eq!( + packet_types + .iter() + .filter(|&&msg_type| msg_type == msg::CHANNEL_CLOSE) + .count(), 1 ); + assert!(encrypted.channels.contains_key(&channel_a)); + assert!(!encrypted.channels.contains_key(&channel_b)); } #[test] @@ -899,4 +1104,365 @@ mod tests { assert_eq!(channel.pending_data.back().unwrap().1, Some(ext)); assert!(encrypted.write.is_empty()); } + + #[test] + fn flush_pending_with_writer_matches_staged_channel_data() { + let channel_id = ChannelId(7); + let mut staged = test_encrypted(); + let mut direct = test_encrypted(); + staged + .channels + .insert(channel_id, test_channel(channel_id, 42, false, false)); + direct + .channels + .insert(channel_id, test_channel(channel_id, 42, false, false)); + + let mut staged_writer = PacketWriter::clear(); + staged.flush_pending(channel_id).unwrap(); + staged + .flush(&Limits::default(), &mut staged_writer) + .unwrap(); + + let mut direct_writer = PacketWriter::clear(); + direct + .flush_pending_with_writer(&mut direct_writer, channel_id) + .unwrap(); + + assert_eq!(direct_writer.buffer().buffer, staged_writer.buffer().buffer); + assert_eq!( + direct.channels[&channel_id].recipient_window_size, + staged.channels[&channel_id].recipient_window_size + ); + assert!(direct.channels[&channel_id].pending_data.is_empty()); + } + + #[test] + fn flush_pending_with_writer_matches_staged_extended_data() { + let channel_id = ChannelId(8); + let mut staged = test_encrypted(); + let mut direct = test_encrypted(); + let mut staged_channel = test_channel(channel_id, 42, false, false); + staged_channel.pending_data = + VecDeque::from([(Bytes::from_static(b"hello"), Some(1), 0)]); + let mut direct_channel = test_channel(channel_id, 42, false, false); + direct_channel.pending_data = + VecDeque::from([(Bytes::from_static(b"hello"), Some(1), 0)]); + staged.channels.insert(channel_id, staged_channel); + direct.channels.insert(channel_id, direct_channel); + + let mut staged_writer = PacketWriter::clear(); + staged.flush_pending(channel_id).unwrap(); + staged + .flush(&Limits::default(), &mut staged_writer) + .unwrap(); + + let mut direct_writer = PacketWriter::clear(); + direct + .flush_pending_with_writer(&mut direct_writer, channel_id) + .unwrap(); + + assert_eq!(direct_writer.buffer().buffer, staged_writer.buffer().buffer); + assert!(direct.channels[&channel_id].pending_data.is_empty()); + } + + #[test] + fn flush_pending_with_writer_falls_back_when_write_queue_nonempty() { + let channel_id = ChannelId(9); + let mut encrypted = test_encrypted(); + encrypted + .channels + .insert(channel_id, test_channel(channel_id, 42, false, false)); + push_packet!(encrypted.write, encrypted.write.push(msg::REQUEST_SUCCESS)); + + let mut writer = PacketWriter::clear(); + encrypted + .flush_pending_with_writer(&mut writer, channel_id) + .unwrap(); + + assert!(writer.buffer().buffer.is_empty()); + assert_eq!( + packet_types(&encrypted.write), + vec![msg::REQUEST_SUCCESS, msg::CHANNEL_DATA] + ); + } + + #[test] + fn flush_pending_with_writer_preserves_partial_window_remainder() { + let channel_id = ChannelId(13); + let payload = Bytes::from_static(b"abcdef"); + let mut encrypted = test_encrypted(); + let mut channel = test_channel_windowed(channel_id, 42, 3, false, false); + channel.pending_data = VecDeque::from([(payload.clone(), None, 0)]); + encrypted.channels.insert(channel_id, channel); + + let mut writer = PacketWriter::clear(); + encrypted + .flush_pending_with_writer(&mut writer, channel_id) + .unwrap(); + + let channel = &encrypted.channels[&channel_id]; + assert_eq!( + clear_packet_types(&writer.buffer().buffer), + vec![msg::CHANNEL_DATA] + ); + assert_eq!(channel.recipient_window_size, 0); + assert_eq!(channel.pending_data.len(), 1); + let pending = channel.pending_data.back().unwrap(); + assert_eq!(pending.0, payload); + assert_eq!(pending.1, None); + assert_eq!(pending.2, 3); + } + + #[test] + fn flush_pending_with_writer_emits_controls_after_replayed_data() { + let channel_id = ChannelId(14); + let mut encrypted = test_encrypted(); + encrypted + .channels + .insert(channel_id, test_channel(channel_id, 42, true, true)); + + let mut writer = PacketWriter::clear(); + encrypted + .flush_pending_with_writer(&mut writer, channel_id) + .unwrap(); + encrypted.flush(&Limits::default(), &mut writer).unwrap(); + + assert_eq!( + clear_packet_types(&writer.buffer().buffer), + vec![msg::CHANNEL_DATA, msg::CHANNEL_EOF, msg::CHANNEL_CLOSE] + ); + assert!(!encrypted.channels.contains_key(&channel_id)); + } + + #[test] + fn data_direct_matches_staged_channel_data() { + let channel_id = ChannelId(20); + let payload = Bytes::from_static(b"direct channel data"); + let mut staged = test_encrypted(); + let mut direct = test_encrypted(); + staged + .channels + .insert(channel_id, test_ready_channel(channel_id, 42)); + direct + .channels + .insert(channel_id, test_ready_channel(channel_id, 42)); + + let mut staged_writer = PacketWriter::clear(); + staged.data(channel_id, payload.clone(), false).unwrap(); + staged + .flush(&Limits::default(), &mut staged_writer) + .unwrap(); + + let mut direct_writer = PacketWriter::clear(); + direct + .data_with_writer(&mut direct_writer, channel_id, payload, false) + .unwrap(); + + assert_eq!( + direct_writer.buffer().buffer, + staged_writer.buffer().buffer + ); + assert_eq!( + direct.channels[&channel_id].recipient_window_size, + staged.channels[&channel_id].recipient_window_size + ); + assert!(direct.channels[&channel_id].pending_data.is_empty()); + } + + #[test] + fn extended_data_direct_matches_staged_channel_data() { + let channel_id = ChannelId(21); + let payload = Bytes::from_static(b"direct extended channel data"); + let mut staged = test_encrypted(); + let mut direct = test_encrypted(); + staged + .channels + .insert(channel_id, test_ready_channel(channel_id, 43)); + direct + .channels + .insert(channel_id, test_ready_channel(channel_id, 43)); + + let mut staged_writer = PacketWriter::clear(); + staged + .extended_data(channel_id, 1, payload.clone(), false) + .unwrap(); + staged + .flush(&Limits::default(), &mut staged_writer) + .unwrap(); + + let mut direct_writer = PacketWriter::clear(); + direct + .extended_data_with_writer(&mut direct_writer, channel_id, 1, payload, false) + .unwrap(); + + assert_eq!( + direct_writer.buffer().buffer, + staged_writer.buffer().buffer + ); + assert_eq!( + direct.channels[&channel_id].recipient_window_size, + staged.channels[&channel_id].recipient_window_size + ); + assert!(direct.channels[&channel_id].pending_data.is_empty()); + } + + #[test] + fn data_direct_is_disabled_when_write_queue_nonempty() { + let channel_id = ChannelId(22); + let mut encrypted = test_encrypted(); + encrypted + .channels + .insert(channel_id, test_ready_channel(channel_id, 44)); + push_packet!(encrypted.write, encrypted.write.push(msg::REQUEST_SUCCESS)); + + let mut writer = PacketWriter::clear(); + encrypted + .data_with_writer(&mut writer, channel_id, Bytes::from_static(b"new"), false) + .unwrap(); + + assert!(writer.buffer().buffer.is_empty()); + assert_eq!( + packet_types(&encrypted.write), + vec![msg::REQUEST_SUCCESS, msg::CHANNEL_DATA] + ); + + encrypted.flush(&Limits::default(), &mut writer).unwrap(); + assert_eq!( + clear_packet_types(&writer.buffer().buffer), + vec![msg::REQUEST_SUCCESS, msg::CHANNEL_DATA] + ); + } + + #[test] + fn data_staged_large_payload_when_write_queue_nonempty_preserves_order_and_chunks() { + let channel_id = ChannelId(26); + let mut encrypted = test_encrypted(); + let mut channel = test_ready_channel(channel_id, 48); + channel.recipient_window_size = 256 * 1024; + channel.recipient_maximum_packet_size = 32 * 1024; + encrypted.channels.insert(channel_id, channel); + push_packet!(encrypted.write, encrypted.write.push(msg::REQUEST_SUCCESS)); + + let mut writer = PacketWriter::clear(); + encrypted + .data_with_writer( + &mut writer, + channel_id, + Bytes::from(vec![0x5a; 256 * 1024]), + false, + ) + .unwrap(); + + assert!(writer.buffer().buffer.is_empty()); + let packet_types = packet_types(&encrypted.write); + assert_eq!(packet_types.first(), Some(&msg::REQUEST_SUCCESS)); + assert_eq!(packet_types.len(), 9); + assert!( + packet_types + .iter() + .skip(1) + .all(|&msg_type| msg_type == msg::CHANNEL_DATA) + ); + assert!(encrypted.channels[&channel_id].pending_data.is_empty()); + assert_eq!(encrypted.channels[&channel_id].recipient_window_size, 0); + } + + #[test] + fn data_staged_rejects_zero_recipient_max_packet_size() { + let channel_id = ChannelId(27); + let mut encrypted = test_encrypted(); + let mut channel = test_ready_channel(channel_id, 49); + channel.recipient_maximum_packet_size = 0; + encrypted.channels.insert(channel_id, channel); + + let result = encrypted.data(channel_id, Bytes::from_static(b"new"), false); + + assert!(matches!(result, Err(crate::Error::Inconsistent))); + assert!(encrypted.write.is_empty()); + assert_eq!(encrypted.channels[&channel_id].recipient_window_size, 1024); + } + + #[test] + fn data_direct_rejects_zero_recipient_max_packet_size() { + let channel_id = ChannelId(28); + let mut encrypted = test_encrypted(); + let mut channel = test_ready_channel(channel_id, 50); + channel.recipient_maximum_packet_size = 0; + encrypted.channels.insert(channel_id, channel); + + let mut writer = PacketWriter::clear(); + let result = + encrypted.data_with_writer(&mut writer, channel_id, Bytes::from_static(b"new"), false); + + assert!(matches!(result, Err(crate::Error::Inconsistent))); + assert!(writer.buffer().buffer.is_empty()); + assert!(encrypted.write.is_empty()); + assert_eq!(encrypted.channels[&channel_id].recipient_window_size, 1024); + } + + #[test] + fn data_direct_is_disabled_while_rekeying() { + let channel_id = ChannelId(23); + let mut encrypted = test_encrypted(); + encrypted + .channels + .insert(channel_id, test_ready_channel(channel_id, 45)); + + let mut writer = PacketWriter::clear(); + encrypted + .data_with_writer(&mut writer, channel_id, Bytes::from_static(b"new"), true) + .unwrap(); + + let channel = &encrypted.channels[&channel_id]; + assert_eq!(channel.pending_data.len(), 1); + assert_eq!(channel.pending_data.back().unwrap().0.as_ref(), b"new"); + assert_eq!(channel.pending_data.back().unwrap().1, None); + assert!(encrypted.write.is_empty()); + assert!(writer.buffer().buffer.is_empty()); + } + + #[test] + fn data_direct_queues_remainder_when_window_is_partial() { + let channel_id = ChannelId(24); + let payload = Bytes::from_static(b"abcdef"); + let mut encrypted = test_encrypted(); + let mut channel = test_ready_channel(channel_id, 46); + channel.recipient_window_size = 3; + encrypted.channels.insert(channel_id, channel); + + let mut writer = PacketWriter::clear(); + encrypted + .data_with_writer(&mut writer, channel_id, payload.clone(), false) + .unwrap(); + + let channel = &encrypted.channels[&channel_id]; + assert_eq!(clear_packet_types(&writer.buffer().buffer), vec![msg::CHANNEL_DATA]); + assert_eq!(channel.recipient_window_size, 0); + assert_eq!(channel.pending_data.len(), 1); + let pending = channel.pending_data.back().unwrap(); + assert_eq!(pending.0, payload); + assert_eq!(pending.1, None); + assert_eq!(pending.2, 3); + } + + #[test] + fn data_direct_disabled_behind_existing_pending_data() { + let channel_id = ChannelId(25); + let mut encrypted = test_encrypted(); + encrypted + .channels + .insert(channel_id, test_channel(channel_id, 47, false, false)); + + let mut writer = PacketWriter::clear(); + encrypted + .data_with_writer(&mut writer, channel_id, Bytes::from_static(b"new"), false) + .unwrap(); + + let channel = &encrypted.channels[&channel_id]; + assert_eq!(channel.pending_data.len(), 2); + assert_eq!(channel.pending_data.front().unwrap().0.as_ref(), b"hello"); + assert_eq!(channel.pending_data.back().unwrap().0.as_ref(), b"new"); + assert!(encrypted.write.is_empty()); + assert!(writer.buffer().buffer.is_empty()); + } } diff --git a/crates/bssh-russh/src/ssh_read.rs b/crates/bssh-russh/src/ssh_read.rs index 548b6eee..89772d7a 100644 --- a/crates/bssh-russh/src/ssh_read.rs +++ b/crates/bssh-russh/src/ssh_read.rs @@ -7,6 +7,8 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, ReadBuf}; use crate::Error; const SSH_ID_BUF_SIZE: usize = 256; +const SSH_ID_MAX_LINE_LEN: usize = 255; +const SSH_ID_MAX_PRE_BANNER_LINES: usize = 20; /// The buffer to read the identification string (first line in the /// protocol). Not sensitive data — just protocol version exchange. @@ -15,6 +17,7 @@ struct ReadSshIdBuffer { pub total: usize, pub bytes_read: usize, pub sshid_len: usize, + pub pre_banner_lines: usize, } impl ReadSshIdBuffer { @@ -29,8 +32,32 @@ impl ReadSshIdBuffer { sshid_len: 0, bytes_read: 0, total: 0, + pre_banner_lines: 0, } } + + fn line(&self) -> Option<(usize, usize)> { + if self.total < 2 { + return None; + } + #[allow(clippy::indexing_slicing)] // loop bounds keep i + 1 in range + for i in 0..self.total - 1 { + if self.buf[i] == b'\r' && self.buf[i + 1] == b'\n' { + return Some((i, i + 2)); + } + if self.buf[i + 1] == b'\n' { + return Some((i + 1, i + 2)); + } + } + None + } + + fn discard_line(&mut self) { + let remaining = self.total - self.bytes_read; + self.buf.copy_within(self.bytes_read..self.total, 0); + self.total = remaining; + self.bytes_read = 0; + } } impl std::fmt::Debug for ReadSshIdBuffer { @@ -113,60 +140,73 @@ impl SshRead { } } - #[allow(clippy::unwrap_used)] pub async fn read_ssh_id(&mut self) -> Result<&[u8], Error> { - let ssh_id = self.id.as_mut().unwrap(); + self.read_ssh_id_inner(true).await + } + + pub async fn read_client_ssh_id(&mut self) -> Result<&[u8], Error> { + self.read_ssh_id_inner(false).await + } + + async fn read_ssh_id_inner(&mut self, allow_pre_banner_lines: bool) -> Result<&[u8], Error> { + let ssh_id = self.id.as_mut().ok_or(Error::Inconsistent)?; loop { - let mut i = 0; - trace!("read_ssh_id: reading"); + let i = if let Some((line_len, bytes_read)) = ssh_id.line() { + ssh_id.bytes_read = bytes_read; + line_len + } else { + trace!("read_ssh_id: reading"); - #[allow(clippy::indexing_slicing)] // length checked - let n = AsyncReadExt::read(&mut self.r, &mut ssh_id.buf[ssh_id.total..]).await?; - trace!("read {n:?}"); + #[allow(clippy::indexing_slicing)] // length checked + let n = AsyncReadExt::read(&mut self.r, &mut ssh_id.buf[ssh_id.total..]).await?; + trace!("read {n:?}"); - ssh_id.total += n; - #[allow(clippy::indexing_slicing)] // length checked - { - trace!("{:?}", std::str::from_utf8(&ssh_id.buf[..ssh_id.total])); - } - if n == 0 { - return Err(Error::Disconnect); - } - #[allow(clippy::indexing_slicing)] // length checked - loop { - if i >= ssh_id.total - 1 { - break; + ssh_id.total += n; + #[allow(clippy::indexing_slicing)] // length checked + { + trace!("{:?}", std::str::from_utf8(&ssh_id.buf[..ssh_id.total])); + } + if n == 0 { + return Err(Error::Disconnect); } - if ssh_id.buf[i] == b'\r' && ssh_id.buf[i + 1] == b'\n' { - ssh_id.bytes_read = i + 2; - break; - } else if ssh_id.buf[i + 1] == b'\n' { - // This is really wrong, but OpenSSH 7.4 uses - // it. - ssh_id.bytes_read = i + 2; - i += 1; - break; + + if let Some((line_len, bytes_read)) = ssh_id.line() { + ssh_id.bytes_read = bytes_read; + line_len + } else if ssh_id.total >= SSH_ID_MAX_LINE_LEN { + return Err(Error::Version); } else { - i += 1; + trace!("bytes_read: {:?}", ssh_id.bytes_read); + continue; } - } + }; if ssh_id.bytes_read > 0 { + if ssh_id.bytes_read > SSH_ID_MAX_LINE_LEN { + return Err(Error::Version); + } // If we have a full line, handle it. if i >= 8 { // Check if we have a valid SSH protocol identifier #[allow(clippy::indexing_slicing)] - if let Ok(s) = std::str::from_utf8(&ssh_id.buf[..i]) - && (s.starts_with("SSH-1.99-") || s.starts_with("SSH-2.0-")) { + if let Ok(s) = std::str::from_utf8(&ssh_id.buf[..i]) { + if s.starts_with("SSH-1.99-") || s.starts_with("SSH-2.0-") { ssh_id.sshid_len = i; return Ok(ssh_id.id()); } + } + } + if !allow_pre_banner_lines { + return Err(Error::Version); + } + ssh_id.pre_banner_lines += 1; + if ssh_id.pre_banner_lines > SSH_ID_MAX_PRE_BANNER_LINES { + return Err(Error::Version); } // Else, it is a "preliminary" (see // https://tools.ietf.org/html/rfc4253#section-4.2), // and we can discard it and read the next one. - ssh_id.total = 0; - ssh_id.bytes_read = 0; + ssh_id.discard_line(); } trace!("bytes_read: {:?}", ssh_id.bytes_read); } @@ -203,7 +243,46 @@ mod tests { let mut read = SshRead::new(data.as_bytes()); let received = read.read_ssh_id().await; - assert!(matches!(received.err(), Some(Error::Disconnect))); + assert!(matches!(received.err(), Some(Error::Version))); + } + + #[tokio::test] + async fn test_ssh_id_accepts_maximum_line_length() { + let data = format!("SSH-2.0-{}\r\n", "A".repeat(245)); + let mut read = SshRead::new(data.as_bytes()); + + let received = read.read_ssh_id().await.unwrap(); + assert_eq!(received.len(), SSH_ID_MAX_LINE_LEN - 2); + } + + #[tokio::test] + async fn test_ssh_id_rejects_oversized_line_with_terminator() { + let data = format!("SSH-2.0-{}\r\n", "A".repeat(246)); + let mut read = SshRead::new(data.as_bytes()); + + let received = read.read_ssh_id().await; + assert!(matches!(received.err(), Some(Error::Version))); + } + + #[tokio::test] + async fn test_ssh_id_rejects_too_many_pre_banner_lines() { + let data = format!( + "{}SSH-2.0-OpenSSH_10.2\r\n", + "debug\r\n".repeat(SSH_ID_MAX_PRE_BANNER_LINES + 1) + ); + let mut read = SshRead::new(data.as_bytes()); + + let received = read.read_ssh_id().await; + assert!(matches!(received.err(), Some(Error::Version))); + } + + #[tokio::test] + async fn test_server_ssh_id_rejects_pre_banner_line() { + let data = "debug\r\nSSH-2.0-OpenSSH_10.2\r\n"; + let mut read = SshRead::new(data.as_bytes()); + + let received = read.read_client_ssh_id().await; + assert!(matches!(received.err(), Some(Error::Version))); } #[tokio::test] diff --git a/crates/bssh-russh/src/sshbuffer.rs b/crates/bssh-russh/src/sshbuffer.rs index cf995891..234693ab 100644 --- a/crates/bssh-russh/src/sshbuffer.rs +++ b/crates/bssh-russh/src/sshbuffer.rs @@ -17,6 +17,9 @@ use core::fmt; use std::borrow::Cow; use std::num::Wrapping; +use bytes::{Bytes, BytesMut}; +use log::debug; +use ssh_encoding::Writer; use super::cipher::SealingKey; use compression::Compress; use tokio::io::{AsyncWrite, AsyncWriteExt}; @@ -70,6 +73,217 @@ fn test_ssh_id() { ); } +#[test] +fn test_write_packet_leaves_reusable_buffer_for_cold_path_packets() { + let mut writer = PacketWriter::clear(); + let large_len = 128 * 1024; + let packet_buffer_capacity = writer.packet_buffer.capacity(); + + writer + .write_packet(|buf| { + buf.resize(buf.len() + large_len, 0x5a); + Ok(()) + }) + .unwrap(); + assert_eq!(writer.packet_buffer.capacity(), packet_buffer_capacity); +} + +#[test] +fn reserve_cleartext_packet_output_reserves_output_capacity() { + let mut writer = PacketWriter::clear(); + let payload_bytes = 4096; + let packet_count = 4; + + writer.reserve_cleartext_packet_output(payload_bytes, packet_count); + + let expected = payload_bytes + + packet_count * (PacketWriter::PACKET_PREFIX_LEN + writer.cipher.tag_len() + 32); + assert!(writer.write_buffer.buffer.capacity() >= expected); + assert!(writer.write_buffer.buffer.is_empty()); +} + +#[cfg(feature = "flate2")] +#[test] +fn reserve_cleartext_packet_output_ignores_compressed_writer() { + let mut writer = PacketWriter::new(Box::new(cipher::clear::Key {}), zlib_compress()); + let capacity = writer.write_buffer.buffer.capacity(); + + writer.reserve_cleartext_packet_output(4096, 4); + + assert_eq!(writer.write_buffer.buffer.capacity(), capacity); +} + +#[test] +fn test_packet_returns_retained_bytes() { + let mut writer = PacketWriter::clear(); + let retained = writer + .packet(|buf| { + buf.extend_from_slice(b"abc"); + Ok(()) + }) + .unwrap(); + + assert_eq!(&retained[..], b"abc"); +} + +#[test] +fn packet_bytes_returns_retained_bytes() { + let mut writer = PacketWriter::clear(); + let retained = writer + .packet_bytes(|buf| { + buf.extend_from_slice(b"abc"); + Ok(()) + }) + .unwrap(); + + assert_eq!(&retained[..], b"abc"); +} + +#[test] +fn packet_bytes_matches_packet_output() { + let payload = b"abcdefghijklmno".to_vec(); + + let mut packet_writer = PacketWriter::clear(); + let packet_retained = packet_writer + .packet(|buf| { + buf.extend_from_slice(&payload); + Ok(()) + }) + .unwrap(); + let packet_buffer = packet_writer.buffer().buffer.clone(); + let packet_bytes = packet_writer.buffer().bytes; + + let mut packet_bytes_writer = PacketWriter::clear(); + let bytes_retained = packet_bytes_writer + .packet_bytes(|buf| { + buf.extend_from_slice(&payload); + Ok(()) + }) + .unwrap(); + + assert_eq!(packet_retained, bytes_retained); + assert_eq!(packet_bytes_writer.buffer().buffer, packet_buffer); + assert_eq!(packet_bytes_writer.buffer().bytes, packet_bytes); +} + +#[test] +fn test_write_packet_matches_clear_cipher_write_output() { + let payload = b"abcdefghijklmno".to_vec(); + + let mut expected = SSHBuffer::new(); + let mut clear = cipher::clear::Key {}; + clear.write(&payload, &mut expected); + + let mut writer = PacketWriter::clear(); + writer + .write_packet(|buf| { + buf.extend_from_slice(&payload); + Ok(()) + }) + .unwrap(); + + assert_eq!(writer.buffer().buffer, expected.buffer); + assert_eq!(writer.buffer().bytes, payload.len()); + assert_eq!(writer.buffer().seqn, Wrapping(1)); +} + +#[test] +fn test_write_packet_restores_output_buffer_on_error() { + let mut writer = PacketWriter::clear(); + writer + .write_packet(|buf| { + buf.extend_from_slice(b"ok"); + Ok(()) + }) + .unwrap(); + let before = writer.buffer().buffer.clone(); + + let err = writer.write_packet(|buf| { + buf.extend_from_slice(b"partial"); + Err(Error::Inconsistent) + }); + + assert!(matches!(err, Err(Error::Inconsistent))); + assert_eq!(writer.buffer().buffer, before); +} + +#[cfg(all(test, feature = "flate2"))] +fn zlib_compress() -> Compress { + let mut compress = Compress::None; + compression::Compression::Zlib.init_compress(&mut compress); + compress +} + +#[cfg(feature = "flate2")] +#[test] +fn test_write_packet_compressed_matches_clear_cipher_output() { + let payload = b"abcdefghijklmnoabcdefghijklmno".to_vec(); + + let mut expected = SSHBuffer::new(); + let mut clear = cipher::clear::Key {}; + let mut compress = zlib_compress(); + let mut compressed = Vec::new(); + let packet = compress.compress(&payload, &mut compressed).unwrap(); + clear.write(packet, &mut expected); + + let mut writer = PacketWriter::new(Box::new(cipher::clear::Key {}), zlib_compress()); + writer + .write_packet(|buf| { + buf.extend_from_slice(&payload); + Ok(()) + }) + .unwrap(); + + assert_eq!(writer.buffer().buffer, expected.buffer); + assert_eq!(writer.buffer().bytes, packet.len()); + assert_eq!(writer.buffer().seqn, Wrapping(1)); +} + +#[cfg(feature = "flate2")] +#[test] +fn test_packet_retains_plaintext_for_compressed_packets() { + let payload = b"abcdefghijklmnoabcdefghijklmno".to_vec(); + + let mut writer = PacketWriter::new(Box::new(cipher::clear::Key {}), zlib_compress()); + let retained = writer + .packet(|buf| { + buf.extend_from_slice(&payload); + Ok(()) + }) + .unwrap(); + + assert_eq!(&retained[..], &payload); +} + +#[cfg(feature = "flate2")] +#[test] +fn packet_bytes_compressed_matches_packet_output() { + let payload = b"abcdefghijklmnoabcdefghijklmno".to_vec(); + + let mut packet_writer = PacketWriter::new(Box::new(cipher::clear::Key {}), zlib_compress()); + let packet_retained = packet_writer + .packet(|buf| { + buf.extend_from_slice(&payload); + Ok(()) + }) + .unwrap(); + let packet_buffer = packet_writer.buffer().buffer.clone(); + let packet_bytes = packet_writer.buffer().bytes; + + let mut packet_bytes_writer = + PacketWriter::new(Box::new(cipher::clear::Key {}), zlib_compress()); + let bytes_retained = packet_bytes_writer + .packet_bytes(|buf| { + buf.extend_from_slice(&payload); + Ok(()) + }) + .unwrap(); + + assert_eq!(packet_retained, bytes_retained); + assert_eq!(packet_bytes_writer.buffer().buffer, packet_buffer); + assert_eq!(packet_bytes_writer.buffer().bytes, packet_bytes); +} + /// SSH packet read/write buffer. Uses Vec (not CryptoVec/mlocked) because /// packet data is not secret material. #[derive(Debug, Default)] @@ -97,6 +311,43 @@ impl SSHBuffer { } } +pub(crate) struct PacketBytesWriter { + buffer: BytesMut, +} + +impl Writer for PacketBytesWriter { + fn write(&mut self, bytes: &[u8]) -> ssh_encoding::Result<()> { + self.buffer.extend_from_slice(bytes); + Ok(()) + } +} + +impl PacketBytesWriter { + #[allow(dead_code)] + pub(crate) fn push(&mut self, byte: u8) { + self.buffer.extend_from_slice(&[byte]); + } + + #[allow(dead_code)] + pub(crate) fn extend_from_slice(&mut self, bytes: &[u8]) { + self.buffer.extend_from_slice(bytes); + } + + #[allow(dead_code)] + pub(crate) fn len(&self) -> usize { + self.buffer.len() + } + + #[allow(dead_code)] + pub(crate) fn is_empty(&self) -> bool { + self.buffer.is_empty() + } + + fn freeze(self) -> Bytes { + self.buffer.freeze() + } +} + /// Incoming SSH packet after decryption and optional decompression. /// Uses Vec (not CryptoVec/mlocked) because incoming network data is not secret. #[derive(Debug)] @@ -109,7 +360,7 @@ pub(crate) struct IncomingSshPacket { pub(crate) struct PacketWriter { cipher: Box, compress: Compress, - compress_buffer: Vec, + packet_buffer: Vec, write_buffer: SSHBuffer, } @@ -120,6 +371,10 @@ impl Debug for PacketWriter { } impl PacketWriter { + // SSH packet prefix = packet_length (cipher::PACKET_LENGTH_LEN bytes) + // + padding_length (1 byte). + const PACKET_PREFIX_LEN: usize = cipher::PACKET_LENGTH_LEN + 1; + pub fn clear() -> Self { Self::new(Box::new(cipher::clear::Key {}), Compress::None) } @@ -128,30 +383,168 @@ impl PacketWriter { Self { cipher, compress, - compress_buffer: Vec::new(), + packet_buffer: Vec::new(), write_buffer: SSHBuffer::new(), } } + fn prepare_packet) -> Result<(), Error>>( + &mut self, + f: F, + ) -> Result, Error> { + let mut buf = std::mem::take(&mut self.packet_buffer); + buf.clear(); + match f(&mut buf) { + Ok(()) => Ok(buf), + Err(err) => { + self.packet_buffer = buf; + Err(err) + } + } + } + + fn write_packet_in_place) -> Result<(), Error>>( + &mut self, + f: F, + ) -> Result<(), Error> { + self.write_payload_into_output(|buffer, payload_start| { + f(buffer)?; + Ok(buffer.len() - payload_start) + }) + } + + fn write_payload_into_output(&mut self, f: F) -> Result<(), Error> + where + F: FnOnce(&mut Vec, usize) -> Result, + { + let offset = self.write_buffer.buffer.len(); + let payload_start = offset + Self::PACKET_PREFIX_LEN; + + self.write_buffer.buffer.resize(payload_start, 0); + match f(&mut self.write_buffer.buffer, payload_start) { + Ok(payload_len) => { + if payload_len == 0 { + self.write_buffer.buffer.truncate(offset); + return Ok(()); + } + + if let Some(message_type) = self.write_buffer.buffer.get(payload_start) { + debug!("> msg type {message_type:?}, len {payload_len}"); + } + + self.cipher + .finish_packet(offset, payload_len, &mut self.write_buffer); + Ok(()) + } + Err(err) => { + self.write_buffer.buffer.truncate(offset); + Err(err) + } + } + } + + fn write_compressed_payload_into_output(&mut self, buf: &[u8]) -> Result<(), Error> { + let offset = self.write_buffer.buffer.len(); + let payload_start = offset + Self::PACKET_PREFIX_LEN; + + self.write_buffer.buffer.resize(payload_start, 0); + match self + .compress + .compress_into(buf, &mut self.write_buffer.buffer, payload_start) + { + Ok(payload_len) => { + if payload_len == 0 { + self.write_buffer.buffer.truncate(offset); + return Ok(()); + } + + self.cipher + .finish_packet(offset, payload_len, &mut self.write_buffer); + Ok(()) + } + Err(err) => { + self.write_buffer.buffer.truncate(offset); + Err(err) + } + } + } + pub fn packet_raw(&mut self, buf: &[u8]) -> Result<(), Error> { if let Some(message_type) = buf.first() { debug!("> msg type {message_type:?}, len {}", buf.len()); - let packet = self.compress.compress(buf, &mut self.compress_buffer)?; - self.cipher.write(packet, &mut self.write_buffer); + if matches!(&self.compress, Compress::None) { + self.cipher.write(buf, &mut self.write_buffer); + } else { + self.write_compressed_payload_into_output(buf)?; + } } Ok(()) } - /// Sends and returns the packet contents. - /// Packet buffer is not secret — use Vec for performance. + /// Sends a packet using the reusable plaintext packet buffer. + /// + /// The closure must append only the packet payload bytes. It must not + /// modify or truncate any existing contents in the provided buffer. + /// When compression is disabled, the buffer may already contain queued + /// packets and the reserved 5-byte packet header prefix for the packet + /// being built, so callers must only write new payload bytes starting at + /// the current end of the buffer. + pub fn write_packet) -> Result<(), Error>>( + &mut self, + f: F, + ) -> Result<(), Error> { + if matches!(&self.compress, Compress::None) { + return self.write_packet_in_place(f); + } + let buf = self.prepare_packet(f)?; + let result = self.packet_raw(&buf); + self.packet_buffer = buf; + result + } + + pub(crate) fn reserve_cleartext_packet_output( + &mut self, + payload_bytes: usize, + packet_count: usize, + ) { + if !matches!(&self.compress, Compress::None) { + return; + } + + // Padding is cipher-dependent and rounded to the cipher block size. + // Reserving a small fixed margin avoids repeated output-buffer growth + // without coupling callers to individual cipher padding formulas. + let per_packet_margin = Self::PACKET_PREFIX_LEN + self.cipher.tag_len() + 32; + let additional = payload_bytes.saturating_add(packet_count.saturating_mul(per_packet_margin)); + self.write_buffer.buffer.reserve(additional); + } + + /// Sends and returns the packet contents for callers that need to retain + /// the plaintext packet after it has been queued for encryption. + #[allow(dead_code)] pub fn packet) -> Result<(), Error>>( &mut self, f: F, - ) -> Result, Error> { - let mut buf = Vec::new(); + ) -> Result { + let buf = self.prepare_packet(f)?; + if let Err(err) = self.packet_raw(&buf) { + self.packet_buffer = buf; + return Err(err); + } + Ok(Bytes::from(buf)) + } + + pub(crate) fn packet_bytes(&mut self, f: F) -> Result + where + F: FnOnce(&mut PacketBytesWriter) -> Result<(), Error>, + { + let mut buf = PacketBytesWriter { + buffer: BytesMut::new(), + }; f(&mut buf)?; - self.packet_raw(&buf)?; - Ok(buf) + let packet = buf.freeze(); + self.packet_raw(packet.as_ref())?; + Ok(packet) } pub fn buffer(&mut self) -> &mut SSHBuffer { diff --git a/crates/bssh-russh/src/tests.rs b/crates/bssh-russh/src/tests.rs index 232ac9fa..7d84c754 100644 --- a/crates/bssh-russh/src/tests.rs +++ b/crates/bssh-russh/src/tests.rs @@ -4,8 +4,11 @@ use futures::Future; use super::*; +#[cfg(feature = "flate2")] mod compress { + use std::borrow::Cow; use std::collections::HashMap; + use std::io::Write; use std::sync::{Arc, Mutex}; use keys::PrivateKeyWithHashAlg; @@ -14,15 +17,18 @@ mod compress { use super::server::{Server as _, Session}; use super::*; + use crate::cipher::MAXIMUM_DECOMPRESSED_PACKET_LEN; use crate::server::Msg; + const OVERSIZED_DEBUG_MESSAGE_LEN: usize = MAXIMUM_DECOMPRESSED_PACKET_LEN + 1024; + #[tokio::test] async fn compress_local_test() { let _ = env_logger::try_init(); let client_key = PrivateKey::random(&mut rand::rng(), ssh_key::Algorithm::Ed25519).unwrap(); let mut config = server::Config::default(); - config.preferred = Preferred::COMPRESSED; + config.preferred = preferred_zlib(); config.inactivity_timeout = None; // Some(std::time::Duration::from_secs(3)); config.auth_rejection_time = std::time::Duration::from_secs(3); config @@ -44,7 +50,7 @@ mod compress { }); let mut config = client::Config::default(); - config.preferred = Preferred::COMPRESSED; + config.preferred = preferred_zlib(); let config = Arc::new(config); let mut session = client::connect(config, addr, Client {}).await.unwrap(); @@ -73,6 +79,19 @@ mod compress { } } + #[test] + fn oversized_debug_payload_can_stay_below_wire_cap() { + let payload = vec![b'A'; OVERSIZED_DEBUG_MESSAGE_LEN]; + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + encoder.write_all(&payload).unwrap(); + let compressed = encoder.finish().unwrap(); + + assert!( + compressed.len() < 256 * 1024, + "attacker-crafted compressed payload should stay below the normal SSH wire cap" + ); + } + #[derive(Clone)] struct Server { clients: Arc>>, @@ -135,6 +154,17 @@ mod compress { Ok(true) } } + + fn preferred_zlib() -> Preferred { + Preferred { + compression: Cow::Borrowed(&[ + crate::compression::ZLIB, + crate::compression::ZLIB_LEGACY, + crate::compression::NONE, + ]), + ..Preferred::DEFAULT + } + } } mod channels { @@ -714,6 +744,458 @@ mod server_kex_junk { } } +pub(crate) mod raw_no_crypto { + use std::borrow::Cow; + use std::io; + use std::sync::{Arc, Mutex, OnceLock}; + use std::time::Duration; + + use byteorder::{BigEndian, ByteOrder}; + use ssh_key::{Algorithm, PrivateKey}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use super::*; + + pub(crate) const MSG_SERVICE_REQUEST: u8 = 5; + pub(crate) const MSG_SERVICE_ACCEPT: u8 = 6; + pub(crate) const MSG_KEXINIT: u8 = 20; + pub(crate) const MSG_NEWKEYS: u8 = 21; + pub(crate) const MSG_USERAUTH_REQUEST: u8 = 50; + pub(crate) const MSG_USERAUTH_FAILURE: u8 = 51; + pub(crate) const MSG_USERAUTH_SUCCESS: u8 = 52; + pub(crate) const MSG_CHANNEL_OPEN: u8 = 90; + pub(crate) const MSG_CHANNEL_OPEN_CONFIRMATION: u8 = 91; + pub(crate) const MSG_CHANNEL_REQUEST: u8 = 98; + + pub(crate) async fn raw_service_request_signal( + build_payload: impl FnOnce(&mut Vec), + ) -> ServerSignal { + let mut stream = RawSession::connect().await; + let mut payload = Vec::new(); + build_payload(&mut payload); + stream.send_packet(&payload).await.unwrap(); + stream.result().await + } + + pub(crate) async fn raw_auth_request_signal( + build_payload: impl FnOnce(&mut Vec), + ) -> ServerSignal { + let mut stream = RawSession::connect().await; + stream.service_request().await.unwrap(); + + let mut payload = Vec::new(); + build_payload(&mut payload); + stream.send_packet(&payload).await.unwrap(); + stream.result().await + } + + pub(crate) async fn raw_kex_signal(build_payload: impl FnOnce(&mut Vec)) -> ServerSignal { + let mut stream = RawSession::connect_without_kex().await; + + let mut payload = Vec::new(); + build_payload(&mut payload); + stream.send_packet(&payload).await.unwrap(); + stream.result().await + } + + pub(crate) async fn raw_channel_request_signal( + build_payload: impl FnOnce(u32) -> Vec, + ) -> ServerSignal { + let mut stream = RawSession::connect().await; + stream.auth_none().await.unwrap(); + let server_channel = stream.open_session().await.unwrap(); + stream + .send_packet(&build_payload(server_channel)) + .await + .unwrap(); + stream.result().await + } + + pub(crate) struct RawSession { + pub(crate) events: Arc>>, + pub(crate) stream: tokio::net::TcpStream, + pub(crate) server_task: tokio::task::JoinHandle>, + } + + impl RawSession { + pub(crate) async fn connect() -> Self { + let mut stream = Self::connect_without_kex().await; + raw_client_no_crypto_kex(&mut stream.stream).await.unwrap(); + stream + } + + pub(crate) async fn connect_without_kex() -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let events = Arc::new(Mutex::new(Vec::new())); + let server_events = events.clone(); + let server_task = tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let running = + server::run_stream( + no_crypto_server_config(), + socket, + MalformedInputServer { + events: server_events, + }, + ) + .await + .unwrap(); + running.await + }); + + let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap(); + stream.write_all(b"SSH-2.0-russh-test\r\n").await.unwrap(); + read_ssh_id(&mut stream).await.unwrap(); + let _server_kex = read_packet(&mut stream).await.unwrap(); + Self { + events, + stream, + server_task, + } + } + + pub(crate) async fn service_request(&mut self) -> io::Result<()> { + let mut service = Vec::new(); + service.push(MSG_SERVICE_REQUEST); + encode_string(&mut service, b"ssh-userauth"); + self.send_packet(&service).await?; + + let accept = read_packet(&mut self.stream).await?; + assert_eq!(accept.first(), Some(&MSG_SERVICE_ACCEPT)); + Ok(()) + } + + pub(crate) async fn auth_none(&mut self) -> io::Result<()> { + self.service_request().await?; + + let mut auth = Vec::new(); + auth.push(MSG_USERAUTH_REQUEST); + encode_string(&mut auth, b"test"); + encode_string(&mut auth, b"ssh-connection"); + encode_string(&mut auth, b"none"); + self.send_packet(&auth).await?; + + let success = read_packet(&mut self.stream).await?; + assert_eq!(success.first(), Some(&MSG_USERAUTH_SUCCESS)); + Ok(()) + } + + pub(crate) async fn open_session(&mut self) -> io::Result { + let mut open = Vec::new(); + open.push(MSG_CHANNEL_OPEN); + encode_string(&mut open, b"session"); + push_u32(&mut open, 0); + push_u32(&mut open, 1024 * 1024); + push_u32(&mut open, 32 * 1024); + self.send_packet(&open).await?; + + let confirmation = read_packet(&mut self.stream).await?; + assert_eq!(confirmation.first(), Some(&MSG_CHANNEL_OPEN_CONFIRMATION)); + Ok(BigEndian::read_u32(&confirmation[5..9])) + } + + pub(crate) async fn send_packet(&mut self, payload: &[u8]) -> io::Result<()> { + self.stream.write_all(&ssh_packet(payload)).await?; + self.stream.flush().await + } + + pub(crate) async fn result(mut self) -> ServerSignal { + let result = + tokio::time::timeout(Duration::from_millis(200), &mut self.server_task).await; + let events = self.events.lock().unwrap().clone(); + + match result { + Ok(Ok(Ok(()))) => ServerSignal::Closed { events }, + Ok(Ok(Err(_error))) => ServerSignal::ProtocolError { events }, + Ok(Err(join)) if join.is_panic() => ServerSignal::Panicked { events }, + Err(_) => { + self.server_task.abort(); + ServerSignal::Survived { events } + } + _ => ServerSignal::Closed { events }, + } + } + } + + fn no_crypto_server_config() -> Arc { + let mut config = server::Config::default(); + config.inactivity_timeout = None; + config.auth_rejection_time = Duration::from_millis(1); + config.auth_rejection_time_initial = Some(Duration::from_millis(1)); + config.preferred = no_crypto_preferred(); + config + .keys + .push(PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap()); + Arc::new(config) + } + + fn no_crypto_preferred() -> Preferred { + Preferred { + kex: Cow::Owned(vec![kex::NONE]), + key: Cow::Owned(vec![Algorithm::Ed25519]), + cipher: Cow::Owned(vec![cipher::NONE]), + mac: Cow::Owned(vec![mac::NONE]), + compression: Cow::Owned(vec![compression::NONE]), + } + } + + async fn raw_client_no_crypto_kex(stream: &mut tokio::net::TcpStream) -> io::Result<()> { + stream + .write_all(&ssh_packet(&kexinit_payload("none"))) + .await?; + let newkeys = read_packet(stream).await?; + assert_eq!(newkeys.first(), Some(&MSG_NEWKEYS)); + stream.write_all(&ssh_packet(&[MSG_NEWKEYS])).await?; + stream.flush().await + } + + pub(crate) fn pty_req_payload(server_channel: u32, terminal_modes: &[u8]) -> Vec { + let mut payload = channel_request_payload(server_channel, b"pty-req"); + encode_string(&mut payload, b"xterm"); + push_u32(&mut payload, 80); + push_u32(&mut payload, 24); + push_u32(&mut payload, 0); + push_u32(&mut payload, 0); + encode_string(&mut payload, terminal_modes); + payload + } + + pub(crate) fn channel_open_payload(channel_type: &[u8]) -> Vec { + let mut payload = Vec::new(); + encode_string(&mut payload, channel_type); + push_u32(&mut payload, 0); + push_u32(&mut payload, 1024 * 1024); + push_u32(&mut payload, 32 * 1024); + payload + } + + pub(crate) fn channel_request_payload(server_channel: u32, request_type: &[u8]) -> Vec { + let mut payload = Vec::new(); + payload.push(MSG_CHANNEL_REQUEST); + push_u32(&mut payload, server_channel); + encode_string(&mut payload, request_type); + payload.push(1); + payload + } + + pub(crate) fn kexinit_payload(kex_name: &str) -> Vec { + let mut payload = Vec::new(); + payload.push(MSG_KEXINIT); + payload.extend_from_slice(&[0; 16]); + encode_name_list(&mut payload, &[kex_name]); + encode_name_list(&mut payload, &["ssh-ed25519"]); + encode_name_list(&mut payload, &["none"]); + encode_name_list(&mut payload, &["none"]); + encode_name_list(&mut payload, &["none"]); + encode_name_list(&mut payload, &["none"]); + encode_name_list(&mut payload, &["none"]); + encode_name_list(&mut payload, &["none"]); + encode_name_list(&mut payload, &[]); + encode_name_list(&mut payload, &[]); + payload.push(0); + push_u32(&mut payload, 0); + payload + } + + fn ssh_packet(payload: &[u8]) -> Vec { + let mut padding_len = 8 - ((5 + payload.len()) % 8); + if padding_len < 4 { + padding_len += 8; + } + let packet_len = 1 + payload.len() + padding_len; + let mut packet = Vec::with_capacity(4 + packet_len); + push_u32(&mut packet, packet_len as u32); + packet.push(padding_len as u8); + packet.extend_from_slice(payload); + packet.resize(packet.len() + padding_len, 0); + packet + } + + pub(crate) async fn read_packet(stream: &mut tokio::net::TcpStream) -> io::Result> { + let mut len_buf = [0; 4]; + stream.read_exact(&mut len_buf).await?; + let packet_len = BigEndian::read_u32(&len_buf) as usize; + let mut packet = vec![0; packet_len]; + stream.read_exact(&mut packet).await?; + let padding_len = packet[0] as usize; + Ok(packet[1..packet.len() - padding_len].to_vec()) + } + + async fn read_ssh_id(stream: &mut tokio::net::TcpStream) -> io::Result> { + let mut id = Vec::new(); + loop { + let mut byte = [0]; + stream.read_exact(&mut byte).await?; + id.push(byte[0]); + if byte[0] == b'\n' { + return Ok(id); + } + } + } + + fn encode_name_list(buf: &mut Vec, names: &[&str]) { + encode_string(buf, names.join(",").as_bytes()); + } + + pub(crate) fn encode_string(buf: &mut Vec, value: &[u8]) { + push_u32(buf, value.len() as u32); + buf.extend_from_slice(value); + } + + pub(crate) fn push_u32(buf: &mut Vec, value: u32) { + let mut bytes = [0; 4]; + BigEndian::write_u32(&mut bytes, value); + buf.extend_from_slice(&bytes); + } + + pub(crate) async fn timeout( + future: impl Future, + ) -> Result { + tokio::time::timeout(Duration::from_secs(3), future).await + } + + pub(crate) async fn capture_panics(future: impl Future) -> (T, bool) { + static PANIC_HOOK_LOCK: OnceLock> = OnceLock::new(); + + let _guard = PANIC_HOOK_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap(); + let panicked = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let panicked_hook = panicked.clone(); + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |_| { + panicked_hook.store(true, std::sync::atomic::Ordering::SeqCst); + })); + + let result = future.await; + + std::panic::set_hook(previous_hook); + (result, panicked.load(std::sync::atomic::Ordering::SeqCst)) + } + + #[derive(Debug)] + pub(crate) enum ServerSignal { + Closed { events: Vec<&'static str> }, + ProtocolError { events: Vec<&'static str> }, + Panicked { events: Vec<&'static str> }, + Survived { events: Vec<&'static str> }, + } + + impl ServerSignal { + pub(crate) fn events(&self) -> &[&'static str] { + match self { + Self::Closed { events } + | Self::ProtocolError { events } + | Self::Panicked { events } + | Self::Survived { events } => events, + } + } + } + + pub(crate) fn assert_rejected( + result: Result, + message: &str, + ) { + assert!( + matches!( + result, + Ok(ServerSignal::Closed { .. } | ServerSignal::ProtocolError { .. }) + ), + "{message}: {result:?}; handler events: {:?}", + result.as_ref().ok().map(ServerSignal::events).unwrap_or(&[]) + ); + } + + #[derive(Clone)] + struct MalformedInputServer { + events: Arc>>, + } + + impl MalformedInputServer { + fn record(&self, event: &'static str) { + self.events.lock().unwrap().push(event); + } + } + + impl server::Handler for MalformedInputServer { + type Error = Error; + + async fn auth_none(&mut self, _user: &str) -> Result { + self.record("auth_none"); + Ok(server::Auth::Accept) + } + + async fn auth_password( + &mut self, + _user: &str, + _password: &str, + ) -> Result { + self.record("auth_password"); + Ok(server::Auth::Reject { + proceed_with_methods: None, + partial_success: false, + }) + } + + async fn channel_open_session( + &mut self, + _channel: Channel, + _session: &mut server::Session, + ) -> Result { + self.record("channel_open_session"); + Ok(true) + } + + async fn pty_request( + &mut self, + _channel: ChannelId, + _term: &str, + _col_width: u32, + _row_height: u32, + _pix_width: u32, + _pix_height: u32, + _modes: &[(Pty, u32)], + _session: &mut server::Session, + ) -> Result<(), Self::Error> { + self.record("pty_request"); + Ok(()) + } + + async fn env_request( + &mut self, + _channel: ChannelId, + _variable_name: &str, + _variable_value: &str, + _session: &mut server::Session, + ) -> Result<(), Self::Error> { + self.record("env_request"); + Ok(()) + } + + async fn exec_request( + &mut self, + _channel: ChannelId, + _data: &[u8], + _session: &mut server::Session, + ) -> Result<(), Self::Error> { + self.record("exec_request"); + Ok(()) + } + + async fn signal( + &mut self, + _channel: ChannelId, + _signal: Sig, + _session: &mut server::Session, + ) -> Result<(), Self::Error> { + self.record("signal"); + Ok(()) + } + } +} + /// Integration test for FutureCertificate authentication flow #[cfg(unix)] mod future_certificate { diff --git a/crates/bssh-russh/tests/pty_handle_data.rs b/crates/bssh-russh/tests/pty_handle_data.rs new file mode 100644 index 00000000..a4a43398 --- /dev/null +++ b/crates/bssh-russh/tests/pty_handle_data.rs @@ -0,0 +1,204 @@ +//! Regression test for the BSSH PTY fix re-ported onto russh 0.61.1. +//! +//! The fork exists to fix one problem: shell/PTY output written via +//! `Handle::data()` / `Channel::data()` from a task *other than* the server +//! session loop could fail to be delivered promptly, because the loop's +//! `tokio::select!` didn't always wake for the internal mpsc channel. The fix +//! is the `try_recv` batch-drain loop in `src/server/session.rs` that flushes +//! pending channel messages before entering `select!`. +//! +//! This test drives a real client<->server pair over a TCP loopback and floods +//! a large number of data chunks from a task distinct from the session loop, +//! then asserts every byte arrives intact. If the drain path ever loses or +//! stalls messages, `wait_for` hits its deadline and the test fails. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use russh::keys::PrivateKeyWithHashAlg; +use russh::keys::ssh_key::{self, PrivateKey}; +use russh::server::{self, Auth, Session}; +use russh::{ChannelId, client}; + +const CHUNK: usize = 1024; +const N_CHUNKS: usize = 2000; +const TOTAL: usize = CHUNK * N_CHUNKS; +const FILL: u8 = b'x'; + +/// Client handler: tallies received bytes and flags any byte that isn't `FILL`. +struct ClientH { + received: Arc, + corrupt: Arc, +} + +impl client::Handler for ClientH { + type Error = russh::Error; + + async fn check_server_key(&mut self, _key: &ssh_key::PublicKey) -> Result { + Ok(true) + } + + async fn data( + &mut self, + _channel: ChannelId, + data: &[u8], + _session: &mut client::Session, + ) -> Result<(), Self::Error> { + if data.iter().any(|&b| b != FILL) { + self.corrupt.fetch_add(1, Ordering::SeqCst); + } + self.received.fetch_add(data.len(), Ordering::SeqCst); + Ok(()) + } +} + +/// Server handler: accept any pubkey and signal once authentication finishes. +struct ServerH { + auth_tx: Option>, +} + +impl server::Handler for ServerH { + type Error = russh::Error; + + async fn auth_publickey( + &mut self, + _user: &str, + _key: &ssh_key::PublicKey, + ) -> Result { + Ok(Auth::Accept) + } + + async fn auth_succeeded(&mut self, _session: &mut Session) -> Result<(), Self::Error> { + if let Some(tx) = self.auth_tx.take() { + let _ = tx.send(()); + } + Ok(()) + } +} + +/// Poll `counter` until it reaches `target`, panicking after a generous deadline +/// (a stalled/lossy drain path manifests here as a timeout). +async fn wait_for(counter: &AtomicUsize, target: usize, label: &str) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + let cur = counter.load(Ordering::SeqCst); + if cur >= target { + return; + } + assert!( + tokio::time::Instant::now() < deadline, + "timeout waiting for {label}: {cur}/{target} bytes" + ); + tokio::time::sleep(Duration::from_millis(5)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn high_throughput_handle_data_from_task_is_fully_delivered() { + let _ = env_logger::try_init(); + + let received = Arc::new(AtomicUsize::new(0)); + let corrupt = Arc::new(AtomicUsize::new(0)); + + let client_key = PrivateKey::random(&mut rand::rng(), ssh_key::Algorithm::Ed25519).unwrap(); + + let mut config = server::Config::default(); + config.inactivity_timeout = None; + config.auth_rejection_time = Duration::from_secs(3); + config + .keys + .push(PrivateKey::random(&mut rand::rng(), ssh_key::Algorithm::Ed25519).unwrap()); + let config = Arc::new(config); + + let socket = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = socket.local_addr().unwrap(); + + let (auth_tx, auth_rx) = tokio::sync::oneshot::channel(); + + // Server side: accept the connection and run the session loop (detached). + let server_join = tokio::spawn(async move { + let (stream, _) = socket.accept().await.unwrap(); + server::run_stream( + config, + stream, + ServerH { + auth_tx: Some(auth_tx), + }, + ) + .await + .map_err(|_| ()) + .unwrap() + }); + + // Client side: connect + authenticate. + let received_c = received.clone(); + let corrupt_c = corrupt.clone(); + let client_join = tokio::spawn(async move { + let cfg = Arc::new(client::Config::default()); + let mut session = client::connect( + cfg, + addr, + ClientH { + received: received_c, + corrupt: corrupt_c, + }, + ) + .await + .map_err(|_| ()) + .unwrap(); + let authed = session + .authenticate_publickey( + "user", + PrivateKeyWithHashAlg::new(Arc::new(client_key), None), + ) + .await + .unwrap(); + assert!(authed.success(), "client authentication failed"); + session + }); + + let (server_session, client_session) = tokio::join!(server_join, client_join); + // `RunningSession::handle()` keeps the detached session loop reachable; the + // client `Handle` must stay alive so the connection isn't torn down. + let handle = server_session.unwrap().handle(); + let _client = client_session.unwrap(); + + // The whole exchange must finish well within this bound. + tokio::time::timeout(Duration::from_secs(45), async { + auth_rx.await.unwrap(); + + // Flood data from a task that is NOT the session loop — the fork's bug + // scenario. `Channel::data()` enqueues onto the session loop's mpsc; the + // drain loop is what must deliver all of it. + let received_s = received.clone(); + let flood = tokio::spawn(async move { + let ch = handle.channel_open_session().await.unwrap(); + let chunk = vec![FILL; CHUNK]; + for _ in 0..N_CHUNKS { + ch.data(&chunk[..]).await.unwrap(); + } + // Hold the channel open until the client has accounted for everything. + wait_for(&received_s, TOTAL, "server-side confirmation").await; + ch + }); + + wait_for(&received, TOTAL, "client receive").await; + let _ch = flood.await.unwrap(); + }) + .await + .expect("PTY data exchange timed out — drain loop did not deliver all messages"); + + assert_eq!( + received.load(Ordering::SeqCst), + TOTAL, + "expected exactly {TOTAL} bytes delivered" + ); + assert_eq!( + corrupt.load(Ordering::SeqCst), + 0, + "received corrupted/misordered chunk(s)" + ); + + drop(_client); +} diff --git a/src/server/sftp.rs b/src/server/sftp.rs index 6a59b69c..afea5112 100644 --- a/src/server/sftp.rs +++ b/src/server/sftp.rs @@ -138,6 +138,15 @@ impl From for StatusCode { } } +// russh-sftp 2.3.0 changed `server::Handler::Error` to require `Into` +// (was `Into`). StatusReply carries an error message, so this also +// surfaces our human-readable message in the SSH_FXP_STATUS reply. +impl From for russh_sftp::server::StatusReply { + fn from(err: SftpError) -> Self { + russh_sftp::server::StatusReply::new(err.code).with_message(err.message) + } +} + /// An open file or directory handle. enum OpenHandle { /// An open file.