Add WASI SSH CLI and in-process Git SSH transport

This commit is contained in:
2026-11-06 19:42:00 +00:00
parent 608d0c3fc1
commit 99a8e3a8be
29 changed files with 3546 additions and 30 deletions
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "ssh"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4", features = ["derive"] }
sausage-ssh = { path = "../ssh-client" }
+60
View File
@@ -0,0 +1,60 @@
# SSH in the world
`ssh` executes one remote command without a PTY or an interactive shell.
It buffers stdin and captures stdout and stderr until the remote channel closes.
It returns the remote exit status. Connection, trust, and authentication failures return 255.
```sh
ssh -p 2222 -l user -o StrictHostKeyChecking=accept-new host 'echo hi; exit 3'
```
The default host-key policy requires a matching key in `$HOME/.ssh/known_hosts`.
The client creates `.ssh` if needed.
`StrictHostKeyChecking=accept-new` appends an unknown key.
A changed or revoked key always fails.
The client supports Ed25519 keys and curve25519 key exchange, as v2 did.
The private key stays in the host.
The client requests a public identity from `POST http://host.sausage/ssh/identity`.
It requests a signature from `POST http://host.sausage/ssh/sign`.
`SAUSAGE_HOST_API` replaces the base URL for standalone tests.
The URL or `-l` supplies the user. Otherwise, the host identity supplies it.
A host denial preserves the item and request information in stderr.
## Tests
Build from `userland/` after the shared startup object exists:
```sh
CARGO_TARGET_DIR=/tmp/p1-ssh/target nix develop .. -c cargo build -p ssh -p git --release --locked
```
Build the native test fixture from the repository root:
```sh
CARGO_TARGET_DIR=/tmp/p1-ssh/server-target nix develop -c cargo build --manifest-path userland/ssh/tests/server/Cargo.toml --locked
```
Start the fixture through a managed job or a separate terminal:
```sh
nix shell nixpkgs#openssh -c /tmp/p1-ssh/server-target/debug/ssh-test-server /tmp/p1-ssh/fixture user
```
Use the current native user's login name instead of `user`.
The fixture creates `ready.json` after sshd and the signing server start.
It keeps all private keys under `fixture/host`, outside the guest preopen.
It serves the two host routes and a test-only host-key rotation route.
```sh
nix develop -c nu userland/ssh/test.nu /tmp/p1-ssh/target/wasm32-wasip2/release/ssh.wasm /tmp/p1-ssh/fixture
```
The Git script needs a separate fresh fixture because the SSH script changes its host key:
```sh
nix develop -c nu userland/git/ssh-test.nu /tmp/p1-ssh/target/wasm32-wasip2/release/git.wasm /tmp/p1-ssh/target/wasm32-wasip2/release/ssh.wasm /tmp/p1-ssh/git-fixture
```
Both scripts check that the components import only WASI interfaces.
They enable TCP, DNS, HTTP, and numeric exit codes through Wasmtime's standard options.
+54
View File
@@ -0,0 +1,54 @@
//! One noninteractive SSH command with host-mediated authentication.
use clap::Parser;
use std::{
io::{self, Read, Write},
time::{Duration, Instant},
};
#[derive(Parser)]
struct Args {
#[arg(short = 'p', default_value_t = 22)]
port: u16,
#[arg(short = 'l')]
user: Option<String>,
#[arg(short = 'o')]
options: Vec<String>,
host: String,
#[arg(required = true, trailing_var_arg = true)]
command: Vec<String>,
}
fn run(args: Args) -> io::Result<u32> {
let mut accept_new = false;
for option in args.options {
match option.as_str() {
"StrictHostKeyChecking=accept-new" => accept_new = true,
"StrictHostKeyChecking=yes" => accept_new = false,
_ => return Err(io::Error::other("unsupported SSH option")),
}
}
let (user, host) = match args.host.split_once('@') {
Some((user, host)) => (Some(user), host),
None => (args.user.as_deref(), args.host.as_str()),
};
let session = sausage_ssh::identity::connect(host, args.port, user, accept_new)?;
let mut input = Vec::new();
io::stdin().read_to_end(&mut input)?;
let output = session.execute_input(
args.command.join(" ").as_bytes(),
&input,
usize::MAX,
Instant::now() + Duration::from_secs(300),
)?;
io::stdout().write_all(&output.stdout)?;
io::stderr().write_all(&output.stderr)?;
Ok(output.exit_code.unwrap_or(255))
}
fn main() {
let status = match run(Args::parse()) {
Ok(status) => status,
Err(error) => {
eprintln!("{error}");
255
}
};
std::process::exit(status.min(255) as i32);
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env nu
# Verify SSH streams, host trust, host denial, and standard WASI imports.
use std/assert
def wasi [component: path, root: path, args: list, input: string = ''] {
let ready = (open ($root | path join ready.json))
$input | ^wasmtime run -S inherit-network -S allow-ip-name-lookup -S http -S cli-exit-with-code=y --dir $'($root)/guest::/' --env HOME=/home --env $'SAUSAGE_HOST_API=http://127.0.0.1:($ready.api)' $component ...$args | complete
}
def main [component: path, root: path] {
let component = ($component | path expand)
let root = ($root | path expand)
let wit = (^wasm-tools component wit $component | complete)
assert equal $wit.exit_code 0
let imports = ($wit.stdout | lines | str trim | where {|line| $line | str starts-with 'import '})
assert (not ($imports | is-empty))
assert ($imports | all {|line| $line | str starts-with 'import wasi:'})
print 'SSH component imports: PASS (WASI only)'
print ($imports | str join (char newline))
let ready = (open ($root | path join ready.json))
let destination = $'($ready.user)@127.0.0.1'
let args = [-p $ready.ssh $destination 'echo hi; exit 3']
let unknown = (wasi $component $root $args)
assert equal $unknown.exit_code 255
assert ($unknown.stderr | str contains 'Host key verification failed')
print 'Unknown host key: PASS'
let accepted = (wasi $component $root ([-o StrictHostKeyChecking=accept-new] | append $args))
assert equal $accepted.exit_code 3
assert equal $accepted.stdout $"hi\n"
assert (($root | path join guest/home/.ssh/known_hosts) | path exists)
print 'Accept-new records the host key, prints hi, and exits 3: PASS'
let repeated = (wasi $component $root $args)
assert equal $repeated.exit_code 3
assert equal $repeated.stdout $"hi\n"
print 'Known host key: PASS'
let streams = (wasi $component $root [-p $ready.ssh -l $ready.user 127.0.0.1 'cat; echo error >&2; exit 7'] $"input\n")
assert equal $streams.exit_code 7
assert equal $streams.stdout $"input\n"
assert equal $streams.stderr $"error\n"
print 'Stdin, stdout, stderr, -l user, and remote exit 7: PASS'
let default_user = (wasi $component $root [-p $ready.ssh 127.0.0.1 'echo default'])
assert equal $default_user.exit_code 0
assert equal $default_user.stdout $"default\n"
print 'Default user from host identity: PASS'
'deny' | save ($root | path join host/deny)
let denied = (wasi $component $root $args)
assert equal $denied.exit_code 255
assert ($denied.stderr | str contains 'test-key')
assert ($denied.stderr | str contains 'test-request')
print 'Host API 403 names the item and request, and exits 255: PASS'
rm ($root | path join host/deny)
http post --content-type application/json $'http://127.0.0.1:($ready.api)/fixture/rotate' '{}' | ignore
let changed = (wasi $component $root ([-o StrictHostKeyChecking=accept-new] | append $args))
assert equal $changed.exit_code 255
assert ($changed.stderr | str contains 'REMOTE HOST IDENTIFICATION HAS CHANGED')
print 'Changed host key fails even with accept-new: PASS'
print $'Fixture: ($root)'
}
+751
View File
@@ -0,0 +1,751 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"subtle",
]
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"sha2",
"subtle",
]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array",
"group",
"pkcs8",
"rand_core",
"sec1",
"subtle",
"zeroize",
]
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core",
"subtle",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
dependencies = [
"spin",
]
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand",
"smallvec",
"zeroize",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "p384"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]]
name = "p521"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
dependencies = [
"base16ct",
"ecdsa",
"elliptic-curve",
"primeorder",
"rand_core",
"sha2",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"sha2",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
]
[[package]]
name = "smallvec"
version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "ssh-cipher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
dependencies = [
"cipher",
"ssh-encoding",
]
[[package]]
name = "ssh-encoding"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
dependencies = [
"base64ct",
"pem-rfc7468",
"sha2",
]
[[package]]
name = "ssh-key"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [
"ed25519-dalek",
"p256",
"p384",
"p521",
"rand_core",
"rsa",
"sec1",
"sha2",
"signature",
"ssh-cipher",
"ssh-encoding",
"subtle",
"zeroize",
]
[[package]]
name = "ssh-test-server"
version = "0.1.0"
dependencies = [
"base64",
"rand_core",
"serde_json",
"signature",
"ssh-key",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "zerocopy"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "ssh-test-server"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
ssh-key = { version = "0.6.7", default-features = false, features = ["std", "ed25519", "getrandom"] }
rand_core = { version = "0.6", features = ["getrandom"] }
signature = "2"
base64 = "0.22"
serde_json = "1"
+155
View File
@@ -0,0 +1,155 @@
//! Native localhost sshd fixture and host-only signing oracle for WASI tests.
use base64::{Engine, engine::general_purpose::STANDARD};
use serde_json::{Value, json};
use signature::Signer;
use ssh_key::{Algorithm, LineEnding, PrivateKey};
use std::{
fs,
io::{BufRead, BufReader, Read, Write},
net::{TcpListener, TcpStream},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
fn key(path: &Path) -> PrivateKey {
let key = PrivateKey::random(&mut rand_core::OsRng, Algorithm::Ed25519).unwrap();
key.write_openssh_file(path, LineEnding::LF).unwrap();
key
}
struct Daemon {
child: Child,
config: PathBuf,
binary: PathBuf,
host_key: PathBuf,
log: PathBuf,
port: u16,
}
impl Daemon {
fn start(binary: &Path, config: &Path, log: &Path, port: u16) -> Child {
let child = Command::new(binary)
.args(["-D", "-e", "-f"])
.arg(config)
.stdout(Stdio::null())
.stderr(
fs::OpenOptions::new()
.create(true)
.append(true)
.open(log)
.unwrap(),
)
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while TcpStream::connect(("127.0.0.1", port)).is_err() {
assert!(
Instant::now() < deadline,
"sshd failed to start; inspect {}",
log.display()
);
std::thread::yield_now();
}
child
}
fn rotate(&mut self) {
self.child.kill().unwrap();
self.child.wait().unwrap();
key(&self.host_key);
self.child = Self::start(&self.binary, &self.config, &self.log, self.port);
}
}
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn main() {
let root = PathBuf::from(std::env::args().nth(1).expect("fixture root"));
let user = std::env::args().nth(2).expect("SSH user");
let host = root.join("host");
fs::create_dir_all(&host).unwrap();
fs::create_dir_all(root.join("guest/home")).unwrap();
let client = key(&host.join("client_key"));
fs::write(
host.join("authorized_keys"),
client.public_key().to_openssh().unwrap(),
)
.unwrap();
let host_key = host.join("server_key");
key(&host_key);
let socket = TcpListener::bind("127.0.0.1:0").unwrap();
let port = socket.local_addr().unwrap().port();
drop(socket);
let binary = std::env::split_paths(&std::env::var_os("PATH").unwrap())
.map(|p| p.join("sshd"))
.find(|p| p.is_file())
.unwrap()
.canonicalize()
.unwrap();
let config = host.join("sshd_config");
fs::write(&config, format!("Port {port}\nListenAddress 127.0.0.1\nHostKey {}\nPidFile {}\nAuthorizedKeysFile {}\nStrictModes no\nUsePAM no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\nAllowUsers {user}\nLogLevel VERBOSE\n", host_key.display(), host.join("sshd.pid").display(), host.join("authorized_keys").display())).unwrap();
let log = host.join("sshd.log");
let child = Daemon::start(&binary, &config, &log, port);
let mut daemon = Daemon {
child,
config,
binary,
host_key,
log,
port,
};
let api = TcpListener::bind("127.0.0.1:0").unwrap();
fs::write(
root.join("ready.json"),
json!({"ssh":port,"api":api.local_addr().unwrap().port(),"user":user}).to_string(),
)
.unwrap();
for connection in api.incoming() {
let mut stream = connection.unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut first = String::new();
reader.read_line(&mut first).unwrap();
let route = first.split_whitespace().nth(1).unwrap_or("");
let mut length = 0;
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if line == "\r\n" || line.is_empty() {
break;
}
if let Some((name, value)) = line.split_once(':') {
if name.eq_ignore_ascii_case("content-length") {
length = value.trim().parse().unwrap();
}
}
}
let mut bytes = vec![0; length];
reader.read_exact(&mut bytes).unwrap();
let request: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
let (status, body) = match route {
"/fixture/rotate" => {
daemon.rotate();
(200, json!({"ok":true}))
}
"/ssh/identity" | "/ssh/sign" if host.join("deny").exists() => (
403,
json!({"item":"test-key","request":"test-request","message":"approval required"}),
),
"/ssh/identity" => (
200,
json!({"item":"test-key","public_key":STANDARD.encode(client.public_key().to_bytes().unwrap()),"user":user}),
),
"/ssh/sign" if request["item"] == "test-key" && request["host"] == "127.0.0.1" => {
let bytes = STANDARD
.decode(request["payload"].as_str().unwrap())
.unwrap();
let signature: ssh_key::Signature = client.try_sign(&bytes).unwrap();
let signature: Vec<u8> = signature.try_into().unwrap();
(200, json!({"signature":STANDARD.encode(signature)}))
}
_ => (400, json!({"error":"invalid request"})),
};
let body = body.to_string();
write!(stream, "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
}
}