diff --git a/userland/Cargo.lock b/userland/Cargo.lock index 6840a5c..c463566 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -163,6 +174,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -179,6 +196,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bigdecimal" version = "0.4.11" @@ -251,7 +274,16 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", ] [[package]] @@ -348,7 +380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -365,6 +397,16 @@ dependencies = [ "windows-link", ] +[[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", +] + [[package]] name = "clap" version = "4.6.7" @@ -541,6 +583,12 @@ 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 = "constant_time_eq" version = "0.4.2" @@ -568,6 +616,15 @@ dependencies = [ "libm", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -584,7 +641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" dependencies = [ "digest 0.10.7", - "spin", + "spin 0.10.1", ] [[package]] @@ -636,6 +693,18 @@ 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", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -655,6 +724,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctrlc" version = "3.5.2" @@ -666,6 +744,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -746,6 +851,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "diff" version = "0.1.13" @@ -782,7 +897,10 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ + "block-buffer 0.10.4", + "const-oid", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -791,7 +909,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "crypto-common 0.2.2", ] @@ -862,12 +980,66 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.10.9", + "subtle", +] + [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1031,6 +1203,22 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.29" @@ -1202,6 +1390,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1256,6 +1457,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1292,6 +1494,7 @@ dependencies = [ "gix-pack", "gix-transport", "gix-zlib", + "sausage-ssh", "serde", "serde_json", "url", @@ -2316,6 +2519,17 @@ dependencies = [ "memmap2", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "half" version = "2.7.1" @@ -2407,6 +2621,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a7763b98ba8a24f59e698bf9ab197e7676c640d6455d1580b4ce7dc560f0f0d" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hostname" version = "0.4.2" @@ -2825,6 +3048,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "intl-memoizer" version = "0.5.3" @@ -3039,7 +3271,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -3062,6 +3294,15 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin 0.9.9", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -3325,6 +3566,22 @@ dependencies = [ "rand 0.8.8", ] +[[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.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-integer" version = "0.1.47" @@ -3334,6 +3591,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-modular" version = "0.6.1" @@ -3368,6 +3635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3437,6 +3705,50 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3493,6 +3805,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3544,6 +3865,27 @@ 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", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3645,6 +3987,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -3848,6 +4199,16 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ripgrep" version = "15.2.0" @@ -3873,12 +4234,42 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.6" @@ -4066,12 +4457,47 @@ dependencies = [ "wit-bindgen 0.46.0", ] +[[package]] +name = "sausage-ssh" +version = "0.1.0" +dependencies = [ + "aes", + "base64", + "ctr", + "futures-lite", + "hmac", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha1 0.10.7", + "sha2 0.10.9", + "signature", + "ssh-key", + "url", + "wasi 0.14.7+wasi-0.2.4", + "x25519-dalek", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "sed" version = "0.3.0" @@ -4151,6 +4577,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -4158,7 +4595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -4168,6 +4605,17 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5b0ebf6537e3e8db8bb7d21ecb6d7399738c953c757945c6b95e46c4e95df7b" +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.1" @@ -4175,7 +4623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -4213,6 +4661,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[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 = "simd-adler32" version = "0.3.10" @@ -4258,18 +4716,84 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spin" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sponge-cursor" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" +[[package]] +name = "ssh" +version = "0.1.0" +dependencies = [ + "clap", + "sausage-ssh", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2 0.10.9", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2 0.10.9", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4292,6 +4816,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -5721,8 +6251,8 @@ dependencies = [ "procfs", "rustc-hash", "rustix", - "sha1", - "sha2", + "sha1 0.11.0", + "sha2 0.11.1", "sha3", "shake", "sm3", @@ -6214,6 +6744,18 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xattr" version = "1.7.0" @@ -6300,6 +6842,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/userland/Cargo.toml b/userland/Cargo.toml index 271c59c..f20996a 100644 --- a/userland/Cargo.toml +++ b/userland/Cargo.toml @@ -1,7 +1,7 @@ # The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's. [workspace] resolver = "3" -members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools"] +members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh"] # WASI compatibility patches: git/vendor/PATCHES.md. [patch.crates-io] diff --git a/userland/git/Cargo.toml b/userland/git/Cargo.toml index c1a0b78..f5ed58d 100644 --- a/userland/git/Cargo.toml +++ b/userland/git/Cargo.toml @@ -15,3 +15,4 @@ gix-pack = { version = "0.77", default-features = false, features = ["sha1", "ge gix-zlib = "0.4" url = "2" wasi = "0.14" +sausage-ssh = { path = "../ssh-client" } diff --git a/userland/git/README.md b/userland/git/README.md index 810cbc6..6a48d9b 100644 --- a/userland/git/README.md +++ b/userland/git/README.md @@ -1,9 +1,18 @@ # Git for WASI 0.2 -This crate adapts the v2 Git plugin's local operations and HTTP transport. +This crate adapts the v2 Git plugin's local operations, HTTP transport, and SSH transport. It uses gix 0.90 and the patches in [`vendor/PATCHES.md`](vendor/PATCHES.md). -It does not use the plugin SDK, RPC dispatch, the vault, or SSH. -The host supplies credentials through standard `wasi:http`. +It does not use the plugin SDK, RPC dispatch, the vault, or native child processes. +The host supplies HTTP credentials and SSH signatures through standard `wasi:http`. + +SSH clone, fetch, and push use the shared [`ssh-client`](../ssh-client/README.md) library in-process. +The remote command runs `git-upload-pack` or `git-receive-pack` over standard WASI sockets. +Remote URLs accept `ssh://user@host[:port]/path` and `user@host:path`. +SCP-style URLs use port 22. +The user can come from the URL or the host identity. +The host key must already match `$HOME/.ssh/known_hosts`. +Use the `ssh` CLI with `-o StrictHostKeyChecking=accept-new` to record a new key. +See [`../ssh/README.md`](../ssh/README.md) for the native sshd fixture and `ssh-test.nu` commands. ## Build diff --git a/userland/git/src/lib.rs b/userland/git/src/lib.rs index 64fba5c..d2cd1f6 100644 --- a/userland/git/src/lib.rs +++ b/userland/git/src/lib.rs @@ -4,6 +4,7 @@ mod branches; mod changes; mod checkout; mod http; +mod ssh; mod merge; mod network; mod output; diff --git a/userland/git/src/network.rs b/userland/git/src/network.rs index b88c41c..bbdb2d2 100644 --- a/userland/git/src/network.rs +++ b/userland/git/src/network.rs @@ -1,9 +1,10 @@ -//! Anonymous HTTP and HTTPS clone/fetch through standard wasi:http. +//! Clone and fetch over standard WASI HTTP or in-process SSH exec channels. use crate::{ Result, - http::{WasiHttp, clean_url}, + http::WasiHttp, remotes, repo::{self, error}, + ssh::clean_url, }; use gix::bstr::ByteSlice; use std::{collections::BTreeSet, sync::atomic::AtomicBool}; @@ -82,18 +83,24 @@ fn perform( )); } } - let transport: Box = { - let transport_url = gix::url::parse(url.as_bytes().as_bstr()) - .map_err(|_| error("fetch", "invalid HTTPS URL"))?; - Box::new( - gix_transport::client::blocking_io::http::Transport::new_http( - WasiHttp::default(), - transport_url, - gix_transport::Protocol::V2, - false, - ), - ) - }; + let transport: Box = + if crate::ssh::is_ssh(&url) { + Box::new(crate::ssh::connection( + &url, + gix_transport::Service::UploadPack, + )?) + } else { + let transport_url = gix::url::parse(url.as_bytes().as_bstr()) + .map_err(|_| error("fetch", "invalid HTTPS URL"))?; + Box::new( + gix_transport::client::blocking_io::http::Transport::new_http( + WasiHttp::default(), + transport_url, + gix_transport::Protocol::V2, + false, + ), + ) + }; let mut options = gix::remote::ref_map::Options::default(); options.extra_refspecs.push( gix::refspec::parse("HEAD".into(), gix::refspec::parse::Operation::Fetch) diff --git a/userland/git/src/push.rs b/userland/git/src/push.rs index 31bde06..5e3b191 100644 --- a/userland/git/src/push.rs +++ b/userland/git/src/push.rs @@ -1,4 +1,4 @@ -//! Single-ref receive-pack client over standard wasi:http. +//! Single-ref receive-pack over standard WASI HTTP or in-process SSH exec channels. use crate::Result; use crate::{ @@ -587,6 +587,9 @@ pub fn push(p: PushParams) -> Result { .to_bstring() .to_str_lossy() .into_owned(); + if crate::ssh::is_ssh(&url) { + return ssh_push(&repo, &url, remote, &plan, p.set_upstream.unwrap_or(false)); + } let base = clean_url(&url) .map_err(|e| error("push URL", e))? .to_string() @@ -670,3 +673,57 @@ pub fn push(p: PushParams) -> Result { })(); result } +fn ssh_push( + repo: &gix::Repository, + url: &str, + remote: &str, + plan: &Plan, + upstream: bool, +) -> Result { + let (mut read, mut write) = + crate::ssh::connection(url, gix_transport::Service::ReceivePack)?.into_inner(); + let advertised = advertise_refs(&mut read)?; + let old = advertised.refs.get(&plan.destination).copied(); + if let (Some(old), Some(new)) = (old, plan.new) { + if old != new && !plan.force && !is_ancestor(repo, old, new)? { + return Err(error("push", "non-fast-forward; fetch first or use force")); + } + } + let up_to_date = old == plan.new; + let mut objects = 0; + if !up_to_date { + if plan.new.is_none() && !advertised.caps.contains("delete-refs") { + return Err(error("push", "remote does not allow deleting refs")); + } + let ids = match plan.new { + Some(new) => objects_to_send(repo, new, advertised.refs.values().copied())?, + None => BTreeSet::new(), + }; + objects = ids.len(); + let sideband = advertised.caps.contains("side-band-64k"); + let mut caps = vec!["report-status"]; + if sideband { + caps.push("side-band-64k"); + } + commands(old, plan, &caps, &mut write)?; + if plan.new.is_some() { + pack(repo, &ids, &mut write)?; + } + write.flush().map_err(|e| error("push SSH flush", e))?; + report(&mut read, &plan.destination, sideband)?; + } else { + write + .write_all(b"0000") + .and_then(|_| write.flush()) + .map_err(|e| error("push SSH finish", e))?; + } + update_local(repo, remote, plan, upstream)?; + Ok(PushResult { + remote: remote.into(), + reference: plan.destination.clone(), + old: old.map(|id| id.to_string()), + new: plan.new.map(|id| id.to_string()), + up_to_date, + objects, + }) +} diff --git a/userland/git/src/remotes.rs b/userland/git/src/remotes.rs index fe104c0..b8d61c5 100644 --- a/userland/git/src/remotes.rs +++ b/userland/git/src/remotes.rs @@ -1,6 +1,6 @@ //! Non-destructive local Git config edits. No plugin-owned persistent format. use crate::Result; -use crate::{http::clean_url, repo::error}; +use crate::{repo::error, ssh::clean_url}; use gix::bstr::ByteSlice; /// Remote identity. @@ -81,7 +81,7 @@ pub fn list(repo: &gix::Repository) -> Result> { // Do not return legacy embedded credentials from repositories imported outside this plugin. let url = url .map(|s| { - clean_url(&s).map(|u| u.to_string()).map_err(|_| { + clean_url(&s).map_err(|_| { error( "remote list", "remote URL must be HTTPS without credentials or SSH without password", diff --git a/userland/git/src/ssh.rs b/userland/git/src/ssh.rs new file mode 100644 index 0000000..76a8fd1 --- /dev/null +++ b/userland/git/src/ssh.rs @@ -0,0 +1,63 @@ +//! Git exec channels over the shared SSH library, with host-mediated signing. +use crate::{Result, repo::error}; +use gix::bstr::ByteSlice; +use std::io; +fn parse(input: &str) -> io::Result { + let url = gix::url::parse(input.as_bytes().as_bstr()) + .map_err(|_| io::Error::other("invalid SSH URL"))?; + if url.scheme != gix::url::Scheme::Ssh + || url.host.as_deref().is_none_or(str::is_empty) + || url.password.is_some() + || url.path.is_empty() + || url.path.iter().any(|b| matches!(b, 0 | b'\n' | b'\r')) + { + return Err(io::Error::other( + "use ssh://[user@]host[:port]/path or [user@]host:path without a password", + )); + } + Ok(url) +} +pub fn clean_url(input: &str) -> io::Result { + if input.starts_with("http://") || input.starts_with("https://") { + crate::http::clean_url(input).map(|url| url.to_string()) + } else { + parse(input)?; + Ok(input.into()) + } +} +pub fn is_ssh(input: &str) -> bool { + !input.starts_with("http://") && !input.starts_with("https://") +} +pub type Connection = + gix_transport::client::git::blocking_io::Connection; +pub fn connection(input: &str, service: gix_transport::Service) -> Result { + let url = parse(input).map_err(|e| error("SSH URL", e))?; + let host = url.host.as_deref().unwrap(); + let mut session = + sausage_ssh::identity::connect(host, url.port.unwrap_or(22), url.user.as_deref(), false) + .map_err(|e| error("SSH", e))?; + let mut command = service.as_str().as_bytes().to_vec(); + command.extend_from_slice(b" '"); + for byte in url.path.iter() { + if *byte == b'\'' { + command.extend_from_slice(b"'\\''"); + } else { + command.push(*byte); + } + } + command.push(b'\''); + session + .open_command(&command) + .map_err(|e| error("SSH exec", e))?; + let (read, write) = session.split(); + Ok(Connection::new( + read, + write, + gix_transport::Protocol::V1, + url.path, + None::<(&str, Option)>, + gix_transport::client::git::ConnectMode::Process, + false, + ) + .custom_url(Some(input.into()))) +} diff --git a/userland/git/ssh-test.nu b/userland/git/ssh-test.nu new file mode 100644 index 0000000..2f7c6fc --- /dev/null +++ b/userland/git/ssh-test.nu @@ -0,0 +1,71 @@ +#!/usr/bin/env nu +# Compare SSH clone, fetch, and push contents and refs with native Git. +use std/assert +def wasi [component: path, root: path, args: list] { + let ready = (open ($root | path join ready.json)) + let result = (^wasmtime run -S inherit-network -S allow-ip-name-lookup -S http -S cli-exit-with-code=y --dir $'($root)/guest::/' --env HOME=/home --env $'SAUSAGE_HOST_API=http://127.0.0.1:($ready.api)' --env GIT_AUTHOR_NAME=Agent --env GIT_AUTHOR_EMAIL=agent@example.test --env GIT_COMMITTER_NAME=Agent --env GIT_COMMITTER_EMAIL=agent@example.test $component ...$args | complete) + if $result.exit_code != 0 {error make {msg: ($result | to json)}} + $result +} +def native [args: list] { + let result = (^git ...$args | complete) + if $result.exit_code != 0 {error make {msg: ($result | to json)}} + $result +} +def main [component: path, ssh_component: path, root: path] { + let component = ($component | path expand) + let ssh_component = ($ssh_component | path expand) + let root = ($root | path expand) + let ready = (open ($root | path join ready.json)) + let wit = (^wasm-tools component wit $component | complete) + assert equal $wit.exit_code 0 + let imports = ($wit.stdout | lines | str trim | where {|line| $line | str starts-with 'import '}) + assert (not ($imports | is-empty)) + assert ($imports | all {|line| $line | str starts-with 'import wasi:'}) + print 'Git component imports: PASS (WASI only)' + print ($imports | str join (char newline)) + wasi $ssh_component $root [-o StrictHostKeyChecking=accept-new -p $ready.ssh $'($ready.user)@127.0.0.1' true] | ignore + let seed = ($root | path join host/seed) + let bare = ($root | path join host/bare.git) + let clone = ($root | path join guest/clone) + let comparison = ($root | path join host/native-clone) + native [init -b main $seed] | ignore + native [-C $seed config user.name Agent] | ignore + native [-C $seed config user.email agent@example.test] | ignore + $"base\n" | save ($seed | path join file.txt) + native [-C $seed add .] | ignore + native [-C $seed commit --no-gpg-sign -m base] | ignore + native [clone --bare $seed $bare] | ignore + let url = $'ssh://($ready.user)@127.0.0.1:($ready.ssh)($bare)' + let ssh_command = $"ssh -F /dev/null -o IdentityAgent=none -o BatchMode=yes -i '($root)/host/client_key' -p ($ready.ssh) -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile='($root)/host/native_known_hosts'" + with-env {GIT_SSH_COMMAND: $ssh_command} { + native [clone $url $comparison] | ignore + wasi $component $root [clone $url /clone] | ignore + assert equal (open --raw ($clone | path join file.txt)) (open --raw ($comparison | path join file.txt)) + assert equal (native [-C $clone show-ref] | get stdout) (native [-C $comparison show-ref] | get stdout) + print 'SSH clone: contents and refs match native Git: PASS' + $"fetch\n" | save --force ($seed | path join file.txt) + native [-C $seed commit --no-gpg-sign -am fetch] | ignore + native [-C $seed push $bare main] | ignore + native [-C $comparison fetch] | ignore + wasi $component $root [-C /clone fetch] | ignore + assert equal (native [-C $clone show-ref] | get stdout) (native [-C $comparison show-ref] | get stdout) + assert equal (native [-C $clone show origin/main:file.txt] | get stdout) $"fetch\n" + print 'SSH fetch: contents and refs match native Git: PASS' + wasi $component $root [-C /clone config user.name Agent] | ignore + wasi $component $root [-C /clone config user.email agent@example.test] | ignore + $"pushed\n" | save ($clone | path join pushed.txt) + wasi $component $root [-C /clone add pushed.txt] | ignore + wasi $component $root [-C /clone commit -m pushed] | ignore + wasi $component $root [-C /clone push origin HEAD:wasi] | ignore + native [-C $comparison fetch] | ignore + assert equal (native [-C $bare rev-parse wasi] | get stdout) (native [-C $clone rev-parse HEAD] | get stdout) + assert equal (native [-C $comparison show origin/wasi:pushed.txt] | get stdout) $"pushed\n" + print 'SSH push: remote contents and refs match native Git: PASS' + let scp = $'($ready.user)@127.0.0.1:($bare)' + wasi $component $root [-C /clone remote add scp $scp] | ignore + assert equal (wasi $component $root [-C /clone remote get-url scp] | get stdout | str trim) $scp + print 'SCP-style remote URL: PASS' + } + print $'Fixture: ($root)' +} diff --git a/userland/ssh-client/Cargo.toml b/userland/ssh-client/Cargo.toml new file mode 100644 index 0000000..c49a8f1 --- /dev/null +++ b/userland/ssh-client/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "sausage-ssh" +version = "0.1.0" +edition = "2024" +description = "WASI SSH2 transport with host signing and OpenSSH host trust" +[dependencies] +ssh-key = { version = "0.6.7", default-features = false, features = ["std", "ed25519"] } +x25519-dalek = { version = "2", features = ["static_secrets"] } +rand_core = { version = "0.6", features = ["getrandom"] } +sha2 = "0.10" +sha1 = "0.10" +hmac = "0.12" +aes = "0.8" +ctr = "0.9" +signature = "2" +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +url = "2" +futures-lite = "2" +wasi = "0.14" diff --git a/userland/ssh-client/README.md b/userland/ssh-client/README.md new file mode 100644 index 0000000..2ca6f46 --- /dev/null +++ b/userland/ssh-client/README.md @@ -0,0 +1,17 @@ +# Shared SSH client + +This library ports v2's Rust SSH transport for the `ssh` and `git` CLIs. +It uses standard WASI sockets for TCP and standard WASI HTTP for host-mediated authentication. +It never reads a private-key file or a v2 configuration file. + +`Session` owns key exchange, authentication, exec channels, flow control, and packet protection. +`identity::connect` obtains a public identity and signs authentication through the host API. +`Session::split` supplies Git's in-process transport with a reader and a writer. +`Session::execute_input` sends buffered stdin and captures both output streams and the remote exit status. +Host trust uses `$HOME/.ssh/known_hosts`, including hashed names, patterns, and revocations. + +The protocol supports curve25519-sha256, Ed25519, AES-128-CTR, and HMAC-SHA2, including OpenSSH EtM variants. +The inherited TCP defaults use a five-second connection budget and fifteen-second read and write timeouts. + +The integration tests in `../ssh/test.nu` and `../git/ssh-test.nu` use native sshd and a fake signing server. +The fake server keeps its private keys outside the guest preopen. diff --git a/userland/ssh-client/src/command.rs b/userland/ssh-client/src/command.rs new file mode 100644 index 0000000..0af6872 --- /dev/null +++ b/userland/ssh-client/src/command.rs @@ -0,0 +1,118 @@ +//! Absolute-deadline blocking TCP adapter for single-command clients. +#[cfg(not(target_os = "wasi"))] +use std::net::ToSocketAddrs; +use std::{ + io::{self, Read, Write}, + net::{SocketAddr, TcpStream}, + time::Instant, +}; + +#[cfg(not(target_os = "wasi"))] +fn resolve(host: &str, port: u16, _deadline: Instant) -> io::Result> { + Ok((host, port).to_socket_addrs()?.take(8).collect()) +} +#[cfg(target_os = "wasi")] +fn resolve(host: &str, port: u16, deadline: Instant) -> io::Result> { + use wasi::sockets::{ip_name_lookup, network::IpAddress}; + if let Ok(ip) = host.parse::() { + return Ok(vec![SocketAddr::new(ip, port)]); + } + let network = wasi::sockets::instance_network::instance_network(); + let addresses = ip_name_lookup::resolve_addresses(&network, host) + .map_err(|_| io::Error::other("DNS resolution failed"))?; + let ready = addresses.subscribe(); + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(timeout)?; + let timer = wasi::clocks::monotonic_clock::subscribe_duration( + remaining.as_nanos().min(u64::MAX as u128) as u64, + ); + let mut result = Vec::new(); + loop { + if Instant::now() >= deadline { + return Err(timeout()); + } + match addresses.resolve_next_address() { + Ok(Some(ip)) => { + let ip = match ip { + IpAddress::Ipv4((a, b, c, d)) => { + std::net::IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, d)) + } + IpAddress::Ipv6((a, b, c, d, e, f, g, h)) => { + std::net::IpAddr::V6(std::net::Ipv6Addr::new(a, b, c, d, e, f, g, h)) + } + }; + result.push(SocketAddr::new(ip, port)); + if result.len() == 8 { + break; + } + } + Ok(None) => break, + Err(wasi::sockets::network::ErrorCode::WouldBlock) => { + if wasi::io::poll::poll(&[&ready, &timer]).contains(&1) { + return Err(timeout()); + } + } + Err(_) => return Err(io::Error::other("DNS resolution failed")), + } + } + Ok(result) +} + +pub(crate) fn timeout() -> io::Error { + io::Error::new(io::ErrorKind::TimedOut, "timeout") +} +/// Captured raw streams, sharing a single byte budget. None means no exit-status (e.g. signal). +#[derive(Default, Debug)] +pub struct CommandOutput { + pub exit_code: Option, + pub stdout: Vec, + pub stderr: Vec, + pub truncated: bool, +} +/// Each underlying operation checks time, including peers sending tiny packets indefinitely. +pub struct DeadlineTcp { + stream: TcpStream, + deadline: Instant, +} +impl DeadlineTcp { + pub fn connect(host: &str, port: u16, deadline: Instant) -> io::Result { + let addresses = resolve(host, port, deadline)?; + let mut last = io::Error::new(io::ErrorKind::ConnectionRefused, "connection refused"); + for address in addresses { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(timeout)?; + match TcpStream::connect_timeout(&address, remaining) { + Ok(stream) => return Ok(Self { stream, deadline }), + Err(e) => last = e, + } + } + Err(last) + } + fn prepare(&self) -> io::Result<()> { + let remaining = self + .deadline + .checked_duration_since(Instant::now()) + .filter(|d| !d.is_zero()) + .ok_or_else(timeout)?; + self.stream.set_read_timeout(Some(remaining))?; + self.stream.set_write_timeout(Some(remaining)) + } +} +impl Read for DeadlineTcp { + fn read(&mut self, b: &mut [u8]) -> io::Result { + self.prepare()?; + self.stream.read(b) + } +} +impl Write for DeadlineTcp { + fn write(&mut self, b: &[u8]) -> io::Result { + self.prepare()?; + self.stream.write(b) + } + fn flush(&mut self) -> io::Result<()> { + self.prepare()?; + self.stream.flush() + } +} diff --git a/userland/ssh-client/src/identity.rs b/userland/ssh-client/src/identity.rs new file mode 100644 index 0000000..faf5f1f --- /dev/null +++ b/userland/ssh-client/src/identity.rs @@ -0,0 +1,128 @@ +//! Public SSH identities and signing through the host's standard HTTP API. +use crate::{Session, Stream, connect_tcp, ssh_wire::fail}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::io; +use wasi::http::{outgoing_handler, types::*}; + +#[derive(Deserialize)] +pub struct Identity { + pub item: String, + pub public_key: String, + pub user: Option, +} +fn post(route: &str, payload: Value) -> io::Result { + let base = std::env::var("SAUSAGE_HOST_API").unwrap_or_else(|_| "http://host.sausage".into()); + let url = url::Url::parse(&format!("{}{route}", base.trim_end_matches('/'))) + .map_err(|_| fail("invalid host API URL"))?; + let bytes = serde_json::to_vec(&payload).map_err(|e| fail(&e.to_string()))?; + let headers = Fields::new(); + headers + .set("content-type", &[b"application/json".to_vec()]) + .map_err(|_| fail("HTTP headers"))?; + headers + .set("content-length", &[bytes.len().to_string().into_bytes()]) + .map_err(|_| fail("HTTP headers"))?; + let request = OutgoingRequest::new(headers); + request + .set_method(&Method::Post) + .map_err(|_| fail("HTTP method"))?; + request + .set_scheme(Some(&Scheme::Http)) + .map_err(|_| fail("HTTP scheme"))?; + request + .set_authority(Some( + &url[url::Position::BeforeHost..url::Position::AfterPort], + )) + .map_err(|_| fail("HTTP authority"))?; + request + .set_path_with_query(Some(url.path())) + .map_err(|_| fail("HTTP path"))?; + let body = request.body().map_err(|_| fail("HTTP body"))?; + let stream = body.write().map_err(|_| fail("HTTP stream"))?; + let response = + outgoing_handler::handle(request, None).map_err(|e| fail(&format!("host API: {e:?}")))?; + stream + .blocking_write_and_flush(&bytes) + .map_err(|_| fail("host API upload"))?; + drop(stream); + OutgoingBody::finish(body, None).map_err(|_| fail("host API upload finish"))?; + response.subscribe().block(); + let response = response + .get() + .ok_or_else(|| fail("host API response missing"))? + .map_err(|_| fail("host API response consumed"))? + .map_err(|e| fail(&format!("host API: {e:?}")))?; + let status = response.status(); + let body = response + .consume() + .map_err(|_| fail("host API response body"))?; + let stream = body + .stream() + .map_err(|_| fail("host API response stream"))?; + let mut bytes = Vec::new(); + loop { + match stream.blocking_read(65536) { + Ok(data) if data.is_empty() => stream.subscribe().block(), + Ok(data) => bytes.extend(data), + Err(wasi::io::streams::StreamError::Closed) => break, + Err(_) => return Err(fail("host API response read")), + } + } + drop(stream); + let trailers = IncomingBody::finish(body); + trailers.subscribe().block(); + if !matches!(trailers.get(), Some(Ok(Ok(_)))) { + return Err(fail("host API response finish")); + } + if status != 200 { + return Err(fail(&format!( + "host API HTTP {status}: {}", + String::from_utf8_lossy(&bytes) + ))); + } + serde_json::from_slice(&bytes).map_err(|_| fail("invalid host API JSON")) +} +impl Identity { + pub fn load(host: &str) -> io::Result { + serde_json::from_value(post("/ssh/identity", json!({"host": host}))?) + .map_err(|_| fail("invalid SSH identity")) + } + pub fn sign(&self, host: &str, payload: Vec) -> io::Result> { + let result = post( + "/ssh/sign", + json!({"host":host,"item":self.item,"payload":STANDARD.encode(payload)}), + )?; + STANDARD + .decode( + result["signature"] + .as_str() + .ok_or_else(|| fail("missing signature"))?, + ) + .map_err(|_| fail("invalid signature")) + } +} +pub fn connect( + host: &str, + port: u16, + user: Option<&str>, + accept_new: bool, +) -> io::Result>> { + let identity = Identity::load(host)?; + let user = user + .or(identity.user.as_deref()) + .filter(|u| !u.is_empty()) + .ok_or_else(|| fail("specify an SSH user"))?; + let public = ssh_key::PublicKey::from_bytes( + &STANDARD + .decode(&identity.public_key) + .map_err(|_| fail("invalid public key"))?, + ) + .map_err(|_| fail("invalid public key"))?; + let mut session = Session::start(connect_tcp(host, port)?, host.into(), port, accept_new)?; + futures_lite::future::block_on(session.authenticate_with(user, &public, |payload| { + std::future::ready(identity.sign(host, payload)) + }))?; + Ok(session) +} diff --git a/userland/ssh-client/src/lib.rs b/userland/ssh-client/src/lib.rs new file mode 100644 index 0000000..ce0d7ef --- /dev/null +++ b/userland/ssh-client/src/lib.rs @@ -0,0 +1,9 @@ +//! SSH2 sessions, world-owned host trust, and host-mediated signing for WASI programs. +mod command; +pub mod identity; +pub mod ssh_hosts; +pub mod ssh_kex; +pub mod ssh_wire; +pub use command::{CommandOutput, DeadlineTcp}; +mod session; +pub use session::*; diff --git a/userland/ssh-client/src/session.rs b/userland/ssh-client/src/session.rs new file mode 100644 index 0000000..fc5ad17 --- /dev/null +++ b/userland/ssh-client/src/session.rs @@ -0,0 +1,463 @@ +//! Blocking SSH2 exec transport. No shell/process on the WASI host. +//! Host trust uses the existing lock-free known_hosts check/append policy. +use super::command::{CommandOutput, timeout}; +use super::{ssh_hosts, ssh_kex, ssh_wire::*}; +use std::{ + cell::RefCell, + collections::VecDeque, + io::{self, Read, Write}, + net::{TcpStream, ToSocketAddrs}, + rc::Rc, + time::{Duration, Instant}, +}; +const WINDOW: u32 = 1024 * 1024; +include!("ssh_channel_tests.inc"); + +pub trait Stream: Read + Write {} +impl Stream for T {} +/// TCP connection and per-operation I/O budgets. The overall budget starts after DNS. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TcpTimeouts { + pub overall: Duration, + pub connect: Duration, + pub read: Duration, + pub write: Duration, +} +impl Default for TcpTimeouts { + fn default() -> Self { + Self { + overall: Duration::from_secs(5), + connect: Duration::from_secs(2), + read: Duration::from_secs(15), + write: Duration::from_secs(15), + } + } +} +pub fn connect_tcp(host: &str, port: u16) -> io::Result> { + connect_tcp_with_timeouts(host, port, TcpTimeouts::default()) +} +pub fn connect_tcp_with_timeouts( + host: &str, + port: u16, + timeouts: TcpTimeouts, +) -> io::Result> { + Ok(Box::new(tcp_stream(host, port, timeouts)?)) +} +fn tcp_stream(host: &str, port: u16, timeouts: TcpTimeouts) -> io::Result { + let addresses = (host, port) + .to_socket_addrs() + .map_err(|_| fail("DNS resolution failed"))?; + let deadline = Instant::now() + timeouts.overall; + for address in addresses.take(8) { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + break; + }; + if let Ok(stream) = TcpStream::connect_timeout(&address, remaining.min(timeouts.connect)) { + stream.set_read_timeout(Some(timeouts.read))?; + stream.set_write_timeout(Some(timeouts.write))?; + return Ok(stream); + } + } + Err(fail(&format!( + "TCP connection failed or timed out ({}s)", + timeouts.overall.as_secs() + ))) +} +#[cfg(test)] +mod timeout_tests { + use super::*; + #[test] + fn tcp_socket_uses_default_and_overridden_io_timeouts() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + for timeouts in [ + TcpTimeouts::default(), + TcpTimeouts { + overall: Duration::from_secs(7), + connect: Duration::from_secs(3), + read: Duration::from_secs(11), + write: Duration::from_secs(13), + }, + ] { + let stream = tcp_stream("127.0.0.1", port, timeouts).unwrap(); + let (_peer, _) = listener.accept().unwrap(); + assert_eq!(stream.read_timeout().unwrap(), Some(timeouts.read)); + assert_eq!(stream.write_timeout().unwrap(), Some(timeouts.write)); + } + } +} +pub struct Session { + pub(crate) packets: Packets, + version: Vec, + id: Vec, + host: String, + port: u16, + accept_new: bool, + remote: u32, + send_window: u32, + send_max: u32, + receive_window: u32, + data: VecDeque, + eof: bool, + closed: bool, + exit: Option, + capture: Option, + capture_limit: usize, + deadline: Option, +} +impl Session { + pub fn start(mut stream: S, host: String, port: u16, accept_new: bool) -> io::Result { + let version = ssh_kex::banner(&mut stream)?; + let mut packets = Packets::new(stream); + let mut id = Vec::new(); + ssh_kex::exchange(&mut packets, &version, &mut id, None, |key| { + ssh_hosts::verify(&host, port, key, accept_new) + })?; + Ok(Self { + packets, + version, + id, + host, + port, + accept_new, + remote: 0, + send_window: 0, + send_max: 0, + receive_window: WINDOW, + data: VecDeque::new(), + eof: false, + closed: false, + exit: None, + capture: None, + capture_limit: 0, + deadline: None, + }) + } + fn next(&mut self) -> io::Result> { + for _ in 0..256 { + if self.deadline.is_some_and(|d| Instant::now() >= d) { + return Err(timeout()); + } + let p = self.packets.meaningful()?; + match p.first() { + Some(20) => { + let (host, port, accept_new) = (&self.host, self.port, self.accept_new); + ssh_kex::exchange( + &mut self.packets, + &self.version, + &mut self.id, + Some(p), + |key| ssh_hosts::verify(host, port, key, accept_new), + )?; + } + Some(80) => { + let mut d = Decoder(&p[1..]); + d.string()?; + if d.byte()? != 0 { + self.packets.write(&[82])?; + } + } + Some(_) => return Ok(p), + None => return Err(fail("empty packet")), + } + } + Err(fail("too many non-progress transport messages")) + } + /// Authenticate without holding a private key. The callback returns an SSH-encoded signature. + /// Async permits a vault endpoint round trip; no retries or key bytes cross this API. + pub async fn authenticate_with( + &mut self, + user: &str, + public: &ssh_key::PublicKey, + sign: F, + ) -> io::Result<()> + where + F: FnOnce(Vec) -> Fut, + Fut: std::future::Future>>, + { + self.packets.write(&message(5, &[b"ssh-userauth"]))?; + let accept = self.next()?; + if accept != message(6, &[b"ssh-userauth"]) { + return Err(fail("userauth service rejected")); + } + if public.algorithm() != ssh_key::Algorithm::Ed25519 { + return Err(fail("only ed25519 authentication supported")); + } + let public = public + .to_bytes() + .map_err(|_| fail("could not encode public key"))?; + let mut request = message(50, &[user.as_bytes(), b"ssh-connection", b"publickey"]); + request.push(1); + string(&mut request, b"ssh-ed25519"); + string(&mut request, &public); + let mut signed = Vec::new(); + string(&mut signed, &self.id); + signed.extend_from_slice(&request); + let signature = sign(signed).await?; + string(&mut request, &signature); + self.packets.write(&request)?; + for _ in 0..16 { + match self.next()?.first() { + Some(52) => return Ok(()), + Some(53) => continue, // banner, never logged + Some(51) => return Err(fail("auth failed: publickey auth rejected")), + _ => return Err(fail("unexpected authentication reply")), + } + } + Err(fail("too many authentication banners")) + } + pub fn open_command(&mut self, command: &[u8]) -> io::Result<()> { + if command.is_empty() || command.contains(&0) { + return Err(fail("invalid exec command")); + } + let mut p = message(90, &[b"session"]); + uint(&mut p, 0); + uint(&mut p, WINDOW); + uint(&mut p, 32768); + self.packets.write(&p)?; + let reply = self.next()?; + let mut d = Decoder(&reply); + if d.byte()? != 91 || d.uint()? != 0 { + return Err(fail("exec channel open rejected")); + } + self.remote = d.uint()?; + self.send_window = d.uint()?; + self.send_max = d.uint()?.min(32768); + if self.send_max == 0 { + return Err(fail("invalid channel max packet size")); + } + let mut p = vec![98]; + uint(&mut p, self.remote); + string(&mut p, b"exec"); + p.push(1); + string(&mut p, command); + self.packets.write(&p)?; + for _ in 0..256 { + let p = self.next()?; + if p.first() == Some(&99) { + let mut d = Decoder(&p[1..]); + if d.uint()? != 0 { + return Err(fail("wrong channel in exec reply")); + } + return Ok(()); + } + if p.first() == Some(&100) { + return Err(fail("remote exec request rejected")); + } + self.handle(p)?; + if self.closed { + return Err(fail("channel closed before exec confirmation")); + } + } + Err(fail("exec confirmation missing")) + } + /// Run exactly one exec channel, send stdin EOF, drain both streams through CLOSE. + /// The stream must enforce the same absolute deadline for blocking reads/writes. + pub fn execute( + self, + command: &[u8], + max_bytes: usize, + deadline: Instant, + ) -> io::Result { + self.execute_input(command, &[], max_bytes, deadline) + } + /// Execute a command with buffered stdin and capture both output streams. + pub fn execute_input( + mut self, + command: &[u8], + mut input: &[u8], + max_bytes: usize, + deadline: Instant, + ) -> io::Result { + self.deadline = Some(deadline); + self.capture_limit = max_bytes; + self.capture = Some(CommandOutput::default()); + self.open_command(command)?; + while !input.is_empty() { + let sent = self.write_data(input)?; + input = &input[sent..]; + } + let mut eof = vec![96]; + uint(&mut eof, self.remote); + self.packets.write(&eof)?; + while !self.closed { + let p = self.next()?; + self.handle(p)?; + } + let mut output = self + .capture + .take() + .ok_or_else(|| fail("missing command result"))?; + output.exit_code = self.exit; + Ok(output) + } + fn replenish(&mut self, count: u32) -> io::Result<()> { + if count == 0 || self.closed { + return Ok(()); + } + self.receive_window = self + .receive_window + .checked_add(count) + .filter(|w| *w <= WINDOW) + .ok_or_else(|| fail("channel receive window overflow"))?; + let mut p = vec![93]; + uint(&mut p, self.remote); + uint(&mut p, count); + self.packets.write(&p) + } + fn handle(&mut self, p: Vec) -> io::Result<()> { + let mut d = Decoder(&p); + let kind = d.byte()?; + if !(93..=100).contains(&kind) { + return Err(fail("unexpected SSH channel message")); + } + if d.uint()? != 0 { + return Err(fail("wrong SSH channel recipient")); + } + match kind { + 93 => { + self.send_window = self + .send_window + .checked_add(d.uint()?) + .ok_or_else(|| fail("channel send window overflow"))?; + } + 94 | 95 => { + if self.eof { + return Err(fail("data after channel EOF")); + } + if kind == 95 { + d.uint()?; + } + let data = d.string()?; + if data.len() > 32768 || data.len() > self.receive_window as usize { + return Err(fail("channel receive window exceeded")); + } + self.receive_window -= data.len() as u32; + if let Some(output) = &mut self.capture { + let remaining = self + .capture_limit + .saturating_sub(output.stdout.len() + output.stderr.len()); + let n = remaining.min(data.len()); + output.truncated |= n < data.len(); + if kind == 94 { + output.stdout.extend_from_slice(&data[..n]); + } else { + output.stderr.extend_from_slice(&data[..n]); + } + self.replenish(data.len() as u32)?; + } else if kind == 94 { + self.data.extend(data); + } else { + self.replenish(data.len() as u32)?; + } // Git discards stderr + } + 96 => { + self.eof = true; + } + 97 => { + if !self.closed { + let mut p = vec![97]; + uint(&mut p, self.remote); + self.packets.write(&p)?; + } + self.closed = true; + self.eof = true; + } + 98 => { + let request = d.string()?; + let want_reply = d.byte()? != 0; + match request { + b"exit-status" => self.exit = Some(d.uint()?), + b"exit-signal" if self.capture.is_some() => {} + b"exit-signal" => return Err(fail("remote git terminated by signal")), + _ => {} + } + if want_reply { + let mut p = vec![100]; + uint(&mut p, self.remote); + self.packets.write(&p)?; + } + } + _ => return Err(fail("unsolicited channel request result")), + } + Ok(()) + } + fn read_data(&mut self, buf: &mut [u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + for _ in 0..1024 { + if !self.data.is_empty() { + let n = buf.len().min(self.data.len()); + for b in &mut buf[..n] { + *b = self + .data + .pop_front() + .ok_or_else(|| fail("channel buffer underflow"))?; + } + self.replenish(n as u32)?; + return Ok(n); + } + if self.exit.is_some_and(|n| n != 0) { + return Err(fail("remote git exited unsuccessfully")); + } + if self.eof { + return Ok(0); + } + let p = self.next()?; + self.handle(p)?; + } + Err(fail("too many non-progress channel messages")) + } + fn write_data(&mut self, buf: &[u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + if self.closed { + return Err(fail("channel closed")); + } + for _ in 0..1024 { + if self.send_window != 0 { + let n = buf + .len() + .min(self.send_window as usize) + .min(self.send_max as usize); + let mut p = vec![94]; + uint(&mut p, self.remote); + string(&mut p, &buf[..n]); + self.packets.write(&p)?; + self.send_window -= n as u32; + return Ok(n); + } + let p = self.next()?; + self.handle(p)?; + if self.closed { + return Err(fail("channel closed")); + } + } + Err(fail("channel send window stalled")) + } +} +pub struct Reader(Rc>>>); +pub struct Writer(Rc>>>); +impl Read for Reader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.0.borrow_mut().read_data(buf) + } +} +impl Write for Writer { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.borrow_mut().write_data(buf) + } + fn flush(&mut self) -> io::Result<()> { + self.0.borrow_mut().packets.stream.flush() + } +} + +impl Session> { + /// Split one channel into blocking reader/writer halves, as used by gitoxide. + pub fn split(self) -> (Reader, Writer) { + let shared = Rc::new(RefCell::new(self)); + (Reader(shared.clone()), Writer(shared)) + } +} diff --git a/userland/ssh-client/src/ssh_channel_tests.inc b/userland/ssh-client/src/ssh_channel_tests.inc new file mode 100644 index 0000000..06c1304 --- /dev/null +++ b/userland/ssh-client/src/ssh_channel_tests.inc @@ -0,0 +1,43 @@ +#[cfg(test)] +mod channel_tests { + use super::*; + use std::io::Cursor; + struct Wire { input: Cursor>, output: Vec } + impl Read for Wire { fn read(&mut self, b: &mut [u8]) -> io::Result { self.input.read(b) } } + impl Write for Wire { + fn write(&mut self, b: &[u8]) -> io::Result { self.output.extend(b); Ok(b.len()) } + fn flush(&mut self) -> io::Result<()> { Ok(()) } + } + fn session() -> Session { + Session { packets: Packets::new(Wire { input: Cursor::new(vec![]), output: vec![] }), version: vec![], id: vec![], + host: "fake.example".into(), port: 22, accept_new: false, remote: 42, send_window: 10, send_max: 4, receive_window: WINDOW, + data: VecDeque::new(), eof: false, closed: false, exit: None, + capture: None, capture_limit: 0, deadline: None } + } + fn channel(kind: u8, data: &[u8]) -> Vec { + let mut p = vec![kind]; uint(&mut p, 0); p.extend(data); p + } + #[test] + fn channel_data_stderr_window_eof_close() { + let mut s = session(); + let mut data = vec![]; string(&mut data, b"hello"); s.handle(channel(94, &data)).unwrap(); + assert_eq!(s.receive_window, WINDOW-5); + let mut stderr = vec![]; uint(&mut stderr, 1); string(&mut stderr, b"discarded stderr"); s.handle(channel(95, &stderr)).unwrap(); + assert_eq!(s.receive_window, WINDOW-5); + s.handle(channel(96, &[])).unwrap(); + let mut buffer = [0;10]; assert_eq!(s.read_data(&mut buffer).unwrap(),5); assert_eq!(&buffer[..5], b"hello"); + assert_eq!(s.receive_window, WINDOW); assert_eq!(s.read_data(&mut buffer).unwrap(), 0); + assert!(s.handle(channel(94, &data)).is_err()); + s.handle(channel(97, &[])).unwrap(); assert!(s.closed); assert!(s.write_data(b"after close").is_err()); + } + #[test] + fn channel_send_chunks_reject_overflow_and_failed_exit() { + let mut s = session(); assert_eq!(s.write_data(b"0123456789").unwrap(), 4); assert_eq!(s.send_window, 6); + let mut wrong = vec![93]; uint(&mut wrong, 99); uint(&mut wrong, 1); assert!(s.handle(wrong).is_err()); + let mut adjustment = vec![]; uint(&mut adjustment, u32::MAX); assert!(s.handle(channel(93,&adjustment)).is_err()); + let mut s = session(); s.receive_window = 1; + let mut data = vec![]; string(&mut data, b"over window"); assert!(s.handle(channel(94,&data)).is_err()); + let mut status = vec![]; string(&mut status, b"exit-status"); status.push(0); uint(&mut status, 1); + s.handle(channel(98,&status)).unwrap(); assert!(s.read_data(&mut [0]).is_err()); + } +} diff --git a/userland/ssh-client/src/ssh_hosts.rs b/userland/ssh-client/src/ssh_hosts.rs new file mode 100644 index 0000000..9318e0a --- /dev/null +++ b/userland/ssh-client/src/ssh_hosts.rs @@ -0,0 +1,239 @@ +//! OpenSSH known_hosts policy: explicit keys, hashed names, patterns and revocations. +use super::ssh_wire::fail; +use base64::{Engine, engine::general_purpose::STANDARD}; +use hmac::{Hmac, Mac}; +use std::io::{self, Write}; + +fn pattern(pattern: &[u8], name: &[u8]) -> bool { + // Linear glob matcher with a single backtracking '*' position, no recursion. + let (mut p, mut n, mut star, mut retry) = (0, 0, None, 0); + while n < name.len() { + if p < pattern.len() && (pattern[p] == b'?' || pattern[p].eq_ignore_ascii_case(&name[n])) { + p += 1; + n += 1; + } else if p < pattern.len() && pattern[p] == b'*' { + star = Some(p); + p += 1; + retry = n; + } else if let Some(s) = star { + retry += 1; + n = retry; + p = s + 1; + } else { + return false; + } + } + while p < pattern.len() && pattern[p] == b'*' { + p += 1; + } + p == pattern.len() +} +fn matches(names: &str, host: &str) -> bool { + if names.starts_with('|') { + let parts: Vec<_> = names.split('|').collect(); + if parts.len() != 4 || parts[1] != "1" { + return false; + } + let (Ok(salt), Ok(hash)) = (STANDARD.decode(parts[2]), STANDARD.decode(parts[3])) else { + return false; + }; + let Ok(mut mac) = as Mac>::new_from_slice(&salt) else { + return false; + }; + mac.update(host.as_bytes()); + return mac.verify_slice(&hash).is_ok(); + } + let mut found = false; + for part in names.split(',') { + if let Some(negated) = part.strip_prefix('!') { + if pattern(negated.as_bytes(), host.as_bytes()) { + return false; + } + } else if pattern(part.as_bytes(), host.as_bytes()) { + found = true; + } + } + found +} +/// Returns false for an unknown host; mismatched/revoked keys are always errors. +pub fn check(text: &str, host: &str, key: &[u8]) -> io::Result { + let mut seen = false; + let mut matched = false; + for line in text.lines() { + let mut fields = line.split_whitespace(); + let Some(first) = fields.next() else { + continue; + }; + if first.starts_with('#') { + continue; + } + let (marker, names) = if first.starts_with('@') { + (Some(first), fields.next().unwrap_or("")) + } else { + (None, first) + }; + if !matches(names, host) { + continue; + } + let algorithm = fields + .next() + .ok_or_else(|| fail("malformed known_hosts entry"))?; + let encoded = fields + .next() + .ok_or_else(|| fail("malformed known_hosts entry"))?; + let bytes = STANDARD + .decode(encoded) + .map_err(|_| fail("malformed known_hosts key"))?; + if marker == Some("@cert-authority") { + continue; + } // certificates not negotiated + if marker.is_some() && marker != Some("@revoked") { + return Err(fail("unsupported known_hosts marker")); + } + if marker == Some("@revoked") { + if bytes == key { + return Err(fail( + "Host key verification failed: revoked key in ~/.ssh/known_hosts", + )); + } + continue; + } + // Other algorithms for this host are not a changed ed25519 key. + if algorithm == "ssh-ed25519" { + seen = true; + if bytes == key { + matched = true; + } + } + } + if matched { + Ok(true) + } else if seen { + Err(fail( + "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! Host key verification failed.", + )) + } else { + Ok(false) + } +} +/// No global lock: network operations in unrelated repositories must stay independent. +/// Accept-new uses append mode, never read/modify/replace. Racing checks may append +/// identical entries; these are harmless. External trust-file edits must still be +/// coordinated by the person (as with any concurrent known_hosts editor). +pub fn verify(host: &str, port: u16, key: &[u8], accept_new: bool) -> io::Result<()> { + let host = if port == 22 { + host.to_owned() + } else { + format!("[{host}]:{port}") + }; + let folder = std::path::PathBuf::from(std::env::var_os("HOME").unwrap_or_else(|| "/".into())) + .join(".ssh"); + std::fs::create_dir_all(&folder)?; + let path = folder.join("known_hosts"); + let text = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(), + Err(_) => return Err(fail("could not read ~/.ssh/known_hosts")), + }; + if check(&text, &host, key)? { + return Ok(()); + } + if !accept_new { + use sha2::Digest; + let fingerprint = + base64::engine::general_purpose::STANDARD_NO_PAD.encode(sha2::Sha256::digest(key)); + return Err(fail(&format!( + "No ED25519 host key is known for {host} and you have requested strict checking. Host key verification failed. SHA256:{fingerprint}" + ))); + } + let prefix = if text.is_empty() || text.ends_with('\n') { + "" + } else { + "\n" + }; + let line = format!("{prefix}{host} ssh-ed25519 {}\n", STANDARD.encode(key)); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path)?; + file.write_all(line.as_bytes())?; + file.sync_all() +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn hosts_plain_hashed_changed_revoked() { + let key = b"synthetic-host-key"; + let encoded = STANDARD.encode(key); + assert!( + check( + &format!("*.example,!bad.example ssh-ed25519 {encoded}"), + "git.example", + key + ) + .unwrap() + ); + assert!( + !check( + &format!("*.example,!bad.example ssh-ed25519 {encoded}"), + "bad.example", + key + ) + .unwrap() + ); + assert!( + check( + &format!("git.example ssh-ed25519 {encoded}"), + "git.example", + b"changed" + ) + .is_err() + ); + assert!( + check( + &format!( + "git.example ssh-ed25519 {encoded}\n@revoked git.example ssh-ed25519 {encoded}" + ), + "git.example", + key + ) + .is_err() + ); + let salt = b"01234567890123456789"; + let mut mac = as Mac>::new_from_slice(salt).unwrap(); + mac.update(b"[git.example]:2222"); + let names = format!( + "|1|{}|{}", + STANDARD.encode(salt), + STANDARD.encode(mac.finalize().into_bytes()) + ); + assert!( + check( + &format!("{names} ssh-ed25519 {encoded}"), + "[git.example]:2222", + key + ) + .unwrap() + ); + assert!( + !check( + &format!("{names} ssh-ed25519 {encoded}"), + "git.example", + key + ) + .unwrap() + ); + } + #[test] + fn duplicate_appends_preserve_trust_policy() { + // Model two accept-new callers that both read an unknown host before appending. + let key = b"synthetic-host-key"; + let line = format!("git.example ssh-ed25519 {}\n", STANDARD.encode(key)); + let duplicated = line.repeat(2); + assert!(check(&duplicated, "git.example", key).unwrap()); + assert!(check(&duplicated, "git.example", b"changed").is_err()); + let revoked = format!("{duplicated}@revoked {line}"); + assert!(check(&revoked, "git.example", key).is_err()); + } +} diff --git a/userland/ssh-client/src/ssh_kex.rs b/userland/ssh-client/src/ssh_kex.rs new file mode 100644 index 0000000..9a66602 --- /dev/null +++ b/userland/ssh-client/src/ssh_kex.rs @@ -0,0 +1,223 @@ +//! RFC8731 curve25519-sha256, RFC4253 key derivation, ed25519 host signatures. +include!("ssh_negotiation_tests.inc"); +use super::ssh_wire::*; +use sha2::{Digest, Sha256}; +use signature::Verifier; +use std::io::{self, Read, Write}; +use x25519_dalek::{PublicKey, StaticSecret}; + +pub const VERSION: &[u8] = b"SSH-2.0-SausageGit"; +const KEX: &[u8] = b"curve25519-sha256"; +const HOST: &[u8] = b"ssh-ed25519"; +const CIPHER: &[u8] = b"aes128-ctr"; +const MAC: &[u8] = b"hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256"; + +pub fn banner(stream: &mut S) -> io::Result> { + stream.write_all(VERSION)?; + stream.write_all(b"\r\n")?; + stream.flush()?; + // RFC4253 permits pre-banner lines, bounded here to 8KiB. + let mut line = Vec::new(); + for _ in 0..8192 { + let mut b = [0]; + stream.read_exact(&mut b)?; + if b[0] == b'\n' { + if line.last() == Some(&b'\r') { + line.pop(); + } + if line.starts_with(b"SSH-") { + if !line.starts_with(b"SSH-2.0-") || line.len() > 253 { + return Err(fail("unsupported server version")); + } + return Ok(line); + } + line.clear(); + } else { + line.push(b[0]); + if line.len() > 1024 { + return Err(fail("server banner too long")); + } + } + } + Err(fail("server banner missing")) +} +fn init() -> io::Result> { + let mut p = vec![20]; + let mut cookie = [0; 16]; + random(&mut cookie)?; + p.extend(cookie); + for list in [ + KEX, HOST, CIPHER, CIPHER, MAC, MAC, b"none", b"none", b"", b"", + ] { + string(&mut p, list); + } + p.push(0); + uint(&mut p, 0); + Ok(p) +} +fn contains(list: &[u8], name: &[u8]) -> bool { + list.split(|b| *b == b',').any(|n| n == name) +} +fn select<'a>(kind: &str, ours: &'a [u8], theirs: &[u8]) -> io::Result<&'a [u8]> { + ours.split(|b| *b == b',') + .find(|name| contains(theirs, name)) + .ok_or_else(|| { + fail(&format!( + "no matching {kind}; client offered {:?}; server offered {:?}", + String::from_utf8_lossy(ours), + String::from_utf8_lossy(theirs) + )) + }) +} +#[derive(Debug)] +struct Negotiated { + send: MacAlgorithm, + recv: MacAlgorithm, + wrong_guess: bool, +} +fn negotiate(server: &[u8]) -> io::Result { + let mut d = Decoder(server); + if d.byte()? != 20 { + return Err(fail("expected KEXINIT")); + } + d.take(16)?; + let kex = d.string()?; + let host = d.string()?; + select("kex", KEX, kex)?; + select("host-key", HOST, host)?; + select("cipher client-to-server", CIPHER, d.string()?)?; + select("cipher server-to-client", CIPHER, d.string()?)?; + let send = MacAlgorithm::from_name(select("MAC client-to-server", MAC, d.string()?)?)?; + let recv = MacAlgorithm::from_name(select("MAC server-to-client", MAC, d.string()?)?)?; + select("compression client-to-server", b"none", d.string()?)?; + select("compression server-to-client", b"none", d.string()?)?; + d.string()?; + d.string()?; + let follows = d.byte()? != 0; + d.uint()?; + d.end()?; + Ok(Negotiated { + send, + recv, + wrong_guess: follows + && (kex.split(|b| *b == b',').next() != Some(KEX) + || host.split(|b| *b == b',').next() != Some(HOST)), + }) +} +/// RFC8731 §3.1 reinterprets the raw X25519 output as network-order unsigned K. +pub fn mpint(shared: &[u8; 32]) -> Vec { + let mut value: Vec<_> = shared.iter().copied().skip_while(|b| *b == 0).collect(); + if value.first().is_some_and(|b| b & 128 != 0) { + value.insert(0, 0); + } + let mut out = Vec::new(); + string(&mut out, &value); + out +} +pub fn derive(k: &[u8], hash: &[u8], letter: u8, session: &[u8]) -> Vec { + let mut h = Sha256::new(); + h.update(k); + h.update(hash); + h.update([letter]); + h.update(session); + h.finalize().to_vec() +} +/// RFC4253 §7.2 expansion: SHA-512 MAC needs 64 bytes even with SHA-256 KEX. +pub fn derive_len(k: &[u8], hash: &[u8], letter: u8, session: &[u8], len: usize) -> Vec { + let mut key = derive(k, hash, letter, session); + while key.len() < len { + let mut h = Sha256::new(); + h.update(k); + h.update(hash); + h.update(&key); + key.extend_from_slice(&h.finalize()); + } + key.truncate(len); + key +} +/// Authenticate a key exchange and switch protection in each direction at NEWKEYS. +pub fn exchange( + packets: &mut Packets, + server_version: &[u8], + session: &mut Vec, + server_init: Option>, + mut verify: impl FnMut(&[u8]) -> io::Result<()>, +) -> io::Result<()> { + let ours = init()?; + packets.write(&ours)?; + let theirs = match server_init { + Some(p) => p, + None => packets.meaningful()?, + }; + let negotiated = negotiate(&theirs)?; + let mut secret = [0; 32]; + random(&mut secret)?; + let secret = StaticSecret::from(secret); + let public = PublicKey::from(&secret); + packets.write(&message(30, &[public.as_bytes()]))?; + if negotiated.wrong_guess { + packets.meaningful()?; + } + let reply = packets.meaningful()?; + let mut d = Decoder(&reply); + if d.byte()? != 31 { + return Err(fail("expected curve25519 reply")); + } + let host = d.string()?; + let remote = d.string()?; + let signature = d.string()?; + d.end()?; + let remote: [u8; 32] = remote + .try_into() + .map_err(|_| fail("invalid curve25519 public key"))?; + let shared = secret.diffie_hellman(&PublicKey::from(remote)); + if !shared.was_contributory() { + return Err(fail("invalid all-zero shared secret")); + } + let k = mpint(shared.as_bytes()); + let mut transcript = Vec::new(); + for item in [ + VERSION, + server_version, + &ours, + &theirs, + host, + public.as_bytes(), + &remote, + ] { + string(&mut transcript, item); + } + transcript.extend_from_slice(&k); + let hash = Sha256::digest(&transcript); + let key = ssh_key::PublicKey::from_bytes(host).map_err(|_| fail("invalid host public key"))?; + if key.algorithm() != ssh_key::Algorithm::Ed25519 { + return Err(fail("unsupported host key")); + } + let signature = + ssh_key::Signature::try_from(signature).map_err(|_| fail("invalid host signature"))?; + Verifier::verify(&key, &hash, &signature) + .map_err(|_| fail("host key exchange signature mismatch"))?; + verify(host)?; + if session.is_empty() { + session.extend_from_slice(&hash); + } + let send = Protection::with_mac( + &derive(&k, &hash, b'C', session)[..16], + &derive(&k, &hash, b'A', session)[..16], + derive_len(&k, &hash, b'E', session, negotiated.send.key_len()), + negotiated.send, + )?; + let recv = Protection::with_mac( + &derive(&k, &hash, b'D', session)[..16], + &derive(&k, &hash, b'B', session)[..16], + derive_len(&k, &hash, b'F', session, negotiated.recv.key_len()), + negotiated.recv, + )?; + packets.write(&[21])?; + packets.send = Some(send); + if packets.meaningful()? != [21] { + return Err(fail("expected NEWKEYS")); + } + packets.recv = Some(recv); + Ok(()) +} diff --git a/userland/ssh-client/src/ssh_negotiation_tests.inc b/userland/ssh-client/src/ssh_negotiation_tests.inc new file mode 100644 index 0000000..7c9d75b --- /dev/null +++ b/userland/ssh-client/src/ssh_negotiation_tests.inc @@ -0,0 +1,48 @@ +#[cfg(test)] +mod negotiation_tests { + use super::*; + fn server(lists: &[&[u8];8], follows: bool) -> Vec { + let mut p = vec![20]; p.extend([7;16]); + for list in lists { string(&mut p, list); } + string(&mut p, b""); string(&mut p, b""); p.push(u8::from(follows)); uint(&mut p, 0); p + } + const NIXOS: &[u8] = b"hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com"; + #[test] + fn nixos_selection_directionality_and_wrong_guess() { + let mut lists = [KEX, HOST, CIPHER, CIPHER, NIXOS, NIXOS, b"none", b"none"]; + let n = negotiate(&server(&lists, false)).unwrap(); + // Client order wins over server preference for SHA-512. + assert_eq!(n.send, MacAlgorithm::Sha256Etm); assert_eq!(n.recv, n.send); assert!(!n.wrong_guess); + lists[4] = b"hmac-sha2-512-etm@openssh.com"; lists[5] = b"hmac-sha2-256"; + let n = negotiate(&server(&lists, true)).unwrap(); + assert_eq!(n.send, MacAlgorithm::Sha512Etm); assert_eq!(n.recv, MacAlgorithm::Sha256); assert!(!n.wrong_guess); + lists[0] = b"unknown-kex,curve25519-sha256"; + assert!(negotiate(&server(&lists, true)).unwrap().wrong_guess); + lists[0] = KEX; lists[1] = b"unknown-host,ssh-ed25519"; + assert!(negotiate(&server(&lists, true)).unwrap().wrong_guess); + } + #[test] + fn mismatch_names_both_offers_and_direction() { + let lists = [KEX, HOST, CIPHER, CIPHER, MAC, MAC, b"none", b"none"]; + let categories = ["kex", "host-key", "cipher client-to-server", "cipher server-to-client", + "MAC client-to-server", "MAC server-to-client", "compression client-to-server", "compression server-to-client"]; + for (i, category) in categories.into_iter().enumerate() { + let mut bad = lists; bad[i] = b"unsupported-one,unsupported-two"; + let error = negotiate(&server(&bad, false)).unwrap_err().to_string(); + assert!(error.contains(category), "{error}"); + assert!(error.contains(&format!("client offered {:?}", String::from_utf8_lossy(lists[i]))), "{error}"); + assert!(error.contains("server offered \"unsupported-one,unsupported-two\""), "{error}"); + } + let valid = server(&lists, false); + for end in 0..valid.len() { assert!(negotiate(&valid[..end]).is_err()); } + } + #[test] + fn sha512_mac_key_uses_rfc4253_sha256_expansion() { + // Independent OpenSSL/Node fixture, including an original session id != H. + let key = derive_len(&[0,0,0,3,1,2,3], &[6;32], b'E', &[7;32], 64); + let hex: String = key.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hex, "2a9e79189e2902184e372b346241650ef74c7c339fd779556f7c09c1c0cc646e1c94097a9ede9b16f621e18b672f1a126c6530502a27032c85018b243cbde381"); + assert_eq!(&key[..32], derive(&[0,0,0,3,1,2,3], &[6;32], b'E', &[7;32])); + assert_eq!(derive_len(&[0,0,0,3,1,2,3], &[6;32], b'E', &[7;32], 16), key[..16]); + } +} diff --git a/userland/ssh-client/src/ssh_protection_tests.inc b/userland/ssh-client/src/ssh_protection_tests.inc new file mode 100644 index 0000000..3833713 --- /dev/null +++ b/userland/ssh-client/src/ssh_protection_tests.inc @@ -0,0 +1,89 @@ +#[cfg(test)] +mod protection_tests { + use super::*; + use std::io::Cursor; + + struct Memory { input: Cursor>, output: Vec } + impl Read for Memory { fn read(&mut self, b: &mut [u8]) -> io::Result { self.input.read(b) } } + impl Write for Memory { + fn write(&mut self, b: &[u8]) -> io::Result { self.output.extend_from_slice(b); Ok(b.len()) } + fn flush(&mut self) -> io::Result<()> { Ok(()) } + } + fn protection(algorithm: MacAlgorithm) -> Protection { + Protection::with_mac(&[3;16], &[4;16], vec![5;algorithm.key_len()], algorithm).unwrap() + } + fn reader(bytes: Vec, algorithm: MacAlgorithm) -> Packets { + let mut packets = Packets::new(Memory { input: Cursor::new(bytes), output: vec![] }); + packets.recv = Some(protection(algorithm)); packets + } + fn hex(s: &str) -> Vec { + s.as_bytes().chunks_exact(2).map(|p| u8::from_str_radix(std::str::from_utf8(p).unwrap(), 16).unwrap()).collect() + } + #[test] + fn etm_openssl_fixtures_and_authenticate_before_decrypt() { + // Generated independently using Node/OpenSSL; generator and output recorded in + // /docs/symphony/artifacts/SAU-415/vectors.mjs (no shared packet/KDF code). + for (algorithm, vector) in [ + (MacAlgorithm::Sha256Etm, "00000010e0682f200ab506a20a4da74db3b6ff408f978288292d6b1f9a194fcf108ee3d8dd20edd265854e0683c7545d89a98533000000208c23fc658200527682c8cef58c2867088a31921a7419b34e9d807ee551118a9d96ec28c65f3d7a23da2197da11b30ea7f2625d4179ece3c724ccbe8f78da4463"), + (MacAlgorithm::Sha512Etm, "00000010e0682f200ab506a20a4da74db3b6ff406d1fd80c98bae4318b01500b79fe77b71ec0ab3ca69841dd51116b6d3277d7e51405c3c76ac95b4964988851f12e55d9430c8e227931fffdd5b95a98859586e5000000208c23fc658200527682c8cef58c2867088a31921a7419b34e9d807ee551118a9da235ac50894cf397f912da5296a848b5d240de3e78fadf4ac278430a377ee6448ddaa491aa9007041e83a6fcd9d77aec0855f0cfff765924d1e060796ca2b09c"), + ] { + let bytes = hex(vector); + let mut packets = reader(bytes.clone(), algorithm); + assert_eq!(packets.read().unwrap(), [5]); + assert_eq!(packets.read().unwrap(), b"independent fixture"); + for offset in [4, 19, 20] { // encrypted padding length, ciphertext, tag + let mut bad = bytes.clone(); bad[offset] ^= 1; + let mut packets = reader(bad, algorithm); + assert!(packets.read().unwrap_err().to_string().contains("MAC mismatch")); + // Failed authentication did not consume cipher state or sequence number. + packets.stream.input = Cursor::new(bytes.clone()); + assert_eq!(packets.read().unwrap(), [5]); + } + let first_len = 4 + 16 + algorithm.key_len(); + assert!(reader(bytes[first_len..].to_vec(), algorithm).read().unwrap_err().to_string().contains("MAC mismatch")); + for end in 0..first_len { assert!(reader(bytes[..end].to_vec(), algorithm).read().is_err()); } + for len in [0u32, 5, 17, 262145, u32::MAX] { + let mut bad = bytes.clone(); bad[..4].copy_from_slice(&len.to_be_bytes()); + assert!(reader(bad, algorithm).read().unwrap_err().to_string().contains("invalid packet length")); + } + let mut bad = bytes.clone(); bad[..4].copy_from_slice(&32u32.to_be_bytes()); + assert!(reader(bad, algorithm).read().unwrap_err().to_string().contains("MAC mismatch")); + } + } + #[test] + fn all_mac_packet_boundaries_and_independent_outbound_check() { + for algorithm in [MacAlgorithm::Sha256, MacAlgorithm::Sha256Etm, MacAlgorithm::Sha512Etm] { + let mut packets = Packets::new(Memory { input: Cursor::new(vec![]), output: vec![] }); + packets.send = Some(protection(algorithm)); + let mut cipher = Aes::new_from_slices(&[3;16], &[4;16]).unwrap(); + let mut incoming = reader(vec![], algorithm); + for (seq, size) in [1, 7, 8, 11, 12, 15, 16, 31, 32768, 65536].into_iter().enumerate() { + let payload = vec![42;size]; packets.write(&payload).unwrap(); + let encoded = std::mem::take(&mut packets.stream.output); + incoming.stream.input = Cursor::new(encoded.clone()); + assert_eq!(incoming.read().unwrap(), payload); + // Check sender without using Protection or Packets for decryption/MAC. + let split = encoded.len() - algorithm.key_len(); + let mut plain = encoded[..split].to_vec(); + if algorithm.etm() { + assert_eq!(Decoder(&plain).uint().unwrap() as usize, split - 4); + assert_eq!((split - 4) % 16, 0); + } + let start = if algorithm.etm() { 4 } else { 0 }; + cipher.apply_keystream(&mut plain[start..]); + let signed = if algorithm.etm() { &encoded[..split] } else { &plain }; + let key = vec![5;algorithm.key_len()]; + let seq = (seq as u32).to_be_bytes(); + if algorithm == MacAlgorithm::Sha512Etm { + let mut h = as Mac>::new_from_slice(&key).unwrap(); + h.update(&seq); h.update(signed); h.verify_slice(&encoded[split..]).unwrap(); + } else { + let mut h = as Mac>::new_from_slice(&key).unwrap(); + h.update(&seq); h.update(signed); h.verify_slice(&encoded[split..]).unwrap(); + } + assert_eq!(&plain[5..5+size], payload); + assert!(plain[4] >= 4); + } + } + } +} diff --git a/userland/ssh-client/src/ssh_wire.rs b/userland/ssh-client/src/ssh_wire.rs new file mode 100644 index 0000000..5fd7c68 --- /dev/null +++ b/userland/ssh-client/src/ssh_wire.rs @@ -0,0 +1,250 @@ +//! Small SSH binary codec and RFC4253 / OpenSSH EtM packet protection. +include!("ssh_protection_tests.inc"); +use aes::cipher::{KeyIvInit, StreamCipher}; +use hmac::{Hmac, Mac}; +use rand_core::{OsRng, RngCore}; +use sha2::{Sha256, Sha512}; +use std::io::{self, Read, Write}; +pub type Aes = ctr::Ctr128BE; +pub fn fail(message: &str) -> io::Error { + io::Error::other(format!("ssh: {message}")) +} +pub fn random(bytes: &mut [u8]) -> io::Result<()> { + OsRng + .try_fill_bytes(bytes) + .map_err(|_| fail("random source unavailable")) +} +pub fn uint(out: &mut Vec, n: u32) { + out.extend_from_slice(&n.to_be_bytes()); +} +pub fn string(out: &mut Vec, bytes: &[u8]) { + uint(out, bytes.len() as u32); + out.extend_from_slice(bytes); +} +pub fn message(kind: u8, strings: &[&[u8]]) -> Vec { + let mut out = vec![kind]; + for s in strings { + string(&mut out, s); + } + out +} +pub struct Decoder<'a>(pub &'a [u8]); +impl<'a> Decoder<'a> { + pub fn take(&mut self, len: usize) -> io::Result<&'a [u8]> { + if len > self.0.len() { + return Err(fail("truncated message")); + } + let (a, b) = self.0.split_at(len); + self.0 = b; + Ok(a) + } + pub fn byte(&mut self) -> io::Result { + Ok(self.take(1)?[0]) + } + pub fn uint(&mut self) -> io::Result { + let b = self.take(4)?; + Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } + pub fn string(&mut self) -> io::Result<&'a [u8]> { + let len = self.uint()? as usize; + self.take(len) + } + pub fn end(&self) -> io::Result<()> { + if self.0.is_empty() { + Ok(()) + } else { + Err(fail("trailing message bytes")) + } + } +} +/// MAC key/tag sizes match OpenSSH mac.c; EtM framing follows PROTOCOL §1.5. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MacAlgorithm { + Sha256, + Sha256Etm, + Sha512Etm, +} +impl MacAlgorithm { + pub fn from_name(name: &[u8]) -> io::Result { + match name { + b"hmac-sha2-256" => Ok(Self::Sha256), + b"hmac-sha2-256-etm@openssh.com" => Ok(Self::Sha256Etm), + b"hmac-sha2-512-etm@openssh.com" => Ok(Self::Sha512Etm), + _ => Err(fail("unsupported MAC")), + } + } + pub fn key_len(self) -> usize { + if self == Self::Sha512Etm { 64 } else { 32 } + } + fn etm(self) -> bool { + self != Self::Sha256 + } +} +enum Tag { + Sha256(Hmac), + Sha512(Hmac), +} +impl Tag { + fn bytes(self) -> Vec { + match self { + Self::Sha256(h) => h.finalize().into_bytes().to_vec(), + Self::Sha512(h) => h.finalize().into_bytes().to_vec(), + } + } + fn verify(self, tag: &[u8]) -> io::Result<()> { + match self { + Self::Sha256(h) => h.verify_slice(tag), + Self::Sha512(h) => h.verify_slice(tag), + } + .map_err(|_| fail("packet MAC mismatch")) + } +} +pub struct Protection { + cipher: Aes, + mac: Vec, + algorithm: MacAlgorithm, +} +impl Protection { + /// Historical non-EtM constructor, retained for existing callers. + pub fn new(key: &[u8], iv: &[u8], mac: Vec) -> io::Result { + Self::with_mac(key, iv, mac, MacAlgorithm::Sha256) + } + pub fn with_mac( + key: &[u8], + iv: &[u8], + mac: Vec, + algorithm: MacAlgorithm, + ) -> io::Result { + if mac.len() != algorithm.key_len() { + return Err(fail("invalid MAC key size")); + } + Ok(Self { + cipher: Aes::new_from_slices(key, iv).map_err(|_| fail("invalid key size"))?, + mac, + algorithm, + }) + } + fn tag(&self, seq: u32, bytes: &[u8]) -> io::Result { + if self.algorithm == MacAlgorithm::Sha512Etm { + let mut h = as Mac>::new_from_slice(&self.mac) + .map_err(|_| fail("invalid MAC key"))?; + h.update(&seq.to_be_bytes()); + h.update(bytes); + Ok(Tag::Sha512(h)) + } else { + let mut h = as Mac>::new_from_slice(&self.mac) + .map_err(|_| fail("invalid MAC key"))?; + h.update(&seq.to_be_bytes()); + h.update(bytes); + Ok(Tag::Sha256(h)) + } + } +} +pub struct Packets { + pub stream: S, + pub send: Option, + pub recv: Option, + tx: u32, + rx: u32, +} +impl Packets { + pub fn new(stream: S) -> Self { + Self { + stream, + send: None, + recv: None, + tx: 0, + rx: 0, + } + } + pub fn write(&mut self, payload: &[u8]) -> io::Result<()> { + if payload.is_empty() || payload.len() > 65536 { + return Err(fail("outgoing packet size")); + } + let block = if self.send.is_some() { 16 } else { 8 }; + let etm = self.send.as_ref().is_some_and(|p| p.algorithm.etm()); + let mut padding = block - (payload.len() + if etm { 1 } else { 5 }) % block; + if padding < 4 { + padding += block; + } + let mut packet = Vec::with_capacity(payload.len() + padding + 5); + uint(&mut packet, (payload.len() + padding + 1) as u32); + packet.push(padding as u8); + packet.extend_from_slice(payload); + let start = packet.len(); + packet.resize(start + padding, 0); + random(&mut packet[start..])?; + // Legacy MAC authenticates plaintext; EtM authenticates length + ciphertext. + let mut tag = None; + if let Some(p) = &mut self.send { + if !etm { + tag = Some(p.tag(self.tx, &packet)?.bytes()); + } + p.cipher + .apply_keystream(&mut packet[if etm { 4 } else { 0 }..]); + if etm { + tag = Some(p.tag(self.tx, &packet)?.bytes()); + } + } + self.stream.write_all(&packet)?; + if let Some(tag) = tag { + self.stream.write_all(&tag)?; + } + self.stream.flush()?; + self.tx = self.tx.wrapping_add(1); + Ok(()) + } + pub fn read(&mut self) -> io::Result> { + let block = if self.recv.is_some() { 16 } else { 8 }; + let etm = self.recv.as_ref().is_some_and(|p| p.algorithm.etm()); + let prefix = if etm { 4 } else { block }; + let mut packet = vec![0; prefix]; + self.stream.read_exact(&mut packet)?; + if !etm { + if let Some(p) = &mut self.recv { + p.cipher.apply_keystream(&mut packet); + } + } + let len = Decoder(&packet).uint()? as usize; + if !(5..=262144).contains(&len) { + return Err(fail("invalid packet length")); + } + let aligned = len + if etm { 0 } else { 4 }; + if aligned % block != 0 || len + 4 < prefix { + return Err(fail("invalid packet length")); + } + packet.resize(len + 4, 0); + self.stream.read_exact(&mut packet[prefix..])?; + if let Some(p) = &mut self.recv { + let mut tag = vec![0; p.algorithm.key_len()]; + if etm { + self.stream.read_exact(&mut tag)?; + p.tag(self.rx, &packet)?.verify(&tag)?; + // Never advance the cipher or inspect padding before authenticating EtM. + p.cipher.apply_keystream(&mut packet[4..]); + } else { + p.cipher.apply_keystream(&mut packet[prefix..]); + self.stream.read_exact(&mut tag)?; + p.tag(self.rx, &packet)?.verify(&tag)?; + } + } + self.rx = self.rx.wrapping_add(1); + let padding = packet[4] as usize; + if padding < 4 || padding + 1 >= len { + return Err(fail("invalid packet padding")); + } + Ok(packet[5..len + 4 - padding].to_vec()) + } + pub fn meaningful(&mut self) -> io::Result> { + for _ in 0..256 { + let p = self.read()?; + match p.first() { + Some(1) => return Err(fail("server disconnected")), + Some(2 | 4) => continue, // IGNORE, DEBUG (never log remote text) + Some(_) => return Ok(p), + None => return Err(fail("empty packet")), + } + } + Err(fail("too many non-progress messages")) + } +} diff --git a/userland/ssh/Cargo.toml b/userland/ssh/Cargo.toml new file mode 100644 index 0000000..c6333f9 --- /dev/null +++ b/userland/ssh/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "ssh" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4", features = ["derive"] } +sausage-ssh = { path = "../ssh-client" } diff --git a/userland/ssh/README.md b/userland/ssh/README.md new file mode 100644 index 0000000..1ff63ad --- /dev/null +++ b/userland/ssh/README.md @@ -0,0 +1,60 @@ +# SSH in the world + +`ssh` executes one remote command without a PTY or an interactive shell. +It buffers stdin and captures stdout and stderr until the remote channel closes. +It returns the remote exit status. Connection, trust, and authentication failures return 255. + +```sh +ssh -p 2222 -l user -o StrictHostKeyChecking=accept-new host 'echo hi; exit 3' +``` + +The default host-key policy requires a matching key in `$HOME/.ssh/known_hosts`. +The client creates `.ssh` if needed. +`StrictHostKeyChecking=accept-new` appends an unknown key. +A changed or revoked key always fails. +The client supports Ed25519 keys and curve25519 key exchange, as v2 did. + +The private key stays in the host. +The client requests a public identity from `POST http://host.sausage/ssh/identity`. +It requests a signature from `POST http://host.sausage/ssh/sign`. +`SAUSAGE_HOST_API` replaces the base URL for standalone tests. +The URL or `-l` supplies the user. Otherwise, the host identity supplies it. +A host denial preserves the item and request information in stderr. + +## Tests + +Build from `userland/` after the shared startup object exists: + +```sh +CARGO_TARGET_DIR=/tmp/p1-ssh/target nix develop .. -c cargo build -p ssh -p git --release --locked +``` + +Build the native test fixture from the repository root: + +```sh +CARGO_TARGET_DIR=/tmp/p1-ssh/server-target nix develop -c cargo build --manifest-path userland/ssh/tests/server/Cargo.toml --locked +``` + +Start the fixture through a managed job or a separate terminal: + +```sh +nix shell nixpkgs#openssh -c /tmp/p1-ssh/server-target/debug/ssh-test-server /tmp/p1-ssh/fixture user +``` + +Use the current native user's login name instead of `user`. +The fixture creates `ready.json` after sshd and the signing server start. +It keeps all private keys under `fixture/host`, outside the guest preopen. +It serves the two host routes and a test-only host-key rotation route. + +```sh +nix develop -c nu userland/ssh/test.nu /tmp/p1-ssh/target/wasm32-wasip2/release/ssh.wasm /tmp/p1-ssh/fixture +``` + +The Git script needs a separate fresh fixture because the SSH script changes its host key: + +```sh +nix develop -c nu userland/git/ssh-test.nu /tmp/p1-ssh/target/wasm32-wasip2/release/git.wasm /tmp/p1-ssh/target/wasm32-wasip2/release/ssh.wasm /tmp/p1-ssh/git-fixture +``` + +Both scripts check that the components import only WASI interfaces. +They enable TCP, DNS, HTTP, and numeric exit codes through Wasmtime's standard options. diff --git a/userland/ssh/src/main.rs b/userland/ssh/src/main.rs new file mode 100644 index 0000000..8692977 --- /dev/null +++ b/userland/ssh/src/main.rs @@ -0,0 +1,54 @@ +//! One noninteractive SSH command with host-mediated authentication. +use clap::Parser; +use std::{ + io::{self, Read, Write}, + time::{Duration, Instant}, +}; +#[derive(Parser)] +struct Args { + #[arg(short = 'p', default_value_t = 22)] + port: u16, + #[arg(short = 'l')] + user: Option, + #[arg(short = 'o')] + options: Vec, + host: String, + #[arg(required = true, trailing_var_arg = true)] + command: Vec, +} +fn run(args: Args) -> io::Result { + let mut accept_new = false; + for option in args.options { + match option.as_str() { + "StrictHostKeyChecking=accept-new" => accept_new = true, + "StrictHostKeyChecking=yes" => accept_new = false, + _ => return Err(io::Error::other("unsupported SSH option")), + } + } + let (user, host) = match args.host.split_once('@') { + Some((user, host)) => (Some(user), host), + None => (args.user.as_deref(), args.host.as_str()), + }; + let session = sausage_ssh::identity::connect(host, args.port, user, accept_new)?; + let mut input = Vec::new(); + io::stdin().read_to_end(&mut input)?; + let output = session.execute_input( + args.command.join(" ").as_bytes(), + &input, + usize::MAX, + Instant::now() + Duration::from_secs(300), + )?; + io::stdout().write_all(&output.stdout)?; + io::stderr().write_all(&output.stderr)?; + Ok(output.exit_code.unwrap_or(255)) +} +fn main() { + let status = match run(Args::parse()) { + Ok(status) => status, + Err(error) => { + eprintln!("{error}"); + 255 + } + }; + std::process::exit(status.min(255) as i32); +} diff --git a/userland/ssh/test.nu b/userland/ssh/test.nu new file mode 100644 index 0000000..60586f8 --- /dev/null +++ b/userland/ssh/test.nu @@ -0,0 +1,56 @@ +#!/usr/bin/env nu +# Verify SSH streams, host trust, host denial, and standard WASI imports. +use std/assert +def wasi [component: path, root: path, args: list, input: string = ''] { + let ready = (open ($root | path join ready.json)) + $input | ^wasmtime run -S inherit-network -S allow-ip-name-lookup -S http -S cli-exit-with-code=y --dir $'($root)/guest::/' --env HOME=/home --env $'SAUSAGE_HOST_API=http://127.0.0.1:($ready.api)' $component ...$args | complete +} +def main [component: path, root: path] { + let component = ($component | path expand) + let root = ($root | path expand) + let wit = (^wasm-tools component wit $component | complete) + assert equal $wit.exit_code 0 + let imports = ($wit.stdout | lines | str trim | where {|line| $line | str starts-with 'import '}) + assert (not ($imports | is-empty)) + assert ($imports | all {|line| $line | str starts-with 'import wasi:'}) + print 'SSH component imports: PASS (WASI only)' + print ($imports | str join (char newline)) + let ready = (open ($root | path join ready.json)) + let destination = $'($ready.user)@127.0.0.1' + let args = [-p $ready.ssh $destination 'echo hi; exit 3'] + let unknown = (wasi $component $root $args) + assert equal $unknown.exit_code 255 + assert ($unknown.stderr | str contains 'Host key verification failed') + print 'Unknown host key: PASS' + let accepted = (wasi $component $root ([-o StrictHostKeyChecking=accept-new] | append $args)) + assert equal $accepted.exit_code 3 + assert equal $accepted.stdout $"hi\n" + assert (($root | path join guest/home/.ssh/known_hosts) | path exists) + print 'Accept-new records the host key, prints hi, and exits 3: PASS' + let repeated = (wasi $component $root $args) + assert equal $repeated.exit_code 3 + assert equal $repeated.stdout $"hi\n" + print 'Known host key: PASS' + let streams = (wasi $component $root [-p $ready.ssh -l $ready.user 127.0.0.1 'cat; echo error >&2; exit 7'] $"input\n") + assert equal $streams.exit_code 7 + assert equal $streams.stdout $"input\n" + assert equal $streams.stderr $"error\n" + print 'Stdin, stdout, stderr, -l user, and remote exit 7: PASS' + let default_user = (wasi $component $root [-p $ready.ssh 127.0.0.1 'echo default']) + assert equal $default_user.exit_code 0 + assert equal $default_user.stdout $"default\n" + print 'Default user from host identity: PASS' + 'deny' | save ($root | path join host/deny) + let denied = (wasi $component $root $args) + assert equal $denied.exit_code 255 + assert ($denied.stderr | str contains 'test-key') + assert ($denied.stderr | str contains 'test-request') + print 'Host API 403 names the item and request, and exits 255: PASS' + rm ($root | path join host/deny) + http post --content-type application/json $'http://127.0.0.1:($ready.api)/fixture/rotate' '{}' | ignore + let changed = (wasi $component $root ([-o StrictHostKeyChecking=accept-new] | append $args)) + assert equal $changed.exit_code 255 + assert ($changed.stderr | str contains 'REMOTE HOST IDENTIFICATION HAS CHANGED') + print 'Changed host key fails even with accept-new: PASS' + print $'Fixture: ($root)' +} diff --git a/userland/ssh/tests/server/Cargo.lock b/userland/ssh/tests/server/Cargo.lock new file mode 100644 index 0000000..654fc7d --- /dev/null +++ b/userland/ssh/tests/server/Cargo.lock @@ -0,0 +1,751 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + +[[package]] +name = "ssh-test-server" +version = "0.1.0" +dependencies = [ + "base64", + "rand_core", + "serde_json", + "signature", + "ssh-key", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/userland/ssh/tests/server/Cargo.toml b/userland/ssh/tests/server/Cargo.toml new file mode 100644 index 0000000..ca7251a --- /dev/null +++ b/userland/ssh/tests/server/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ssh-test-server" +version = "0.1.0" +edition = "2024" + +[workspace] + +[dependencies] +ssh-key = { version = "0.6.7", default-features = false, features = ["std", "ed25519", "getrandom"] } +rand_core = { version = "0.6", features = ["getrandom"] } +signature = "2" +base64 = "0.22" +serde_json = "1" diff --git a/userland/ssh/tests/server/src/main.rs b/userland/ssh/tests/server/src/main.rs new file mode 100644 index 0000000..54a725d --- /dev/null +++ b/userland/ssh/tests/server/src/main.rs @@ -0,0 +1,155 @@ +//! Native localhost sshd fixture and host-only signing oracle for WASI tests. +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde_json::{Value, json}; +use signature::Signer; +use ssh_key::{Algorithm, LineEnding, PrivateKey}; +use std::{ + fs, + io::{BufRead, BufReader, Read, Write}, + net::{TcpListener, TcpStream}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; +fn key(path: &Path) -> PrivateKey { + let key = PrivateKey::random(&mut rand_core::OsRng, Algorithm::Ed25519).unwrap(); + key.write_openssh_file(path, LineEnding::LF).unwrap(); + key +} +struct Daemon { + child: Child, + config: PathBuf, + binary: PathBuf, + host_key: PathBuf, + log: PathBuf, + port: u16, +} +impl Daemon { + fn start(binary: &Path, config: &Path, log: &Path, port: u16) -> Child { + let child = Command::new(binary) + .args(["-D", "-e", "-f"]) + .arg(config) + .stdout(Stdio::null()) + .stderr( + fs::OpenOptions::new() + .create(true) + .append(true) + .open(log) + .unwrap(), + ) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while TcpStream::connect(("127.0.0.1", port)).is_err() { + assert!( + Instant::now() < deadline, + "sshd failed to start; inspect {}", + log.display() + ); + std::thread::yield_now(); + } + child + } + fn rotate(&mut self) { + self.child.kill().unwrap(); + self.child.wait().unwrap(); + key(&self.host_key); + self.child = Self::start(&self.binary, &self.config, &self.log, self.port); + } +} +impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} +fn main() { + let root = PathBuf::from(std::env::args().nth(1).expect("fixture root")); + let user = std::env::args().nth(2).expect("SSH user"); + let host = root.join("host"); + fs::create_dir_all(&host).unwrap(); + fs::create_dir_all(root.join("guest/home")).unwrap(); + let client = key(&host.join("client_key")); + fs::write( + host.join("authorized_keys"), + client.public_key().to_openssh().unwrap(), + ) + .unwrap(); + let host_key = host.join("server_key"); + key(&host_key); + let socket = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = socket.local_addr().unwrap().port(); + drop(socket); + let binary = std::env::split_paths(&std::env::var_os("PATH").unwrap()) + .map(|p| p.join("sshd")) + .find(|p| p.is_file()) + .unwrap() + .canonicalize() + .unwrap(); + let config = host.join("sshd_config"); + fs::write(&config, format!("Port {port}\nListenAddress 127.0.0.1\nHostKey {}\nPidFile {}\nAuthorizedKeysFile {}\nStrictModes no\nUsePAM no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\nAllowUsers {user}\nLogLevel VERBOSE\n", host_key.display(), host.join("sshd.pid").display(), host.join("authorized_keys").display())).unwrap(); + let log = host.join("sshd.log"); + let child = Daemon::start(&binary, &config, &log, port); + let mut daemon = Daemon { + child, + config, + binary, + host_key, + log, + port, + }; + let api = TcpListener::bind("127.0.0.1:0").unwrap(); + fs::write( + root.join("ready.json"), + json!({"ssh":port,"api":api.local_addr().unwrap().port(),"user":user}).to_string(), + ) + .unwrap(); + for connection in api.incoming() { + let mut stream = connection.unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut first = String::new(); + reader.read_line(&mut first).unwrap(); + let route = first.split_whitespace().nth(1).unwrap_or(""); + let mut length = 0; + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" || line.is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + length = value.trim().parse().unwrap(); + } + } + } + let mut bytes = vec![0; length]; + reader.read_exact(&mut bytes).unwrap(); + let request: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + let (status, body) = match route { + "/fixture/rotate" => { + daemon.rotate(); + (200, json!({"ok":true})) + } + "/ssh/identity" | "/ssh/sign" if host.join("deny").exists() => ( + 403, + json!({"item":"test-key","request":"test-request","message":"approval required"}), + ), + "/ssh/identity" => ( + 200, + json!({"item":"test-key","public_key":STANDARD.encode(client.public_key().to_bytes().unwrap()),"user":user}), + ), + "/ssh/sign" if request["item"] == "test-key" && request["host"] == "127.0.0.1" => { + let bytes = STANDARD + .decode(request["payload"].as_str().unwrap()) + .unwrap(); + let signature: ssh_key::Signature = client.try_sign(&bytes).unwrap(); + let signature: Vec = signature.try_into().unwrap(); + (200, json!({"signature":STANDARD.encode(signature)})) + } + _ => (400, json!({"error":"invalid request"})), + }; + let body = body.to_string(); + write!(stream, "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + } +}