diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 76a2f673d0562df60c311b71950ff4e5cda46418..567e41ba194bcb903923bf33bfbc1fe6350f4398 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,5 +30,6 @@
           persist-credentials: false
       - run: cargo --version
       - run: cargo test --locked --all-targets --all-features
+      - run: cargo test --locked --release --test auth --test ssh
       - run: cargo test --locked --release --test sqlite_workload -- --ignored --nocapture
       - run: cargo build --locked --release
diff --git a/Cargo.lock b/Cargo.lock
index 4451e6686ecd31490925e9a164870705f730c88f..4dd0b064da834229d34509463ab5932ce7bed066 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3,16 +3,168 @@
 version = 4
 
 [[package]]
+name = "aead"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
+[[package]]
+name = "aes"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
+dependencies = [
+ "cipher",
+ "cpubits",
+ "cpufeatures",
+ "zeroize",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
 name = "anstyle"
 version = "1.0.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
 
 [[package]]
+name = "argon2"
+version = "0.6.0-rc.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures",
+ "password-hash",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base16ct"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bcrypt-pbkdf"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0"
+dependencies = [
+ "blowfish",
+ "pbkdf2",
+ "sha2",
+]
+
+[[package]]
 name = "bitflags"
 version = "2.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "blake2"
+version = "0.11.0-rc.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+ "zeroize",
+]
+
+[[package]]
+name = "block-padding"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "blowfish"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298"
+dependencies = [
+ "byteorder",
+ "cipher",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cbc"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
+dependencies = [
+ "cipher",
+]
 
 [[package]]
 name = "cc"
@@ -29,6 +181,50 @@
 version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+ "rand_core",
+ "zeroize",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "cipher"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "inout",
+ "zeroize",
+]
 
 [[package]]
 name = "clap"
@@ -69,11 +265,243 @@
 checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
 
 [[package]]
