Add WASI SSH CLI and in-process Git SSH transport
This commit is contained in:
Generated
+571
-9
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ mod branches;
|
||||
mod changes;
|
||||
mod checkout;
|
||||
mod http;
|
||||
mod ssh;
|
||||
mod merge;
|
||||
mod network;
|
||||
mod output;
|
||||
|
||||
+21
-14
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())))
|
||||
}
|
||||
@@ -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)'
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "ssh"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
sausage-ssh = { path = "../ssh-client" }
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)'
|
||||
}
|
||||
Generated
+751
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user