Add WASI SSH CLI and in-process Git SSH transport

This commit is contained in:
2026-11-06 19:42:00 +00:00
parent 608d0c3fc1
commit 99a8e3a8be
29 changed files with 3546 additions and 30 deletions
+571 -9
View File
@@ -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"
+1 -1
View File
@@ -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]
+1
View File
@@ -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" }
+12 -3
View File
@@ -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
+1
View File
@@ -4,6 +4,7 @@ mod branches;
mod changes;
mod checkout;
mod http;
mod ssh;
mod merge;
mod network;
mod output;
+21 -14
View File
@@ -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<dyn gix_transport::client::blocking_io::Transport> = {
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<dyn gix_transport::client::blocking_io::Transport> =
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)
+58 -1
View File
@@ -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<PushResult> {
.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<PushResult> {
})();
result
}
fn ssh_push(
repo: &gix::Repository,
url: &str,
remote: &str,
plan: &Plan,
upstream: bool,
) -> Result<PushResult> {
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,
})
}
+2 -2
View File
@@ -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<Vec<RemoteInfo>> {
// 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",
+63
View File
@@ -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<gix::Url> {
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<String> {
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<sausage_ssh::Reader, sausage_ssh::Writer>;
pub fn connection(input: &str, service: gix_transport::Service) -> Result<Connection> {
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<u16>)>,
gix_transport::client::git::ConnectMode::Process,
false,
)
.custom_url(Some(input.into())))
}
+71
View File
@@ -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)'
}
+21
View File
@@ -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"
+17
View File
@@ -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.
+118
View File
@@ -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<Vec<SocketAddr>> {
Ok((host, port).to_socket_addrs()?.take(8).collect())
}
#[cfg(target_os = "wasi")]
fn resolve(host: &str, port: u16, deadline: Instant) -> io::Result<Vec<SocketAddr>> {
use wasi::sockets::{ip_name_lookup, network::IpAddress};
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
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<u32>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
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<Self> {
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<usize> {
self.prepare()?;
self.stream.read(b)
}
}
impl Write for DeadlineTcp {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
self.prepare()?;
self.stream.write(b)
}
fn flush(&mut self) -> io::Result<()> {
self.prepare()?;
self.stream.flush()
}
}
+128
View File
@@ -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<String>,
}
fn post(route: &str, payload: Value) -> io::Result<Value> {
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<Self> {
serde_json::from_value(post("/ssh/identity", json!({"host": host}))?)
.map_err(|_| fail("invalid SSH identity"))
}
pub fn sign(&self, host: &str, payload: Vec<u8>) -> io::Result<Vec<u8>> {
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<Session<Box<dyn Stream>>> {
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)
}
+9
View File
@@ -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::*;
+463
View File
@@ -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<T: Read + Write> 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<Box<dyn Stream>> {
connect_tcp_with_timeouts(host, port, TcpTimeouts::default())
}
pub fn connect_tcp_with_timeouts(
host: &str,
port: u16,
timeouts: TcpTimeouts,
) -> io::Result<Box<dyn Stream>> {
Ok(Box::new(tcp_stream(host, port, timeouts)?))
}
fn tcp_stream(host: &str, port: u16, timeouts: TcpTimeouts) -> io::Result<TcpStream> {
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<S> {
pub(crate) packets: Packets<S>,
version: Vec<u8>,
id: Vec<u8>,
host: String,
port: u16,
accept_new: bool,
remote: u32,
send_window: u32,
send_max: u32,
receive_window: u32,
data: VecDeque<u8>,
eof: bool,
closed: bool,
exit: Option<u32>,
capture: Option<CommandOutput>,
capture_limit: usize,
deadline: Option<Instant>,
}
impl<S: Read + Write> Session<S> {
pub fn start(mut stream: S, host: String, port: u16, accept_new: bool) -> io::Result<Self> {
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<Vec<u8>> {
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<F, Fut>(
&mut self,
user: &str,
public: &ssh_key::PublicKey,
sign: F,
) -> io::Result<()>
where
F: FnOnce(Vec<u8>) -> Fut,
Fut: std::future::Future<Output = io::Result<Vec<u8>>>,
{
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<CommandOutput> {
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<CommandOutput> {
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<u8>) -> 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<usize> {
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<usize> {
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<RefCell<Session<Box<dyn Stream>>>>);
pub struct Writer(Rc<RefCell<Session<Box<dyn Stream>>>>);
impl Read for Reader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.0.borrow_mut().read_data(buf)
}
}
impl Write for Writer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.borrow_mut().write_data(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.borrow_mut().packets.stream.flush()
}
}
impl Session<Box<dyn Stream>> {
/// 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))
}
}
@@ -0,0 +1,43 @@
#[cfg(test)]
mod channel_tests {
use super::*;
use std::io::Cursor;
struct Wire { input: Cursor<Vec<u8>>, output: Vec<u8> }
impl Read for Wire { fn read(&mut self, b: &mut [u8]) -> io::Result<usize> { self.input.read(b) } }
impl Write for Wire {
fn write(&mut self, b: &[u8]) -> io::Result<usize> { self.output.extend(b); Ok(b.len()) }
fn flush(&mut self) -> io::Result<()> { Ok(()) }
}
fn session() -> Session<Wire> {
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<u8> {
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());
}
}
+239
View File
@@ -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) = <Hmac<sha1::Sha1> 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<bool> {
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 = <Hmac<sha1::Sha1> 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());
}
}
+223
View File
@@ -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<S: Read + Write>(stream: &mut S) -> io::Result<Vec<u8>> {
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<Vec<u8>> {
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<Negotiated> {
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<u8> {
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<u8> {
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<u8> {
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<S: Read + Write>(
packets: &mut Packets<S>,
server_version: &[u8],
session: &mut Vec<u8>,
server_init: Option<Vec<u8>>,
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(())
}
@@ -0,0 +1,48 @@
#[cfg(test)]
mod negotiation_tests {
use super::*;
fn server(lists: &[&[u8];8], follows: bool) -> Vec<u8> {
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]);
}
}
@@ -0,0 +1,89 @@
#[cfg(test)]
mod protection_tests {
use super::*;
use std::io::Cursor;
struct Memory { input: Cursor<Vec<u8>>, output: Vec<u8> }
impl Read for Memory { fn read(&mut self, b: &mut [u8]) -> io::Result<usize> { self.input.read(b) } }
impl Write for Memory {
fn write(&mut self, b: &[u8]) -> io::Result<usize> { 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<u8>, algorithm: MacAlgorithm) -> Packets<Memory> {
let mut packets = Packets::new(Memory { input: Cursor::new(bytes), output: vec![] });
packets.recv = Some(protection(algorithm)); packets
}
fn hex(s: &str) -> Vec<u8> {
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 = <Hmac<Sha512> as Mac>::new_from_slice(&key).unwrap();
h.update(&seq); h.update(signed); h.verify_slice(&encoded[split..]).unwrap();
} else {
let mut h = <Hmac<Sha256> 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);
}
}
}
}
+250
View File
@@ -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<aes::Aes128>;
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<u8>, n: u32) {
out.extend_from_slice(&n.to_be_bytes());
}
pub fn string(out: &mut Vec<u8>, bytes: &[u8]) {
uint(out, bytes.len() as u32);
out.extend_from_slice(bytes);
}
pub fn message(kind: u8, strings: &[&[u8]]) -> Vec<u8> {
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<u8> {
Ok(self.take(1)?[0])
}
pub fn uint(&mut self) -> io::Result<u32> {
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<Self> {
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<Sha256>),
Sha512(Hmac<Sha512>),
}
impl Tag {
fn bytes(self) -> Vec<u8> {
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<u8>,
algorithm: MacAlgorithm,
}
impl Protection {
/// Historical non-EtM constructor, retained for existing callers.
pub fn new(key: &[u8], iv: &[u8], mac: Vec<u8>) -> io::Result<Self> {
Self::with_mac(key, iv, mac, MacAlgorithm::Sha256)
}
pub fn with_mac(
key: &[u8],
iv: &[u8],
mac: Vec<u8>,
algorithm: MacAlgorithm,
) -> io::Result<Self> {
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<Tag> {
if self.algorithm == MacAlgorithm::Sha512Etm {
let mut h = <Hmac<Sha512> 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 = <Hmac<Sha256> 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<S> {
pub stream: S,
pub send: Option<Protection>,
pub recv: Option<Protection>,
tx: u32,
rx: u32,
}
impl<S: Read + Write> Packets<S> {
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<Vec<u8>> {
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<Vec<u8>> {
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"))
}
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "ssh"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4", features = ["derive"] }
sausage-ssh = { path = "../ssh-client" }
+60
View File
@@ -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.
+54
View File
@@ -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<String>,
#[arg(short = 'o')]
options: Vec<String>,
host: String,
#[arg(required = true, trailing_var_arg = true)]
command: Vec<String>,
}
fn run(args: Args) -> io::Result<u32> {
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);
}
+56
View File
@@ -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)'
}
+751
View File
@@ -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"
+13
View File
@@ -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"
+155
View File
@@ -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<u8> = 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();
}
}