+name = "cmov"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpubits"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-bigint"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
+dependencies = [
+ "cpubits",
+ "ctutils",
+ "getrandom 0.4.3",
+ "hybrid-array",
+ "num-traits",
+ "rand_core",
+ "serdect",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "getrandom 0.4.3",
+ "hybrid-array",
+ "rand_core",
+]
+
+[[package]]
+name = "ctr"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "ctutils"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+dependencies = [
+ "cmov",
+ "subtle",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rand_core",
+ "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 = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
+[[package]]
+name = "delegate"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "der"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
+dependencies = [
+ "const-oid",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "des"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+ "ctutils",
+]
+
+[[package]]
 name = "displaydoc"
 version = "0.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
 dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "ecdsa"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
+dependencies = [
+ "der",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+ "zeroize",
+]
+
+[[package]]
+name = "ed25519"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "rand_core",
+ "serde",
+ "sha2",
+ "signature",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "elliptic-curve"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "crypto-common",
+ "digest",
+ "ff",
+ "group",
+ "hkdf",
+ "hybrid-array",
+ "pem-rfc7468",
+ "pkcs8",
+ "rand_core",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "enum_dispatch"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
+dependencies = [
+ "once_cell",
  "proc-macro2",
  "quote",
  "syn 2.0.119",
@@ -92,7 +520,7 @@
 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
 dependencies = [
  "libc",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -114,6 +542,22 @@
 checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
 
 [[package]]
+name = "ff"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f"
+dependencies = [
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
+
+[[package]]
 name = "find-msvc-tools"
 version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -129,14 +573,157 @@
 ]
 
 [[package]]
+name = "futures"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
+
+[[package]]
+name = "futures-task"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+
+[[package]]
+name = "futures-util"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "generic-array"
+version = "1.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab4e5aa225bc56696909483320f0ff9b600f1a971b52e07a17d70f3d9b43254b"
+dependencies = [
+ "generic-array 0.14.9",
+ "rustversion",
+ "typenum",
+]
+
+[[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 = "getrandom"
 version = "0.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
 dependencies = [
  "cfg-if",
+ "js-sys",
  "libc",
  "r-efi",
+ "rand_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "ghash"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
+dependencies = [
+ "polyval",
+]
+
+[[package]]
+name = "group"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4"
+dependencies = [
+ "ff",
+ "rand_core",
+ "subtle",
 ]
 
 [[package]]
@@ -150,6 +737,72 @@
 version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hex-literal"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1"
+
+[[package]]
+name = "hkdf"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "hybrid-array"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
+dependencies = [
+ "ctutils",
+ "subtle",
+ "typenum",
+ "zeroize",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
 
 [[package]]
 name = "icu_collections"
@@ -265,6 +918,59 @@
 ]
 
 [[package]]
+name = "inout"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
+dependencies = [
+ "block-padding",
+ "hybrid-array",
+]
+
+[[package]]
+name = "internal-russh-num-bigint"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8"
+dependencies = [
+ "num-integer",
+ "num-traits",
+ "rand",
+ "rand_core",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "keccak"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+]
+
+[[package]]
+name = "kem"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd"
+dependencies = [
+ "crypto-common",
+ "rand_core",
+]
+
+[[package]]
 name = "libc"
 version = "0.2.189"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -294,10 +1000,193 @@
 checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
 
 [[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "md5"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "ml-kem"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66"
+dependencies = [
+ "hybrid-array",
+ "kem",
+ "module-lattice",
+ "pkcs8",
+ "rand_core",
+ "sha3 0.11.0",
+]
+
+[[package]]
+name = "module-lattice"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe"
+dependencies = [
+ "ctutils",
+ "hybrid-array",
+ "num-traits",
+]
+
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
 name = "once_cell"
 version = "1.21.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "p256"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primefield",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "p384"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "fiat-crypto",
+ "primefield",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "p521"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449"
+dependencies = [
+ "base16ct",
+ "ecdsa",
+ "elliptic-curve",
+ "primefield",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "pageant"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f3a5ae18f65a85c67a77d18d42d3606c07948e3c17c1e5f74852b26589e88a5"
+dependencies = [
+ "base16ct",
+ "byteorder",
+ "bytes",
+ "delegate",
+ "futures",
+ "log",
+ "rand",
+ "sha2",
+ "thiserror",
+ "tokio",
+ "windows",
+ "windows-strings",
+]
+
+[[package]]
+name = "password-hash"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
+dependencies = [
+ "phc",
+]
+
+[[package]]
+name = "pbkdf2"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
+dependencies = [
+ "digest",
+ "hmac",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
+dependencies = [
+ "base64ct",
+]
 
 [[package]]
 name = "percent-encoding"
@@ -306,10 +1195,77 @@
 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
 
 [[package]]
+name = "phc"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
+dependencies = [
+ "base64ct",
+ "ctutils",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs5"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca"
+dependencies = [
+ "aes",
+ "aes-gcm",
+ "cbc",
+ "der",
+ "pbkdf2",
+ "rand_core",
+ "scrypt",
+ "sha2",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
+dependencies = [
+ "der",
+ "pkcs5",
+ "rand_core",
+ "spki",
+]
+
+[[package]]
 name = "pkg-config"
 version = "0.3.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "poly1305"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c"
+dependencies = [
+ "cpufeatures",
+ "universal-hash",
+ "zeroize",
+]
+
+[[package]]
+name = "polyval"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
+dependencies = [
+ "cpubits",
+ "cpufeatures",
+ "universal-hash",
+]
 
 [[package]]
 name = "potential_utf"
@@ -318,6 +1274,33 @@
 checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
 dependencies = [
  "zerovec",
+]
+
+[[package]]
+name = "primefield"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4"
+dependencies = [
+ "crypto-bigint",
+ "crypto-common",
+ "ff",
+ "rand_core",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "primeorder"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06"
+dependencies = [
+ "elliptic-curve",
+ "once_cell",
+ "primefield",
+ "serdect",
+ "wnaf",
 ]
 
 [[package]]
@@ -345,6 +1328,47 @@
 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
 
 [[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rfc6979"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b"
+dependencies = [
+ "crypto-bigint",
+ "hmac",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
 name = "rusqlite"
 version = "0.40.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -358,6 +1382,108 @@
 ]
 
 [[package]]
+name = "russh"
+version = "0.62.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8b67b5a0d8068c89dcbe9d95df986af7a851d1f3c604525274c37468e60464f"
+dependencies = [
+ "aes",
+ "bitflags",
+ "block-padding",
+ "byteorder",
+ "bytes",
+ "cbc",
+ "cipher",
+ "crypto-bigint",
+ "ctr",
+ "curve25519-dalek",
+ "data-encoding",
+ "delegate",
+ "der",
+ "digest",
+ "ecdsa",
+ "ed25519-dalek",
+ "elliptic-curve",
+ "enum_dispatch",
+ "futures",
+ "generic-array 1.4.4",
+ "getrandom 0.4.3",
+ "ghash",
+ "hex-literal",
+ "hmac",
+ "inout",
+ "internal-russh-num-bigint",
+ "keccak",
+ "log",
+ "md5",
+ "ml-kem",
+ "module-lattice",
+ "num-bigint",
+ "p256",
+ "p384",
+ "p521",
+ "pageant",
+ "pbkdf2",
+ "pkcs5",
+ "pkcs8",
+ "polyval",
+ "rand",
+ "rand_core",
+ "ring",
+ "russh-cryptovec",
+ "russh-util",
+ "salsa20",
+ "scrypt",
+ "sec1",
+ "sha1",
+ "sha2",
+ "sha3 0.12.0",
+ "signature",
+ "spki",
+ "ssh-encoding",
+ "ssh-key",
+ "subtle",
+ "thiserror",
+ "tokio",
+ "typenum",
+ "universal-hash",
+ "zeroize",
+]
+
+[[package]]
+name = "russh-cryptovec"
+version = "0.62.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438"
+dependencies = [
+ "log",
+ "nix",
+ "ssh-encoding",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "russh-util"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6"
+dependencies = [
+ "chrono",
+ "tokio",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+]
+
+[[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.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -367,8 +1493,56 @@
  "errno",
  "libc",
  "linux-raw-sys",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "salsa20"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c"
+dependencies = [
+ "cfg-if",
+ "cipher",
+]
+
+[[package]]
+name = "scrypt"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0"
+dependencies = [
+ "cfg-if",
+ "pbkdf2",
+ "salsa20",
+ "sha2",
+]
+
+[[package]]
+name = "sec1"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
+dependencies = [
+ "base16ct",
+ "ctutils",
+ "der",
+ "hybrid-array",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
 
 [[package]]
 name = "serde"
@@ -410,10 +1584,79 @@
 ]
 
 [[package]]
+name = "serdect"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
+dependencies = [
+ "base16ct",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha3"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1"
+dependencies = [
+ "digest",
+ "keccak",
+]
+
+[[package]]
+name = "sha3"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
+dependencies = [
+ "digest",
+ "keccak",
+ "sponge-cursor",
+]
+
+[[package]]
 name = "shlex"
 version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signature"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
+dependencies = [
+ "digest",
+ "rand_core",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
 
 [[package]]
 name = "smallvec"
@@ -422,10 +1665,99 @@
 checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
 
 [[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spki"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
+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-cipher"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597"
+dependencies = [
+ "aead",
+ "aes",
+ "aes-gcm",
+ "chacha20",
+ "cipher",
+ "ctutils",
+ "des",
+ "poly1305",
+ "ssh-encoding",
+ "zeroize",
+]
+
+[[package]]
+name = "ssh-encoding"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e"
+dependencies = [
+ "base64ct",
+ "bytes",
+ "ctutils",
+ "digest",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "ssh-key"
+version = "0.7.0-rc.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3"
+dependencies = [
+ "argon2",
+ "bcrypt-pbkdf",
+ "ctutils",
+ "ed25519-dalek",
+ "hex",
+ "hmac",
+ "p256",
+ "p384",
+ "p521",
+ "rand_core",
+ "sec1",
+ "sha1",
+ "sha2",
+ "signature",
+ "ssh-cipher",
+ "ssh-encoding",
+ "zeroize",
+]
+
+[[package]]
 name = "stable_deref_trait"
 version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
 
 [[package]]
 name = "syn"
@@ -467,10 +1799,10 @@
 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
 dependencies = [
  "fastrand",
- "getrandom",
+ "getrandom 0.4.3",
  "once_cell",
  "rustix",
- "windows-sys",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -508,12 +1840,43 @@
 version = "0.1.0"
 dependencies = [
  "clap",
+ "rand",
  "rusqlite",
+ "russh",
  "serde",
+ "sha2",
+ "ssh-key",
  "tempfile",
  "thiserror",
+ "tokio",
  "toml",
  "url",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
 ]
 
 [[package]]
@@ -556,10 +1919,32 @@
 checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
 
 [[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.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "universal-hash"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
+dependencies = [
+ "crypto-common",
+ "ctutils",
+]
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
 
 [[package]]
 name = "url"
@@ -587,10 +1972,181 @@
 checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
 
 [[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 = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
 name = "windows-link"
 version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
 
 [[package]]
 name = "windows-sys"
@@ -602,10 +2158,94 @@
 ]
 
 [[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
 name = "winnow"
 version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+
+[[package]]
+name = "wnaf"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1"
+dependencies = [
+ "ff",
+ "group",
+ "hybrid-array",
+]
 
 [[package]]
 name = "writeable"
@@ -656,6 +2296,12 @@
  "syn 2.0.119",
  "synstructure",
 ]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
 
 [[package]]
 name = "zerotrie"
diff --git a/Cargo.toml b/Cargo.toml
index 26ceb22c474a13d6c7d584f4cac4e84403668cb4..f954d7e44109e29c161313b7ce20ca60f931a8d1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,9 +12,14 @@
 
 [dependencies]
 clap = { version = "4.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
+rand = "0.10"
 rusqlite = { version = "0.40", default-features = false, features = ["backup", "bundled"] }
+russh = { version = "0.62", default-features = false, features = ["ring"] }
 serde = { version = "1.0", features = ["derive"] }
+sha2 = { version = "0.11", default-features = false }
+ssh-key = { version = "=0.7.0-rc.11", default-features = false, features = ["ed25519", "p256", "std"] }
 thiserror = "2.0"
+tokio = { version = "1.53", default-features = false, features = ["macros", "net", "rt-multi-thread", "sync", "time"] }
 toml = { version = "1.1", default-features = false, features = ["parse", "serde", "std"] }
 url = { version = "2.5", features = ["serde"] }
 
diff --git a/README.md b/README.md
index 3250c8a5c92de36e1b4b96f8d716ea385520ed87..df309213a0cc0e9cafec6102b503e04315183500 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,20 @@
 [architectural decision record](docs/adr/0001-sqlite-storage.md) for the limits
 and current platform evidence.
 
+## Milestone 1B gate
+
+Install stock OpenSSH. Then, run the SSH identity gate:
+
+```text
+./scripts/check-m1b
+```
+
+This command uses stock `ssh`, `ssh-agent`, `ssh-add`, and `ssh-keygen` to do
+tests of public-key authentication, SSH request restrictions, and SSHSIG login
+challenges. Read the SSH identity
+[architectural decision record](docs/adr/0002-ssh-identity.md) for the supported
+algorithms, limits, and current platform evidence.
+
 ## Database check
 
 An initialized instance keeps its metadata in `tit.sqlite3`. Check an existing
diff --git a/deny.toml b/deny.toml
index ad1443a5358b146c2ae9070439666ac06faecdbd..7b80f0d03a76705dd5238248508ee54d925d04ce 100644
--- a/deny.toml
+++ b/deny.toml
@@ -7,6 +7,8 @@
 confidence-threshold = 0.93
 allow = [
     "Apache-2.0",
+    "BSD-3-Clause",
+    "ISC",
     "MIT",
     "Unicode-3.0",
 ]
diff --git a/docs/adr/0002-ssh-identity.md b/docs/adr/0002-ssh-identity.md
new file mode 100644
index 0000000000000000000000000000000000000000..ecb88c54e1b9cdf1ae8c693fee6bab4caa204d1d
--- /dev/null
+++ b/docs/adr/0002-ssh-identity.md
@@ -1,0 +1,103 @@
+# Architectural decision record 0002: SSH identity
+
+Status: Provisional
+
+Date: 2026-07-22
+
+## Context
+
+`tit` needs one SSH identity for Git transport, SSH commands, and Web login.
+The SSH username must not select the account. The SSH public key must select
+the account. Web login must use the standard SSHSIG format and must prevent
+replay.
+
+## Decision
+
+Use `ssh-key` version 0.7.0-rc.11 for OpenSSH public-key parsing,
+fingerprints, and SSHSIG verification. Russh version 0.62.4 uses this exact
+version, which prevents two SSH key models in the executable. Use Russh with
+the `ring` feature for the built-in SSH server. Do not enable compression or
+the Russh RSA feature. Use Tokio for the asynchronous listener. Use `rand` for
+operating-system random bytes and `sha2` for login nonce hashes.
+
+Accept Ed25519 and ECDSA P-256 public keys. Remove the public-key comment during
+normalization and use its SHA-256 fingerprint. Reject DSA, other ECDSA curves,
+security keys, certificates, unknown algorithms, and RSA. Report an RSA key
+that is smaller than 3,072 bits as undersized before the general RSA rejection.
+
+Russh advertises RSA-SHA2, but its server handler does not receive the RSA
+signature algorithm that the client selected. Thus, the application cannot
+independently reject a custom client that uses RSA-SHA1. Do not accept RSA keys
+until this boundary can enforce RSA-SHA2. A stock OpenSSH client that is forced
+to use `ssh-rsa` cannot authenticate to the M1B server.
+
+Use this canonical login challenge:
+
+```text
+tit-auth-v1
+purpose=web-login
+origin=ORIGIN
+username=USERNAME
+fingerprint=FINGERPRINT
+nonce=NONCE
+issued-at=ISSUED_TIME
+expires-at=EXPIRY_TIME
+```
+
+The trailing newline is necessary. Sign the exact bytes with the `tit-auth`
+SSHSIG namespace. A challenge is valid for a maximum of five minutes. The
+challenge is at most 4 KiB. The SSHSIG envelope and SSH public key are each at
+most 16 KiB.
+
+Keep only the SHA-256 hash and expiry of each active login nonce. Keep a maximum
+of 1,024 active challenges and remove expired entries when a new challenge is
+issued. Consume the hash under one lock only after all context and signature
+checks pass. A process restart invalidates all challenges in this feasibility
+implementation. Milestone 3 must put nonce hashes in SQLite before Web login is
+available.
+
+The SSH server accepts public-key authentication only. It ignores the SSH
+username. It accepts session channels and the exact `tit --version` command.
+It rejects shells, PTYs, subsystems, agents, forwarding, and all other exec
+requests. It accepts only `GIT_PROTOCOL` environment requests with the exact
+values `version=0`, `version=1`, or `version=2`.
+
+## Threats and controls
+
+A copied SSHSIG envelope cannot start a second session because the first valid
+verification removes its nonce hash. Concurrent verification can have only one
+successful result. A signer cannot change the origin, username, fingerprint,
+issue time, or expiry because these fields are in the signed bytes. The verifier
+also compares these fields with its own origin, username, selected key, clock,
+and stored expiry.
+
+The key and signature parsers have input limits before cryptographic work. The
+server has only public-key authentication, three authentication attempts, a
+30-second inactivity limit, and a 32-KiB packet limit from Russh. Rejected SSH
+requests do not start a shell or a process.
+
+## Evidence
+
+The local macOS gate uses stock `ssh`, `ssh-agent`, `ssh-add`, and `ssh-keygen`.
+It verifies Ed25519 and ECDSA P-256 authentication with two different SSH
+usernames. It verifies SSHSIG envelopes that stock `ssh-keygen -Y sign` creates.
+It also verifies the negative cases for replay, concurrent use, expiry, origin,
+namespace, key, malformed input, DSA, ECDSA P-384, RSA, RSA-SHA1, an unknown
+key, shells, PTYs, subsystems, agents, forwarding, arbitrary exec requests, and
+environment values.
+
+This decision stays provisional until the Linux and macOS hosted M1B gates
+pass.
+
+## Consequences
+
+The selected crates add cryptographic and asynchronous code to the executable,
+but they remove custom SSH cryptography and transport code. The initial key set
+is smaller because Russh does not give the application the information that it
+needs to enforce RSA-SHA2. This omission is safer than acceptance of RSA-SHA1.
+
+The server boundary is not connected to `tit serve` in M1B because the account
+store and instance host-key lifecycle do not exist. The server accepts an
+explicit set of normalized keys. Subsequent milestones must supply that set
+through the account and authorization services without changing the transport
+policy.
diff --git a/scripts/check-m1b b/scripts/check-m1b
new file mode 100755
index 0000000000000000000000000000000000000000..6b238181687a4ac8c8faf9f7beb389c24365b23e
--- /dev/null
+++ b/scripts/check-m1b
@@ -1,0 +1,5 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test auth --test ssh
diff --git a/src/auth.rs b/src/auth.rs
new file mode 100644
index 0000000000000000000000000000000000000000..51223225fc54c7036f040f25224c05d87fa3c13e
--- /dev/null
+++ b/src/auth.rs
@@ -1,0 +1,369 @@
+use std::collections::HashMap;
+use std::sync::Mutex;
+
+use rand::TryRng;
+use sha2::{Digest, Sha256};
+use ssh_key::{Algorithm, EcdsaCurve, HashAlg, PublicKey, SshSig};
+use thiserror::Error;
+use url::Url;
+
+const CHALLENGE_HEADER: &str = "tit-auth-v1";
+const CHALLENGE_PURPOSE: &str = "web-login";
+const SIGNATURE_NAMESPACE: &str = "tit-auth";
+const MAX_KEY_BYTES: usize = 16 * 1024;
+const MAX_CHALLENGE_BYTES: usize = 4 * 1024;
+const MAX_SIGNATURE_BYTES: usize = 16 * 1024;
+const MAX_CHALLENGE_LIFETIME_SECONDS: u64 = 5 * 60;
+const MAX_OUTSTANDING_CHALLENGES: usize = 1_024;
+const NONCE_BYTES: usize = 32;
+const MINIMUM_RSA_BITS: u32 = 3_072;
+
+#[derive(Clone, Debug)]
+pub(crate) struct SshPublicKey {
+    key: PublicKey,
+    canonical: String,
+    fingerprint: String,
+}
+
+impl SshPublicKey {
+    pub(crate) fn parse(input: &str) -> Result<Self, AuthError> {
+        if input.len() > MAX_KEY_BYTES {
+            return Err(AuthError::InputTooLarge("SSH public key"));
+        }
+
+        let mut key = PublicKey::from_openssh(input).map_err(AuthError::PublicKey)?;
+        validate_key_algorithm(&key)?;
+        key.set_comment("");
+        let canonical = key.to_openssh().map_err(AuthError::PublicKey)?;
+        let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
+        Ok(Self {
+            key,
+            canonical,
+            fingerprint,
+        })
+    }
+
+    pub(crate) fn canonical(&self) -> &str {
+        &self.canonical
+    }
+
+    pub(crate) fn fingerprint(&self) -> &str {
+        &self.fingerprint
+    }
+
+    pub(crate) fn public_key(&self) -> &PublicKey {
+        &self.key
+    }
+}
+
+#[derive(Debug)]
+pub(crate) struct LoginChallenges {
+    origin: String,
+    nonces: Mutex<HashMap<[u8; 32], u64>>,
+}
+
+impl LoginChallenges {
+    pub(crate) fn new(public_url: &Url) -> Result<Self, AuthError> {
+        if !matches!(public_url.scheme(), "http" | "https")
+            || public_url.host().is_none()
+            || !public_url.username().is_empty()
+            || public_url.password().is_some()
+            || public_url.query().is_some()
+            || public_url.fragment().is_some()
+            || public_url.path() != "/"
+        {
+            return Err(AuthError::InvalidOrigin);
+        }
+
+        Ok(Self {
+            origin: public_url.origin().ascii_serialization(),
+            nonces: Mutex::new(HashMap::new()),
+        })
+    }
+
+    pub(crate) fn issue(
+        &self,
+        username: &str,
+        key: &SshPublicKey,
+        issued_at: u64,
+        lifetime_seconds: u64,
+    ) -> Result<String, AuthError> {
+        validate_username(username)?;
+        if lifetime_seconds == 0 || lifetime_seconds > MAX_CHALLENGE_LIFETIME_SECONDS {
+            return Err(AuthError::InvalidLifetime);
+        }
+        let expires_at = issued_at
+            .checked_add(lifetime_seconds)
+            .ok_or(AuthError::InvalidLifetime)?;
+
+        let nonce = self.new_nonce(issued_at, expires_at)?;
+        Ok(format!(
+            "{CHALLENGE_HEADER}\npurpose={CHALLENGE_PURPOSE}\norigin={}\nusername={username}\nfingerprint={}\nnonce={}\nissued-at={issued_at}\nexpires-at={expires_at}\n",
+            self.origin,
+            key.fingerprint(),
+            encode_hex(&nonce)
+        ))
+    }
+
+    pub(crate) fn verify(
+        &self,
+        challenge: &str,
+        signature: &str,
+        expected_username: &str,
+        expected_key: &SshPublicKey,
+        now: u64,
+    ) -> Result<VerifiedLogin, AuthError> {
+        if challenge.len() > MAX_CHALLENGE_BYTES {
+            return Err(AuthError::InputTooLarge("login challenge"));
+        }
+        if signature.len() > MAX_SIGNATURE_BYTES {
+            return Err(AuthError::InputTooLarge("SSHSIG envelope"));
+        }
+        validate_username(expected_username)?;
+
+        let fields = ChallengeFields::parse(challenge)?;
+        if fields.origin != self.origin {
+            return Err(AuthError::WrongOrigin);
+        }
+        if fields.username != expected_username {
+            return Err(AuthError::WrongUsername);
+        }
+        if fields.fingerprint != expected_key.fingerprint() {
+            return Err(AuthError::WrongKey);
+        }
+        if fields.expires_at <= fields.issued_at
+            || fields.expires_at - fields.issued_at > MAX_CHALLENGE_LIFETIME_SECONDS
+        {
+            return Err(AuthError::InvalidLifetime);
+        }
+        if now < fields.issued_at || now > fields.expires_at {
+            return Err(AuthError::ExpiredChallenge);
+        }
+
+        let sshsig = SshSig::from_pem(signature).map_err(AuthError::SignatureEnvelope)?;
+        validate_signature_algorithm(expected_key.public_key(), &sshsig)?;
+        expected_key
+            .public_key()
+            .verify(SIGNATURE_NAMESPACE, challenge.as_bytes(), &sshsig)
+            .map_err(AuthError::SignatureVerification)?;
+
+        let nonce_hash = hash_nonce(&fields.nonce);
+        let mut nonces = self.nonces.lock().map_err(|_| AuthError::NonceStore)?;
+        match nonces.get(&nonce_hash) {
+            Some(stored_expiry) if *stored_expiry == fields.expires_at => {
+                nonces.remove(&nonce_hash);
+            }
+            _ => return Err(AuthError::ConsumedChallenge),
+        }
+
+        Ok(VerifiedLogin {
+            username: fields.username.to_owned(),
+            fingerprint: fields.fingerprint.to_owned(),
+        })
+    }
+
+    fn new_nonce(&self, issued_at: u64, expires_at: u64) -> Result<[u8; NONCE_BYTES], AuthError> {
+        loop {
+            let mut nonce = [0_u8; NONCE_BYTES];
+            rand::rngs::SysRng
+                .try_fill_bytes(&mut nonce)
+                .map_err(|_| AuthError::Random)?;
+            let hash = hash_nonce(&nonce);
+            let mut nonces = self.nonces.lock().map_err(|_| AuthError::NonceStore)?;
+            nonces.retain(|_, stored_expiry| *stored_expiry >= issued_at);
+            if nonces.len() >= MAX_OUTSTANDING_CHALLENGES {
+                return Err(AuthError::NonceLimit);
+            }
+            if let std::collections::hash_map::Entry::Vacant(entry) = nonces.entry(hash) {
+                entry.insert(expires_at);
+                return Ok(nonce);
+            }
+        }
+    }
+}
+
+#[derive(Debug, Eq, PartialEq)]
+pub(crate) struct VerifiedLogin {
+    pub(crate) username: String,
+    pub(crate) fingerprint: String,
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum AuthError {
+    #[error("{0} is too large")]
+    InputTooLarge(&'static str),
+    #[error("SSH public key is not valid: {0}")]
+    PublicKey(ssh_key::Error),
+    #[error("SSH public key algorithm is not supported")]
+    UnsupportedKeyAlgorithm,
+    #[error("RSA key has {actual} bits, but a minimum of {minimum} bits is necessary")]
+    UndersizedRsa { actual: u32, minimum: u32 },
+    #[error("username is not valid")]
+    InvalidUsername,
+    #[error("canonical origin is not valid")]
+    InvalidOrigin,
+    #[error("challenge lifetime is not valid")]
+    InvalidLifetime,
+    #[error("cannot get random bytes")]
+    Random,
+    #[error("login challenge is not valid")]
+    MalformedChallenge,
+    #[error("login challenge has the wrong origin")]
+    WrongOrigin,
+    #[error("login challenge has the wrong username")]
+    WrongUsername,
+    #[error("login challenge has the wrong SSH key")]
+    WrongKey,
+    #[error("login challenge has expired or is not active")]
+    ExpiredChallenge,
+    #[error("SSHSIG envelope is not valid: {0}")]
+    SignatureEnvelope(ssh_key::Error),
+    #[error("SSHSIG algorithm is not supported")]
+    UnsupportedSignatureAlgorithm,
+    #[error("SSHSIG verification failed: {0}")]
+    SignatureVerification(ssh_key::Error),
+    #[error("login challenge was consumed or was not issued")]
+    ConsumedChallenge,
+    #[error("login nonce store is not available")]
+    NonceStore,
+    #[error("too many login challenges are active")]
+    NonceLimit,
+}
+
+struct ChallengeFields<'a> {
+    origin: &'a str,
+    username: &'a str,
+    fingerprint: &'a str,
+    nonce: [u8; NONCE_BYTES],
+    issued_at: u64,
+    expires_at: u64,
+}
+
+impl<'a> ChallengeFields<'a> {
+    fn parse(challenge: &'a str) -> Result<Self, AuthError> {
+        let body = challenge
+            .strip_suffix('\n')
+            .ok_or(AuthError::MalformedChallenge)?;
+        let mut lines = body.split('\n');
+        if lines.next() != Some(CHALLENGE_HEADER) || lines.next() != Some("purpose=web-login") {
+            return Err(AuthError::MalformedChallenge);
+        }
+        let origin = field(lines.next(), "origin=")?;
+        let username = field(lines.next(), "username=")?;
+        validate_username(username)?;
+        let fingerprint = field(lines.next(), "fingerprint=")?;
+        let nonce = decode_hex(field(lines.next(), "nonce=")?)?;
+        let issued_at = parse_time(field(lines.next(), "issued-at=")?)?;
+        let expires_at = parse_time(field(lines.next(), "expires-at=")?)?;
+        if lines.next().is_some()
+            || origin.is_empty()
+            || fingerprint.is_empty()
+            || origin.contains(['\r', '\n'])
+            || fingerprint.contains(['\r', '\n'])
+        {
+            return Err(AuthError::MalformedChallenge);
+        }
+        Ok(Self {
+            origin,
+            username,
+            fingerprint,
+            nonce,
+            issued_at,
+            expires_at,
+        })
+    }
+}
+
+fn validate_key_algorithm(key: &PublicKey) -> Result<(), AuthError> {
+    match key.key_data() {
+        ssh_key::public::KeyData::Ed25519(_) => Ok(()),
+        ssh_key::public::KeyData::Ecdsa(key) if key.curve() == EcdsaCurve::NistP256 => Ok(()),
+        ssh_key::public::KeyData::Rsa(key) if key.key_size() < MINIMUM_RSA_BITS => {
+            Err(AuthError::UndersizedRsa {
+                actual: key.key_size(),
+                minimum: MINIMUM_RSA_BITS,
+            })
+        }
+        _ => Err(AuthError::UnsupportedKeyAlgorithm),
+    }
+}
+
+fn validate_signature_algorithm(key: &PublicKey, signature: &SshSig) -> Result<(), AuthError> {
+    let valid = match (key.key_data(), signature.algorithm()) {
+        (ssh_key::public::KeyData::Ed25519(_), Algorithm::Ed25519) => true,
+        (ssh_key::public::KeyData::Ecdsa(key), Algorithm::Ecdsa { curve }) => {
+            key.curve() == EcdsaCurve::NistP256 && curve == EcdsaCurve::NistP256
+        }
+        _ => false,
+    };
+    if valid {
+        Ok(())
+    } else {
+        Err(AuthError::UnsupportedSignatureAlgorithm)
+    }
+}
+
+fn validate_username(username: &str) -> Result<(), AuthError> {
+    let bytes = username.as_bytes();
+    let valid_character = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit();
+    if !(1..=40).contains(&bytes.len())
+        || !valid_character(bytes[0])
+        || !valid_character(bytes[bytes.len() - 1])
+        || !bytes
+            .iter()
+            .all(|byte| valid_character(*byte) || *byte == b'-')
+    {
+        return Err(AuthError::InvalidUsername);
+    }
+    Ok(())
+}
+
+fn field<'a>(line: Option<&'a str>, prefix: &str) -> Result<&'a str, AuthError> {
+    line.and_then(|line| line.strip_prefix(prefix))
+        .ok_or(AuthError::MalformedChallenge)
+}
+
+fn parse_time(value: &str) -> Result<u64, AuthError> {
+    if value.is_empty()
+        || !value.bytes().all(|byte| byte.is_ascii_digit())
+        || (value.len() > 1 && value.starts_with('0'))
+    {
+        return Err(AuthError::MalformedChallenge);
+    }
+    value
+        .parse::<u64>()
+        .map_err(|_| AuthError::MalformedChallenge)
+}
+
+fn hash_nonce(nonce: &[u8; NONCE_BYTES]) -> [u8; 32] {
+    Sha256::digest(nonce).into()
+}
+
+fn encode_hex(bytes: &[u8]) -> String {
+    const HEX: &[u8; 16] = b"0123456789abcdef";
+    let mut encoded = String::with_capacity(bytes.len() * 2);
+    for byte in bytes {
+        encoded.push(char::from(HEX[usize::from(byte >> 4)]));
+        encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
+    }
+    encoded
+}
+
+fn decode_hex(value: &str) -> Result<[u8; NONCE_BYTES], AuthError> {
+    if value.len() != NONCE_BYTES * 2 {
+        return Err(AuthError::MalformedChallenge);
+    }
+    let mut bytes = [0_u8; NONCE_BYTES];
+    for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
+        bytes[index] = (decode_hex_digit(pair[0])? << 4) | decode_hex_digit(pair[1])?;
+    }
+    Ok(bytes)
+}
+
+fn decode_hex_digit(value: u8) -> Result<u8, AuthError> {
+    match value {
+        b'0'..=b'9' => Ok(value - b'0'),
+        b'a'..=b'f' => Ok(value - b'a' + 10),
+        _ => Err(AuthError::MalformedChallenge),
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 1a3f95b4687abb61db0a44f66572d7acb4abbb0c..4ae77ffedf38482102941aea4d828fc7fb8f5fc9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,9 @@
+#[allow(dead_code, reason = "M1B proves SSH identity before M2 calls it")]
+mod auth;
 mod cli;
 mod config;
+#[allow(dead_code, reason = "M1B proves the SSH server before M2 calls it")]
+mod ssh;
 mod store;
 
 use std::process::ExitCode;
diff --git a/src/ssh.rs b/src/ssh.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f3c93da72da3ee721f8eda28cd61e27d4535db34
--- /dev/null
+++ b/src/ssh.rs
@@ -1,0 +1,375 @@
+use std::borrow::Cow;
+use std::collections::HashSet;
+use std::net::SocketAddr;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::time::Duration;
+
+use rand::rng;
+use russh::server::{Auth, ChannelOpenHandle, Handler, Msg, Server, Session};
+use russh::{Channel, ChannelId, MethodKind, MethodSet, Preferred, Pty};
+use ssh_key::{Algorithm, EcdsaCurve, PrivateKey, PublicKey};
+use thiserror::Error;
+use tokio::net::TcpListener;
+use tokio::sync::oneshot;
+use tokio::task::JoinHandle;
+
+use crate::auth::SshPublicKey;
+
+const VERSION_COMMAND: &[u8] = b"tit --version";
+const GIT_PROTOCOL_VARIABLE: &str = "GIT_PROTOCOL";
+
+pub(crate) struct RunningSshServer {
+    address: SocketAddr,
+    handle: russh::server::RunningServerHandle,
+    task: JoinHandle<std::io::Result<()>>,
+    audit: Arc<RequestAudit>,
+}
+
+impl RunningSshServer {
+    pub(crate) async fn start(
+        address: SocketAddr,
+        authorized_keys: &[SshPublicKey],
+    ) -> Result<Self, SshServerError> {
+        let listener = TcpListener::bind(address).await?;
+        let address = listener.local_addr()?;
+        let host_key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
+        let mut methods = MethodSet::empty();
+        methods.push(MethodKind::PublicKey);
+        let config = Arc::new(russh::server::Config {
+            methods,
+            auth_rejection_time: Duration::from_millis(250),
+            auth_rejection_time_initial: Some(Duration::ZERO),
+            keys: vec![host_key],
+            preferred: Preferred {
+                key: Cow::Owned(vec![
+                    Algorithm::Ed25519,
+                    Algorithm::Ecdsa {
+                        curve: EcdsaCurve::NistP256,
+                    },
+                ]),
+                ..Preferred::default()
+            },
+            max_auth_attempts: 3,
+            inactivity_timeout: Some(Duration::from_secs(30)),
+            nodelay: true,
+            ..Default::default()
+        });
+        let authorized_keys = Arc::new(
+            authorized_keys
+                .iter()
+                .map(|key| key.public_key().clone())
+                .collect(),
+        );
+        let audit = Arc::new(RequestAudit::default());
+        let server = SshServer {
+            authorized_keys,
+            audit: Arc::clone(&audit),
+        };
+        let (handle_sender, handle_receiver) = oneshot::channel();
+        let task = tokio::spawn(async move {
+            let mut server = server;
+            let running = server.run_on_socket(config, &listener);
+            let _ = handle_sender.send(running.handle());
+            running.await
+        });
+        let handle = handle_receiver.await.map_err(|_| SshServerError::Startup)?;
+        Ok(Self {
+            address,
+            handle,
+            task,
+            audit,
+        })
+    }
+
+    pub(crate) fn address(&self) -> SocketAddr {
+        self.address
+    }
+
+    pub(crate) fn audit(&self) -> RequestAuditSnapshot {
+        self.audit.snapshot()
+    }
+
+    pub(crate) async fn shutdown(self) -> Result<(), SshServerError> {
+        self.handle.shutdown("tit test shutdown".to_owned());
+        self.task.await.map_err(|_| SshServerError::Join)??;
+        Ok(())
+    }
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum SshServerError {
+    #[error("SSH listener error: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("SSH key error: {0}")]
+    Key(#[from] ssh_key::Error),
+    #[error("SSH server did not start")]
+    Startup,
+    #[error("SSH server task failed")]
+    Join,
+}
+
+#[derive(Clone)]
+struct SshServer {
+    authorized_keys: Arc<HashSet<PublicKey>>,
+    audit: Arc<RequestAudit>,
+}
+
+impl Server for SshServer {
+    type Handler = SshSession;
+
+    fn new_client(&mut self, _peer_address: Option<SocketAddr>) -> Self::Handler {
+        SshSession {
+            authorized_keys: Arc::clone(&self.authorized_keys),
+            audit: Arc::clone(&self.audit),
+        }
+    }
+}
+
+struct SshSession {
+    authorized_keys: Arc<HashSet<PublicKey>>,
+    audit: Arc<RequestAudit>,
+}
+
+impl Handler for SshSession {
+    type Error = russh::Error;
+
+    async fn auth_publickey_offered(
+        &mut self,
+        _user: &str,
+        public_key: &PublicKey,
+    ) -> Result<Auth, Self::Error> {
+        Ok(self.authorize(public_key))
+    }
+
+    async fn auth_publickey(
+        &mut self,
+        _user: &str,
+        public_key: &PublicKey,
+    ) -> Result<Auth, Self::Error> {
+        Ok(self.authorize(public_key))
+    }
+
+    async fn channel_open_session(
+        &mut self,
+        _channel: Channel<Msg>,
+        reply: ChannelOpenHandle,
+        _session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        reply.accept().await;
+        Ok(())
+    }
+
+    async fn channel_open_x11(
+        &mut self,
+        _channel: Channel<Msg>,
+        _originator_address: &str,
+        _originator_port: u32,
+        _reply: ChannelOpenHandle,
+        _session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        self.audit.rejected_forward.fetch_add(1, Ordering::Relaxed);
+        Ok(())
+    }
+
+    async fn channel_open_direct_tcpip(
+        &mut self,
+        _channel: Channel<Msg>,
+        _host_to_connect: &str,
+        _port_to_connect: u32,
+        _originator_address: &str,
+        _originator_port: u32,
+        _reply: ChannelOpenHandle,
+        _session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        self.audit.rejected_forward.fetch_add(1, Ordering::Relaxed);
+        Ok(())
+    }
+
+    async fn channel_open_direct_streamlocal(
+        &mut self,
+        _channel: Channel<Msg>,
+        _socket_path: &str,
+        _reply: ChannelOpenHandle,
+        _session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        self.audit.rejected_forward.fetch_add(1, Ordering::Relaxed);
+        Ok(())
+    }
+
+    async fn pty_request(
+        &mut self,
+        channel: ChannelId,
+        _term: &str,
+        _col_width: u32,
+        _row_height: u32,
+        _pix_width: u32,
+        _pix_height: u32,
+        _modes: &[(Pty, u32)],
+        session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        self.audit.rejected_pty.fetch_add(1, Ordering::Relaxed);
+        session.channel_failure(channel)?;
+        Ok(())
+    }
+
+    async fn x11_request(
+        &mut self,
+        channel: ChannelId,
+        _single_connection: bool,
+        _x11_auth_protocol: &str,
+        _x11_auth_cookie: &str,
+        _x11_screen_number: u32,
+        session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        self.audit.rejected_forward.fetch_add(1, Ordering::Relaxed);
+        session.channel_failure(channel)?;
+        Ok(())
+    }
+
+    async fn env_request(
+        &mut self,
+        channel: ChannelId,
+        variable_name: &str,
+        variable_value: &str,
+        session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        if variable_name == GIT_PROTOCOL_VARIABLE && valid_git_protocol(variable_value) {
+            self.audit.accepted_env.fetch_add(1, Ordering::Relaxed);
+            session.channel_success(channel)?;
+        } else {
+            self.audit.rejected_env.fetch_add(1, Ordering::Relaxed);
+            session.channel_failure(channel)?;
+        }
+        Ok(())
+    }
+
+    async fn shell_request(
+        &mut self,
+        channel: ChannelId,
+        session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        self.audit.rejected_shell.fetch_add(1, Ordering::Relaxed);
+        session.channel_failure(channel)?;
+        session.close(channel)?;
+        Ok(())
+    }
+
+    async fn exec_request(
+        &mut self,
+        channel: ChannelId,
+        command: &[u8],
+        session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        if command == VERSION_COMMAND {
+            self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
+            session.channel_success(channel)?;
+            session.data(
+                channel,
+                format!("tit {}\n", env!("CARGO_PKG_VERSION")).into_bytes(),
+            )?;
+            session.exit_status_request(channel, 0)?;
+            session.eof(channel)?;
+            session.close(channel)?;
+        } else {
+            self.audit.rejected_exec.fetch_add(1, Ordering::Relaxed);
+            session.channel_failure(channel)?;
+            session.close(channel)?;
+        }
+        Ok(())
+    }
+
+    async fn subsystem_request(
+        &mut self,
+        channel: ChannelId,
+        _name: &str,
+        session: &mut Session,
+    ) -> Result<(), Self::Error> {
+        self.audit.rejected_exec.fetch_add(1, Ordering::Relaxed);
+        session.channel_failure(channel)?;
+        session.close(channel)?;
+        Ok(())
+    }
+
+    async fn agent_request(
+        &mut self,
+        channel: ChannelId,
+        session: &mut Session,
+    ) -> Result<bool, Self::Error> {
+        self.audit.rejected_agent.fetch_add(1, Ordering::Relaxed);
+        session.channel_failure(channel)?;
+        Ok(false)
+    }
+
+    async fn tcpip_forward(
+        &mut self,
+        _address: &str,
+        _port: &mut u32,
+        _session: &mut Session,
+    ) -> Result<bool, Self::Error> {
+        self.audit.rejected_forward.fetch_add(1, Ordering::Relaxed);
+        Ok(false)
+    }
+
+    async fn streamlocal_forward(
+        &mut self,
+        _socket_path: &str,
+        _session: &mut Session,
+    ) -> Result<bool, Self::Error> {
+        self.audit.rejected_forward.fetch_add(1, Ordering::Relaxed);
+        Ok(false)
+    }
+}
+
+impl SshSession {
+    fn authorize(&self, public_key: &PublicKey) -> Auth {
+        if self.authorized_keys.contains(public_key) {
+            Auth::Accept
+        } else {
+            Auth::reject()
+        }
+    }
+}
+
+fn valid_git_protocol(value: &str) -> bool {
+    matches!(value, "version=0" | "version=1" | "version=2")
+}
+
+#[derive(Default)]
+struct RequestAudit {
+    accepted_env: AtomicUsize,
+    rejected_env: AtomicUsize,
+    accepted_exec: AtomicUsize,
+    rejected_exec: AtomicUsize,
+    rejected_shell: AtomicUsize,
+    rejected_pty: AtomicUsize,
+    rejected_agent: AtomicUsize,
+    rejected_forward: AtomicUsize,
+}
+
+impl RequestAudit {
+    fn snapshot(&self) -> RequestAuditSnapshot {
+        RequestAuditSnapshot {
+            accepted_env: self.accepted_env.load(Ordering::Relaxed),
+            rejected_env: self.rejected_env.load(Ordering::Relaxed),
+            accepted_exec: self.accepted_exec.load(Ordering::Relaxed),
+            rejected_exec: self.rejected_exec.load(Ordering::Relaxed),
+            rejected_shell: self.rejected_shell.load(Ordering::Relaxed),
+            rejected_pty: self.rejected_pty.load(Ordering::Relaxed),
+            rejected_agent: self.rejected_agent.load(Ordering::Relaxed),
+            rejected_forward: self.rejected_forward.load(Ordering::Relaxed),
+        }
+    }
+}
+
+#[derive(Clone, Copy, Debug, Default)]
+pub(crate) struct RequestAuditSnapshot {
+    pub(crate) accepted_env: usize,
+    pub(crate) rejected_env: usize,
+    pub(crate) accepted_exec: usize,
+    pub(crate) rejected_exec: usize,
+    pub(crate) rejected_shell: usize,
+    pub(crate) rejected_pty: usize,
+    pub(crate) rejected_agent: usize,
+    pub(crate) rejected_forward: usize,
+}
diff --git a/tests/auth.rs b/tests/auth.rs
new file mode 100644
index 0000000000000000000000000000000000000000..7dba0784dc1fcef2a9eca58d098275a9c7eadaa3
--- /dev/null
+++ b/tests/auth.rs
@@ -1,0 +1,461 @@
+#[path = "../src/auth.rs"]
+mod auth;
+
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::{Arc, Barrier};
+use std::thread;
+
+use auth::{AuthError, LoginChallenges, SshPublicKey};
+use tempfile::TempDir;
+use url::Url;
+
+const ISSUED_AT: u64 = 1_750_000_000;
+const LIFETIME: u64 = 120;
+const DSA_PUBLIC_KEY: &str = "ssh-dss AAAAB3NzaC1kc3MAAACBANw9iSUO2UYhFMssjUgW46URqv8bBrDgHeF8HLBOWBvKuXF2Rx2J/XyhgX48SOLMuv0hcPaejlyLarabnF9F2V4dkpPpZSJ+7luHmxEjNxwhsdtg8UteXAWkeCzrQ6MvRJZHcDBjYh56KGvslbFnJsGLXlI4PQCyl6awNImwYGilAAAAFQCJGBU3hZf+QtP9Jh/nbfNlhFu7hwAAAIBHObOQioQVRm3HsVb7mOy3FVKhcLoLO3qoG9gTkd4KeuehtFAC3+rckiX7xSCnE/5BBKdL7VP9WRXac2Nlr9Pwl3e7zPut96wrCHt/TZX6vkfXKkbpUIj5zSqfvyNrWKaYJkfzwAQwrXNS1Hol676Ud/DDEn2oatdEhkS3beWHXAAAAIBgQqaz/YYTRMshzMzYcZ4lqgvgmA55y6v0h39e8HH2A5dwNS6sPUw2jyna+le0dceNRJifFld1J+WYM0vmquSr11DDavgEidOSaXwfMvPPPJqLmbzdtT16N+Gij9U9STQTHPQcQ3xnNNHgQAStzZJbhLOVbDDDo5BO7LMUALDfSA==";
+
+#[test]
+fn normalizes_supported_openssh_keys_and_matches_stock_fingerprints() {
+    let directory = TempDir::new().expect("create a key directory");
+    for fixture in [KeyFixture::Ed25519, KeyFixture::EcdsaP256] {
+        let private_key = directory.path().join(fixture.name());
+        generate_key(&private_key, fixture);
+        let public_key_path = public_key_path(&private_key);
+        let encoded = fs::read_to_string(&public_key_path).expect("read the public key");
+        let key = SshPublicKey::parse(&encoded).expect("normalize the public key");
+
+        assert_eq!(key.canonical().split_whitespace().count(), 2);
+        assert_eq!(key.fingerprint(), stock_fingerprint(&public_key_path));
+        assert_eq!(
+            SshPublicKey::parse(key.canonical())
+                .expect("parse the normalized key")
+                .canonical(),
+            key.canonical()
+        );
+    }
+}
+
+#[test]
+fn rejects_unsupported_and_undersized_keys() {
+    let directory = TempDir::new().expect("create a key directory");
+
+    assert!(matches!(
+        SshPublicKey::parse(DSA_PUBLIC_KEY),
+        Err(AuthError::UnsupportedKeyAlgorithm)
+    ));
+
+    let ecdsa_p384 = directory.path().join("ecdsa-p384");
+    generate_key(&ecdsa_p384, KeyFixture::EcdsaP384);
+    assert!(matches!(
+        parse_public_key(&ecdsa_p384),
+        Err(AuthError::UnsupportedKeyAlgorithm)
+    ));
+
+    let rsa_2048 = directory.path().join("rsa-2048");
+    generate_key(&rsa_2048, KeyFixture::Rsa2048);
+    assert!(matches!(
+        parse_public_key(&rsa_2048),
+        Err(AuthError::UndersizedRsa {
+            actual: 2_048,
+            minimum: 3_072
+        })
+    ));
+
+    let rsa_3072 = directory.path().join("rsa-3072");
+    generate_key(&rsa_3072, KeyFixture::Rsa3072);
+    assert!(matches!(
+        parse_public_key(&rsa_3072),
+        Err(AuthError::UnsupportedKeyAlgorithm)
+    ));
+
+    assert!(SshPublicKey::parse("not a key").is_err());
+}
+
+#[test]
+fn verifies_stock_sshsig_envelopes_for_each_supported_key() {
+    let directory = TempDir::new().expect("create a key directory");
+    for fixture in [KeyFixture::Ed25519, KeyFixture::EcdsaP256] {
+        let private_key = directory.path().join(fixture.name());
+        generate_key(&private_key, fixture);
+        let key = parse_public_key(&private_key).expect("parse the public key");
+        let challenges = issuer("https://tit.example/");
+        let challenge = challenges
+            .issue("alice", &key, ISSUED_AT, LIFETIME)
+            .expect("issue a login challenge");
+        let signature = sign(
+            &directory,
+            fixture.name(),
+            &private_key,
+            "tit-auth",
+            &challenge,
+        );
+
+        let verified = challenges
+            .verify(&challenge, &signature, "alice", &key, ISSUED_AT + 1)
+            .expect("verify the stock SSHSIG envelope");
+        assert_eq!(verified.username, "alice");
+        assert_eq!(verified.fingerprint, key.fingerprint());
+    }
+}
+
+#[test]
+fn rejects_replay_expiry_wrong_context_and_malformed_envelopes() {
+    let directory = TempDir::new().expect("create a key directory");
+    let private_key = directory.path().join("ed25519");
+    generate_key(&private_key, KeyFixture::Ed25519);
+    let key = parse_public_key(&private_key).expect("parse the public key");
+
+    let replay_issuer = issuer("https://tit.example/");
+    let replay_challenge = replay_issuer
+        .issue("alice", &key, ISSUED_AT, LIFETIME)
+        .expect("issue a replay challenge");
+    let replay_signature = sign(
+        &directory,
+        "replay",
+        &private_key,
+        "tit-auth",
+        &replay_challenge,
+    );
+    replay_issuer
+        .verify(
+            &replay_challenge,
+            &replay_signature,
+            "alice",
+            &key,
+            ISSUED_AT + 1,
+        )
+        .expect("verify the challenge one time");
+    assert!(matches!(
+        replay_issuer.verify(
+            &replay_challenge,
+            &replay_signature,
+            "alice",
+            &key,
+            ISSUED_AT + 1
+        ),
+        Err(AuthError::ConsumedChallenge)
+    ));
+
+    let expired_issuer = issuer("https://tit.example/");
+    let expired_challenge = expired_issuer
+        .issue("alice", &key, ISSUED_AT, LIFETIME)
+        .expect("issue an expiring challenge");
+    let expired_signature = sign(
+        &directory,
+        "expired",
+        &private_key,
+        "tit-auth",
+        &expired_challenge,
+    );
+    assert!(matches!(
+        expired_issuer.verify(
+            &expired_challenge,
+            &expired_signature,
+            "alice",
+            &key,
+            ISSUED_AT + LIFETIME + 1
+        ),
+        Err(AuthError::ExpiredChallenge)
+    ));
+
+    let namespace_issuer = issuer("https://tit.example/");
+    let namespace_challenge = namespace_issuer
+        .issue("alice", &key, ISSUED_AT, LIFETIME)
+        .expect("issue a namespace challenge");
+    let wrong_namespace = sign(
+        &directory,
+        "namespace",
+        &private_key,
+        "other",
+        &namespace_challenge,
+    );
+    assert!(matches!(
+        namespace_issuer.verify(
+            &namespace_challenge,
+            &wrong_namespace,
+            "alice",
+            &key,
+            ISSUED_AT + 1
+        ),
+        Err(AuthError::SignatureVerification(_))
+    ));
+
+    let origin_issuer = issuer("https://tit.example/");
+    let origin_challenge = origin_issuer
+        .issue("alice", &key, ISSUED_AT, LIFETIME)
+        .expect("issue an origin challenge");
+    let origin_signature = sign(
+        &directory,
+        "origin",
+        &private_key,
+        "tit-auth",
+        &origin_challenge,
+    );
+    assert!(matches!(
+        issuer("https://other.example/").verify(
+            &origin_challenge,
+            &origin_signature,
+            "alice",
+            &key,
+            ISSUED_AT + 1
+        ),
+        Err(AuthError::WrongOrigin)
+    ));
+
+    let malformed_issuer = issuer("https://tit.example/");
+    let malformed_challenge = malformed_issuer
+        .issue("alice", &key, ISSUED_AT, LIFETIME)
+        .expect("issue a malformed-envelope challenge");
+    assert!(matches!(
+        malformed_issuer.verify(
+            &malformed_challenge,
+            "not an SSHSIG envelope",
+            "alice",
+            &key,
+            ISSUED_AT + 1
+        ),
+        Err(AuthError::SignatureEnvelope(_))
+    ));
+}
+
+#[test]
+fn rejects_wrong_key_username_and_rsa_keys() {
+    let directory = TempDir::new().expect("create a key directory");
+    let first_private = directory.path().join("first");
+    let second_private = directory.path().join("second");
+    generate_key(&first_private, KeyFixture::Ed25519);
+    generate_key(&second_private, KeyFixture::Ed25519);
+    let first_key = parse_public_key(&first_private).expect("parse the first public key");
+    let second_key = parse_public_key(&second_private).expect("parse the second public key");
+    let challenges = issuer("https://tit.example/");
+    let challenge = challenges
+        .issue("alice", &first_key, ISSUED_AT, LIFETIME)
+        .expect("issue a key challenge");
+    let signature = sign(
+        &directory,
+        "wrong-key",
+        &first_private,
+        "tit-auth",
+        &challenge,
+    );
+
+    assert!(matches!(
+        challenges.verify(&challenge, &signature, "alice", &second_key, ISSUED_AT + 1),
+        Err(AuthError::WrongKey)
+    ));
+    assert!(matches!(
+        challenges.verify(&challenge, &signature, "bob", &first_key, ISSUED_AT + 1),
+        Err(AuthError::WrongUsername)
+    ));
+
+    let rsa_private = directory.path().join("rsa");
+    generate_key(&rsa_private, KeyFixture::Rsa3072);
+    assert!(matches!(
+        parse_public_key(&rsa_private),
+        Err(AuthError::UnsupportedKeyAlgorithm)
+    ));
+}
+
+#[test]
+fn consumes_a_nonce_atomically() {
+    let directory = TempDir::new().expect("create a key directory");
+    let private_key = directory.path().join("ed25519");
+    generate_key(&private_key, KeyFixture::Ed25519);
+    let key = parse_public_key(&private_key).expect("parse the public key");
+    let challenges = Arc::new(issuer("https://tit.example/"));
+    let challenge = challenges
+        .issue("alice", &key, ISSUED_AT, LIFETIME)
+        .expect("issue a concurrent challenge");
+    let signature = sign(
+        &directory,
+        "concurrent",
+        &private_key,
+        "tit-auth",
+        &challenge,
+    );
+    let barrier = Arc::new(Barrier::new(8));
+
+    let workers: Vec<_> = (0..8)
+        .map(|_| {
+            let challenges = Arc::clone(&challenges);
+            let barrier = Arc::clone(&barrier);
+            let challenge = challenge.clone();
+            let signature = signature.clone();
+            let key = key.clone();
+            thread::spawn(move || {
+                barrier.wait();
+                challenges
+                    .verify(&challenge, &signature, "alice", &key, ISSUED_AT + 1)
+                    .is_ok()
+            })
+        })
+        .collect();
+
+    let successes = workers
+        .into_iter()
+        .map(|worker| worker.join().expect("join a verifier"))
+        .filter(|success| *success)
+        .count();
+    assert_eq!(successes, 1);
+}
+
+#[test]
+fn enforces_input_and_active_challenge_limits() {
+    let directory = TempDir::new().expect("create a key directory");
+    let private_key = directory.path().join("ed25519");
+    generate_key(&private_key, KeyFixture::Ed25519);
+    let key = parse_public_key(&private_key).expect("parse the public key");
+
+    assert!(matches!(
+        SshPublicKey::parse(&"x".repeat(16 * 1024 + 1)),
+        Err(AuthError::InputTooLarge("SSH public key"))
+    ));
+
+    let challenges = issuer("https://tit.example/");
+    let challenge = challenges
+        .issue("alice", &key, ISSUED_AT, LIFETIME)
+        .expect("issue a size-limit challenge");
+    assert!(matches!(
+        challenges.verify(
+            &"x".repeat(4 * 1024 + 1),
+            "signature",
+            "alice",
+            &key,
+            ISSUED_AT + 1
+        ),
+        Err(AuthError::InputTooLarge("login challenge"))
+    ));
+    assert!(matches!(
+        challenges.verify(
+            &challenge,
+            &"x".repeat(16 * 1024 + 1),
+            "alice",
+            &key,
+            ISSUED_AT + 1
+        ),
+        Err(AuthError::InputTooLarge("SSHSIG envelope"))
+    ));
+
+    let bounded = issuer("https://tit.example/");
+    for _ in 0..1_024 {
+        bounded
+            .issue("alice", &key, ISSUED_AT, LIFETIME)
+            .expect("fill the active challenge store");
+    }
+    assert!(matches!(
+        bounded.issue("alice", &key, ISSUED_AT, LIFETIME),
+        Err(AuthError::NonceLimit)
+    ));
+    bounded
+        .issue("alice", &key, ISSUED_AT + LIFETIME + 1, LIFETIME)
+        .expect("prune expired challenges before issuing another one");
+}
+
+fn issuer(origin: &str) -> LoginChallenges {
+    LoginChallenges::new(&Url::parse(origin).expect("parse the test origin"))
+        .expect("create a login challenge issuer")
+}
+
+fn parse_public_key(private_key: &Path) -> Result<SshPublicKey, AuthError> {
+    let encoded = fs::read_to_string(public_key_path(private_key)).expect("read the public key");
+    SshPublicKey::parse(&encoded)
+}
+
+fn public_key_path(private_key: &Path) -> PathBuf {
+    let mut path = private_key.as_os_str().to_owned();
+    path.push(".pub");
+    PathBuf::from(path)
+}
+
+fn stock_fingerprint(public_key: &Path) -> String {
+    let output = Command::new("ssh-keygen")
+        .args(["-l", "-E", "sha256", "-f"])
+        .arg(public_key)
+        .output()
+        .expect("run ssh-keygen fingerprint");
+    assert!(output.status.success());
+    String::from_utf8(output.stdout)
+        .expect("read the fingerprint output")
+        .split_whitespace()
+        .nth(1)
+        .expect("find the fingerprint")
+        .to_owned()
+}
+
+fn sign(
+    directory: &TempDir,
+    name: &str,
+    private_key: &Path,
+    namespace: &str,
+    message: &str,
+) -> String {
+    let message_path = directory.path().join(format!("{name}.challenge"));
+    fs::write(&message_path, message).expect("write the challenge");
+    let output = Command::new("ssh-keygen")
+        .args(["-q", "-Y", "sign", "-f"])
+        .arg(private_key)
+        .args(["-n", namespace])
+        .arg(&message_path)
+        .output()
+        .expect("run ssh-keygen sign");
+    assert!(
+        output.status.success(),
+        "sign the challenge: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let mut signature_path = message_path.into_os_string();
+    signature_path.push(".sig");
+    fs::read_to_string(signature_path).expect("read the SSHSIG envelope")
+}
+
+#[derive(Clone, Copy)]
+enum KeyFixture {
+    Ed25519,
+    EcdsaP256,
+    EcdsaP384,
+    Rsa2048,
+    Rsa3072,
+}
+
+impl KeyFixture {
+    fn name(self) -> &'static str {
+        match self {
+            Self::Ed25519 => "ed25519",
+            Self::EcdsaP256 => "ecdsa-p256",
+            Self::EcdsaP384 => "ecdsa-p384",
+            Self::Rsa2048 => "rsa-2048",
+            Self::Rsa3072 => "rsa-3072",
+        }
+    }
+}
+
+fn generate_key(path: &Path, fixture: KeyFixture) {
+    let mut command = Command::new("ssh-keygen");
+    command.args(["-q", "-N", "", "-C", "test comment", "-f"]);
+    command.arg(path);
+    match fixture {
+        KeyFixture::Ed25519 => {
+            command.args(["-t", "ed25519"]);
+        }
+        KeyFixture::EcdsaP256 => {
+            command.args(["-t", "ecdsa", "-b", "256"]);
+        }
+        KeyFixture::EcdsaP384 => {
+            command.args(["-t", "ecdsa", "-b", "384"]);
+        }
+        KeyFixture::Rsa2048 => {
+            command.args(["-t", "rsa", "-b", "2048"]);
+        }
+        KeyFixture::Rsa3072 => {
+            command.args(["-t", "rsa", "-b", "3072"]);
+        }
+    }
+    let output = command.output().expect("run ssh-keygen key generation");
+    assert!(
+        output.status.success(),
+        "generate {}: {}",
+        fixture.name(),
+        String::from_utf8_lossy(&output.stderr)
+    );
+}
diff --git a/tests/ssh.rs b/tests/ssh.rs
new file mode 100644
index 0000000000000000000000000000000000000000..92555510b96d6811b79b89ccc54577dc4338a677
--- /dev/null
+++ b/tests/ssh.rs
@@ -1,0 +1,311 @@
+#[allow(dead_code, reason = "the SSH test uses only the shared key boundary")]
+#[path = "../src/auth.rs"]
+mod auth;
+#[path = "../src/ssh.rs"]
+mod ssh;
+
+use std::fs;
+use std::net::{Ipv4Addr, SocketAddr};
+use std::path::{Path, PathBuf};
+use std::process::{Child, Command, Output, Stdio};
+use std::thread;
+use std::time::{Duration, Instant};
+
+use auth::SshPublicKey;
+use ssh::RunningSshServer;
+use tempfile::TempDir;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn stock_openssh_authenticates_supported_keys_and_ignores_the_username() {
+    for fixture in [KeyFixture::Ed25519, KeyFixture::EcdsaP256] {
+        let directory = TempDir::new().expect("create a key directory");
+        let private_key = directory.path().join(fixture.name());
+        generate_key(&private_key, fixture);
+        let key = parse_public_key(&private_key);
+        let server = start(&[key]).await;
+
+        for username in ["alice", "the-ssh-user-is-ignored"] {
+            let output = ssh(&server, &private_key, username, &["tit --version"]);
+            assert!(
+                output.status.success(),
+                "authenticate {}: {}",
+                fixture.name(),
+                String::from_utf8_lossy(&output.stderr)
+            );
+            assert_eq!(
+                String::from_utf8(output.stdout).expect("read the version output"),
+                format!("tit {}\n", env!("CARGO_PKG_VERSION"))
+            );
+        }
+
+        assert_eq!(server.audit().accepted_exec, 2);
+        server.shutdown().await.expect("stop the SSH server");
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn rejects_unknown_keys_and_forced_rsa_sha1_authentication() {
+    let directory = TempDir::new().expect("create a key directory");
+    let authorized_private = directory.path().join("authorized");
+    let unknown_private = directory.path().join("unknown");
+    generate_key(&authorized_private, KeyFixture::Ed25519);
+    generate_key(&unknown_private, KeyFixture::Ed25519);
+    let authorized_key = parse_public_key(&authorized_private);
+    let server = start(&[authorized_key]).await;
+
+    let unknown = ssh(&server, &unknown_private, "alice", &["tit --version"]);
+    assert!(!unknown.status.success());
+    server.shutdown().await.expect("stop the SSH server");
+
+    let rsa_private = directory.path().join("rsa");
+    generate_key(&rsa_private, KeyFixture::Rsa3072);
+    let rsa_server = start(&[parse_public_key(&authorized_private)]).await;
+    let rsa_sha1 = ssh(
+        &rsa_server,
+        &rsa_private,
+        "alice",
+        &["-o", "PubkeyAcceptedAlgorithms=ssh-rsa", "tit --version"],
+    );
+    assert!(
+        !rsa_sha1.status.success(),
+        "the server accepted RSA-SHA1: {}",
+        String::from_utf8_lossy(&rsa_sha1.stdout)
+    );
+    rsa_server
+        .shutdown()
+        .await
+        .expect("stop the RSA SSH server");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn rejects_shell_pty_arbitrary_exec_subsystem_agent_and_forwarding() {
+    let directory = TempDir::new().expect("create a key directory");
+    let private_key = directory.path().join("ed25519");
+    generate_key(&private_key, KeyFixture::Ed25519);
+    let key = parse_public_key(&private_key);
+    let server = start(&[key]).await;
+
+    assert!(!ssh(&server, &private_key, "alice", &[]).status.success());
+    assert!(
+        !ssh(&server, &private_key, "alice", &["uname", "-a"])
+            .status
+            .success()
+    );
+    assert!(
+        !ssh(&server, &private_key, "alice", &["-s", "sftp"])
+            .status
+            .success()
+    );
+    let pty = ssh(&server, &private_key, "alice", &["-tt", "tit --version"]);
+    assert!(
+        !pty.status.success() || !pty.stderr.is_empty(),
+        "the PTY request was not rejected"
+    );
+    assert!(
+        !ssh(&server, &private_key, "alice", &["-W", "127.0.0.1:1"])
+            .status
+            .success()
+    );
+    let agent_socket = directory.path().join("agent.sock");
+    let mut agent_process = start_agent(&agent_socket, &private_key);
+    let agent = ssh_with_env(
+        &server,
+        &private_key,
+        "alice",
+        &["-A", "tit --version"],
+        &[(
+            "SSH_AUTH_SOCK",
+            agent_socket.to_str().expect("a UTF-8 agent socket path"),
+        )],
+    );
+    agent_process.kill().expect("stop the test SSH agent");
+    agent_process.wait().expect("wait for the test SSH agent");
+    assert!(agent.status.success());
+
+    let audit = server.audit();
+    assert!(audit.rejected_shell >= 1);
+    assert!(audit.rejected_exec >= 2);
+    assert!(audit.rejected_pty >= 1);
+    assert!(audit.rejected_agent >= 1);
+    assert!(audit.rejected_forward >= 1);
+    server.shutdown().await.expect("stop the SSH server");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn accepts_only_strict_git_protocol_environment_values() {
+    let directory = TempDir::new().expect("create a key directory");
+    let private_key = directory.path().join("ed25519");
+    generate_key(&private_key, KeyFixture::Ed25519);
+    let key = parse_public_key(&private_key);
+    let server = start(&[key]).await;
+
+    for value in ["version=0", "version=1", "version=2"] {
+        let output = ssh_with_env(
+            &server,
+            &private_key,
+            "alice",
+            &["-o", "SendEnv=GIT_PROTOCOL", "tit --version"],
+            &[("GIT_PROTOCOL", value)],
+        );
+        assert!(output.status.success());
+    }
+    for value in ["", "2", "version=3", "version=2:extra", "version=2\nBAD=1"] {
+        let output = ssh_with_env(
+            &server,
+            &private_key,
+            "alice",
+            &["-o", "SendEnv=GIT_PROTOCOL", "tit --version"],
+            &[("GIT_PROTOCOL", value)],
+        );
+        assert!(output.status.success());
+    }
+    let unrelated = ssh_with_env(
+        &server,
+        &private_key,
+        "alice",
+        &["-o", "SendEnv=UNRELATED", "tit --version"],
+        &[("UNRELATED", "value")],
+    );
+    assert!(unrelated.status.success());
+
+    let audit = server.audit();
+    assert_eq!(audit.accepted_env, 3);
+    assert_eq!(audit.rejected_env, 6);
+    server.shutdown().await.expect("stop the SSH server");
+}
+
+async fn start(keys: &[SshPublicKey]) -> RunningSshServer {
+    RunningSshServer::start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), keys)
+        .await
+        .expect("start the SSH server")
+}
+
+fn ssh(
+    server: &RunningSshServer,
+    private_key: &Path,
+    username: &str,
+    arguments: &[&str],
+) -> Output {
+    ssh_with_env(server, private_key, username, arguments, &[])
+}
+
+fn ssh_with_env(
+    server: &RunningSshServer,
+    private_key: &Path,
+    username: &str,
+    arguments: &[&str],
+    environment: &[(&str, &str)],
+) -> Output {
+    let mut command = Command::new("ssh");
+    command.args([
+        "-F",
+        "/dev/null",
+        "-o",
+        "BatchMode=yes",
+        "-o",
+        "IdentitiesOnly=yes",
+        "-o",
+        "StrictHostKeyChecking=no",
+        "-o",
+        "UserKnownHostsFile=/dev/null",
+        "-o",
+        "LogLevel=ERROR",
+        "-o",
+        "PreferredAuthentications=publickey",
+        "-o",
+        "PasswordAuthentication=no",
+        "-o",
+        "KbdInteractiveAuthentication=no",
+        "-o",
+        "ConnectTimeout=5",
+        "-i",
+    ]);
+    command.arg(private_key);
+    command.args(["-p", &server.address().port().to_string()]);
+    command.arg(format!("{username}@{}", server.address().ip()));
+    command.args(arguments);
+    command.envs(environment.iter().copied());
+    command.output().expect("run the stock SSH client")
+}
+
+fn parse_public_key(private_key: &Path) -> SshPublicKey {
+    let encoded = fs::read_to_string(public_key_path(private_key)).expect("read the public key");
+    SshPublicKey::parse(&encoded).expect("parse the public key")
+}
+
+fn public_key_path(private_key: &Path) -> PathBuf {
+    let mut path = private_key.as_os_str().to_owned();
+    path.push(".pub");
+    PathBuf::from(path)
+}
+
+#[derive(Clone, Copy)]
+enum KeyFixture {
+    Ed25519,
+    EcdsaP256,
+    Rsa3072,
+}
+
+impl KeyFixture {
+    fn name(self) -> &'static str {
+        match self {
+            Self::Ed25519 => "ed25519",
+            Self::EcdsaP256 => "ecdsa-p256",
+            Self::Rsa3072 => "rsa-3072",
+        }
+    }
+}
+
+fn generate_key(path: &Path, fixture: KeyFixture) {
+    let mut command = Command::new("ssh-keygen");
+    command.args(["-q", "-N", "", "-f"]);
+    command.arg(path);
+    match fixture {
+        KeyFixture::Ed25519 => {
+            command.args(["-t", "ed25519"]);
+        }
+        KeyFixture::EcdsaP256 => {
+            command.args(["-t", "ecdsa", "-b", "256"]);
+        }
+        KeyFixture::Rsa3072 => {
+            command.args(["-t", "rsa", "-b", "3072"]);
+        }
+    }
+    let output = command.output().expect("run ssh-keygen key generation");
+    assert!(
+        output.status.success(),
+        "generate {}: {}",
+        fixture.name(),
+        String::from_utf8_lossy(&output.stderr)
+    );
+}
+
+fn start_agent(socket: &Path, private_key: &Path) -> Child {
+    let mut child = Command::new("ssh-agent")
+        .args(["-D", "-a"])
+        .arg(socket)
+        .stdout(Stdio::null())
+        .stderr(Stdio::null())
+        .spawn()
+        .expect("start the stock SSH agent");
+    let deadline = Instant::now() + Duration::from_secs(5);
+    while !socket.exists() {
+        if let Some(status) = child.try_wait().expect("inspect the SSH agent") {
+            panic!("SSH agent stopped before it was ready: {status}");
+        }
+        assert!(Instant::now() < deadline, "SSH agent was not ready");
+        thread::sleep(Duration::from_millis(10));
+    }
+
+    let output = Command::new("ssh-add")
+        .arg(private_key)
+        .env("SSH_AUTH_SOCK", socket)
+        .output()
+        .expect("add the test key to the SSH agent");
+    assert!(
+        output.status.success(),
+        "add the test key to the SSH agent: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    child
+}
