From 0866104b1090a27bb647e289c9b7e0b2d0abf2b2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 14:10:41 +0700 Subject: [PATCH 01/13] feat(core-ssh): tunnel through SSH in process on Windows --- .github/workflows/build-linux.yml | 9 +- .github/workflows/core.yml | 19 +- Cargo.lock | 873 +++++++++++++++++- Cargo.toml | 6 + core/ARCHITECTURE.md | 34 +- core/crates/core/src/ssh_failure.rs | 38 + core/crates/core/src/transport_route.rs | 2 + core/crates/drivers/cassandra/Cargo.toml | 3 - core/crates/drivers/cassandra/src/lib.rs | 1 - .../drivers/cassandra/src/peer_owner.rs | 107 --- core/crates/drivers/cassandra/src/relay.rs | 79 +- core/crates/drivers/cassandra/src/session.rs | 2 +- core/crates/drivers/mongodb/src/client_tls.rs | 26 +- core/crates/drivers/mongodb/src/endpoint.rs | 111 ++- core/crates/drivers/mongodb/src/lib.rs | 2 - core/crates/drivers/mongodb/src/relay.rs | 72 +- core/crates/drivers/mysql/Cargo.toml | 2 +- core/crates/drivers/mysql/src/lib.rs | 5 +- core/crates/drivers/mysql/src/physical.rs | 163 +++- core/crates/drivers/mysql/src/relay.rs | 142 +++ core/crates/net/Cargo.toml | 10 +- core/crates/net/src/lib.rs | 8 + core/crates/net/src/loopback_listener.rs | 82 ++ core/crates/net/src/loopback_owner/mod.rs | 72 ++ .../net/src/loopback_owner/proc_net_tcp.rs | 52 ++ .../net/src/loopback_owner/tcp_table.rs | 100 ++ core/crates/net/src/private_dir.rs | 31 + core/crates/net/src/private_listener.rs | 131 +++ core/crates/session/Cargo.toml | 2 + .../session/src/connect/in_process_tunnels.rs | 53 ++ core/crates/session/src/connect/mod.rs | 2 + .../crates/session/src/connect/ssh_tunnels.rs | 5 +- core/crates/ssh/Cargo.toml | 25 + core/crates/ssh/src/forget_error.rs | 14 + core/crates/ssh/src/in_process/abortable.rs | 97 ++ core/crates/ssh/src/in_process/agent.rs | 194 ++++ core/crates/ssh/src/in_process/auth.rs | 517 +++++++++++ core/crates/ssh/src/in_process/clock.rs | 134 +++ core/crates/ssh/src/in_process/failure.rs | 142 +++ core/crates/ssh/src/in_process/forward.rs | 65 ++ core/crates/ssh/src/in_process/handler.rs | 344 +++++++ core/crates/ssh/src/in_process/identity.rs | 282 ++++++ core/crates/ssh/src/in_process/known_hosts.rs | 501 ++++++++++ core/crates/ssh/src/in_process/mod.rs | 18 + core/crates/ssh/src/in_process/pipe_owner.rs | 93 ++ core/crates/ssh/src/in_process/prompts.rs | 270 ++++++ core/crates/ssh/src/in_process/services.rs | 184 ++++ core/crates/ssh/src/in_process/session.rs | 408 ++++++++ core/crates/ssh/src/known_hosts.rs | 13 +- core/crates/ssh/src/lib.rs | 18 +- core/crates/ssh/tests/in_process_matrix.rs | 451 +++++++++ core/crates/ssh/tests/in_process_session.rs | 362 ++++++++ core/crates/ssh/tests/in_process_windows.rs | 180 ++++ core/docs/decisions/0001-shared-core.md | 2 +- .../0008-in-process-ssh-on-windows.md | 45 + core/docs/decisions/README.md | 3 +- deny.toml | 4 +- linux/build-aux/debian/copyright | 2 +- linux/data/licenses/THIRD-PARTY-NOTICE.md | 2 +- linux/data/licenses/third-party.json | 4 +- linux/docs/adding-drivers.md | 2 +- .../0009-crypto-provider-and-sources.md | 2 +- .../0013-openssh-client-transport.md | 2 +- .../decisions/0023-loopback-relay-for-cql.md | 2 +- linux/docs/decisions/README.md | 6 +- linux/docs/drivers/cassandra.md | 2 +- linux/docs/drivers/mongodb.md | 4 +- linux/docs/drivers/mysql.md | 3 +- scripts/ci/detect-changed-paths.sh | 2 +- scripts/ci/start-windows-sshd.ps1 | 73 ++ scripts/ci/test_detect_changed_paths.py | 1 + 71 files changed, 6367 insertions(+), 350 deletions(-) delete mode 100644 core/crates/drivers/cassandra/src/peer_owner.rs create mode 100644 core/crates/drivers/mysql/src/relay.rs create mode 100644 core/crates/net/src/loopback_listener.rs create mode 100644 core/crates/net/src/loopback_owner/mod.rs create mode 100644 core/crates/net/src/loopback_owner/proc_net_tcp.rs create mode 100644 core/crates/net/src/loopback_owner/tcp_table.rs create mode 100644 core/crates/net/src/private_dir.rs create mode 100644 core/crates/net/src/private_listener.rs create mode 100644 core/crates/session/src/connect/in_process_tunnels.rs create mode 100644 core/crates/ssh/src/forget_error.rs create mode 100644 core/crates/ssh/src/in_process/abortable.rs create mode 100644 core/crates/ssh/src/in_process/agent.rs create mode 100644 core/crates/ssh/src/in_process/auth.rs create mode 100644 core/crates/ssh/src/in_process/clock.rs create mode 100644 core/crates/ssh/src/in_process/failure.rs create mode 100644 core/crates/ssh/src/in_process/forward.rs create mode 100644 core/crates/ssh/src/in_process/handler.rs create mode 100644 core/crates/ssh/src/in_process/identity.rs create mode 100644 core/crates/ssh/src/in_process/known_hosts.rs create mode 100644 core/crates/ssh/src/in_process/mod.rs create mode 100644 core/crates/ssh/src/in_process/pipe_owner.rs create mode 100644 core/crates/ssh/src/in_process/prompts.rs create mode 100644 core/crates/ssh/src/in_process/services.rs create mode 100644 core/crates/ssh/src/in_process/session.rs create mode 100644 core/crates/ssh/tests/in_process_matrix.rs create mode 100644 core/crates/ssh/tests/in_process_session.rs create mode 100644 core/crates/ssh/tests/in_process_windows.rs create mode 100644 core/docs/decisions/0008-in-process-ssh-on-windows.md create mode 100644 scripts/ci/start-windows-sshd.ps1 diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 27c7bb7606..cdd4595583 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -197,6 +197,10 @@ jobs: esac - name: Clippy run: meson devenv -C _build cargo clippy --locked --workspace --all-targets -- -D warnings + - name: Clippy with the in-process SSH transport + run: | + meson devenv -C _build cargo clippy --locked --workspace --all-targets \ + --features tablepro-ssh/in-process,tablepro-session/in-process-ssh -- -D warnings - name: Clippy without default features run: meson devenv -C _build cargo clippy --locked --workspace --all-targets --no-default-features -- -D warnings - name: Build @@ -440,12 +444,14 @@ jobs: include: - package: tablepro-ssh apt: openssh-client + features: tablepro-ssh/in-process - package: tablepro-test-fixtures apt: openssh-client - package: tablepro-ssh release: openssh-9.9 openssh: "9.9" apt: openssh-client + features: tablepro-ssh/in-process - package: tablepro-driver-postgres release: postgres-14 postgres: "14" @@ -521,7 +527,8 @@ jobs: TABLEPRO_TEST_REDIS: ${{ matrix.redis }} TABLEPRO_TEST_MONGO: ${{ matrix.mongo }} TABLEPRO_TEST_OPENSSH: ${{ matrix.openssh }} - run: cargo nextest run --locked --profile ci -p "$PACKAGE" --run-ignored only + FEATURES: ${{ matrix.features }} + run: cargo nextest run --locked --profile ci -p "$PACKAGE" ${FEATURES:+--features "$FEATURES"} --run-ignored only - name: Upload JUnit report if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 13ecb9f7b5..11e0cc8cea 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -34,6 +34,8 @@ jobs: # aws-lc-sys assembles x64 with NASM, which the Windows runner lacks; # its prebuilt objects stand in. AWS_LC_SYS_PREBUILT_NASM: "1" + # The SSH transport the Windows app runs (core ADR 0008). + IN_PROCESS_SSH: tablepro-ssh/in-process,tablepro-session/in-process-ssh defaults: run: shell: bash @@ -52,7 +54,7 @@ jobs: # on Linux alone. - name: Check the core crates run: | - cargo check --locked --target "$TARGET" \ + cargo check --locked --target "$TARGET" --features "$IN_PROCESS_SSH" \ -p tablepro-core -p tablepro-net -p tablepro-storage -p tablepro-ssh -p tablepro-session \ -p tablepro-ai -p tablepro-mcp -p tablepro-test-support -p tablepro-test-fixtures \ -p tablepro-driver-cassandra -p tablepro-driver-clickhouse -p tablepro-driver-duckdb \ @@ -60,3 +62,18 @@ jobs: -p tablepro-driver-postgres -p tablepro-driver-redis -p tablepro-driver-sqlite - name: Core unit tests run: cargo test --locked --target "$TARGET" -p tablepro-core --lib + # The transports and the drivers' relays each have a split per platform. + - name: Transport unit tests + run: | + cargo test --locked --target "$TARGET" -p tablepro-driver-mysql -p tablepro-driver-mongodb \ + -p tablepro-driver-cassandra --lib + # The rest of net's tests read how Linux handles a full listen backlog. + cargo test --locked --target "$TARGET" -p tablepro-net --lib -- loopback private_ + cargo test --locked --target "$TARGET" --features tablepro-ssh/in-process -p tablepro-ssh --lib in_process + - name: Start Windows' OpenSSH Server + if: runner.os == 'Windows' + shell: pwsh + run: scripts/ci/start-windows-sshd.ps1 -Out (Join-Path $env:RUNNER_TEMP 'tablepro-ssh') + - name: SSH tunnels through Windows' OpenSSH Server + if: runner.os == 'Windows' + run: cargo test --locked --target "$TARGET" --features tablepro-ssh/in-process -p tablepro-ssh --test in_process_windows -- --ignored diff --git a/Cargo.lock b/Cargo.lock index b53d06fd5b..b2757af1b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.5", +] + [[package]] name = "aes" version = "0.8.4" @@ -15,11 +25,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", "zeroize", ] +[[package]] +name = "aes" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead", + "aes 0.9.4", + "cipher 0.5.2", + "ctr", + "ctutils", + "ghash", + "zeroize", +] + [[package]] name = "ahash" version = "0.8.12" @@ -79,6 +116,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" +[[package]] +name = "argon2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.3.1", + "password-hash", +] + [[package]] name = "arrow" version = "60.1.0" @@ -553,6 +602,17 @@ 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 0.13.0", + "sha2 0.11.0", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -642,6 +702,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "block" version = "0.1.6" @@ -654,7 +723,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -664,6 +733,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -672,7 +742,16 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", ] [[package]] @@ -688,6 +767,16 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + [[package]] name = "bnum" version = "0.13.0" @@ -872,7 +961,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", ] [[package]] @@ -925,8 +1023,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", + "cipher 0.5.2", "cpufeatures 0.3.1", "rand_core 0.10.1", + "zeroize", ] [[package]] @@ -950,7 +1050,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.5", "zeroize", ] @@ -1275,9 +1387,12 @@ checksum = "d811ea23fdfbcce2e2d5132344d471abdb8f901ac496a0893a3df92e70525e52" dependencies = [ "cpubits", "ctutils", + "getrandom 0.4.3", + "hybrid-array", "num-traits", "rand_core 0.10.1", "serdect", + "subtle", "zeroize", ] @@ -1287,7 +1402,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "typenum", ] @@ -1297,7 +1412,9 @@ 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 0.10.1", ] [[package]] @@ -1331,13 +1448,51 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "ctutils" -version = "0.4.5" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" 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 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core 0.10.1", + "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]] @@ -1458,6 +1613,17 @@ dependencies = [ "thiserror", ] +[[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.7.10" @@ -1560,6 +1726,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "digest" version = "0.10.7" @@ -1657,6 +1832,47 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der 0.8.2", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki 0.8.0", + "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 0.10.1", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.18.0" @@ -1666,6 +1882,28 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf 0.13.0", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.41" @@ -1687,6 +1925,18 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[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", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1813,6 +2063,22 @@ dependencies = [ "web-time", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -2139,6 +2405,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2219,6 +2496,16 @@ dependencies = [ "temp-dir", ] +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", + "zeroize", +] + [[package]] name = "gio" version = "0.22.9" @@ -2439,6 +2726,17 @@ dependencies = [ "system-deps 9.0.0", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "gsk4" version = "0.11.4" @@ -2649,6 +2947,12 @@ 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 = "hickory-net" version = "0.26.3" @@ -2815,7 +3119,10 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "ctutils", + "subtle", "typenum", + "zeroize", ] [[package]] @@ -3146,8 +3453,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", - "generic-array", + "block-padding 0.3.3", + "generic-array 0.14.7", +] + +[[package]] +name = "inout" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", ] [[package]] @@ -3329,6 +3646,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3695,8 +4032,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e6bcd6433cff03a4bfc3d9834d504467db1f1cf6d0ea765d37d330249ed629d" [[package]] -name = "memchr" -version = "2.8.3" +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" @@ -3760,6 +4103,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ca78b66f46050d49fa21693ae8adab2081d8101b65d8067316b6b70b64a0d1" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8", + "rand_core 0.10.1", + "sha3", + "shake", +] + +[[package]] +name = "module-lattice" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e412c8ee83a1c678f427806219c49c96b2511b9f07497514f0e43fb26155" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "moka" version = "0.12.16" @@ -3961,6 +4330,8 @@ checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" dependencies = [ "num-integer", "num-traits", + "rand 0.10.2", + "rand_core 0.10.1", ] [[package]] @@ -4107,10 +4478,10 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" dependencies = [ - "aes", + "aes 0.8.4", "ashpd", - "cbc", - "cipher", + "cbc 0.1.2", + "cipher 0.4.4", "digest 0.10.7", "endi", "futures-util", @@ -4148,6 +4519,67 @@ dependencies = [ "pin-project-lite", ] +[[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 0.11.0", +] + +[[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 0.11.0", +] + +[[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 0.11.0", +] + +[[package]] +name = "pageant" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0bc922d9aa8cfcf598de27b3c6122cb2eeda9d6fe71527a22ebcb7f633540ff" +dependencies = [ + "base16ct", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.2", + "sha2 0.11.0", + "thiserror", + "tokio", + "windows", + "windows-strings", +] + [[package]] name = "pango" version = "0.22.9" @@ -4225,6 +4657,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + [[package]] name = "paste" version = "1.0.15" @@ -4254,6 +4695,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ "digest 0.11.3", + "hmac 0.13.0", ] [[package]] @@ -4281,6 +4723,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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 = "phf" version = "0.13.1" @@ -4347,6 +4799,23 @@ dependencies = [ "spki 0.8.0", ] +[[package]] +name = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes 0.9.4", + "aes-gcm", + "cbc 0.2.1", + "der 0.8.2", + "pbkdf2 0.13.0", + "rand_core 0.10.1", + "scrypt", + "sha2 0.11.0", + "spki 0.8.0", +] + [[package]] name = "pkcs8" version = "0.11.0" @@ -4354,6 +4823,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ "der 0.8.2", + "pkcs5", + "rand_core 0.10.1", "spki 0.8.0", ] @@ -4363,6 +4834,29 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "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 0.3.1", + "universal-hash", + "zeroize", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -4461,6 +4955,33 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "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]] name = "proc-macro-crate" version = "3.5.0" @@ -4828,6 +5349,16 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac 0.13.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -4906,11 +5437,106 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.10.1", + "sha2 0.11.0", "signature", "spki 0.8.0", "zeroize", ] +[[package]] +name = "russh" +version = "0.65.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb77a4b259312b2abbc541bca1c13cb4e9b61c560a7fd894068c3b92e0c9dc3" +dependencies = [ + "aes 0.9.4", + "aws-lc-rs", + "bitflags 2.13.2", + "block-padding 0.4.2", + "byteorder", + "bytes", + "cbc 0.2.1", + "cipher 0.5.2", + "crypto-bigint", + "ctr", + "curve25519-dalek", + "data-encoding", + "delegate", + "der 0.8.2", + "digest 0.11.3", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "futures", + "generic-array 1.4.5", + "getrandom 0.4.3", + "ghash", + "hex-literal", + "hmac 0.13.0", + "inout 0.2.5", + "keccak", + "log", + "md5 0.8.1", + "ml-kem", + "module-lattice", + "num-bigint 0.5.1", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2 0.13.0", + "pkcs1", + "pkcs5", + "pkcs8", + "polyval", + "rand 0.10.2", + "rand_core 0.10.1", + "rsa", + "russh-cryptovec", + "russh-util", + "salsa20", + "scrypt", + "sec1", + "sha1", + "sha2 0.11.0", + "sha3", + "signature", + "spki 0.8.0", + "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 0.31.3", + "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 = "rust_xlsxwriter" version = "0.102.0" @@ -5068,6 +5694,16 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + [[package]] name = "same-file" version = "1.0.6" @@ -5130,6 +5766,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2 0.13.0", + "salsa20", + "sha2 0.11.0", +] + [[package]] name = "scylla" version = "1.9.0" @@ -5207,6 +5855,20 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der 0.8.2", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -5429,6 +6091,28 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -5593,6 +6277,12 @@ dependencies = [ "der 0.8.2", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "sqlformat" version = "0.5.0" @@ -5718,7 +6408,7 @@ dependencies = [ "either", "futures-core", "futures-util", - "generic-array", + "generic-array 0.14.7", "log", "percent-encoding", "rand 0.10.2", @@ -5808,6 +6498,65 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ssh-cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" +dependencies = [ + "aead", + "aes 0.9.4", + "aes-gcm", + "chacha20", + "cipher 0.5.2", + "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", + "crypto-bigint", + "ctutils", + "digest 0.11.3", + "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 0.13.0", + "p256", + "p384", + "p521", + "rand_core 0.10.1", + "rsa", + "sec1", + "sha1", + "sha2 0.11.0", + "signature", + "ssh-cipher", + "ssh-encoding", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6094,7 +6843,6 @@ dependencies = [ "chrono", "futures-util", "num-bigint 0.4.8", - "rustix", "rustls", "rustls-pki-types", "scylla", @@ -6343,6 +7091,7 @@ dependencies = [ "hyper-util", "pem-rfc7468", "rcgen", + "rustix", "rustls", "rustls-native-certs", "rustls-pki-types", @@ -6355,6 +7104,7 @@ dependencies = [ "tokio-rustls", "tower-service", "tracing", + "windows-sys 0.61.2", ] [[package]] @@ -6386,10 +7136,13 @@ name = "tablepro-ssh" version = "0.0.0" dependencies = [ "async-trait", + "aws-lc-rs", "futures", + "russh", "rustix", "secrecy", "tablepro-core", + "tablepro-net", "tablepro-test-fixtures", "tablepro-test-support", "tempfile", @@ -6399,6 +7152,7 @@ dependencies = [ "tokio-util", "tracing", "uuid", + "windows-sys 0.61.2", "zeroize", ] @@ -7119,6 +7873,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.7.1" @@ -7299,6 +8063,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.128" @@ -7428,11 +8202,32 @@ dependencies = [ "base64 0.10.1", "bitflags 1.3.2", "byteorder", - "md5", + "md5 0.6.1", "rand 0.7.3", "winapi", ] +[[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" @@ -7446,6 +8241,17 @@ dependencies = [ "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" @@ -7474,6 +8280,16 @@ 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-registry" version = "0.6.1" @@ -7537,6 +8353,15 @@ dependencies = [ "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" @@ -7609,6 +8434,18 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index fe08a7a370..cffda3d64e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,6 +121,10 @@ rustls-native-certs = "0.8" rustls-platform-verifier = "0.7" pem-rfc7468 = { version = "1", features = ["alloc"] } socket2 = { version = "0.6", features = ["all"] } +# In-process SSH (core ADR 0008): aws-lc-rs, rsa for RSA keys, no compression. +russh = { version = "0.65", default-features = false, features = ["aws-lc-rs", "rsa"] } +# HMAC-SHA1 for hashed known_hosts names, on the one provider (ADR 0009). +aws-lc-rs = "1" rustix = { version = "1.1", default-features = false, features = ["std"] } tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "logging", "tls12"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } @@ -145,6 +149,8 @@ uuid = { version = "1", features = ["v4", "serde"] } zeroize = "1.9" # The volume and index that name a file on Windows, which std reads only on nightly. winapi-util = "0.1" +# The owning process of a loopback connection, from GetExtendedTcpTable. +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock"] } tempfile = "3" testcontainers = { version = "0.27", default-features = false, features = ["aws-lc-rs"] } testcontainers-modules = { version = "0.15", default-features = false, features = ["aws-lc-rs"] } diff --git a/core/ARCHITECTURE.md b/core/ARCHITECTURE.md index b9f7056dff..bd2bcfa487 100644 --- a/core/ARCHITECTURE.md +++ b/core/ARCHITECTURE.md @@ -10,7 +10,7 @@ Cargo.toml, Cargo.lock the workspace, at the top of the repository, core/crates/ ├── core/ domain types and the driver contract, no GUI deps ├── net/ direct TCP transport and rustls client configs -├── ssh/ system OpenSSH transport and the askpass helper (Unix) +├── ssh/ system OpenSSH transport and askpass helper (Unix); in-process transport on russh ├── session/ connect, sessions, health monitor, operation runner ├── storage/ saved connections, history, drafts, credentials, file persistence ├── ai/ the AI assistant's providers, prompts and replies @@ -34,7 +34,8 @@ core/crates/ | Crate | Depends on | |---|---| | `core` | no workspace crate | -| `net`, `ssh`, `storage` | `core` | +| `net`, `storage` | `core` | +| `ssh` | `core`, and `net` with its `in-process` feature | | `drivers/postgres`, `drivers/mysql`, `drivers/mssql`, `drivers/clickhouse`, `drivers/cassandra`, `drivers/redis`, `drivers/mongodb` | `core`, `net` | | `drivers/sqlite`, `drivers/duckdb` | `core` | | `session` | `core`, `net`, `ssh`, `storage` | @@ -45,7 +46,7 @@ core/crates/ Rules, enforced by review: - `core` depends on **no other workspace crate**. It holds the domain types and the driver contract, and no I/O beyond the traits and the file identity the file engines share. -- `net`, `ssh` and `storage` depend on `core` only. +- `net` and `storage` depend on `core` only, and `ssh` on `core` and, for the in-process transport's first dial, `net`. - Each `drivers/` crate depends on `core`, and on `net` when it speaks TLS or dials TCP. **Drivers never depend on each other, on `session` or on `ssh`.** - `session` depends on `core`, `net`, `ssh` and `storage`, and on no driver outside its tests: it reaches drivers through `DriverRegistry`, and SSH through the `SshTunnels` the app passes in. Its Docker tests take `tablepro-driver-postgres`, `tablepro-driver-redis` and `tablepro-driver-mongodb` as dev-dependencies. - `mcp` depends on `core`, `session` and `storage`, and on no driver outside its tests. It reaches the windows and the user through the `McpHost` trait the app implements. @@ -68,7 +69,7 @@ A value or a trait the app passes in: |---|---| | `StoragePaths` | `tablepro_linux_platform::storage_paths`, GLib's XDG directories | | `SecretVault` and `SecretItems`, labelled through `SecretLabeler` and `SecretItemLabeler` | `tablepro_linux_platform::SecretStore`, the Secret Service through oo7 | -| `SshTunnels` | `tablepro_ssh::SshServices`, the system OpenSSH client | +| `SshTunnels` | `tablepro_ssh::SshServices`, the system OpenSSH client; the Windows app passes `tablepro_ssh::InProcessSsh` ([ADR 0008](docs/decisions/0008-in-process-ssh-on-windows.md)) | | `ProcessHold` | `gio::Application::hold` | A `cfg` split inside a crate: @@ -82,8 +83,9 @@ A `cfg` split inside a crate: | DuckDB's file lock while a file is replaced | fcntl | fcntl | LockFileEx | | SQL Server integrated login | MIT Kerberos, behind the `kerberos` feature | the `kerberos` feature, which CI does not build | SSPI | | OpenSSH transport, askpass helper | yes | yes | not built | -| MongoDB's relay for an SSH route | yes | yes | not built | -| Cassandra's loopback relay | serves this user, from `/proc/net/tcp` | serves no one | serves no one | +| The in-process SSH transport's agent | `SSH_AUTH_SOCK` | `SSH_AUTH_SOCK` | `SSH_AUTH_SOCK`, else `\\.\pipe\openssh-ssh-agent`, then Pageant | +| MySQL's and MongoDB's relay for an in-process route (`tablepro_net::PrivateListener`) | a Unix socket in a 0700 directory | the same | a loopback port that serves this process, from `GetExtendedTcpTable` | +| Cassandra's loopback relay | serves this user, from `/proc/net/tcp` | serves no one | serves this process, from `GetExtendedTcpTable` | Each boundary error has a stable `code()`, such as `driver.timeout` or `ssh.host_key_changed`, so a UI outside Rust can word it from the code and the variant's fields. The core holds no translation; `linux/docs/error-handling.md` has the error types and how the Linux app words them. @@ -128,7 +130,7 @@ A `DELIMITER` or `\d` line that starts a line outside a statement is a directive `DriverConnector` in `tablepro-session` turns a saved connection into a connected one: 1. It checks the request against the driver's capabilities: the auth mode, the TLS settings for the route, a required database name. -2. It builds the transport: `SshSession::connect` and then `SshSession::forward` for an SSH connection, or a `DirectTcpTransport`. +2. It builds the transport: the app's `SshTunnels` opens a session and forwards through it for an SSH connection, or a `DirectTcpTransport`. 3. It calls `DatabaseDriver::connect` with `ConnectOptions`: the `NetworkTarget` (transport, credentials, database, TLS settings) or the `FileEndpoint`, the `WorkspaceSettings`, the `LivenessPolicy` and the app's version. 4. It reads the first catalogue, the schemas and the default schema's objects, under the metadata deadline. @@ -139,21 +141,21 @@ The cancel token is checked between stages, and a stage that finishes after a ca `Transport` (`crates/core/src/transport.rs`) is how a driver reaches a server without knowing whether the route is direct or through SSH: - `service_endpoint()`: the host and port the user typed, which TLS verifies and Kerberos names. -- `kind()` and `route()`: `DirectTcp` with a TCP endpoint, or `SshForward` with the Unix socket the forward listens on. +- `kind()` and `route()`: `DirectTcp` with a TCP endpoint, or `SshForward` with the Unix socket an OpenSSH forward listens on, or `InProcess` for an in-process channel, which has no address. - `open()`: a new byte stream to the server. - `reroute(endpoint)`: the same kind of route to another endpoint, for SQL Server's login routing. - `explain_closed_stream()`: why a stream closed, when the transport knows better than "connection lost", as when the SSH master exited. -| | `DirectTcpTransport` (`tablepro-net`) | `SshForwardTransport` (`tablepro-ssh`) | -|---|---|---| -| `open` | Resolves the name, then tries each address in turn under the connect timeout | Connects to the forward's Unix socket under the connect timeout | -| Socket options | `TCP_NODELAY`; keepalive after 30 s idle, every 10 s, 3 probes; `TCP_USER_TIMEOUT` 45 s | OpenSSH's own: `ServerAliveInterval=15`, `ServerAliveCountMax=3` | -| `reroute` | A new direct transport | A new forward on the same master | -| `explain_closed_stream` | Nothing to add | The master's exit, or the "open failed" line ssh printed | +| | `DirectTcpTransport` (`tablepro-net`) | `SshForwardTransport` (`tablepro-ssh`) | `InProcessForward` (`tablepro-ssh`, `in-process`) | +|---|---|---|---| +| `open` | Resolves the name, then tries each address in turn under the connect timeout | Connects to the forward's Unix socket under the connect timeout | Opens a `direct-tcpip` channel under the channel open timeout | +| Socket options | `TCP_NODELAY`; keepalive after 30 s idle, every 10 s, 3 probes; `TCP_USER_TIMEOUT` 45 s | OpenSSH's own: `ServerAliveInterval=15`, `ServerAliveCountMax=3` | The first hop's dial is a `DirectTcpTransport`'s; SSH keepalives every 15 s, 3 unanswered end the session | +| `reroute` | A new direct transport | A new forward on the same master | A forward on the same session | +| `explain_closed_stream` | Nothing to add | The master's exit, or the "open failed" line ssh printed | Why the session ended | -PostgreSQL, SQL Server and ClickHouse open every stream through `open()`. sqlx dials its own sockets, so the MySQL driver passes it the route instead: host and port, or the forward's socket path. Nothing relays bytes between the two. scylla dials only a TCP address, so each Cassandra session dials a relay on `127.0.0.1` that serves only this user and carries every connection it accepts over `open()` ([ADR 0023](../linux/docs/decisions/0023-loopback-relay-for-cql.md)). The mongodb crate dials its own sockets as well, and gets the host and port on a direct route; it does no TLS over a Unix socket, so through SSH the MongoDB driver listens on a private Unix socket of its own and relays each connection the crate opens there to a stream from `open()`, with its own TLS around it. The socket options above apply only to streams `open()` makes, so a direct MySQL or MongoDB connection has no keepalive and finds a dead peer through the probe and its deadlines. +PostgreSQL, SQL Server and ClickHouse open every stream through `open()`. sqlx dials its own sockets, so the MySQL driver passes it the route instead: host and port, or the forward's socket path; an in-process route has neither, so the driver relays it through a `tablepro_net::PrivateListener` of its own. scylla dials only a TCP address, so each Cassandra session dials a relay on `127.0.0.1` that serves only this user, or on Windows this process, and carries every connection it accepts over `open()` ([ADR 0023](../linux/docs/decisions/0023-loopback-relay-for-cql.md)). The mongodb crate dials its own sockets as well, and gets the host and port on a direct route; it does no TLS over a Unix socket, so through SSH the MongoDB driver listens on a `PrivateListener` of its own and relays each connection the crate opens there to a stream from `open()`, with its own TLS around it. The socket options above apply only to streams `open()` makes, so a direct MySQL or MongoDB connection has no keepalive and finds a dead peer through the probe and its deadlines. -SSH runs the system OpenSSH client ([ADR 0013](../linux/docs/decisions/0013-openssh-client-transport.md)): one ControlMaster (`ssh -M -N -T`) per connection, with the user's `~/.ssh/config` applied, and one `ssh -O forward` Unix socket per server it reaches. The sockets live under `$XDG_RUNTIME_DIR/app//ssh/` in directories only the user can open, with mode 0600. Only Flatpak creates `app/`, so a host install makes the missing levels with mode 0700, each opened without following a symlink and checked to be the user's; with `XDG_RUNTIME_DIR` unset, GLib puts the same layout under the cache directory. Every prompt (host key, password, passphrase, keyboard-interactive) reaches the app through the `tablepro-askpass` helper, and the 20 s handshake timeout pauses while a prompt is open. Under Flatpak, `ssh` comes from the GNOME runtime, and a `ProxyCommand` or `Match exec` that runs a host program, the host's `/etc/ssh` and FIDO keys kept in files do not work. +The Windows app runs SSH in process on russh ([ADR 0008](docs/decisions/0008-in-process-ssh-on-windows.md)): one session per connection, its jump hosts each carried over the one before, with known_hosts, the agent and every prompt handled in the core. The Linux app runs the system OpenSSH client ([ADR 0013](../linux/docs/decisions/0013-openssh-client-transport.md)): one ControlMaster (`ssh -M -N -T`) per connection, with the user's `~/.ssh/config` applied, and one `ssh -O forward` Unix socket per server it reaches. The sockets live under `$XDG_RUNTIME_DIR/app//ssh/` in directories only the user can open, with mode 0600. Only Flatpak creates `app/`, so a host install makes the missing levels with mode 0700, each opened without following a symlink and checked to be the user's; with `XDG_RUNTIME_DIR` unset, GLib puts the same layout under the cache directory. Every prompt (host key, password, passphrase, keyboard-interactive) reaches the app through the `tablepro-askpass` helper, and the 20 s handshake timeout pauses while a prompt is open. Under Flatpak, `ssh` comes from the GNOME runtime, and a `ProxyCommand` or `Match exec` that runs a host program, the host's `/etc/ssh` and FIDO keys kept in files do not work. ### Trust diff --git a/core/crates/core/src/ssh_failure.rs b/core/crates/core/src/ssh_failure.rs index 2cca5bcbc4..6943b57071 100644 --- a/core/crates/core/src/ssh_failure.rs +++ b/core/crates/core/src/ssh_failure.rs @@ -26,6 +26,19 @@ pub enum SshFailure { #[error("the ssh_config file {} could not be read: {kind}", .path.display())] ConfigUnreadable { path: PathBuf, kind: ErrorKind }, + #[error("the SSH key file {} could not be read: {kind}", .path.display())] + IdentityFileUnreadable { path: PathBuf, kind: ErrorKind }, + + // A security key, or a format the in-process transport cannot read. + #[error("the SSH key file {} is not a usable private key", .path.display())] + IdentityFileInvalid { path: PathBuf }, + + #[error("no SSH agent answered at {location}")] + AgentUnavailable { location: String }, + + #[error("the known_hosts file {} could not be written: {kind}", .path.display())] + KnownHostsUnwritable { path: PathBuf, kind: ErrorKind }, + #[error("the SSH control socket path {} is too long", .path.display())] ControlPathTooLong { path: PathBuf }, @@ -115,6 +128,10 @@ impl SshFailure { Self::InvalidDestination { .. } => "ssh.invalid_destination", Self::ConfigRejected { .. } => "ssh.config_rejected", Self::ConfigUnreadable { .. } => "ssh.config_unreadable", + Self::IdentityFileUnreadable { .. } => "ssh.identity_file_unreadable", + Self::IdentityFileInvalid { .. } => "ssh.identity_file_invalid", + Self::AgentUnavailable { .. } => "ssh.agent_unavailable", + Self::KnownHostsUnwritable { .. } => "ssh.known_hosts_unwritable", Self::ControlPathTooLong { .. } => "ssh.control_path_too_long", Self::RuntimeDirUnsafe { .. } => "ssh.runtime_dir_unsafe", Self::HostKeyUnknown { .. } => "ssh.host_key_unknown", @@ -148,6 +165,10 @@ impl SshFailure { | Self::InvalidDestination { .. } | Self::ConfigRejected { .. } | Self::ConfigUnreadable { .. } + | Self::IdentityFileUnreadable { .. } + | Self::IdentityFileInvalid { .. } + | Self::AgentUnavailable { .. } + | Self::KnownHostsUnwritable { .. } | Self::ControlPathTooLong { .. } | Self::RuntimeDirUnsafe { .. } | Self::NoCommonAlgorithm { .. } => ErrorCategory::Configuration, @@ -320,6 +341,23 @@ mod tests { assert_eq!(launch.category(), ErrorCategory::Configuration); assert_eq!(unreadable.category(), ErrorCategory::Configuration); + let key_file = PathBuf::from("C:\\Users\\deploy\\.ssh\\id_ed25519"); + for failure in [ + SshFailure::IdentityFileUnreadable { + path: key_file.clone(), + kind: ErrorKind::PermissionDenied, + }, + SshFailure::IdentityFileInvalid { path: key_file }, + SshFailure::AgentUnavailable { + location: "\\\\.\\pipe\\openssh-ssh-agent".to_owned(), + }, + SshFailure::KnownHostsUnwritable { + path: PathBuf::from("C:\\Users\\deploy\\.ssh\\known_hosts"), + kind: ErrorKind::PermissionDenied, + }, + ] { + assert_eq!(failure.category(), ErrorCategory::Configuration, "{failure:?}"); + } assert_eq!(protocol.category(), ErrorCategory::Internal); assert_eq!(SshFailure::Internal.category(), ErrorCategory::Internal); } diff --git a/core/crates/core/src/transport_route.rs b/core/crates/core/src/transport_route.rs index d2210e4986..6211e501cf 100644 --- a/core/crates/core/src/transport_route.rs +++ b/core/crates/core/src/transport_route.rs @@ -6,4 +6,6 @@ use crate::NetworkEndpoint; pub enum TransportRoute { Tcp(NetworkEndpoint), UnixSocket(PathBuf), + // Only `Transport::open` reaches the server, so a library that dials needs the driver's relay. + InProcess, } diff --git a/core/crates/drivers/cassandra/Cargo.toml b/core/crates/drivers/cassandra/Cargo.toml index df842b8056..17b50d615b 100644 --- a/core/crates/drivers/cassandra/Cargo.toml +++ b/core/crates/drivers/cassandra/Cargo.toml @@ -20,9 +20,6 @@ tokio-util.workspace = true tracing.workspace = true uuid.workspace = true -[target.'cfg(unix)'.dependencies] -rustix = { workspace = true, features = ["process"] } - [dev-dependencies] futures-util.workspace = true tablepro-test-fixtures.workspace = true diff --git a/core/crates/drivers/cassandra/src/lib.rs b/core/crates/drivers/cassandra/src/lib.rs index 43d6a03600..1115f9f927 100644 --- a/core/crates/drivers/cassandra/src/lib.rs +++ b/core/crates/drivers/cassandra/src/lib.rs @@ -14,7 +14,6 @@ mod dialect; mod driver; mod editor; mod errors; -mod peer_owner; mod pin; mod relay; mod rows; diff --git a/core/crates/drivers/cassandra/src/peer_owner.rs b/core/crates/drivers/cassandra/src/peer_owner.rs deleted file mode 100644 index 87beed989f..0000000000 --- a/core/crates/drivers/cassandra/src/peer_owner.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! Who owns the other end of a loopback TCP connection, read from the -//! kernel's socket table as proc(5) describes `/proc/net/tcp`. Other -//! systems keep no such table, so there the relay serves no one. - -use std::io; -#[cfg(any(target_os = "linux", test))] -use std::net::Ipv4Addr; -use std::net::{SocketAddr, SocketAddrV4}; - -/// The uid that owns the socket at `peer` connected to `local`, or -/// `None` when the table has no such row, as for a socket closed since. -#[cfg(target_os = "linux")] -pub(crate) fn loopback_peer_uid(peer: SocketAddrV4, local: SocketAddrV4) -> io::Result> { - let table = std::fs::read_to_string("/proc/net/tcp")?; - Ok(owner_in(&table, peer, local)) -} - -#[cfg(not(target_os = "linux"))] -pub(crate) fn loopback_peer_uid(_peer: SocketAddrV4, _local: SocketAddrV4) -> io::Result> { - Err(io::Error::new( - io::ErrorKind::Unsupported, - "only Linux names the owner of a loopback connection", - )) -} - -/// The table lists each socket from its own side, so the peer's row has -/// the peer as its local address and `local` as its remote one. -#[cfg(any(target_os = "linux", test))] -fn owner_in(table: &str, peer: SocketAddrV4, local: SocketAddrV4) -> Option { - table.lines().skip(1).find_map(|line| { - let mut fields = line.split_ascii_whitespace(); - let _slot = fields.next()?; - let from = parse_address(fields.next()?)?; - let to = parse_address(fields.next()?)?; - if from != peer || to != local { - return None; - } - let uid = fields.nth(4)?; - uid.parse().ok() - }) -} - -/// `0100007F:1F90`: the address as the kernel's 32-bit value in host -/// byte order, then the port, both in hex. -#[cfg(any(target_os = "linux", test))] -fn parse_address(field: &str) -> Option { - let (address, port) = field.split_once(':')?; - let address = u32::from_str_radix(address, 16).ok()?; - let port = u16::from_str_radix(port, 16).ok()?; - Some(SocketAddrV4::new(Ipv4Addr::from(address.to_ne_bytes()), port)) -} - -/// The process's own uid. -#[cfg(unix)] -pub(crate) fn own_uid() -> u32 { - rustix::process::getuid().as_raw() -} - -/// Windows has no uid, and `loopback_peer_uid` names no owner there. -#[cfg(not(unix))] -pub(crate) fn own_uid() -> u32 { - u32::MAX -} - -/// Both ends as IPv4 loopback addresses, which is all the relay listens on. -pub(crate) fn as_v4(address: SocketAddr) -> Option { - match address { - SocketAddr::V4(address) => Some(address), - SocketAddr::V6(_) => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn loopback(port: u16) -> SocketAddrV4 { - SocketAddrV4::new(Ipv4Addr::LOCALHOST, port) - } - - #[test] - fn the_peers_row_names_its_owner() { - let local_hex = format!("{:08X}", u32::from_ne_bytes(Ipv4Addr::LOCALHOST.octets())); - let table = format!( - " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n\ - \x20 0: {local_hex}:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 11\n\ - \x20 1: {local_hex}:C350 {local_hex}:1F90 01 00000000:00000000 00:00000000 00000000 1234 0 12\n\ - \x20 2: {local_hex}:1F90 {local_hex}:C350 01 00000000:00000000 00:00000000 00000000 1000 0 13\n" - ); - - assert_eq!(owner_in(&table, loopback(50_000), loopback(8080)), Some(1234)); - assert_eq!(owner_in(&table, loopback(50_001), loopback(8080)), None); - } - - #[cfg(target_os = "linux")] - #[test] - fn a_connection_this_process_opens_is_its_own() { - let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); - let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); - let (_server, peer) = listener.accept().unwrap(); - - let owner = loopback_peer_uid(as_v4(peer).unwrap(), as_v4(listener.local_addr().unwrap()).unwrap()).unwrap(); - - assert_eq!(owner, Some(own_uid())); - drop(client); - } -} diff --git a/core/crates/drivers/cassandra/src/relay.rs b/core/crates/drivers/cassandra/src/relay.rs index 29f79c2c60..5c5b046328 100644 --- a/core/crates/drivers/cassandra/src/relay.rs +++ b/core/crates/drivers/cassandra/src/relay.rs @@ -2,20 +2,19 @@ //! connections over the target's transport. //! //! scylla dials TCP socket addresses itself and takes no stream, so it -//! cannot reach an SSH forward, which is a Unix socket. It dials this -//! listener instead, and every connection it opens becomes a stream from -//! `Transport::open`. Only sockets this user owns are carried, so the -//! port gives no other local user a way to the server (ADR 0023). +//! cannot reach an SSH forward. It dials this listener instead, and every +//! connection it opens becomes a stream from `Transport::open`. Only +//! sockets this user owns, or on Windows this process, are carried, so +//! the port gives no other local user a way to the server (ADR 0023). -use std::net::{Ipv4Addr, SocketAddr}; +use std::net::SocketAddr; use std::sync::{Arc, Mutex, PoisonError}; use tablepro_core::{DriverError, Transport, TransportError}; -use tokio::net::{TcpListener, TcpStream}; +use tablepro_net::LoopbackListener; +use tokio::net::TcpStream; use tokio_util::sync::CancellationToken; -use crate::peer_owner::{as_v4, loopback_peer_uid, own_uid}; - /// The listener and its connections, all closed when it drops. #[derive(Debug)] pub(crate) struct Relay { @@ -25,22 +24,17 @@ pub(crate) struct Relay { } impl Relay { - pub(crate) async fn start(transport: Arc) -> Result { - let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.map_err(|error| { + pub(crate) fn start(transport: Arc) -> Result { + let listener = LoopbackListener::bind().map_err(|error| { DriverError::Transport(TransportError::Unreachable { endpoint: transport.service_endpoint().clone(), detail: format!("the local relay could not listen: {error}"), }) })?; - let address = listener.local_addr().map_err(|error| { - DriverError::Transport(TransportError::Unreachable { - endpoint: transport.service_endpoint().clone(), - detail: format!("the local relay has no address: {error}"), - }) - })?; + let address = listener.local_addr(); let stop = CancellationToken::new(); let failure = Arc::new(Mutex::new(None)); - tokio::spawn(accept(listener, address, transport, stop.clone(), failure.clone())); + tokio::spawn(accept(listener, transport, stop.clone(), failure.clone())); Ok(Self { address, stop, failure }) } @@ -62,8 +56,7 @@ impl Drop for Relay { } async fn accept( - listener: TcpListener, - address: SocketAddr, + listener: LoopbackListener, transport: Arc, stop: CancellationToken, failure: Arc>>, @@ -74,15 +67,8 @@ async fn accept( accepted = listener.accept() => accepted, }; match accepted { - Ok((socket, peer)) => { - tokio::spawn(carry( - socket, - peer, - address, - transport.clone(), - stop.clone(), - failure.clone(), - )); + Ok(socket) => { + tokio::spawn(carry(socket, transport.clone(), stop.clone(), failure.clone())); } Err(error) => tracing::warn!(%error, "the CQL relay could not accept a connection"), } @@ -91,16 +77,10 @@ async fn accept( async fn carry( mut socket: TcpStream, - peer: SocketAddr, - address: SocketAddr, transport: Arc, stop: CancellationToken, failure: Arc>>, ) { - if !owned_by_this_user(peer, address).await { - tracing::warn!("the CQL relay refused a connection another user opened"); - return; - } let mut upstream = match transport.open().await { Ok(upstream) => upstream, Err(error) => { @@ -108,38 +88,23 @@ async fn carry( return; } }; - let _ = socket.set_nodelay(true); tokio::select! { () = stop.cancelled() => {} _ = tokio::io::copy_bidirectional(&mut socket, &mut upstream) => {} } } -/// A socket table that cannot be read refuses the connection: the relay -/// never carries one it could not attribute. -async fn owned_by_this_user(peer: SocketAddr, address: SocketAddr) -> bool { - let (Some(peer), Some(address)) = (as_v4(peer), as_v4(address)) else { - return false; - }; - let owner = tokio::task::spawn_blocking(move || loopback_peer_uid(peer, address)).await; - match owner { - Ok(Ok(Some(uid))) => uid == own_uid(), - Ok(Ok(None)) => false, - Ok(Err(error)) => { - tracing::warn!(%error, "the CQL relay could not read the socket table"); - false - } - Err(_) => false, - } -} - +// Only Linux and Windows name a loopback connection's owner; elsewhere the relay serves no one. #[cfg(test)] +#[cfg(any(target_os = "linux", windows))] mod tests { + use std::net::Ipv4Addr; use std::time::Duration; use tablepro_core::{LivenessPolicy, NetworkEndpoint}; use tablepro_net::DirectTcpTransport; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; use super::*; @@ -160,7 +125,7 @@ mod tests { #[tokio::test] async fn a_connection_of_this_user_reaches_the_server() { let transport = Arc::new(DirectTcpTransport::new(echo_server().await, &LivenessPolicy::DESKTOP)); - let relay = Relay::start(transport).await.unwrap(); + let relay = Relay::start(transport).unwrap(); let mut client = TcpStream::connect(relay.address()).await.unwrap(); client.write_all(b"through the relay").await.unwrap(); @@ -178,9 +143,7 @@ mod tests { let unused = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); let endpoint = NetworkEndpoint::new("127.0.0.1", unused.local_addr().unwrap().port()).unwrap(); drop(unused); - let relay = Relay::start(Arc::new(DirectTcpTransport::new(endpoint, &LivenessPolicy::DESKTOP))) - .await - .unwrap(); + let relay = Relay::start(Arc::new(DirectTcpTransport::new(endpoint, &LivenessPolicy::DESKTOP))).unwrap(); let mut client = TcpStream::connect(relay.address()).await.unwrap(); let mut byte = [0_u8; 1]; @@ -198,7 +161,7 @@ mod tests { #[tokio::test] async fn dropping_the_relay_closes_its_connections() { let transport = Arc::new(DirectTcpTransport::new(echo_server().await, &LivenessPolicy::DESKTOP)); - let relay = Relay::start(transport).await.unwrap(); + let relay = Relay::start(transport).unwrap(); let mut client = TcpStream::connect(relay.address()).await.unwrap(); client.write_all(b"x").await.unwrap(); let mut byte = [0_u8; 1]; diff --git a/core/crates/drivers/cassandra/src/session.rs b/core/crates/drivers/cassandra/src/session.rs index 56d7cc4479..a89c0e68be 100644 --- a/core/crates/drivers/cassandra/src/session.rs +++ b/core/crates/drivers/cassandra/src/session.rs @@ -99,7 +99,7 @@ impl CqlEndpoint { cancel: &CancellationToken, ) -> Result { let until = Instant::now() + self.liveness.login_timeout; - let relay = Relay::start(self.transport.clone()).await?; + let relay = Relay::start(self.transport.clone())?; let pin = Arc::new(ContactPin::new(relay.address())); let profile = ExecutionProfile::builder() .request_timeout(None) diff --git a/core/crates/drivers/mongodb/src/client_tls.rs b/core/crates/drivers/mongodb/src/client_tls.rs index 23af3b41c9..7d1334639a 100644 --- a/core/crates/drivers/mongodb/src/client_tls.rs +++ b/core/crates/drivers/mongodb/src/client_tls.rs @@ -81,32 +81,8 @@ impl ClientTls { } } -/// A directory only the user can open, in the runtime directory where -/// there is one, for files that live while a client is built. -#[cfg(unix)] -pub(crate) fn private_dir() -> io::Result { - use std::os::unix::fs::PermissionsExt; - - let builder = || { - let mut builder = tempfile::Builder::new(); - // The default follows the umask, which leaves the directory - // listable by anyone. - builder - .prefix("tablepro-mongodb-") - .permissions(std::fs::Permissions::from_mode(0o700)); - builder - }; - match std::env::var_os("XDG_RUNTIME_DIR").filter(|dir| !dir.is_empty()) { - Some(runtime) => builder().tempdir_in(runtime).or_else(|_| builder().tempdir()), - None => builder().tempdir(), - } -} - -/// A directory in the user's own temporary folder, which only they and -/// the system can open. -#[cfg(windows)] pub(crate) fn private_dir() -> io::Result { - tempfile::Builder::new().prefix("tablepro-mongodb-").tempdir() + tablepro_net::private_dir("tablepro-mongodb-") } fn write_private(dir: &Path, name: &str, contents: &[u8]) -> io::Result { diff --git a/core/crates/drivers/mongodb/src/endpoint.rs b/core/crates/drivers/mongodb/src/endpoint.rs index c9627812f5..0d042819fd 100644 --- a/core/crates/drivers/mongodb/src/endpoint.rs +++ b/core/crates/drivers/mongodb/src/endpoint.rs @@ -18,7 +18,6 @@ use crate::client_tls::{ClientTls, private_dir}; use crate::connect_watch::ConnectWatch; use crate::errors::closed_unanswered; use crate::errors::map_error; -#[cfg(unix)] use crate::relay::{Relay, RelayTls}; use crate::server_version; @@ -50,7 +49,6 @@ enum Route { /// The library dials the servers and sets up TLS itself. Dialed(ClientTls), /// The library dials a relay whose streams come from the transport. - #[cfg(unix)] Relayed(Relay), } @@ -78,13 +76,15 @@ impl MongoEndpoint { let database = target.database.clone().filter(|name| !name.trim().is_empty()); let mut template = match (target.options.dns_seed_list, target.transport.route()) { (true, TransportRoute::Tcp(_)) => seed_list(&service).await?, - (true, TransportRoute::UnixSocket(_)) => return Err(ConfigError::SeedListOverSsh.into()), + (true, TransportRoute::UnixSocket(_) | TransportRoute::InProcess) => { + return Err(ConfigError::SeedListOverSsh.into()); + } (false, TransportRoute::Tcp(endpoint)) => single_host(ServerAddress::Tcp { host: endpoint.host().to_owned(), port: Some(endpoint.port()), }), // Filled in once the relay listens. - (false, TransportRoute::UnixSocket(_)) => single_host(ServerAddress::Tcp { + (false, TransportRoute::UnixSocket(_) | TransportRoute::InProcess) => single_host(ServerAddress::Tcp { host: service.host().to_owned(), port: Some(service.port()), }), @@ -115,24 +115,15 @@ impl MongoEndpoint { TransportRoute::Tcp(_) => { Route::Dialed(tablepro_net::tls::set_up_off_runtime(move || ClientTls::new(&tls)).await?) } - #[cfg(unix)] - TransportRoute::UnixSocket(_) => { + TransportRoute::UnixSocket(_) | TransportRoute::InProcess => { let server_name = target.tls_server_name().to_owned(); let relay_tls = tablepro_net::tls::set_up_off_runtime(move || relay_tls(&tls, server_name)).await?; let relay = Relay::start(target.transport.clone(), relay_tls, liveness.login_timeout) .map_err(|error| DriverError::Protocol(format!("the relay socket could not open: {error}")))?; - template.hosts = vec![ServerAddress::Unix { - path: relay.path().to_owned(), - }]; + template.hosts = vec![relay.server_address()]; template.tls = None; Route::Relayed(relay) } - #[cfg(not(unix))] - TransportRoute::UnixSocket(_) => { - return Err(DriverError::Protocol( - "a route through a Unix socket needs the relay, which runs on Unix alone".to_owned(), - )); - } }; Ok(Self { transport: target.transport.clone(), @@ -169,7 +160,6 @@ impl MongoEndpoint { // What an earlier client's streams met is not this one's. The // client starts its monitors as it is built, so a failure // they meet lands after this. - #[cfg(unix)] if let Route::Relayed(relay) = &self.route { relay.failure().take(); } @@ -205,7 +195,6 @@ impl MongoEndpoint { async fn build(&self, mut options: ClientOptions) -> Result { let tls = match &self.route { Route::Dialed(tls) => Some(tls.clone()), - #[cfg(unix)] Route::Relayed(_) => None, }; let runtime = tokio::runtime::Handle::current(); @@ -255,7 +244,6 @@ impl MongoEndpoint { fn closed_in_tls(&self, error: &mongodb::error::Error, during: LossPhase) -> bool { let tls = match &self.route { Route::Dialed(tls) => tls, - #[cfg(unix)] Route::Relayed(_) => return false, }; during == LossPhase::Connect @@ -265,7 +253,6 @@ impl MongoEndpoint { fn relayed_failure(&self) -> Option { match &self.route { - #[cfg(unix)] Route::Relayed(relay) => relay.failure().take(), Route::Dialed(_) => None, } @@ -335,7 +322,6 @@ fn credential(target: &NetworkTarget) -> Result, DriverError> Ok(Some(credential)) } -#[cfg(unix)] fn relay_tls(tls: &tablepro_core::TlsConfig, server_name: String) -> Result, DriverError> { let Some(config) = tablepro_net::tls::build_client_config(tls).map_err(setup_error)? else { return Ok(None); @@ -414,6 +400,50 @@ mod tests { } } + // An SSH route that only `open` reaches, as the in-process transport's forwards are. + #[derive(Debug)] + struct Tunnelled(NetworkEndpoint); + + #[async_trait::async_trait] + impl Transport for Tunnelled { + fn service_endpoint(&self) -> &NetworkEndpoint { + &self.0 + } + + fn kind(&self) -> TransportKind { + TransportKind::SshForward { + destination: "bastion".to_owned(), + } + } + + fn route(&self) -> TransportRoute { + TransportRoute::InProcess + } + + async fn open(&self) -> Result, TransportError> { + Err(TransportError::Refused { + endpoint: self.0.clone(), + }) + } + + async fn reroute(&self, endpoint: NetworkEndpoint) -> Result, TransportError> { + Ok(Arc::new(Self(endpoint))) + } + + async fn explain_closed_stream(&self) -> Option { + None + } + } + + fn tunnelled(options: NetworkOptions) -> NetworkTarget { + NetworkTarget { + transport: Arc::new(Tunnelled( + NetworkEndpoint::new("db.example.com", 27017).expect("an endpoint"), + )), + ..target("ann", Some("shop"), options) + } + } + fn target(username: &str, database: Option<&str>, options: NetworkOptions) -> NetworkTarget { NetworkTarget { transport: Arc::new(Direct( @@ -467,6 +497,47 @@ mod tests { ); } + #[tokio::test] + async fn an_in_process_route_dials_the_relay() { + let endpoint = MongoEndpoint::new( + &tunnelled(NetworkOptions::default()), + LivenessPolicy::DESKTOP, + &ConnectEnvironment::new("test"), + ) + .await + .expect("an endpoint"); + + let Route::Relayed(relay) = &endpoint.route else { + panic!("the library would dial the server itself"); + }; + assert_eq!(endpoint.template.hosts, vec![relay.server_address()]); + assert!(endpoint.template.tls.is_none(), "the relay does TLS"); + #[cfg(unix)] + assert!(matches!(&endpoint.template.hosts[..], [ServerAddress::Unix { .. }])); + #[cfg(not(unix))] + assert!(matches!(&endpoint.template.hosts[..], [ServerAddress::Tcp { host, .. }] if host == "127.0.0.1")); + } + + #[tokio::test] + async fn a_seed_list_is_refused_over_an_in_process_route() { + let seed_list = NetworkOptions { + dns_seed_list: true, + ..NetworkOptions::default() + }; + + let refused = MongoEndpoint::new( + &tunnelled(seed_list), + LivenessPolicy::DESKTOP, + &ConnectEnvironment::new("test"), + ) + .await; + + assert!(matches!( + refused, + Err(DriverError::Config(ConfigError::SeedListOverSsh)) + )); + } + #[test] fn a_host_is_escaped_for_a_connection_string() { assert_eq!(percent_encoded("cluster0.example.net"), "cluster0.example.net"); diff --git a/core/crates/drivers/mongodb/src/lib.rs b/core/crates/drivers/mongodb/src/lib.rs index a82c2dbb05..98745b90fb 100644 --- a/core/crates/drivers/mongodb/src/lib.rs +++ b/core/crates/drivers/mongodb/src/lib.rs @@ -26,8 +26,6 @@ mod execute; mod extended_json; mod field_types; mod nested_document; -// The relay listens on a Unix socket, which the SSH route needs alone. -#[cfg(unix)] mod relay; mod rows; mod server_version; diff --git a/core/crates/drivers/mongodb/src/relay.rs b/core/crates/drivers/mongodb/src/relay.rs index 2378792a4c..a612a138ca 100644 --- a/core/crates/drivers/mongodb/src/relay.rs +++ b/core/crates/drivers/mongodb/src/relay.rs @@ -1,19 +1,17 @@ use std::io; -use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::{Arc, Mutex, PoisonError}; use std::task::{Context, Poll}; use std::time::Duration; +use mongodb::options::ServerAddress; use rustls_pki_types::ServerName; -use tablepro_core::{DriverError, Transport}; -use tempfile::TempDir; +use tablepro_core::{DriverError, Transport, TransportStream}; +use tablepro_net::{PrivateAddress, PrivateListener}; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf}; -use tokio::net::{UnixListener, UnixStream}; use tokio::task::{JoinHandle, JoinSet}; use tokio_rustls::TlsConnector; -use crate::client_tls::private_dir; use crate::errors::closed_unanswered; /// TLS the driver negotiates itself on a relayed stream, so it checks the @@ -25,16 +23,16 @@ pub(crate) struct RelayTls { pub(crate) server_name: ServerName<'static>, } -/// A private socket the client library dials, where each stream it opens -/// is a stream from the connection's transport, wrapped in TLS here. +/// A private listener the client library dials, where each stream it +/// opens is a stream from the connection's transport, wrapped in TLS here. /// -/// The library dials a Unix socket without TLS, and an SSH forward is a -/// Unix socket. Relaying keeps every stream on the transport, so the -/// forward, its keepalives and what it knows about a closed stream all -/// apply, and TLS still reaches the server past the forward. +/// The library does no TLS over a Unix socket, and an SSH route is either +/// a Unix socket or reached only through `Transport::open`. Relaying keeps +/// every stream on the transport, so the forward, its keepalives and what +/// it knows about a closed stream all apply, and TLS still reaches the +/// server past the forward. pub(crate) struct Relay { - path: PathBuf, - _dir: TempDir, + address: PrivateAddress, accepting: JoinHandle<()>, failure: FailureSlot, } @@ -61,38 +59,48 @@ impl Relay { tls: Option, handshake_timeout: Duration, ) -> io::Result { - let dir = private_dir()?; - let path = dir.path().join("relay.sock"); - let listener = UnixListener::bind(&path)?; + let listener = PrivateListener::bind()?; + let address = listener.address().clone(); let failure = FailureSlot::default(); let slot = failure.clone(); let accepting = tokio::spawn(async move { let mut streams = JoinSet::new(); + // The accept is never raced: a loopback one dropped while it checks an owner loses that connection. loop { - tokio::select! { - accepted = listener.accept() => match accepted { - Ok((inbound, _)) => { - streams.spawn(relay(inbound, transport.clone(), tls.clone(), handshake_timeout, slot.clone())); - } - Err(error) => { - tracing::warn!(%error, "the relay stopped taking streams"); - break; - } - }, - Some(_) = streams.join_next(), if !streams.is_empty() => {} + match listener.accept().await { + Ok(inbound) => { + while streams.try_join_next().is_some() {} + streams.spawn(relay( + inbound, + transport.clone(), + tls.clone(), + handshake_timeout, + slot.clone(), + )); + } + Err(error) => { + tracing::warn!(%error, "the relay stopped taking streams"); + break; + } } } }); Ok(Self { - path, - _dir: dir, + address, accepting, failure, }) } - pub(crate) fn path(&self) -> &Path { - &self.path + pub(crate) fn server_address(&self) -> ServerAddress { + match &self.address { + #[cfg(unix)] + PrivateAddress::Unix(path) => ServerAddress::Unix { path: path.clone() }, + PrivateAddress::Tcp(address) => ServerAddress::Tcp { + host: address.ip().to_string(), + port: Some(address.port()), + }, + } } pub(crate) fn failure(&self) -> &FailureSlot { @@ -108,7 +116,7 @@ impl Drop for Relay { } async fn relay( - mut inbound: UnixStream, + mut inbound: Box, transport: Arc, tls: Option, handshake_timeout: Duration, diff --git a/core/crates/drivers/mysql/Cargo.toml b/core/crates/drivers/mysql/Cargo.toml index 9d47ae179a..11a12057f4 100644 --- a/core/crates/drivers/mysql/Cargo.toml +++ b/core/crates/drivers/mysql/Cargo.toml @@ -15,7 +15,7 @@ futures-util.workspace = true secrecy.workspace = true sqlx = { workspace = true, features = ["mysql", "mysql-rsa", "tls-rustls-aws-lc-rs"] } thiserror.workspace = true -tokio = { workspace = true, features = ["rt"] } +tokio = { workspace = true, features = ["io-util", "rt"] } tokio-util.workspace = true tracing.workspace = true diff --git a/core/crates/drivers/mysql/src/lib.rs b/core/crates/drivers/mysql/src/lib.rs index d6c152a150..fbfdb4ddc9 100644 --- a/core/crates/drivers/mysql/src/lib.rs +++ b/core/crates/drivers/mysql/src/lib.rs @@ -3,8 +3,8 @@ //! editor sessions on the server's own defaults. //! //! sqlx dials the route itself, so a direct route is a TCP endpoint and -//! an SSH forward is the Unix socket OpenSSH listens on; nothing relays -//! bytes in between. +//! an SSH forward is the Unix socket OpenSSH listens on. A route only +//! `Transport::open` reaches is dialled through a relay of the driver's. mod cancel; mod capabilities; @@ -26,6 +26,7 @@ mod mysql_temporal; mod mysql_timestamp_mode; mod mysql_wire_format; mod physical; +mod relay; mod script_split; mod server_zone; mod session_statements; diff --git a/core/crates/drivers/mysql/src/physical.rs b/core/crates/drivers/mysql/src/physical.rs index 0cb74e001c..6c2c2bacf1 100644 --- a/core/crates/drivers/mysql/src/physical.rs +++ b/core/crates/drivers/mysql/src/physical.rs @@ -1,4 +1,5 @@ -use std::sync::Arc; +use std::path::Path; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use secrecy::ExposeSecret; @@ -6,12 +7,14 @@ use sqlx::mysql::{MySqlConnectOptions, MySqlConnection, MySqlSslMode}; use sqlx::{AssertSqlSafe, ConnectOptions as _, Executor as _, Row as _}; use tablepro_core::{ ConfigError, Credentials, DriverError, LivenessPolicy, NetworkEndpoint, NetworkTarget, TimeoutPhase, TlsMode, - Transport, TransportRoute, + Transport, TransportError, TransportRoute, }; +use tablepro_net::PrivateAddress; use tokio_util::sync::CancellationToken; use crate::errors::{map_connect_error, map_error}; use crate::flavour::MySqlFlavour; +use crate::relay::Relay; use crate::session_statements::{EDITOR_SQL_MODE_STATEMENT, SessionLimits, for_session}; use crate::workspace_session::{WORKSPACE_SQL_MODE_STATEMENT, WORKSPACE_TIME_ZONE}; @@ -42,14 +45,16 @@ pub(crate) struct MySqlPhysical { /// Everything needed to log in to one server, again and again. /// /// sqlx dials the route itself rather than taking a stream, so the -/// transport is asked only for where it leads: a TCP endpoint, or the -/// Unix socket an SSH forward listens on. +/// transport is asked only for where it leads: a TCP endpoint, the Unix +/// socket an SSH forward listens on, or the relay's own listener. pub(crate) struct MysqlEndpoint { transport: Arc, options: MySqlConnectOptions, tls_server_name: String, verifies_name: bool, liveness: LivenessPolicy, + // Started by the first login over an in-process route; it stops when the endpoint drops. + relay: OnceLock, } impl MysqlEndpoint { @@ -99,6 +104,7 @@ impl MysqlEndpoint { tls_server_name: target.tls_server_name().to_owned(), verifies_name: target.tls.mode == TlsMode::VerifyFull, liveness, + relay: OnceLock::new(), }) } @@ -119,15 +125,13 @@ impl MysqlEndpoint { /// rather than checked against the wrong one. fn options_for(&self, kind: SessionKind) -> Result { let options = match self.transport.route() { - TransportRoute::Tcp(endpoint) => { - if self.verifies_name && endpoint.host() != self.tls_server_name { - return Err(DriverError::Config(ConfigError::ServerNameOverrideUnsupported { - route: self.transport.kind().class(), - })); - } - self.options.clone().host(endpoint.host()).port(endpoint.port()) - } - TransportRoute::UnixSocket(path) => self.options.clone().socket(path).host(&self.tls_server_name), + TransportRoute::Tcp(endpoint) => self.tcp_options(endpoint.host(), endpoint.port())?, + TransportRoute::UnixSocket(path) => self.socket_options(&path), + TransportRoute::InProcess => match self.relay()?.address() { + #[cfg(unix)] + PrivateAddress::Unix(path) => self.socket_options(path), + PrivateAddress::Tcp(address) => self.tcp_options(&address.ip().to_string(), address.port())?, + }, }; Ok(match kind { SessionKind::Workspace => options.timezone(Some(WORKSPACE_TIME_ZONE.to_owned())), @@ -135,6 +139,38 @@ impl MysqlEndpoint { }) } + fn tcp_options(&self, host: &str, port: u16) -> Result { + if self.verifies_name && host != self.tls_server_name { + return Err(DriverError::Config(ConfigError::ServerNameOverrideUnsupported { + route: self.transport.kind().class(), + })); + } + Ok(self.options.clone().host(host).port(port)) + } + + fn socket_options(&self, path: &Path) -> MySqlConnectOptions { + self.options.clone().socket(path).host(&self.tls_server_name) + } + + fn relay(&self) -> Result<&Relay, DriverError> { + if let Some(relay) = self.relay.get() { + return Ok(relay); + } + let started = Relay::start(self.transport.clone()).map_err(|error| { + DriverError::Transport(TransportError::Unreachable { + endpoint: self.service().clone(), + detail: format!("the local relay could not listen: {error}"), + }) + })?; + // A login racing this one may have set its own first; then this one stops as it drops. + Ok(self.relay.get_or_init(|| started)) + } + + // What the relay met opening a stream, which a closed or silent login cannot say. + fn relayed_failure(&self) -> Option { + self.relay.get()?.take_failure().map(DriverError::Transport) + } + /// Log in and set the session up, within the connect and login /// timeouts. `cancel` abandons the attempt. pub(crate) async fn connect_physical( @@ -150,16 +186,20 @@ impl MysqlEndpoint { () = cancel.cancelled() => Err(DriverError::Cancelled), opened = opening => match opened { Ok(opened) => opened, - Err(_) => Err(DriverError::Timeout { + Err(_) => Err(self.relayed_failure().unwrap_or(DriverError::Timeout { phase: TimeoutPhase::Login, server_cancelled: false, - }), + })), }, } } async fn open(&self, kind: SessionKind, limits: &SessionLimits) -> Result { let options = self.options_for(kind)?; + // What an earlier login's stream met is not this one's. + if let Some(relay) = self.relay.get() { + relay.take_failure(); + } let mut connection = match options.connect().await { Ok(connection) => connection, Err(error) => return Err(self.explain(map_connect_error(&error, self.service())).await), @@ -195,6 +235,9 @@ impl MysqlEndpoint { if !matches!(error, DriverError::ConnectionLost { .. }) { return error; } + if let Some(relayed) = self.relayed_failure() { + return relayed; + } match self.transport.explain_closed_stream().await { Some(transport) => DriverError::Transport(transport), None => error, @@ -277,7 +320,7 @@ async fn identify(connection: &mut MySqlConnection, endpoint: &NetworkEndpoint) mod tests { use async_trait::async_trait; use secrecy::SecretString; - use tablepro_core::{TlsConfig, TransportClass, TransportError, TransportKind, TransportStream}; + use tablepro_core::{TlsConfig, TransportClass, TransportKind, TransportStream}; use super::*; @@ -326,6 +369,16 @@ mod tests { } fn mysql_endpoint(route: Route, server_name: Option<&str>) -> MysqlEndpoint { + mysql_endpoint_with( + route, + TlsConfig { + server_name: server_name.map(str::to_owned), + ..TlsConfig::verify_full() + }, + ) + } + + fn mysql_endpoint_with(route: Route, tls: TlsConfig) -> MysqlEndpoint { let target = NetworkTarget { transport: Arc::new(route), credentials: Credentials::Password { @@ -333,15 +386,22 @@ mod tests { password: SecretString::from("secret"), }, database: Some("app".to_owned()), - tls: TlsConfig { - server_name: server_name.map(str::to_owned), - ..TlsConfig::verify_full() - }, + tls, options: Default::default(), }; MysqlEndpoint::new(&target, LivenessPolicy::DESKTOP).unwrap() } + fn in_process() -> Route { + Route { + service: endpoint("db.prod"), + kind: TransportKind::SshForward { + destination: "bastion".to_owned(), + }, + route: TransportRoute::InProcess, + } + } + #[test] fn a_direct_route_verifies_the_host_it_dials() { let direct = mysql_endpoint( @@ -410,4 +470,67 @@ mod tests { Some(std::path::Path::new("/run/user/1000/forward.sock")) ); } + + #[cfg(unix)] + #[tokio::test] + async fn an_in_process_route_dials_the_relay_socket_with_the_name_tls_verifies() { + let relayed = mysql_endpoint(in_process(), Some("db.internal")); + + let first = relayed.options_for(SessionKind::Workspace).unwrap(); + let again = relayed.options_for(SessionKind::Editor).unwrap(); + + assert_eq!(first.get_host(), "db.internal"); + let socket = first.get_socket().expect("a socket path"); + assert!(socket.exists(), "nothing listens at {}", socket.display()); + assert_eq!(again.get_socket(), Some(socket), "one relay serves every login"); + } + + // sqlx would check the certificate against 127.0.0.1, so only a login that checks no name dials the port. + #[cfg(not(unix))] + #[tokio::test] + async fn an_in_process_route_dials_the_relay_port_unless_a_name_is_checked() { + let verified = mysql_endpoint(in_process(), None); + let required = mysql_endpoint_with( + in_process(), + TlsConfig { + mode: TlsMode::Require, + ..TlsConfig::disabled() + }, + ); + + let options = required.options_for(SessionKind::Control).unwrap(); + + assert!(matches!( + verified.options_for(SessionKind::Workspace), + Err(DriverError::Config(ConfigError::ServerNameOverrideUnsupported { + route: TransportClass::SshForward + })) + )); + let Some(PrivateAddress::Tcp(relay)) = required.relay.get().map(Relay::address) else { + panic!("no relay listens on a port"); + }; + assert_eq!(options.get_host(), "127.0.0.1"); + assert_eq!(options.get_port(), relay.port()); + assert_eq!(options.get_socket(), None); + } + + #[tokio::test] + async fn a_relayed_login_reports_why_the_transport_could_not_open() { + let relayed = mysql_endpoint_with(in_process(), TlsConfig::disabled()); + + let opened = tokio::time::timeout( + Duration::from_secs(10), + relayed.open(SessionKind::Control, &no_limits()), + ) + .await + .expect("the login ended"); + + let Err(error) = opened else { + panic!("a login with no server succeeded"); + }; + assert!( + matches!(error, DriverError::Transport(TransportError::Refused { .. })), + "{error:?}" + ); + } } diff --git a/core/crates/drivers/mysql/src/relay.rs b/core/crates/drivers/mysql/src/relay.rs new file mode 100644 index 0000000000..99b6f313cf --- /dev/null +++ b/core/crates/drivers/mysql/src/relay.rs @@ -0,0 +1,142 @@ +use std::io; +use std::sync::{Arc, Mutex, PoisonError}; + +use tablepro_core::{Transport, TransportError, TransportStream}; +use tablepro_net::{PrivateAddress, PrivateListener}; +use tokio_util::sync::CancellationToken; + +// sqlx dials an address and takes no stream, so a route only `Transport::open` reaches is carried from here. +#[derive(Debug)] +pub(crate) struct Relay { + address: PrivateAddress, + stop: CancellationToken, + failure: Arc>>, +} + +impl Relay { + pub(crate) fn start(transport: Arc) -> io::Result { + let listener = PrivateListener::bind()?; + let address = listener.address().clone(); + let stop = CancellationToken::new(); + let failure = Arc::new(Mutex::new(None)); + tokio::spawn(accept(listener, transport, stop.clone(), failure.clone())); + Ok(Self { address, stop, failure }) + } + + pub(crate) fn address(&self) -> &PrivateAddress { + &self.address + } + + // sqlx sees only a closed stream, so this is what a failed login reports instead. + pub(crate) fn take_failure(&self) -> Option { + self.failure.lock().unwrap_or_else(PoisonError::into_inner).take() + } +} + +impl Drop for Relay { + fn drop(&mut self) { + self.stop.cancel(); + } +} + +async fn accept( + listener: PrivateListener, + transport: Arc, + stop: CancellationToken, + failure: Arc>>, +) { + loop { + let accepted = tokio::select! { + () = stop.cancelled() => return, + accepted = listener.accept() => accepted, + }; + match accepted { + Ok(inbound) => { + tokio::spawn(carry(inbound, transport.clone(), stop.clone(), failure.clone())); + } + Err(error) => { + tracing::warn!(%error, "the MySQL relay stopped taking connections"); + return; + } + } + } +} + +async fn carry( + mut inbound: Box, + transport: Arc, + stop: CancellationToken, + failure: Arc>>, +) { + let mut outbound = match transport.open().await { + Ok(outbound) => outbound, + Err(error) => { + *failure.lock().unwrap_or_else(PoisonError::into_inner) = Some(error); + return; + } + }; + tokio::select! { + () = stop.cancelled() => {} + _ = tokio::io::copy_bidirectional(&mut inbound, &mut outbound) => {} + } +} + +#[cfg(test)] +mod tests { + use std::net::Ipv4Addr; + use std::time::Duration; + + use tablepro_core::{LivenessPolicy, NetworkEndpoint}; + use tablepro_net::DirectTcpTransport; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + use super::*; + + async fn echo_server() -> NetworkEndpoint { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + tokio::spawn(async move { + let (mut reader, mut writer) = stream.split(); + let _ = tokio::io::copy(&mut reader, &mut writer).await; + }); + } + }); + NetworkEndpoint::new("127.0.0.1", port).unwrap() + } + + async fn dial(address: &PrivateAddress) -> Box { + match address { + #[cfg(unix)] + PrivateAddress::Unix(path) => Box::new(tokio::net::UnixStream::connect(path).await.unwrap()), + PrivateAddress::Tcp(address) => Box::new(tokio::net::TcpStream::connect(*address).await.unwrap()), + } + } + + #[tokio::test] + async fn a_connection_reaches_the_server_until_the_relay_drops() { + let transport = Arc::new(DirectTcpTransport::new(echo_server().await, &LivenessPolicy::DESKTOP)); + let relay = Relay::start(transport).unwrap(); + + let mut client = dial(relay.address()).await; + client.write_all(b"through the relay").await.unwrap(); + let mut reply = [0_u8; 17]; + tokio::time::timeout(Duration::from_secs(5), client.read_exact(&mut reply)) + .await + .unwrap() + .unwrap(); + drop(relay); + let mut byte = [0_u8; 1]; + let after = tokio::time::timeout(Duration::from_secs(5), client.read(&mut byte)) + .await + .unwrap(); + + assert_eq!(&reply, b"through the relay"); + assert!( + matches!(after, Ok(0) | Err(_)), + "a connection outlived its relay: {after:?}" + ); + } +} diff --git a/core/crates/net/Cargo.toml b/core/crates/net/Cargo.toml index d176f36486..29cb5f294f 100644 --- a/core/crates/net/Cargo.toml +++ b/core/crates/net/Cargo.toml @@ -14,16 +14,22 @@ rustls-native-certs.workspace = true rustls-pki-types.workspace = true rustls-platform-verifier.workspace = true socket2.workspace = true +tempfile.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["rt"] } +tokio = { workspace = true, features = ["net", "rt"] } tracing.workspace = true +[target.'cfg(target_os = "linux")'.dependencies] +rustix = { workspace = true, features = ["process"] } + +[target.'cfg(windows)'.dependencies] +windows-sys.workspace = true + [dev-dependencies] http.workspace = true hyper-rustls.workspace = true hyper-util.workspace = true rcgen.workspace = true -tempfile.workspace = true tokio = { workspace = true, features = ["io-util"] } tokio-rustls.workspace = true tower-service.workspace = true diff --git a/core/crates/net/src/lib.rs b/core/crates/net/src/lib.rs index 3325b116bc..f7e09d4c5d 100644 --- a/core/crates/net/src/lib.rs +++ b/core/crates/net/src/lib.rs @@ -1,6 +1,14 @@ mod dial; mod direct_tcp; +mod loopback_listener; +mod loopback_owner; +mod private_dir; +mod private_listener; mod socket_options; pub mod tls; pub use direct_tcp::DirectTcpTransport; +pub use loopback_listener::LoopbackListener; +pub use loopback_owner::loopback_peer_is_ours; +pub use private_dir::private_dir; +pub use private_listener::{PrivateAddress, PrivateListener}; diff --git a/core/crates/net/src/loopback_listener.rs b/core/crates/net/src/loopback_listener.rs new file mode 100644 index 0000000000..bbc22477be --- /dev/null +++ b/core/crates/net/src/loopback_listener.rs @@ -0,0 +1,82 @@ +use std::io; +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use tokio::net::{TcpListener, TcpStream}; + +use crate::loopback_owner::loopback_peer_is_ours; + +const ACCEPT_RETRY: Duration = Duration::from_millis(50); + +// A port on 127.0.0.1 that hands out only the connections `loopback_peer_is_ours` confirms. +#[derive(Debug)] +pub struct LoopbackListener { + listener: TcpListener, + address: SocketAddr, +} + +impl LoopbackListener { + pub fn bind() -> io::Result { + let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + listener.set_nonblocking(true)?; + let listener = TcpListener::from_std(listener)?; + let address = listener.local_addr()?; + Ok(Self { listener, address }) + } + + pub fn local_addr(&self) -> SocketAddr { + self.address + } + + // Not cancel safe: a connection taken while its owner is checked closes with the dropped future. + // Waits past accept errors: any local user can reset a queued connection or use up sockets for a moment. + pub async fn accept(&self) -> io::Result { + loop { + let (stream, peer) = match self.listener.accept().await { + Ok(accepted) => accepted, + Err(error) => { + tracing::debug!(%error, "a loopback connection was lost before it was accepted"); + tokio::time::sleep(ACCEPT_RETRY).await; + continue; + } + }; + let local = self.address; + let owned = tokio::task::spawn_blocking(move || loopback_peer_is_ours(peer, local)) + .await + .unwrap_or_else(|error| Err(io::Error::other(error))); + match owned { + Ok(true) => { + let _ = stream.set_nodelay(true); + return Ok(stream); + } + Ok(false) => tracing::warn!(%peer, "refused a loopback connection another owner opened"), + Err(error) => tracing::warn!(%peer, %error, "refused a loopback connection whose owner is unknown"), + } + } + } +} + +#[cfg(test)] +#[cfg(any(target_os = "linux", windows))] +mod tests { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use super::*; + + #[tokio::test] + async fn a_connection_this_process_opens_is_accepted() { + let listener = LoopbackListener::bind().unwrap(); + let mut client = TcpStream::connect(listener.local_addr()).await.unwrap(); + let mut accepted = tokio::time::timeout(Duration::from_secs(5), listener.accept()) + .await + .unwrap() + .unwrap(); + + client.write_all(b"ours").await.unwrap(); + let mut read = [0_u8; 4]; + accepted.read_exact(&mut read).await.unwrap(); + + assert_eq!(&read, b"ours"); + assert_eq!(listener.local_addr().ip(), Ipv4Addr::LOCALHOST); + } +} diff --git a/core/crates/net/src/loopback_owner/mod.rs b/core/crates/net/src/loopback_owner/mod.rs new file mode 100644 index 0000000000..10cc41880a --- /dev/null +++ b/core/crates/net/src/loopback_owner/mod.rs @@ -0,0 +1,72 @@ +use std::io; +use std::net::{SocketAddr, SocketAddrV4}; + +#[cfg(any(target_os = "linux", test))] +mod proc_net_tcp; +#[cfg(any(windows, test))] +mod tcp_table; + +// Ours is this user's on Linux and this process's on Windows, the owner each socket table records. Blocks. +pub fn loopback_peer_is_ours(peer: SocketAddr, local: SocketAddr) -> io::Result { + let (SocketAddr::V4(peer), SocketAddr::V4(local)) = (peer, local) else { + return Ok(false); + }; + owned_here(peer, local) +} + +#[cfg(target_os = "linux")] +fn owned_here(peer: SocketAddrV4, local: SocketAddrV4) -> io::Result { + let table = std::fs::read_to_string("/proc/net/tcp")?; + Ok(proc_net_tcp::owner(&table, peer, local) == Some(rustix::process::getuid().as_raw())) +} + +#[cfg(windows)] +fn owned_here(peer: SocketAddrV4, local: SocketAddrV4) -> io::Result { + let table = tcp_table::connections()?; + Ok(tcp_table::owner(&table, peer, local) == Some(std::process::id())) +} + +#[cfg(not(any(target_os = "linux", windows)))] +fn owned_here(_peer: SocketAddrV4, _local: SocketAddrV4) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "only Linux and Windows name the owner of a loopback connection", + )) +} + +#[cfg(test)] +mod tests { + use std::net::Ipv4Addr; + + use super::*; + + #[cfg(any(target_os = "linux", windows))] + #[test] + fn a_connection_this_process_opens_is_its_own() { + let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (_server, peer) = listener.accept().unwrap(); + + assert!(loopback_peer_is_ours(peer, listener.local_addr().unwrap()).unwrap()); + drop(client); + } + + #[cfg(not(any(target_os = "linux", windows)))] + #[test] + fn other_systems_name_no_owner() { + let peer = SocketAddr::from((Ipv4Addr::LOCALHOST, 50_000)); + let local = SocketAddr::from((Ipv4Addr::LOCALHOST, 8080)); + + let error = loopback_peer_is_ours(peer, local).unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + } + + #[test] + fn an_ipv6_peer_is_never_ours() { + let peer = SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 50_000)); + let local = SocketAddr::from((Ipv4Addr::LOCALHOST, 8080)); + + assert!(!loopback_peer_is_ours(peer, local).unwrap()); + } +} diff --git a/core/crates/net/src/loopback_owner/proc_net_tcp.rs b/core/crates/net/src/loopback_owner/proc_net_tcp.rs new file mode 100644 index 0000000000..968687ef33 --- /dev/null +++ b/core/crates/net/src/loopback_owner/proc_net_tcp.rs @@ -0,0 +1,52 @@ +//! Who owns the other end of a loopback TCP connection, read from the +//! kernel's socket table as proc(5) describes `/proc/net/tcp`. + +use std::net::{Ipv4Addr, SocketAddrV4}; + +/// The table lists each socket from its own side, so the peer's row has +/// the peer as its local address and `local` as its remote one. +pub(super) fn owner(table: &str, peer: SocketAddrV4, local: SocketAddrV4) -> Option { + table.lines().skip(1).find_map(|line| { + let mut fields = line.split_ascii_whitespace(); + let _slot = fields.next()?; + let from = parse_address(fields.next()?)?; + let to = parse_address(fields.next()?)?; + if from != peer || to != local { + return None; + } + let uid = fields.nth(4)?; + uid.parse().ok() + }) +} + +/// `0100007F:1F90`: the address as the kernel's 32-bit value in host +/// byte order, then the port, both in hex. +fn parse_address(field: &str) -> Option { + let (address, port) = field.split_once(':')?; + let address = u32::from_str_radix(address, 16).ok()?; + let port = u16::from_str_radix(port, 16).ok()?; + Some(SocketAddrV4::new(Ipv4Addr::from(address.to_ne_bytes()), port)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn loopback(port: u16) -> SocketAddrV4 { + SocketAddrV4::new(Ipv4Addr::LOCALHOST, port) + } + + #[test] + fn the_peers_row_names_its_owner() { + let local_hex = format!("{:08X}", u32::from_ne_bytes(Ipv4Addr::LOCALHOST.octets())); + let table = format!( + " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n\ + \x20 0: {local_hex}:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 11\n\ + \x20 1: {local_hex}:C350 {local_hex}:1F90 01 00000000:00000000 00:00000000 00000000 1234 0 12\n\ + \x20 2: {local_hex}:1F90 {local_hex}:C350 01 00000000:00000000 00:00000000 00000000 1000 0 13\n" + ); + + assert_eq!(owner(&table, loopback(50_000), loopback(8080)), Some(1234)); + assert_eq!(owner(&table, loopback(50_001), loopback(8080)), None); + } +} diff --git a/core/crates/net/src/loopback_owner/tcp_table.rs b/core/crates/net/src/loopback_owner/tcp_table.rs new file mode 100644 index 0000000000..b2b2ebcba0 --- /dev/null +++ b/core/crates/net/src/loopback_owner/tcp_table.rs @@ -0,0 +1,100 @@ +use std::net::{Ipv4Addr, SocketAddrV4}; + +// MIB_TCPROW_OWNER_PID: state, local address, local port, remote address, remote port, owning pid. +const ROW_WORDS: usize = 6; + +#[cfg(windows)] +const _: () = assert!( + size_of::() == ROW_WORDS * size_of::() +); + +#[cfg(windows)] +const ATTEMPTS: usize = 4; + +// The IPv4 connections with their owning pids, as the MIB_TCPTABLE_OWNER_PID words GetExtendedTcpTable fills. +#[cfg(windows)] +pub(super) fn connections() -> std::io::Result> { + use std::io; + + use windows_sys::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, FALSE, NO_ERROR}; + use windows_sys::Win32::NetworkManagement::IpHelper::{GetExtendedTcpTable, TCP_TABLE_OWNER_PID_CONNECTIONS}; + use windows_sys::Win32::Networking::WinSock::AF_INET; + + let mut bytes: u32 = 0; + // The first call sizes the table, which can grow before the next one fills it. + for _ in 0..ATTEMPTS { + let mut table = vec![0_u32; usize::try_from(bytes).map_err(io::Error::other)?.div_ceil(4)]; + let buffer: *mut core::ffi::c_void = if table.is_empty() { + std::ptr::null_mut() + } else { + table.as_mut_ptr().cast() + }; + // SAFETY: `buffer` is null while `bytes` is 0, else `table`: u32-aligned like the rows, `bytes` long or more. + let status = unsafe { + GetExtendedTcpTable( + buffer, + &mut bytes, + FALSE, + u32::from(AF_INET), + TCP_TABLE_OWNER_PID_CONNECTIONS, + 0, + ) + }; + match status { + NO_ERROR => return Ok(table), + ERROR_INSUFFICIENT_BUFFER => {} + error => return Err(io::Error::from_raw_os_error(error.cast_signed())), + } + } + Err(io::Error::other("the TCP table kept growing while it was read")) +} + +// Each row is from its socket's side, so the peer's has the peer as its local end and `local` as its remote. +pub(super) fn owner(table: &[u32], peer: SocketAddrV4, local: SocketAddrV4) -> Option { + let (&count, rows) = table.split_first()?; + let count = usize::try_from(count).ok()?; + let (rows, _) = rows.as_chunks::(); + rows.iter() + .take(count) + .find_map(|&[_state, from, from_port, to, to_port, pid]| { + (endpoint(from, from_port) == peer && endpoint(to, to_port) == local).then_some(pid) + }) +} + +// Both in network byte order; only the port's low 16 bits are set, the rest may be garbage. +fn endpoint(address: u32, port: u32) -> SocketAddrV4 { + SocketAddrV4::new(Ipv4Addr::from(address.to_ne_bytes()), u16::from_be(port as u16)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(from: u16, to: u16, pid: u32) -> [u32; ROW_WORDS] { + let loopback = u32::from_ne_bytes(Ipv4Addr::LOCALHOST.octets()); + let port = |port: u16| u32::from(port.to_be()) | 0xBEEF_0000; + [5, loopback, port(from), loopback, port(to), pid] + } + + fn loopback(port: u16) -> SocketAddrV4 { + SocketAddrV4::new(Ipv4Addr::LOCALHOST, port) + } + + #[test] + fn the_peers_row_names_its_process() { + let mut table = vec![3]; + table.extend(row(8080, 50_000, 7)); + table.extend(row(50_000, 8080, 4242)); + table.extend(row(50_002, 8080, 9)); + table.extend(row(50_003, 8080, 11)); + + assert_eq!(owner(&table, loopback(50_000), loopback(8080)), Some(4242)); + assert_eq!(owner(&table, loopback(50_001), loopback(8080)), None); + assert_eq!( + owner(&table, loopback(50_003), loopback(8080)), + None, + "a row past the entry count is not read" + ); + assert_eq!(owner(&[], loopback(50_000), loopback(8080)), None); + } +} diff --git a/core/crates/net/src/private_dir.rs b/core/crates/net/src/private_dir.rs new file mode 100644 index 0000000000..088d94de0c --- /dev/null +++ b/core/crates/net/src/private_dir.rs @@ -0,0 +1,31 @@ +use std::io; + +use tempfile::TempDir; + +/// A directory only the user can open, in the runtime directory where +/// there is one. +#[cfg(unix)] +pub fn private_dir(prefix: &str) -> io::Result { + use std::os::unix::fs::PermissionsExt; + + let builder = || { + let mut builder = tempfile::Builder::new(); + // The default follows the umask, which leaves the directory + // listable by anyone. + builder + .prefix(prefix) + .permissions(std::fs::Permissions::from_mode(0o700)); + builder + }; + match std::env::var_os("XDG_RUNTIME_DIR").filter(|dir| !dir.is_empty()) { + Some(runtime) => builder().tempdir_in(runtime).or_else(|_| builder().tempdir()), + None => builder().tempdir(), + } +} + +/// A directory in the user's own temporary folder, which only they and +/// the system can open. +#[cfg(not(unix))] +pub fn private_dir(prefix: &str) -> io::Result { + tempfile::Builder::new().prefix(prefix).tempdir() +} diff --git a/core/crates/net/src/private_listener.rs b/core/crates/net/src/private_listener.rs new file mode 100644 index 0000000000..a71a4a3c19 --- /dev/null +++ b/core/crates/net/src/private_listener.rs @@ -0,0 +1,131 @@ +use std::io; +use std::net::SocketAddr; +#[cfg(unix)] +use std::path::PathBuf; + +use tablepro_core::TransportStream; +#[cfg(unix)] +use tempfile::TempDir; +#[cfg(unix)] +use tokio::net::UnixListener; + +#[cfg(not(unix))] +use crate::LoopbackListener; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PrivateAddress { + #[cfg(unix)] + Unix(PathBuf), + Tcp(SocketAddr), +} + +// For a library that dials an address: a socket in a 0700 directory, else a LoopbackListener; no other user gets in. +#[derive(Debug)] +pub struct PrivateListener { + address: PrivateAddress, + listening: Listening, +} + +#[derive(Debug)] +enum Listening { + #[cfg(unix)] + Unix { + listener: UnixListener, + // Dropped after the listener, taking the socket file with it. + _dir: TempDir, + }, + #[cfg(not(unix))] + Loopback(LoopbackListener), +} + +impl PrivateListener { + #[cfg(unix)] + pub fn bind() -> io::Result { + let dir = crate::private_dir("tablepro-relay-")?; + let path = dir.path().join("relay.sock"); + let listener = UnixListener::bind(&path)?; + Ok(Self { + address: PrivateAddress::Unix(path), + listening: Listening::Unix { listener, _dir: dir }, + }) + } + + #[cfg(not(unix))] + pub fn bind() -> io::Result { + let listener = LoopbackListener::bind()?; + Ok(Self { + address: PrivateAddress::Tcp(listener.local_addr()), + listening: Listening::Loopback(listener), + }) + } + + pub fn address(&self) -> &PrivateAddress { + &self.address + } + + pub async fn accept(&self) -> io::Result> { + match &self.listening { + #[cfg(unix)] + Listening::Unix { listener, .. } => { + let (stream, _) = listener.accept().await?; + Ok(Box::new(stream)) + } + #[cfg(not(unix))] + Listening::Loopback(listener) => Ok(Box::new(listener.accept().await?)), + } + } +} + +#[cfg(test)] +#[cfg(any(unix, windows))] +mod tests { + use std::time::Duration; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use super::*; + + async fn dial(address: &PrivateAddress) -> Box { + match address { + #[cfg(unix)] + PrivateAddress::Unix(path) => Box::new(tokio::net::UnixStream::connect(path).await.unwrap()), + PrivateAddress::Tcp(address) => Box::new(tokio::net::TcpStream::connect(*address).await.unwrap()), + } + } + + #[tokio::test] + async fn bytes_cross_a_connection_this_process_dials() { + let listener = PrivateListener::bind().unwrap(); + + let mut client = dial(listener.address()).await; + let mut accepted = tokio::time::timeout(Duration::from_secs(5), listener.accept()) + .await + .unwrap() + .unwrap(); + client.write_all(b"to the relay").await.unwrap(); + let mut inbound = [0_u8; 12]; + accepted.read_exact(&mut inbound).await.unwrap(); + accepted.write_all(b"back").await.unwrap(); + let mut outbound = [0_u8; 4]; + client.read_exact(&mut outbound).await.unwrap(); + + assert_eq!(&inbound, b"to the relay"); + assert_eq!(&outbound, b"back"); + } + + #[cfg(unix)] + #[tokio::test] + async fn the_socket_lives_in_a_private_directory_that_goes_with_the_listener() { + use std::os::unix::fs::PermissionsExt; + + let listener = PrivateListener::bind().unwrap(); + let PrivateAddress::Unix(path) = listener.address().clone() else { + panic!("a Unix system listens on a Unix socket"); + }; + let dir = path.parent().unwrap().to_owned(); + + assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700); + drop(listener); + assert!(!dir.exists(), "{} outlived its listener", dir.display()); + } +} diff --git a/core/crates/session/Cargo.toml b/core/crates/session/Cargo.toml index aa7d00cfad..f450da700d 100644 --- a/core/crates/session/Cargo.toml +++ b/core/crates/session/Cargo.toml @@ -9,6 +9,8 @@ publish.workspace = true # FakeConnector and FakeCredentialSource for the tests of crates that # start sessions. Never on in a release build. fakes = ["dep:tablepro-test-support"] +# InProcessSsh as an SshTunnels, for an app that tunnels in process (core ADR 0008). +in-process-ssh = ["tablepro-ssh/in-process"] [dependencies] tablepro-core.workspace = true diff --git a/core/crates/session/src/connect/in_process_tunnels.rs b/core/crates/session/src/connect/in_process_tunnels.rs new file mode 100644 index 0000000000..f59cd62b71 --- /dev/null +++ b/core/crates/session/src/connect/in_process_tunnels.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use futures::future::BoxFuture; +use tablepro_core::credentials::CredentialInteraction; +use tablepro_core::{LivenessPolicy, NetworkEndpoint, Transport, TransportError}; +use tablepro_ssh::{InProcessSession, InProcessSsh, SshConfig}; +use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; + +use super::{SshTunnel, SshTunnels}; + +impl SshTunnels for InProcessSsh { + fn open( + &self, + config: &SshConfig, + liveness: LivenessPolicy, + interaction: CredentialInteraction, + cancel: CancellationToken, + ) -> BoxFuture<'static, Result, TransportError>> { + let services = self.clone(); + let config = config.clone(); + Box::pin(async move { + let session: Arc = + InProcessSession::connect(&config, &services, &liveness, interaction, cancel).await?; + Ok(session) + }) + } +} + +impl SshTunnel for InProcessSession { + fn forward( + self: Arc, + target: NetworkEndpoint, + liveness: LivenessPolicy, + ) -> BoxFuture<'static, Result, TransportError>> { + Box::pin(async move { InProcessSession::forward(&self, target, &liveness).await }) + } + + fn closed(&self) -> WaitForCancellationFutureOwned { + InProcessSession::closed(self) + } + + fn destination(&self) -> &str { + InProcessSession::destination(self) + } + + fn first_hop_endpoint(&self) -> Option<&NetworkEndpoint> { + Some(InProcessSession::first_hop_endpoint(self)) + } + + fn process_id(&self) -> Option { + None + } +} diff --git a/core/crates/session/src/connect/mod.rs b/core/crates/session/src/connect/mod.rs index b608ad399e..562539c74c 100644 --- a/core/crates/session/src/connect/mod.rs +++ b/core/crates/session/src/connect/mod.rs @@ -15,6 +15,8 @@ mod connection_writes; mod connector; mod credential_source; mod driver_connector; +#[cfg(feature = "in-process-ssh")] +mod in_process_tunnels; mod keyring_source; mod no_stored_credentials; #[cfg(unix)] diff --git a/core/crates/session/src/connect/ssh_tunnels.rs b/core/crates/session/src/connect/ssh_tunnels.rs index d2a9b1a759..61891e0d0e 100644 --- a/core/crates/session/src/connect/ssh_tunnels.rs +++ b/core/crates/session/src/connect/ssh_tunnels.rs @@ -8,8 +8,9 @@ use tablepro_ssh::SshConfig; use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; /// How a connect reaches a server through SSH. The app passes in its -/// platform's transport, as it passes a `ProcessHold`; on Unix that is -/// `tablepro_ssh::SshServices`, the system OpenSSH client. +/// platform's transport, as it passes a `ProcessHold`: on Linux +/// `tablepro_ssh::SshServices`, the system OpenSSH client, and on Windows +/// `tablepro_ssh::InProcessSsh` (core ADR 0008). pub trait SshTunnels: fmt::Debug + Send + Sync { fn open( &self, diff --git a/core/crates/ssh/Cargo.toml b/core/crates/ssh/Cargo.toml index 8923678eab..c8fdb71b8e 100644 --- a/core/crates/ssh/Cargo.toml +++ b/core/crates/ssh/Cargo.toml @@ -5,11 +5,20 @@ rust-version.workspace = true license.workspace = true publish.workspace = true +[features] +# The transport that runs SSH in process (core ADR 0008), which the Windows +# app turns on. Off by default, so the Linux app links no russh. +in-process = ["dep:aws-lc-rs", "dep:russh", "dep:tablepro-net", "dep:tempfile", "dep:windows-sys"] + [dependencies] tablepro-core.workspace = true +tablepro-net = { workspace = true, optional = true } async-trait.workspace = true +aws-lc-rs = { workspace = true, optional = true } futures.workspace = true +russh = { workspace = true, optional = true } secrecy.workspace = true +tempfile = { workspace = true, optional = true } thiserror.workspace = true tokio = { workspace = true, features = ["io-util", "fs"] } tokio-util.workspace = true @@ -21,6 +30,10 @@ zeroize.workspace = true # mkdirat and openat with O_NOFOLLOW, and the effective uid, none of which std has. rustix = { workspace = true, features = ["fs", "process"] } +[target.'cfg(windows)'.dependencies] +# Who owns the agent's named pipe. +windows-sys = { workspace = true, optional = true, features = ["Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading"] } + [dev-dependencies] tablepro-test-fixtures.workspace = true tablepro-test-support.workspace = true @@ -28,5 +41,17 @@ tempfile.workspace = true testcontainers.workspace = true tokio = { workspace = true, features = ["rt", "test-util"] } +[[test]] +name = "in_process_matrix" +required-features = ["in-process"] + +[[test]] +name = "in_process_session" +required-features = ["in-process"] + +[[test]] +name = "in_process_windows" +required-features = ["in-process"] + [lints] workspace = true diff --git a/core/crates/ssh/src/forget_error.rs b/core/crates/ssh/src/forget_error.rs new file mode 100644 index 0000000000..5fde6ab7d7 --- /dev/null +++ b/core/crates/ssh/src/forget_error.rs @@ -0,0 +1,14 @@ +use std::path::PathBuf; + +use tablepro_core::SshFailure; + +/// Why a key reported as changed was left in known_hosts. +#[derive(Debug, thiserror::Error)] +pub enum ForgetHostKeyError { + /// No host of this connection reads `known_hosts` under `host`, so + /// the report did not come from this connection's own check. + #[error("{host} in {} is not a host key this connection uses", .known_hosts.display())] + NotThisConnection { host: String, known_hosts: PathBuf }, + #[error(transparent)] + Ssh(#[from] SshFailure), +} diff --git a/core/crates/ssh/src/in_process/abortable.rs b/core/crates/ssh/src/in_process/abortable.rs new file mode 100644 index 0000000000..cdc7d51dbe --- /dev/null +++ b/core/crates/ssh/src/in_process/abortable.rs @@ -0,0 +1,97 @@ +use std::future::Future; +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use tablepro_core::TransportStream; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; + +// russh runs a handshake on a task of its own that a dropped connect cannot reach during key +// exchange, so a hop's stream fails once the connect it belongs to gives up. +pub(crate) struct Abortable { + inner: Box, + aborted: Pin>, +} + +impl Abortable { + pub fn new(inner: Box, abort: CancellationToken) -> Self { + Self { + inner, + aborted: Box::pin(abort.cancelled_owned()), + } + } + + fn aborted(&mut self, cx: &mut Context<'_>) -> bool { + self.aborted.as_mut().poll(cx).is_ready() + } +} + +fn gave_up() -> io::Error { + io::Error::new(io::ErrorKind::ConnectionAborted, "the SSH connect gave up") +} + +impl AsyncRead for Abortable { + fn poll_read(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let this = self.get_mut(); + if this.aborted(cx) { + return Poll::Ready(Err(gave_up())); + } + Pin::new(&mut this.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for Abortable { + fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { + let this = self.get_mut(); + if this.aborted(cx) { + return Poll::Ready(Err(gave_up())); + } + Pin::new(&mut this.inner).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + if this.aborted(cx) { + return Poll::Ready(Err(gave_up())); + } + Pin::new(&mut this.inner).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use super::*; + + #[tokio::test] + async fn a_read_waiting_on_a_silent_peer_ends_when_the_connect_gives_up() { + let (near, mut far) = tokio::io::duplex(64); + let abort = CancellationToken::new(); + let mut stream = Abortable::new(Box::new(near), abort.clone()); + far.write_all(b"SSH-").await.unwrap(); + let mut banner = [0_u8; 4]; + stream.read_exact(&mut banner).await.unwrap(); + + let waiting = tokio::spawn(async move { + let mut more = [0_u8; 1]; + stream.read(&mut more).await + }); + abort.cancel(); + let read = tokio::time::timeout(Duration::from_secs(5), waiting) + .await + .unwrap() + .unwrap(); + + assert_eq!(&banner, b"SSH-"); + assert_eq!(read.unwrap_err().kind(), io::ErrorKind::ConnectionAborted); + drop(far); + } +} diff --git a/core/crates/ssh/src/in_process/agent.rs b/core/crates/ssh/src/in_process/agent.rs new file mode 100644 index 0000000000..9b3e4b641b --- /dev/null +++ b/core/crates/ssh/src/in_process/agent.rs @@ -0,0 +1,194 @@ +use std::path::Path; +use std::time::Duration; + +use russh::keys::agent::client::{AgentClient, AgentStream}; +use russh::keys::agent::{AgentIdentity, AgentSessionBinding}; +use russh::keys::ssh_key::PublicKey; + +use super::services::InProcessSsh; + +// The bound OpenSSH's own ssh-add -l gets in the system transport (Linux ADR 0020). +const AGENT_TIMEOUT: Duration = Duration::from_secs(5); + +pub(crate) type Agent = AgentClient>; + +// The user's agent for one hop. OpenSSH binds each agent connection to one session, which keys +// added with destination constraints need, so every hop opens its own. +pub(crate) struct AgentSlot { + binding: Option, + state: State, +} + +enum State { + Untried, + Ready { + agent: Agent, + identities: Vec, + }, + Unavailable { + location: String, + }, +} + +impl AgentSlot { + pub fn new(binding: Option) -> Self { + Self { + binding, + state: State::Untried, + } + } + + pub async fn session(&mut self, services: &InProcessSsh) -> Option<(&mut Agent, &[AgentIdentity])> { + if matches!(self.state, State::Untried) { + self.state = connect(services, self.binding.as_ref()).await; + } + match &mut self.state { + State::Ready { agent, identities } => Some((agent, identities.as_slice())), + State::Untried | State::Unavailable { .. } => None, + } + } + + pub async fn holds(&mut self, services: &InProcessSsh, key: &PublicKey) -> bool { + self.session(services) + .await + .is_some_and(|(_, identities)| identities.iter().any(|identity| signs_for(identity, key))) + } + + pub fn unavailable(&self) -> Option<&str> { + match &self.state { + State::Unavailable { location } => Some(location), + State::Untried | State::Ready { .. } => None, + } + } +} + +// A certificate the agent holds signs with the key it certifies. +pub(crate) fn signs_for(identity: &AgentIdentity, key: &PublicKey) -> bool { + match identity { + AgentIdentity::PublicKey { key: held, .. } => held.key_data() == key.key_data(), + AgentIdentity::Certificate { certificate, .. } => certificate.public_key() == key.key_data(), + } +} + +pub(crate) fn identity_algorithm(identity: &AgentIdentity) -> russh::keys::ssh_key::Algorithm { + match identity { + AgentIdentity::PublicKey { key, .. } => key.algorithm(), + AgentIdentity::Certificate { certificate, .. } => certificate.public_key().algorithm(), + } +} + +async fn connect(services: &InProcessSsh, binding: Option<&AgentSessionBinding>) -> State { + let location = services + .agent + .as_deref() + .map_or_else(|| "SSH_AUTH_SOCK".to_owned(), |path| path.display().to_string()); + let attempt = async { + let mut agent = open(services).await?; + // An agent without the extension, as Pageant is, refuses it and lists every key anyway. + if let Some(binding) = binding + && let Err(error) = agent.bind_session(binding, false).await + { + tracing::debug!(%error, "the SSH agent did not bind to the session"); + } + let identities = agent.request_identities().await.ok()?; + Some(State::Ready { agent, identities }) + }; + match tokio::time::timeout(AGENT_TIMEOUT, attempt).await { + Ok(Some(ready)) => ready, + Ok(None) | Err(_) => { + tracing::debug!(%location, "no SSH agent answered"); + State::Unavailable { location } + } + } +} + +async fn open(services: &InProcessSsh) -> Option { + if let Some(path) = services.agent.as_deref() { + match open_at(path).await { + Ok(agent) => return Some(agent), + Err(error) => tracing::debug!(%error, path = %path.display(), "the SSH agent could not be opened"), + } + } + if services.pageant { + return open_pageant().await; + } + None +} + +#[cfg(unix)] +async fn open_at(path: &Path) -> Result { + Ok(AgentClient::connect_uds(path).await?.dynamic()) +} + +// Any user can create the agent's pipe while the agent service is stopped, which is its default, +// so the pipe is used only when this user, LocalSystem or the Administrators own it. +#[cfg(windows)] +async fn open_at(path: &Path) -> Result { + use std::os::windows::io::AsRawHandle; + + use tokio::net::windows::named_pipe::ClientOptions; + + const ERROR_PIPE_BUSY: i32 = 231; + let pipe = loop { + match ClientOptions::new().open(path) { + Ok(pipe) => break pipe, + Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY) => { + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(error) => return Err(error.into()), + } + }; + if !super::pipe_owner::owned_by_user_or_system(pipe.as_raw_handle())? { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "the SSH agent's pipe belongs to another user", + ) + .into()); + } + Ok(AgentClient::connect(pipe).dynamic()) +} + +#[cfg(windows)] +async fn open_pageant() -> Option { + match AgentClient::connect_pageant().await { + Ok(agent) => Some(agent.dynamic()), + Err(error) => { + tracing::debug!(%error, "Pageant is not running"); + None + } + } +} + +#[cfg(not(windows))] +async fn open_pageant() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn services(agent: Option<&Path>) -> InProcessSsh { + InProcessSsh { + user_known_hosts: None, + other_known_hosts: Vec::new(), + identity_files: Vec::new(), + agent: agent.map(Path::to_path_buf), + pageant: false, + } + } + + #[tokio::test] + async fn an_agent_that_is_not_there_is_named_where_it_was_looked_for() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("agent.sock"); + let mut slot = AgentSlot::new(None); + + assert!(slot.session(&services(Some(&missing))).await.is_none()); + assert_eq!(slot.unavailable(), Some(missing.display().to_string().as_str())); + + let mut unset = AgentSlot::new(None); + assert!(unset.session(&services(None)).await.is_none()); + assert_eq!(unset.unavailable(), Some("SSH_AUTH_SOCK")); + } +} diff --git a/core/crates/ssh/src/in_process/auth.rs b/core/crates/ssh/src/in_process/auth.rs new file mode 100644 index 0000000000..a8c591aed7 --- /dev/null +++ b/core/crates/ssh/src/in_process/auth.rs @@ -0,0 +1,517 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use russh::client::{AuthResult, Handle, KeyboardInteractiveAuthResponse}; +use russh::keys::PrivateKeyWithHashAlg; +use russh::keys::agent::AgentIdentity; +use russh::keys::ssh_key::public::KeyData; +use russh::keys::ssh_key::{Algorithm, Certificate, HashAlg, PrivateKey, PublicKey}; +use russh::{MethodKind, MethodSet}; +use secrecy::{ExposeSecret, SecretString}; +use tablepro_core::credentials::PromptReason; +use tablepro_core::{SshFailure, TransportError}; + +use super::agent::{AgentSlot, identity_algorithm, signs_for}; +use super::failure::from_russh; +use super::handler::{HopHandler, SessionEnd}; +use super::identity::{self, KeySigner, TRIES}; +use super::prompts::Prompts; +use super::services::InProcessSsh; +use crate::SshAuth; + +// A server re-asking after a wrong answer gets this many rounds before the method is given up. +const KEYBOARD_ROUNDS: usize = 8; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Method { + Password, + KeyboardInteractive, + Agent, + KeyFile(PathBuf), + DefaultKeyFiles, +} + +impl Method { + fn kind(&self) -> MethodKind { + match self { + Self::Password => MethodKind::Password, + Self::KeyboardInteractive => MethodKind::KeyboardInteractive, + Self::Agent | Self::KeyFile(_) | Self::DefaultKeyFiles => MethodKind::PublicKey, + } + } +} + +// The order the OpenSSH transport's PreferredAuthentications and identities give. +pub(crate) fn destination_methods(auth: &SshAuth) -> Vec { + use Method::{Agent, DefaultKeyFiles, KeyFile, KeyboardInteractive, Password}; + match auth { + SshAuth::Password { .. } => vec![Password, KeyboardInteractive, Agent, DefaultKeyFiles], + SshAuth::KeyboardInteractive => vec![KeyboardInteractive, Password, Agent, DefaultKeyFiles], + SshAuth::PrivateKey { path: Some(path), .. } => vec![KeyFile(path.clone()), KeyboardInteractive, Password], + SshAuth::Agent | SshAuth::PrivateKey { path: None, .. } => { + vec![Agent, DefaultKeyFiles, KeyboardInteractive, Password] + } + } +} + +// A jump host has no fields of its own, so it tries what OpenSSH would, plus the connection's key file. +pub(crate) fn hop_methods(auth: &SshAuth) -> Vec { + let mut methods = vec![Method::Agent]; + if let SshAuth::PrivateKey { path: Some(path), .. } = auth { + methods.push(Method::KeyFile(path.clone())); + } + methods.extend([Method::DefaultKeyFiles, Method::KeyboardInteractive, Method::Password]); + methods +} + +enum Step { + Done, + Partial(MethodSet), + Refused(MethodSet), + NotTried, +} + +impl From for Step { + fn from(result: AuthResult) -> Self { + match result { + AuthResult::Success => Self::Done, + AuthResult::Failure { + remaining_methods, + partial_success: true, + } => Self::Partial(remaining_methods), + AuthResult::Failure { remaining_methods, .. } => Self::Refused(remaining_methods), + } + } +} + +// A key, or a certificate for it, offered once. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Offered { + Key(KeyData), + Certificate(KeyData), +} + +impl Offered { + fn of(identity: &AgentIdentity) -> Self { + match identity { + AgentIdentity::PublicKey { key, .. } => Self::Key(key.key_data().clone()), + AgentIdentity::Certificate { certificate, .. } => Self::Certificate(certificate.public_key().clone()), + } + } +} + +pub(crate) struct Login<'a> { + pub user: &'a str, + pub host: &'a str, + // Only the destination's; a jump host is never handed it. + pub password: Option, + // The connection names the agent, so its absence is the reason a login failed. + pub agent_chosen: bool, + pub passphrase: &'a mut Option, + // A key file opened for one hop serves the next without its passphrase again. + pub keys: &'a mut HashMap>, + // Keys already offered to this hop, so a second round after a partial success offers the rest. + pub offered: Vec, + pub agent: &'a mut AgentSlot, + pub services: &'a InProcessSsh, + pub prompts: &'a Prompts, + pub end: &'a SessionEnd, +} + +impl Login<'_> { + fn failed(&self, error: russh::Error) -> TransportError { + from_russh(error, self.host, self.end) + } + + fn user_host(&self) -> String { + format!("{}@{}", self.user, self.host) + } +} + +pub(crate) async fn log_in( + handle: &mut Handle, + methods: &[Method], + login: &mut Login<'_>, +) -> Result<(), TransportError> { + let mut allowed = match handle + .authenticate_none(login.user) + .await + .map_err(|error| login.failed(error))? + { + AuthResult::Success => return Ok(()), + AuthResult::Failure { remaining_methods, .. } => remaining_methods, + }; + let mut used = vec![false; methods.len()]; + 'rounds: loop { + for (index, method) in methods.iter().enumerate() { + if used[index] || !allowed.contains(&method.kind()) { + continue; + } + used[index] = true; + match try_method(handle, method, login).await? { + Step::Done => return Ok(()), + // The server wants another method too, so earlier ones it now lists get their turn, and + // the agent and default identities their keys not yet offered. + Step::Partial(remaining) => { + allowed = remaining; + for (index, method) in methods.iter().enumerate() { + if matches!(method, Method::Agent | Method::DefaultKeyFiles) { + used[index] = false; + } + } + continue 'rounds; + } + Step::Refused(remaining) => allowed = remaining, + Step::NotTried => {} + } + } + break; + } + // A server that ends the login, as on MaxAuthTries, says why in its disconnect. + if handle.is_closed() || login.end.closed().is_cancelled() { + return Err(login.failed(russh::Error::Disconnect)); + } + if login.agent_chosen + && let Some(location) = login.agent.unavailable() + { + return Err(SshFailure::AgentUnavailable { + location: location.to_owned(), + } + .into()); + } + Err(SshFailure::AuthenticationRejected { + user: login.user.to_owned(), + host: login.host.to_owned(), + methods: allowed.iter().map(|kind| <&str>::from(kind).to_owned()).collect(), + } + .into()) +} + +async fn try_method( + handle: &mut Handle, + method: &Method, + login: &mut Login<'_>, +) -> Result { + match method { + Method::Password => password(handle, login).await, + Method::KeyboardInteractive => keyboard_interactive(handle, login).await, + Method::Agent => agent(handle, login, None).await, + Method::KeyFile(path) => key_file(handle, path, true, login).await, + Method::DefaultKeyFiles => { + let mut last = Step::NotTried; + for path in login.services.identity_files.clone() { + if !tokio::fs::try_exists(&path).await.unwrap_or(false) { + continue; + } + match key_file(handle, &path, false, login).await? { + Step::Refused(remaining) if remaining.contains(&MethodKind::PublicKey) => { + last = Step::Refused(remaining); + } + Step::NotTried => {} + other => return Ok(other), + } + } + Ok(last) + } + } +} + +async fn password(handle: &mut Handle, login: &mut Login<'_>) -> Result { + let mut reason = PromptReason::NotStored; + let mut last = Step::NotTried; + for _ in 0..TRIES { + let secret = match login.password.take() { + Some(stored) => stored, + None => match login.prompts.password(&login.user_host(), reason.clone()).await? { + Some(typed) => typed, + None => return Ok(last), + }, + }; + let result = handle + .authenticate_password(login.user, secret.expose_secret()) + .await + .map_err(|error| login.failed(error))?; + match Step::from(result) { + Step::Refused(remaining) => { + reason = PromptReason::Rejected; + let again = remaining.contains(&MethodKind::Password); + last = Step::Refused(remaining); + if !again { + break; + } + } + other => return Ok(other), + } + } + Ok(last) +} + +async fn keyboard_interactive(handle: &mut Handle, login: &mut Login<'_>) -> Result { + let mut response = handle + .authenticate_keyboard_interactive_start(login.user, None) + .await + .map_err(|error| login.failed(error))?; + for _ in 0..KEYBOARD_ROUNDS { + let (name, instructions, prompts) = match response { + KeyboardInteractiveAuthResponse::Success => return Ok(Step::Done), + KeyboardInteractiveAuthResponse::Failure { + remaining_methods, + partial_success, + } => { + return Ok(match partial_success { + true => Step::Partial(remaining_methods), + false => Step::Refused(remaining_methods), + }); + } + KeyboardInteractiveAuthResponse::InfoRequest { + name, + instructions, + prompts, + } => (name, instructions, prompts), + }; + let answers = if prompts.is_empty() { + Vec::new() + } else if let Some(stored) = stored_password_answer(&prompts, login) { + vec![stored] + } else { + match login + .prompts + .keyboard_interactive(&login.user_host(), &name, &instructions, &prompts) + .await? + { + Some(answers) => answers, + // russh takes nothing but answers until the exchange ends, so nobody to ask answers blank. + None => prompts.iter().map(|_| SecretString::from("")).collect(), + } + }; + let answers = answers.iter().map(|answer| answer.expose_secret().to_owned()).collect(); + response = handle + .authenticate_keyboard_interactive_respond(answers) + .await + .map_err(|error| login.failed(error))?; + } + Ok(Step::NotTried) +} + +// PAM asks for the password through keyboard-interactive, so the stored one answers its one hidden prompt. +fn stored_password_answer(prompts: &[russh::client::Prompt], login: &mut Login<'_>) -> Option { + let [prompt] = prompts else { + return None; + }; + if prompt.echo || !prompt.prompt.to_ascii_lowercase().contains("password") { + return None; + } + login.password.take() +} + +// `only` limits the agent to one key, the connection's key file. +async fn agent( + handle: &mut Handle, + login: &mut Login<'_>, + only: Option<&PublicKey>, +) -> Result { + let (user, host, end) = (login.user, login.host, login.end); + let Some((agent, identities)) = login.agent.session(login.services).await else { + return Ok(Step::NotTried); + }; + let wanted: Vec = identities + .iter() + .filter(|identity| only.is_none_or(|key| signs_for(identity, key))) + .filter(|identity| !login.offered.contains(&Offered::of(identity))) + .cloned() + .collect(); + let mut last = Step::NotTried; + for identity in wanted { + login.offered.push(Offered::of(&identity)); + let hash = rsa_hash(handle, identity_algorithm(&identity)).await; + let result = match identity { + AgentIdentity::PublicKey { key, .. } => handle.authenticate_publickey_with(user, key, hash, agent).await, + AgentIdentity::Certificate { certificate, .. } => { + handle + .authenticate_certificate_with(user, certificate, hash, agent) + .await + } + }; + let result = match result { + Ok(result) => result, + Err(error) if handle.is_closed() => { + tracing::debug!(%error, "the session ended while the agent signed"); + return Err(from_russh(russh::Error::Disconnect, host, end)); + } + Err(error) => { + tracing::debug!(%error, "the agent did not sign"); + continue; + } + }; + match Step::from(result) { + Step::Refused(remaining) => { + let more = remaining.contains(&MethodKind::PublicKey); + last = Step::Refused(remaining); + if !more { + break; + } + } + other => return Ok(other), + } + } + Ok(last) +} + +async fn key_file( + handle: &mut Handle, + path: &Path, + chosen: bool, + login: &mut Login<'_>, +) -> Result { + let certificate = identity::certificate_beside(path).await; + // A key the agent holds signs there with no passphrase, certificates included (Linux ADR 0020). + if let Some(public) = identity::public_half(path).await + && login.agent.holds(login.services, &public).await + { + if let Some(certificate) = &certificate + && !login.offered.contains(&Offered::Certificate(public.key_data().clone())) + { + login.offered.push(Offered::Certificate(public.key_data().clone())); + match agent_certificate(handle, login, certificate).await? { + Step::Refused(remaining) if remaining.contains(&MethodKind::PublicKey) => {} + Step::NotTried => {} + other => return Ok(other), + } + } + let step = agent(handle, login, Some(&public)).await?; + if !matches!(step, Step::NotTried) { + return Ok(step); + } + } + let key = match login.keys.get(path) { + Some(key) => key.clone(), + None => { + let Some(key) = identity::load(path, chosen, &mut *login.passphrase, login.prompts).await? else { + return Ok(Step::NotTried); + }; + let key = Arc::new(key); + login.keys.insert(path.to_owned(), key.clone()); + key + } + }; + let data = key.public_key().key_data().clone(); + if let Some(certificate) = certificate + && !login.offered.contains(&Offered::Certificate(data.clone())) + { + login.offered.push(Offered::Certificate(data.clone())); + let hash = rsa_hash(handle, certificate.public_key().algorithm()).await; + let result = handle + .authenticate_certificate_with(login.user, certificate, hash, &mut KeySigner(key.clone())) + .await; + match result { + Ok(result) => match Step::from(result) { + Step::Refused(remaining) if remaining.contains(&MethodKind::PublicKey) => {} + other => return Ok(other), + }, + Err(error) => tracing::debug!(%error, "the certificate beside the key file did not sign"), + } + } + if login.offered.contains(&Offered::Key(data.clone())) { + return Ok(Step::NotTried); + } + login.offered.push(Offered::Key(data)); + let hash = rsa_hash(handle, key.algorithm()).await; + let result = handle + .authenticate_publickey(login.user, PrivateKeyWithHashAlg::new(key, hash)) + .await + .map_err(|error| login.failed(error))?; + Ok(Step::from(result)) +} + +// The agent signs with the key a certificate kept on disk certifies. +async fn agent_certificate( + handle: &mut Handle, + login: &mut Login<'_>, + certificate: &Certificate, +) -> Result { + let (user, host, end) = (login.user, login.host, login.end); + let Some((agent, _)) = login.agent.session(login.services).await else { + return Ok(Step::NotTried); + }; + let hash = rsa_hash(handle, certificate.public_key().algorithm()).await; + match handle + .authenticate_certificate_with(user, certificate.clone(), hash, agent) + .await + { + Ok(result) => Ok(Step::from(result)), + Err(error) if handle.is_closed() => { + tracing::debug!(%error, "the session ended while the agent signed"); + Err(from_russh(russh::Error::Disconnect, host, end)) + } + Err(error) => { + tracing::debug!(%error, "the agent did not sign the certificate"); + Ok(Step::NotTried) + } + } +} + +// rsa-sha2-512 or -256 when the server lists one, and ssh-rsa only for a server that names none. +async fn rsa_hash(handle: &Handle, algorithm: Algorithm) -> Option { + if !algorithm.is_rsa() { + return None; + } + handle.best_supported_rsa_hash().await.ok().flatten().flatten() +} + +#[cfg(test)] +mod tests { + use secrecy::SecretString; + + use super::*; + + #[test] + fn each_auth_tries_methods_in_the_order_the_openssh_transport_does() { + let key = PathBuf::from("/keys/id_ed25519"); + let password = SshAuth::Password { + password: SecretString::from("s3cret"), + }; + let chosen = SshAuth::PrivateKey { + path: Some(key.clone()), + passphrase: None, + }; + + assert_eq!( + destination_methods(&password), + [ + Method::Password, + Method::KeyboardInteractive, + Method::Agent, + Method::DefaultKeyFiles + ] + ); + assert_eq!( + destination_methods(&chosen), + [ + Method::KeyFile(key.clone()), + Method::KeyboardInteractive, + Method::Password + ] + ); + assert_eq!( + destination_methods(&SshAuth::Agent), + [ + Method::Agent, + Method::DefaultKeyFiles, + Method::KeyboardInteractive, + Method::Password + ] + ); + assert_eq!( + hop_methods(&chosen), + [ + Method::Agent, + Method::KeyFile(key), + Method::DefaultKeyFiles, + Method::KeyboardInteractive, + Method::Password + ] + ); + assert_eq!(hop_methods(&password)[0], Method::Agent); + } +} diff --git a/core/crates/ssh/src/in_process/clock.rs b/core/crates/ssh/src/in_process/clock.rs new file mode 100644 index 0000000000..d02b7584be --- /dev/null +++ b/core/crates/ssh/src/in_process/clock.rs @@ -0,0 +1,134 @@ +use std::sync::Mutex; +use std::time::Duration; + +use tokio::sync::Notify; +use tokio::time::Instant; + +use crate::lock; + +// The handshake timeout, stopped while the user answers a prompt, as the OpenSSH transport's is. +#[derive(Debug)] +pub(crate) struct PromptClock { + state: Mutex, + changed: Notify, +} + +#[derive(Debug)] +struct State { + remaining: Duration, + running_since: Option, + prompts_open: usize, +} + +impl PromptClock { + pub fn new(timeout: Duration) -> Self { + Self { + state: Mutex::new(State { + remaining: timeout, + running_since: Some(Instant::now()), + prompts_open: 0, + }), + changed: Notify::new(), + } + } + + pub fn pause(&self) -> Paused<'_> { + { + let mut state = lock(&self.state); + state.prompts_open += 1; + if let Some(since) = state.running_since.take() { + state.remaining = state.remaining.saturating_sub(since.elapsed()); + } + } + self.changed.notify_waiters(); + Paused { clock: self } + } + + fn resume(&self) { + { + let mut state = lock(&self.state); + state.prompts_open = state.prompts_open.saturating_sub(1); + if state.prompts_open == 0 && state.running_since.is_none() { + state.running_since = Some(Instant::now()); + } + } + self.changed.notify_waiters(); + } + + fn deadline(&self) -> Option { + let state = lock(&self.state); + state.running_since.map(|since| since + state.remaining) + } + + // Resolves once the time runs out while no prompt is open. + pub async fn expired(&self) { + loop { + let changed = self.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + match self.deadline() { + None => changed.await, + Some(deadline) => { + tokio::select! { + () = tokio::time::sleep_until(deadline) => { + if self.deadline().is_some_and(|now_due| now_due <= Instant::now()) { + return; + } + } + () = changed => {} + } + } + } + } + } +} + +pub(crate) struct Paused<'a> { + clock: &'a PromptClock, +} + +impl Drop for Paused<'_> { + fn drop(&mut self) { + self.clock.resume(); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + #[tokio::test(start_paused = true)] + async fn the_time_spent_on_a_prompt_does_not_count() { + let clock = Arc::new(PromptClock::new(Duration::from_secs(20))); + let watching = tokio::spawn({ + let clock = clock.clone(); + async move { clock.expired().await } + }); + + tokio::time::advance(Duration::from_secs(5)).await; + let paused = clock.pause(); + tokio::time::advance(Duration::from_secs(60)).await; + assert!(!watching.is_finished(), "the clock ran while a prompt was open"); + drop(paused); + tokio::time::advance(Duration::from_secs(14)).await; + tokio::task::yield_now().await; + assert!(!watching.is_finished(), "the clock lost the time it had left"); + tokio::time::advance(Duration::from_secs(2)).await; + + watching.await.unwrap(); + } + + #[tokio::test(start_paused = true)] + async fn two_prompts_at_once_keep_it_stopped_until_both_close() { + let clock = PromptClock::new(Duration::from_secs(1)); + let first = clock.pause(); + let second = clock.pause(); + drop(first); + + assert_eq!(clock.deadline(), None); + drop(second); + assert!(clock.deadline().is_some()); + } +} diff --git a/core/crates/ssh/src/in_process/failure.rs b/core/crates/ssh/src/in_process/failure.rs new file mode 100644 index 0000000000..88ff79b4d1 --- /dev/null +++ b/core/crates/ssh/src/in_process/failure.rs @@ -0,0 +1,142 @@ +use tablepro_core::{SshAlgorithmKind, SshFailure, TimeoutPhase, TransportError}; + +use super::handler::SessionEnd; + +const TOO_MANY_FAILURES: &str = "Too many authentication failures"; + +// A russh error from a hop's handshake or login, read with what the session's end recorded. +pub(crate) fn from_russh(error: russh::Error, host: &str, end: &SessionEnd) -> TransportError { + let detail = end.detail(); + if detail + .as_deref() + .is_some_and(|detail| detail.contains(TOO_MANY_FAILURES)) + { + return SshFailure::TooManyAuthenticationFailures { host: host.to_owned() }.into(); + } + match error { + russh::Error::NoCommonAlgo { kind, theirs, .. } => SshFailure::NoCommonAlgorithm { + host: host.to_owned(), + kind: algorithm_kind(&kind), + server_offer: theirs + .into_iter() + // Markers a server appends to its key exchange list, not methods it offers. + .filter(|name| !name.starts_with("ext-info-") && !name.starts_with("kex-strict-")) + .collect(), + } + .into(), + russh::Error::ConnectionTimeout | russh::Error::Elapsed(_) => TransportError::Timeout { + phase: TimeoutPhase::SshHandshake, + }, + russh::Error::Disconnect + | russh::Error::HUP + | russh::Error::IO(_) + | russh::Error::SendError + | russh::Error::RecvError + | russh::Error::KeepaliveTimeout + | russh::Error::InactivityTimeout => SshFailure::MasterExited { + status: None, + detail: detail.unwrap_or_else(|| error.to_string()), + } + .into(), + other => SshFailure::Protocol { + status: None, + detail: other.to_string(), + } + .into(), + } +} + +pub(crate) fn ended(end: &SessionEnd) -> TransportError { + SshFailure::MasterExited { + status: None, + detail: end.detail().unwrap_or_default(), + } + .into() +} + +pub(crate) fn channel_failure(reason: &russh::ChannelOpenFailure) -> String { + let text = match reason { + russh::ChannelOpenFailure::AdministrativelyProhibited => "administratively prohibited", + russh::ChannelOpenFailure::ConnectFailed => "connect failed", + russh::ChannelOpenFailure::UnknownChannelType => "unknown channel type", + russh::ChannelOpenFailure::ResourceShortage => "resource shortage", + russh::ChannelOpenFailure::Other { reason, .. } => return format!("open failed: {reason}"), + }; + format!("open failed: {text}") +} + +fn algorithm_kind(kind: &russh::AlgorithmKind) -> SshAlgorithmKind { + match kind { + russh::AlgorithmKind::Kex => SshAlgorithmKind::KeyExchange, + russh::AlgorithmKind::Key => SshAlgorithmKind::HostKey, + russh::AlgorithmKind::Cipher => SshAlgorithmKind::Cipher, + russh::AlgorithmKind::Mac => SshAlgorithmKind::Mac, + russh::AlgorithmKind::Compression => SshAlgorithmKind::Compression, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_failed_negotiation_names_the_kind_and_what_the_server_offers() { + let error = russh::Error::NoCommonAlgo { + kind: russh::AlgorithmKind::Kex, + ours: vec!["mlkem768x25519-sha256".to_owned()], + theirs: vec![ + "diffie-hellman-group1-sha1".to_owned(), + "kex-strict-s-v00@openssh.com".to_owned(), + ], + }; + + assert_eq!( + from_russh(error, "bastion", &SessionEnd::default()), + TransportError::Ssh(SshFailure::NoCommonAlgorithm { + host: "bastion".to_owned(), + kind: SshAlgorithmKind::KeyExchange, + server_offer: vec!["diffie-hellman-group1-sha1".to_owned()], + }) + ); + } + + #[test] + fn the_servers_disconnect_message_explains_a_lost_session() { + let end = SessionEnd::default(); + end.record(TOO_MANY_FAILURES.to_owned()); + let refused = from_russh(russh::Error::Disconnect, "bastion", &end); + + let end = SessionEnd::default(); + end.record("Keepalive timeout".to_owned()); + let lost = from_russh(russh::Error::SendError, "bastion", &end); + + assert_eq!( + refused, + TransportError::Ssh(SshFailure::TooManyAuthenticationFailures { + host: "bastion".to_owned() + }) + ); + assert_eq!( + lost, + TransportError::Ssh(SshFailure::MasterExited { + status: None, + detail: "Keepalive timeout".to_owned(), + }) + ); + assert_eq!( + ended(&end), + TransportError::Ssh(SshFailure::MasterExited { + status: None, + detail: "Keepalive timeout".to_owned(), + }) + ); + } + + #[test] + fn a_refused_channel_reads_as_ssh_prints_it() { + assert_eq!( + channel_failure(&russh::ChannelOpenFailure::ConnectFailed), + "open failed: connect failed" + ); + } +} diff --git a/core/crates/ssh/src/in_process/forward.rs b/core/crates/ssh/src/in_process/forward.rs new file mode 100644 index 0000000000..b741d17854 --- /dev/null +++ b/core/crates/ssh/src/in_process/forward.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tablepro_core::{ + LivenessPolicy, NetworkEndpoint, TimeoutPhase, Transport, TransportError, TransportKind, TransportRoute, + TransportStream, +}; + +use super::session::InProcessSession; + +// Each stream is a direct-tcpip channel of the session, so nothing listens on the machine. +#[derive(Debug)] +pub struct InProcessForward { + session: Arc, + service: NetworkEndpoint, + liveness: LivenessPolicy, +} + +impl InProcessForward { + pub(crate) fn new(session: Arc, service: NetworkEndpoint, liveness: &LivenessPolicy) -> Self { + Self { + session, + service, + liveness: *liveness, + } + } +} + +#[async_trait] +impl Transport for InProcessForward { + fn service_endpoint(&self) -> &NetworkEndpoint { + &self.service + } + + fn kind(&self) -> TransportKind { + TransportKind::SshForward { + destination: self.session.destination().to_owned(), + } + } + + fn route(&self) -> TransportRoute { + TransportRoute::InProcess + } + + async fn open(&self) -> Result, TransportError> { + if self.session.is_closed() { + return Err(self.session.ended()); + } + let opening = self.session.open_channel(&self.service); + match tokio::time::timeout(self.liveness.ssh_channel_open_timeout, opening).await { + Ok(opened) => opened, + Err(_) => Err(TransportError::Timeout { + phase: TimeoutPhase::SshChannelOpen, + }), + } + } + + async fn reroute(&self, endpoint: NetworkEndpoint) -> Result, TransportError> { + self.session.forward(endpoint, &self.liveness).await + } + + async fn explain_closed_stream(&self) -> Option { + self.session.is_closed().then(|| self.session.ended()) + } +} diff --git a/core/crates/ssh/src/in_process/handler.rs b/core/crates/ssh/src/in_process/handler.rs new file mode 100644 index 0000000000..d90089cbce --- /dev/null +++ b/core/crates/ssh/src/in_process/handler.rs @@ -0,0 +1,344 @@ +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use russh::client::{DisconnectReason, Handler}; +use russh::keys::PublicKeyOrCertificate; +use russh::keys::ssh_key::{Algorithm, HashAlg, PublicKey}; +use tablepro_core::SshFailure; +use tokio_util::sync::CancellationToken; + +use super::known_hosts::{KnownKey, KnownTypes, Verdict, append, known_types, lookup_name, read_known, verdict}; +use super::prompts::Prompts; +use super::services::InProcessSsh; +use crate::lock; + +// OpenSSH's sshkey_type() names, which its host key prompt shows. +pub(crate) fn key_type_label(algorithm: &Algorithm) -> String { + match algorithm { + Algorithm::Ed25519 => "ED25519".to_owned(), + Algorithm::Ecdsa { .. } => "ECDSA".to_owned(), + Algorithm::Rsa { .. } => "RSA".to_owned(), + Algorithm::Dsa => "DSA".to_owned(), + Algorithm::SkEcdsaSha2NistP256 => "ECDSA-SK".to_owned(), + Algorithm::SkEd25519 => "ED25519-SK".to_owned(), + other => other.as_str().to_ascii_uppercase(), + } +} + +// Why the session ended, kept for the error a later call reports. +#[derive(Debug, Default)] +pub(crate) struct SessionEnd { + closed: CancellationToken, + detail: Mutex>, +} + +impl SessionEnd { + pub fn closed(&self) -> &CancellationToken { + &self.closed + } + + pub fn detail(&self) -> Option { + lock(&self.detail).clone() + } + + pub fn record(&self, detail: String) { + lock(&self.detail).get_or_insert(detail); + self.closed.cancel(); + } +} + +// One hop's host key decision: known_hosts first, then the user. +pub(crate) struct HostKeyCheck { + name: String, + principal: String, + files: Vec, + user_file: Option, + prompts: Arc, + refusal: Mutex>, +} + +impl HostKeyCheck { + pub fn new(services: &InProcessSsh, host: &str, port: u16, prompts: Arc) -> Self { + Self { + name: lookup_name(host, port), + principal: host.to_ascii_lowercase(), + files: services.known_hosts_files().map(PathBuf::from).collect(), + user_file: services.user_known_hosts.clone(), + prompts, + refusal: Mutex::new(None), + } + } + + pub async fn known_types(&self) -> KnownTypes { + let (files, name) = (self.files.clone(), self.name.clone()); + tokio::task::spawn_blocking(move || known_types(&read_all(&files, &name))) + .await + .unwrap_or_default() + } + + pub fn take_refusal(&self) -> Option { + lock(&self.refusal).take() + } + + fn refuse(&self, failure: SshFailure) -> bool { + *lock(&self.refusal) = Some(failure); + false + } + + async fn accept(&self, presented: &PublicKeyOrCertificate) -> bool { + let (key, certificate) = match presented { + PublicKeyOrCertificate::PublicKey { key, .. } => (key.clone(), None), + PublicKeyOrCertificate::Certificate(certificate) => ( + PublicKey::from(certificate.public_key().clone()), + Some(certificate.clone()), + ), + }; + let decided = { + let (files, name, principal, key) = ( + self.files.clone(), + self.name.clone(), + self.principal.clone(), + key.clone(), + ); + tokio::task::spawn_blocking(move || { + verdict( + &read_all(&files, &name), + &key, + certificate.as_ref(), + &principal, + unix_now(), + ) + }) + .await + }; + let Ok(decided) = decided else { + return self.refuse(SshFailure::Internal); + }; + let algorithm = key_type_label(&key.algorithm()); + let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); + match decided { + Verdict::Trusted => true, + Verdict::Revoked { path, line } => self.refuse(SshFailure::HostKeyRevoked { + host: self.name.clone(), + detail: format!( + "{} key {fingerprint} is revoked at {}:{line}", + algorithm, + path.display() + ), + }), + Verdict::Changed { path, line } => self.refuse(SshFailure::HostKeyChanged { + host: self.name.clone(), + algorithm, + sha256_fingerprint: fingerprint, + known_hosts: path, + line, + trust_allowed: true, + }), + Verdict::Unknown => { + if self.prompts.trust_host_key(&self.name, &algorithm, &fingerprint).await { + self.learn(key).await; + return true; + } + self.refuse(SshFailure::HostKeyUnknown { + host: self.name.clone(), + algorithm, + sha256_fingerprint: fingerprint, + trust_allowed: true, + declined: true, + }) + } + } + } + + // ssh goes on with a key it could not record, warning only; so does this. + async fn learn(&self, key: PublicKey) { + let Some(file) = self.user_file.clone() else { + tracing::warn!("no home directory to record a trusted host key in"); + return; + }; + let name = self.name.clone(); + let written = tokio::task::spawn_blocking(move || append(&file, &name, &key)).await; + match written { + Ok(Ok(())) => {} + Ok(Err(error)) => tracing::warn!(%error, "the trusted host key could not be recorded"), + Err(error) => tracing::warn!(%error, "recording the trusted host key stopped"), + } + } +} + +// ssh reads past a file it cannot open, as if it named nothing. +fn read_all(files: &[PathBuf], name: &str) -> Vec { + files + .iter() + .flat_map(|file| { + read_known(file, name).unwrap_or_else(|error| { + tracing::debug!(%error, file = %file.display(), "a known_hosts file could not be read"); + Vec::new() + }) + }) + .collect() +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs()) +} + +pub(crate) struct HopHandler { + pub check: Arc, + pub end: Arc, +} + +impl Handler for HopHandler { + type Error = russh::Error; + + async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result { + Ok(self.check.accept(key).await) + } + + async fn disconnected(&mut self, reason: DisconnectReason) -> Result<(), Self::Error> { + match reason { + DisconnectReason::ReceivedDisconnect(info) => { + let detail = if info.message.trim().is_empty() { + format!("{:?}", info.reason_code) + } else { + info.message.trim().to_owned() + }; + self.end.record(detail); + Ok(()) + } + DisconnectReason::Error(error) => { + self.end.record(error.to_string()); + Err(error) + } + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use russh::keys::ssh_key::private::Ed25519Keypair; + use russh::keys::ssh_key::{EcdsaCurve, PrivateKey}; + use tablepro_core::credentials::{CredentialInteraction, PromptReply}; + use tablepro_test_support::FakePrompter; + + use super::super::clock::PromptClock; + use super::*; + + fn host_key(seed: u8) -> PublicKey { + PrivateKey::from(Ed25519Keypair::from_seed(&[seed; 32])) + .public_key() + .clone() + } + + fn check(dir: &std::path::Path, replies: Vec) -> (HostKeyCheck, Arc) { + let prompter = Arc::new(FakePrompter::new(replies)); + let prompts = Arc::new(Prompts::new( + CredentialInteraction::Attended(prompter.clone()), + Arc::new(PromptClock::new(Duration::from_secs(20))), + "deploy@db.internal:2222".to_owned(), + tokio_util::sync::CancellationToken::new(), + )); + let services = InProcessSsh { + user_known_hosts: Some(dir.join("known_hosts")), + other_known_hosts: Vec::new(), + identity_files: Vec::new(), + agent: None, + pageant: false, + }; + (HostKeyCheck::new(&services, "DB.internal", 2222, prompts), prompter) + } + + fn presented(key: &PublicKey) -> PublicKeyOrCertificate { + PublicKeyOrCertificate::PublicKey { + key: key.clone(), + hash_alg: None, + } + } + + #[test] + fn key_types_read_as_openssh_prints_them() { + assert_eq!(key_type_label(&Algorithm::Ed25519), "ED25519"); + assert_eq!( + key_type_label(&Algorithm::Ecdsa { + curve: EcdsaCurve::NistP384 + }), + "ECDSA" + ); + assert_eq!(key_type_label(&Algorithm::Rsa { hash: None }), "RSA"); + } + + #[tokio::test] + async fn an_unknown_key_the_user_trusts_is_recorded_and_not_asked_about_again() { + let dir = tempfile::tempdir().unwrap(); + let accept = PromptReply::Submitted { + values: Vec::new(), + remember: false, + }; + let (check, prompter) = check(dir.path(), vec![accept]); + + assert!(check.accept(&presented(&host_key(1))).await); + assert!(check.accept(&presented(&host_key(1))).await); + + let requests = prompter.requests(); + assert_eq!(requests.len(), 1); + let text = std::fs::read_to_string(dir.path().join("known_hosts")).unwrap(); + assert!(text.starts_with("[db.internal]:2222 ssh-ed25519 "), "{text}"); + } + + #[tokio::test] + async fn a_declined_key_is_refused_with_its_fingerprint() { + let dir = tempfile::tempdir().unwrap(); + let (check, _) = check(dir.path(), vec![PromptReply::Cancelled]); + let key = host_key(1); + + assert!(!check.accept(&presented(&key)).await); + + assert_eq!( + check.take_refusal(), + Some(SshFailure::HostKeyUnknown { + host: "[db.internal]:2222".to_owned(), + algorithm: "ED25519".to_owned(), + sha256_fingerprint: key.fingerprint(HashAlg::Sha256).to_string(), + trust_allowed: true, + declined: true, + }) + ); + assert!(!dir.path().join("known_hosts").exists()); + } + + #[tokio::test] + async fn a_changed_key_names_the_file_and_line_and_asks_nothing() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("known_hosts"); + std::fs::write( + &file, + format!("# first\n[db.internal]:2222 {}\n", host_key(2).to_openssh().unwrap()), + ) + .unwrap(); + let (check, prompter) = check(dir.path(), Vec::new()); + + assert!(!check.accept(&presented(&host_key(1))).await); + + assert!(prompter.requests().is_empty()); + assert!(matches!( + check.take_refusal(), + Some(SshFailure::HostKeyChanged { known_hosts, line: 2, trust_allowed: true, .. }) if known_hosts == file + )); + } + + #[test] + fn the_first_end_is_the_one_kept() { + let end = SessionEnd::default(); + + end.record("Keepalive timeout".to_owned()); + end.record("Disconnected".to_owned()); + + assert!(end.closed().is_cancelled()); + assert_eq!(end.detail().as_deref(), Some("Keepalive timeout")); + } +} diff --git a/core/crates/ssh/src/in_process/identity.rs b/core/crates/ssh/src/in_process/identity.rs new file mode 100644 index 0000000000..9ca5707ce6 --- /dev/null +++ b/core/crates/ssh/src/in_process/identity.rs @@ -0,0 +1,282 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use russh::keys::agent::AgentIdentity; +use russh::keys::ssh_encoding::Encode; +use russh::keys::ssh_key::private::KeypairData; +use russh::keys::ssh_key::{Algorithm, Certificate, HashAlg, PrivateKey, PublicKey, Signature}; +use secrecy::{ExposeSecret, SecretString}; +use tablepro_core::SshFailure; +use tablepro_core::credentials::PromptReason; +use zeroize::Zeroizing; + +use super::prompts::Prompts; + +// OpenSSH's NumberOfPasswordPrompts default, which also bounds a key's passphrase. +pub(crate) const TRIES: usize = 3; + +// The key's public half without its passphrase: the `.pub` beside it, or the +// copy an OpenSSH key file keeps in the clear. +pub(crate) async fn public_half(path: &Path) -> Option { + let mut beside = path.as_os_str().to_owned(); + beside.push(".pub"); + if let Ok(text) = tokio::fs::read_to_string(PathBuf::from(beside)).await + && let Ok(key) = PublicKey::from_openssh(text.trim()) + { + return Some(key); + } + let text = Zeroizing::new(tokio::fs::read_to_string(path).await.ok()?); + PrivateKey::from_openssh(text.as_bytes()) + .ok() + .map(|key| key.public_key().clone()) +} + +// The `-cert.pub` OpenSSH offers beside a key file. +pub(crate) async fn certificate_beside(path: &Path) -> Option { + let mut beside = path.as_os_str().to_owned(); + beside.push("-cert.pub"); + let text = tokio::fs::read_to_string(PathBuf::from(beside)).await.ok()?; + Certificate::from_openssh(text.trim()).ok() +} + +// `chosen` is the file the connection names: what is wrong with it is the +// error, where a default identity that cannot be used is skipped as ssh skips it. +// `None` means nobody could give the passphrase. +pub(crate) async fn load( + path: &Path, + chosen: bool, + stored: &mut Option, + prompts: &Prompts, +) -> Result, SshFailure> { + let text = match tokio::fs::read_to_string(path).await { + Ok(text) => Arc::new(Zeroizing::new(text)), + Err(error) if chosen => { + return Err(SshFailure::IdentityFileUnreadable { + path: path.to_owned(), + kind: error.kind(), + }); + } + Err(error) => { + tracing::debug!(%error, path = %path.display(), "a default identity could not be read"); + return Ok(None); + } + }; + match decode(&text, None).await { + Ok(key) => return usable(key, path, chosen), + Err(russh::keys::Error::KeyIsEncrypted) => {} + Err(error) => return unusable(&error, path, chosen), + } + let mut reason = PromptReason::NotStored; + for _ in 0..TRIES { + let passphrase = match stored.take() { + Some(passphrase) => passphrase, + None => match prompts.passphrase(path, reason.clone()).await? { + Some(passphrase) => passphrase, + None => return Ok(None), + }, + }; + match decode(&text, Some(passphrase)).await { + Ok(key) => return usable(key, path, chosen), + Err(error) => { + tracing::debug!(%error, path = %path.display(), "the passphrase did not open the key"); + reason = PromptReason::Rejected; + } + } + } + Ok(None) +} + +// Signs as an agent does, so a certificate beside a key file goes out with the RSA hash the server +// takes, which russh's own certificate login does not choose. +pub(crate) struct KeySigner(pub Arc); + +impl russh::Signer for KeySigner { + type Error = russh::AgentAuthError; + + async fn auth_sign( + &mut self, + _identity: &AgentIdentity, + hash_alg: Option, + mut to_sign: Vec, + ) -> Result, Self::Error> { + let signature = sign(&self.0, hash_alg, &to_sign).map_err(russh::keys::Error::SshKey)?; + let blob = signature.encode_vec().map_err(russh::keys::Error::SshEncoding)?; + blob.encode(&mut to_sign).map_err(russh::keys::Error::SshEncoding)?; + Ok(to_sign) + } +} + +fn sign(key: &PrivateKey, hash_alg: Option, data: &[u8]) -> Result { + use russh::keys::signature::Signer; + Ok(match key.key_data() { + KeypairData::Rsa(rsa) => (rsa, hash_alg).try_sign(data)?, + keypair => keypair.try_sign(data)?, + }) +} + +async fn decode( + text: &Arc>, + passphrase: Option, +) -> Result { + let text = text.clone(); + // A key's KDF rounds take a while, so they run off the runtime. + tokio::task::spawn_blocking(move || { + russh::keys::decode_secret_key(&text, passphrase.as_ref().map(ExposeSecret::expose_secret)) + }) + .await + .map_err(|_| russh::keys::Error::CouldNotReadKey)? +} + +fn usable(key: PrivateKey, path: &Path, chosen: bool) -> Result, SshFailure> { + // A security key signs on the device, which nothing in process can reach. + if matches!(key.algorithm(), Algorithm::SkEd25519 | Algorithm::SkEcdsaSha2NistP256) { + return unusable(&russh::keys::Error::CouldNotReadKey, path, chosen); + } + Ok(Some(key)) +} + +fn unusable(error: &russh::keys::Error, path: &Path, chosen: bool) -> Result, SshFailure> { + tracing::debug!(%error, path = %path.display(), "not a private key this transport can use"); + if chosen { + return Err(SshFailure::IdentityFileInvalid { path: path.to_owned() }); + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use russh::keys::ssh_key::private::Ed25519Keypair; + use russh::keys::ssh_key::{Cipher, Kdf, LineEnding}; + use tablepro_core::credentials::{CredentialInteraction, PromptPurpose, PromptReply}; + use tablepro_test_support::FakePrompter; + + use super::super::clock::PromptClock; + use super::*; + + fn key() -> PrivateKey { + PrivateKey::from(Ed25519Keypair::from_seed(&[3; 32])) + } + + fn write_key(dir: &Path, passphrase: Option<&str>) -> PathBuf { + let path = dir.join("id_ed25519"); + let key = match passphrase { + Some(passphrase) => { + let kdf = Kdf::Bcrypt { + salt: vec![5; 16], + rounds: 4, + }; + key().encrypt_with(Cipher::Aes256Ctr, kdf, 7, passphrase).unwrap() + } + None => key(), + }; + std::fs::write(&path, key.to_openssh(LineEnding::LF).unwrap().as_bytes()).unwrap(); + path + } + + fn prompts(replies: Vec) -> (Prompts, Arc) { + let prompter = Arc::new(FakePrompter::new(replies)); + let prompts = Prompts::new( + CredentialInteraction::Attended(prompter.clone()), + Arc::new(PromptClock::new(Duration::from_secs(20))), + "db".to_owned(), + tokio_util::sync::CancellationToken::new(), + ); + (prompts, prompter) + } + + fn typed(text: &str) -> PromptReply { + PromptReply::Submitted { + values: vec![SecretString::from(text)], + remember: false, + } + } + + #[tokio::test] + async fn the_stored_passphrase_goes_first_and_a_wrong_one_asks_again() { + let dir = tempfile::tempdir().unwrap(); + let path = write_key(dir.path(), Some("right")); + let (prompts, prompter) = prompts(vec![typed("right")]); + let mut stored = Some(SecretString::from("wrong")); + + let loaded = load(&path, true, &mut stored, &prompts).await.unwrap(); + + assert_eq!(loaded.unwrap().public_key(), key().public_key()); + let requests = prompter.requests(); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].purpose, + PromptPurpose::SshPassphrase { + path: path.clone(), + reason: PromptReason::Rejected, + } + ); + } + + #[tokio::test] + async fn the_signer_appends_a_signature_the_key_verifies_as_an_agent_does() { + use russh::keys::signature::Verifier; + use russh::keys::ssh_encoding::Decode; + + let key = Arc::new(key()); + let data = b"session and request".to_vec(); + let identity = AgentIdentity::from(key.public_key().clone()); + + let signed = russh::Signer::auth_sign(&mut KeySigner(key.clone()), &identity, None, data.clone()) + .await + .unwrap(); + + assert!(signed.starts_with(&data)); + let mut appended = &signed[data.len()..]; + let blob = Vec::::decode(&mut appended).unwrap(); + let signature = Signature::decode(&mut blob.as_slice()).unwrap(); + assert!(appended.is_empty()); + key.public_key().key_data().verify(&data, &signature).unwrap(); + } + + #[tokio::test] + async fn the_public_half_of_an_encrypted_key_reads_without_its_passphrase() { + let dir = tempfile::tempdir().unwrap(); + let path = write_key(dir.path(), Some("right")); + + assert_eq!(public_half(&path).await.as_ref(), Some(key().public_key())); + } + + #[tokio::test] + async fn a_chosen_file_that_is_missing_or_not_a_key_is_the_error_and_a_default_one_is_skipped() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing"); + let garbage = dir.path().join("garbage"); + std::fs::write(&garbage, "not a key").unwrap(); + let (prompts, _) = prompts(Vec::new()); + + assert!(matches!( + load(&missing, true, &mut None, &prompts).await, + Err(SshFailure::IdentityFileUnreadable { + kind: std::io::ErrorKind::NotFound, + .. + }) + )); + assert_eq!( + load(&garbage, true, &mut None, &prompts).await.unwrap_err(), + SshFailure::IdentityFileInvalid { path: garbage.clone() } + ); + assert!(load(&missing, false, &mut None, &prompts).await.unwrap().is_none()); + assert!(load(&garbage, false, &mut None, &prompts).await.unwrap().is_none()); + } + + #[tokio::test] + async fn an_encrypted_key_with_nobody_to_ask_is_not_used() { + let dir = tempfile::tempdir().unwrap(); + let path = write_key(dir.path(), Some("right")); + let unattended = Prompts::new( + CredentialInteraction::Unattended, + Arc::new(PromptClock::new(Duration::from_secs(20))), + "db".to_owned(), + tokio_util::sync::CancellationToken::new(), + ); + + assert!(load(&path, true, &mut None, &unattended).await.unwrap().is_none()); + } +} diff --git a/core/crates/ssh/src/in_process/known_hosts.rs b/core/crates/ssh/src/in_process/known_hosts.rs new file mode 100644 index 0000000000..0bb8047c0d --- /dev/null +++ b/core/crates/ssh/src/in_process/known_hosts.rs @@ -0,0 +1,501 @@ +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use aws_lc_rs::hmac; +use russh::keys::ssh_key::certificate::CertType; +use russh::keys::ssh_key::known_hosts::{Entry, HostPatterns, Marker}; +use russh::keys::ssh_key::public::KeyData; +use russh::keys::ssh_key::{Algorithm, Certificate, HashAlg, PublicKey}; + +// Lower case, and the port in brackets unless it is 22, as OpenSSH files a host. +pub(crate) fn lookup_name(host: &str, port: u16) -> String { + let host = host.to_ascii_lowercase(); + if port == 22 { host } else { format!("[{host}]:{port}") } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Verdict { + Trusted, + Unknown, + Changed { path: PathBuf, line: usize }, + Revoked { path: PathBuf, line: usize }, +} + +#[derive(Debug, Clone)] +pub(crate) struct KnownKey { + pub path: PathBuf, + pub line: usize, + marker: Option, + key: PublicKey, +} + +// A missing file names nothing, and a line ssh could not read either, bytes that are not UTF-8 included, is skipped. +pub(crate) fn read_known(path: &Path, name: &str) -> io::Result> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error), + }; + Ok(bytes + .split(|&byte| byte == b'\n') + .enumerate() + .filter_map(|(index, raw)| { + let entry = parse_line(std::str::from_utf8(raw).ok()?)?; + names_host(entry.host_patterns(), name).then(|| KnownKey { + path: path.to_owned(), + line: index + 1, + marker: entry.marker().copied(), + key: entry.public_key().clone(), + }) + }) + .collect()) +} + +// What OpenSSH asks the server for first: the key types the host is known by, and certificates +// when an authority vouches for it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct KnownTypes { + pub plain: Vec, + pub authority: bool, +} + +pub(crate) fn known_types(known: &[KnownKey]) -> KnownTypes { + let mut types = KnownTypes::default(); + for entry in known { + match entry.marker { + None => { + let algorithm = entry.key.algorithm(); + if !types.plain.contains(&algorithm) { + types.plain.push(algorithm); + } + } + Some(Marker::CertAuthority) => types.authority = true, + Some(Marker::Revoked) => {} + } + } + types +} + +// OpenSSH's decision over every line naming the host; `principal` is the name a host certificate must list. +pub(crate) fn verdict( + known: &[KnownKey], + key: &PublicKey, + certificate: Option<&Certificate>, + principal: &str, + now: u64, +) -> Verdict { + let authority = certificate.map(Certificate::signature_key); + let revoked = known.iter().find(|entry| { + entry.marker == Some(Marker::Revoked) + && (entry.key.key_data() == key.key_data() || authority.is_some_and(|ca| entry.key.key_data() == ca)) + }); + if let Some(entry) = revoked { + return Verdict::Revoked { + path: entry.path.clone(), + line: entry.line, + }; + } + if let Some(certificate) = certificate + && known.iter().any(|entry| { + entry.marker == Some(Marker::CertAuthority) && certified(certificate, entry.key.key_data(), principal, now) + }) + { + return Verdict::Trusted; + } + // A certificate no authority vouches for is checked as the plain key it carries, as OpenSSH does. + let mut plain = known.iter().filter(|entry| entry.marker.is_none()); + if plain.clone().any(|entry| entry.key.key_data() == key.key_data()) { + return Verdict::Trusted; + } + match plain.find(|entry| entry.key.algorithm() == key.algorithm()) { + Some(entry) => Verdict::Changed { + path: entry.path.clone(), + line: entry.line, + }, + None => Verdict::Unknown, + } +} + +fn certified(certificate: &Certificate, authority: &KeyData, principal: &str, now: u64) -> bool { + let fingerprint = authority.fingerprint(HashAlg::Sha256); + certificate.cert_type() == CertType::Host + && certificate.signature_key() == authority + && certificate.critical_options().is_empty() + && (certificate.valid_principals().is_empty() + || certificate + .valid_principals() + .iter() + .any(|listed| listed.eq_ignore_ascii_case(principal))) + && certificate.validate_at(now, [&fingerprint]).is_ok() +} + +pub(crate) fn append(path: &Path, name: &str, key: &PublicKey) -> io::Result<()> { + if !path.is_absolute() { + return Err(io::Error::new(io::ErrorKind::NotFound, "no home directory")); + } + if let Some(dir) = path.parent() { + create_ssh_dir(dir)?; + } + let text = PublicKey::new(key.key_data().clone(), "") + .to_openssh() + .map_err(io::Error::other)?; + let needs_newline = fs::read(path).is_ok_and(|bytes| !bytes.is_empty() && !bytes.ends_with(b"\n")); + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + let mut line = String::new(); + if needs_newline { + line.push('\n'); + } + line.push_str(&format!("{name} {text}\n")); + file.write_all(line.as_bytes()) +} + +// As `ssh-keygen -R`: plain key lines for the host go, markers and the rest stay as they were. +pub(crate) fn forget_host(path: &Path, name: &str) -> io::Result<()> { + let bytes = fs::read(path)?; + let forgotten = |raw: &[u8]| { + std::str::from_utf8(raw) + .ok() + .and_then(parse_line) + .is_some_and(|entry| entry.marker().is_none() && names_host(entry.host_patterns(), name)) + }; + let kept: Vec = bytes + .split_inclusive(|&byte| byte == b'\n') + .filter(|raw| !forgotten(raw)) + .flatten() + .copied() + .collect(); + if kept.len() == bytes.len() { + return Ok(()); + } + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + let mut replacement = tempfile::NamedTempFile::new_in(dir)?; + replacement.write_all(&kept)?; + replacement.as_file().sync_all()?; + replacement.persist(path).map_err(|error| error.error)?; + Ok(()) +} + +// ssh-key splits fields on one space, where OpenSSH takes any run of blanks and a trailing comment. +fn parse_line(raw: &str) -> Option { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let fields: Vec<&str> = line.split_whitespace().collect(); + let wanted = if line.starts_with('@') { 4 } else { 3 }; + fields.get(..wanted)?.join(" ").parse().ok() +} + +fn names_host(patterns: &HostPatterns, name: &str) -> bool { + match patterns { + HostPatterns::HashedName { salt, hash } => { + let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, salt); + hmac::verify(&key, name.as_bytes(), hash).is_ok() + } + HostPatterns::Patterns(patterns) => { + let mut named = false; + for pattern in patterns { + match pattern.strip_prefix('!') { + Some(negated) if wildcard(negated, name) => return false, + Some(_) => {} + None => named |= wildcard(pattern, name), + } + } + named + } + } +} + +// `*` and `?` as OpenSSH's match_pattern reads them, ignoring case. +fn wildcard(pattern: &str, text: &str) -> bool { + let pattern: Vec = pattern.to_lowercase().chars().collect(); + let text: Vec = text.to_lowercase().chars().collect(); + let (mut p, mut t) = (0, 0); + let mut retry: Option<(usize, usize)> = None; + while t < text.len() { + match pattern.get(p) { + Some('*') => { + retry = Some((p, t)); + p += 1; + } + Some(&c) if c == '?' || c == text[t] => { + p += 1; + t += 1; + } + _ => match retry { + Some((star, matched)) => { + p = star + 1; + t = matched + 1; + retry = Some((star, matched + 1)); + } + None => return false, + }, + } + } + pattern[p..].iter().all(|&c| c == '*') +} + +#[cfg(unix)] +fn create_ssh_dir(dir: &Path) -> io::Result<()> { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new().recursive(true).mode(0o700).create(dir) +} + +#[cfg(not(unix))] +fn create_ssh_dir(dir: &Path) -> io::Result<()> { + fs::create_dir_all(dir) +} + +#[cfg(test)] +mod tests { + use russh::keys::ssh_key::private::Ed25519Keypair; + use russh::keys::ssh_key::{PrivateKey, certificate}; + + use super::*; + + const ECDSA: &str = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHwf2HMM5TRXvo2SQJjsNkiDD5KqiiNjrGVv3UUh+mMT5RHxiRtOnlqvjhQtBq0VpmpCV/PwUdhOig4vkbqAcEc="; + + fn ed25519_private(seed: u8) -> PrivateKey { + PrivateKey::from(Ed25519Keypair::from_seed(&[seed; 32])) + } + + fn ed25519(seed: u8) -> PublicKey { + ed25519_private(seed).public_key().clone() + } + + fn line(key: &PublicKey) -> String { + key.to_openssh().unwrap() + } + + fn ecdsa() -> PublicKey { + PublicKey::from_openssh(ECDSA).unwrap() + } + + fn known_file(contents: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("known_hosts"); + fs::write(&path, contents).unwrap(); + (dir, path) + } + + fn decide(contents: &str, name: &str, presented: &PublicKey) -> Verdict { + let (_dir, path) = known_file(contents); + let known = read_known(&path, name).unwrap(); + verdict(&known, presented, None, "db.internal", 0) + } + + #[test] + fn the_lookup_name_brackets_a_port_other_than_22() { + assert_eq!(lookup_name("Bastion.Example.com", 22), "bastion.example.com"); + assert_eq!(lookup_name("db.internal", 2222), "[db.internal]:2222"); + assert_eq!(lookup_name("fd00::1", 2200), "[fd00::1]:2200"); + } + + #[test] + fn patterns_take_wildcards_and_a_negation_wins() { + let patterns = |list: &[&str]| HostPatterns::Patterns(list.iter().map(|p| (*p).to_owned()).collect()); + + assert!(names_host(&patterns(&["*.example.com"]), "db.example.com")); + assert!(names_host(&patterns(&["db?.example.com"]), "db1.example.com")); + assert!(names_host(&patterns(&["[db.internal]:2222"]), "[db.internal]:2222")); + assert!(!names_host(&patterns(&["db.internal"]), "[db.internal]:2222")); + assert!(!names_host( + &patterns(&["*.example.com", "!db.example.com"]), + "db.example.com" + )); + assert!(names_host( + &patterns(&["*.example.com", "!db.example.com"]), + "web.example.com" + )); + assert!(names_host(&patterns(&["DB.Example.COM"]), "db.example.com")); + assert!(!names_host(&patterns(&["!db.example.com"]), "web.example.com")); + } + + #[test] + fn a_hashed_name_matches_only_its_host() { + let salt = b"0123456789abcdefghij".to_vec(); + let signature = hmac::sign( + &hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, &salt), + b"[db.internal]:2222", + ); + let mut hash = [0_u8; 20]; + hash.copy_from_slice(signature.as_ref()); + let hashed = HostPatterns::HashedName { salt, hash }; + + assert!(names_host(&hashed, "[db.internal]:2222")); + assert!(!names_host(&hashed, "db.internal")); + } + + #[test] + fn a_known_key_is_trusted_and_blanks_and_comments_are_read_as_ssh_reads_them() { + let contents = format!( + "# comment\n\n@cert-authority *.example.com {ECDSA}\ndb.internal\t{} laptop\n", + line(&ed25519(1)) + ); + + assert_eq!(decide(&contents, "db.internal", &ed25519(1)), Verdict::Trusted); + } + + #[test] + fn another_key_of_the_same_type_is_a_change_on_its_line() { + let contents = format!( + "other {}\ndb.internal,10.0.0.5 {}\n", + line(&ed25519(1)), + line(&ed25519(2)) + ); + + assert!(matches!( + decide(&contents, "db.internal", &ed25519(1)), + Verdict::Changed { line: 2, .. } + )); + } + + #[test] + fn a_key_of_another_type_or_no_line_at_all_is_unknown() { + let contents = format!("db.internal {ECDSA}\n"); + + assert_eq!(decide(&contents, "db.internal", &ed25519(1)), Verdict::Unknown); + assert_eq!(decide("", "db.internal", &ed25519(1)), Verdict::Unknown); + assert_eq!(decide(&contents, "[db.internal]:2222", &ecdsa()), Verdict::Unknown); + } + + #[test] + fn a_revoked_key_is_refused_even_when_listed_as_trusted() { + let key = line(&ed25519(1)); + let contents = format!("db.internal {key}\n@revoked * {key}\n"); + + assert!(matches!( + decide(&contents, "db.internal", &ed25519(1)), + Verdict::Revoked { line: 2, .. } + )); + } + + #[test] + fn a_host_certificate_from_a_listed_authority_is_trusted_for_its_principals() { + let authority = ed25519_private(9); + let host_key = ecdsa(); + let sign = |principal: &str, cert_type: CertType| { + let mut builder = certificate::Builder::new(vec![7; 16], host_key.key_data().clone(), 100, 200).unwrap(); + builder.cert_type(cert_type).unwrap(); + builder.valid_principal(principal).unwrap(); + builder.sign(&authority).unwrap() + }; + let (_dir, path) = known_file(&format!( + "@cert-authority *.internal {}\n", + line(authority.public_key()) + )); + let known = read_known(&path, "db.internal").unwrap(); + + let trusted = sign("db.internal", CertType::Host); + let wrong_host = sign("web.internal", CertType::Host); + let user_cert = sign("db.internal", CertType::User); + + assert_eq!( + verdict(&known, &host_key, Some(&trusted), "db.internal", 150), + Verdict::Trusted + ); + assert_eq!( + verdict(&known, &host_key, Some(&trusted), "db.internal", 250), + Verdict::Unknown + ); + assert_eq!( + verdict(&known, &host_key, Some(&wrong_host), "db.internal", 150), + Verdict::Unknown + ); + assert_eq!( + verdict(&known, &host_key, Some(&user_cert), "db.internal", 150), + Verdict::Unknown + ); + } + + #[test] + fn a_trusted_key_is_appended_on_a_line_of_its_own_and_read_back() { + let (_dir, path) = known_file(&format!("other {ECDSA}")); + + append(&path, "[db.internal]:2222", &ed25519(1)).unwrap(); + + let text = fs::read_to_string(&path).unwrap(); + assert_eq!( + text, + format!("other {ECDSA}\n[db.internal]:2222 {}\n", line(&ed25519(1))) + ); + let known = read_known(&path, "[db.internal]:2222").unwrap(); + assert_eq!(verdict(&known, &ed25519(1), None, "db.internal", 0), Verdict::Trusted); + } + + #[test] + fn appending_makes_the_directory_and_refuses_a_relative_path() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(".ssh").join("known_hosts"); + + append(&path, "db.internal", &ed25519(1)).unwrap(); + + assert!(path.is_file()); + assert!(append(Path::new("known_hosts"), "db.internal", &ed25519(1)).is_err()); + } + + #[test] + fn forgetting_removes_only_the_hosts_plain_lines() { + let (one, two) = (line(&ed25519(1)), line(&ed25519(2))); + let contents = + format!("# keep\ndb.internal {one}\n@revoked db.internal {two}\nweb.internal {one}\ndb.internal {ECDSA}\n"); + let (_dir, path) = known_file(&contents); + + forget_host(&path, "db.internal").unwrap(); + + assert_eq!( + fs::read_to_string(&path).unwrap(), + format!("# keep\n@revoked db.internal {two}\nweb.internal {one}\n") + ); + } + + #[test] + fn a_line_that_is_not_utf8_is_skipped_and_the_rest_still_read() { + let key = line(&ed25519(1)); + let mut contents = b"# caf\xe9\n".to_vec(); + contents.extend(format!("@revoked db.internal {key}\nweb.internal {key}\n").as_bytes()); + let (_dir, path) = known_file(""); + fs::write(&path, &contents).unwrap(); + + let known = read_known(&path, "db.internal").unwrap(); + forget_host(&path, "web.internal").unwrap(); + + assert!(matches!( + verdict(&known, &ed25519(1), None, "db.internal", 0), + Verdict::Revoked { line: 2, .. } + )); + assert!(fs::read(&path).unwrap().starts_with(b"# caf\xe9\n@revoked")); + } + + #[test] + fn the_types_a_host_is_known_by_come_from_its_plain_lines() { + let contents = format!( + "db.internal {ECDSA}\n@revoked db.internal {}\ndb.internal {}\ndb.internal {ECDSA}\n", + line(&ed25519(1)), + line(&ed25519(2)) + ); + let (_dir, path) = known_file(&contents); + + let known = read_known(&path, "db.internal").unwrap(); + + assert_eq!( + known_types(&known), + KnownTypes { + plain: vec![ecdsa().algorithm(), Algorithm::Ed25519], + authority: false, + } + ); + let (_dir, path) = known_file(&format!("@cert-authority *.internal {}\n", line(&ed25519(3)))); + assert!(known_types(&read_known(&path, "db.internal").unwrap()).authority); + } + + #[test] + fn wildcards_backtrack() { + assert!(wildcard("*.*.com", "db.example.com")); + assert!(wildcard("a*b*c", "aXbYbZc")); + assert!(!wildcard("a*b*c", "aXbYbZ")); + assert!(wildcard("*", "")); + assert!(!wildcard("?", "")); + } +} diff --git a/core/crates/ssh/src/in_process/mod.rs b/core/crates/ssh/src/in_process/mod.rs new file mode 100644 index 0000000000..8e0be12b57 --- /dev/null +++ b/core/crates/ssh/src/in_process/mod.rs @@ -0,0 +1,18 @@ +mod abortable; +mod agent; +mod auth; +mod clock; +mod failure; +mod forward; +mod handler; +mod identity; +mod known_hosts; +#[cfg(windows)] +mod pipe_owner; +mod prompts; +mod services; +mod session; + +pub use forward::InProcessForward; +pub use services::InProcessSsh; +pub use session::InProcessSession; diff --git a/core/crates/ssh/src/in_process/pipe_owner.rs b/core/crates/ssh/src/in_process/pipe_owner.rs new file mode 100644 index 0000000000..fadea922ba --- /dev/null +++ b/core/crates/ssh/src/in_process/pipe_owner.rs @@ -0,0 +1,93 @@ +use std::io; +use std::os::windows::io::RawHandle; +use std::ptr; + +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree}; +use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_KERNEL_OBJECT}; +use windows_sys::Win32::Security::{ + EqualSid, GetTokenInformation, IsWellKnownSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, TOKEN_QUERY, + TOKEN_USER, TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + +// The OpenSSH agent service runs as LocalSystem, whose objects the Administrators group may own; +// an agent the user runs, such as 1Password's, owns its pipe as the user. +pub(crate) fn owned_by_user_or_system(pipe: RawHandle) -> io::Result { + let mut owner: PSID = ptr::null_mut(); + let mut descriptor: PSECURITY_DESCRIPTOR = ptr::null_mut(); + // SAFETY: `pipe` is open; `owner` points into `descriptor`, freed below. + let status = unsafe { + GetSecurityInfo( + pipe, + SE_KERNEL_OBJECT, + OWNER_SECURITY_INFORMATION, + &mut owner, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + &mut descriptor, + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error(status.cast_signed())); + } + let trusted = trusted_owner(owner); + // SAFETY: `descriptor` came from GetSecurityInfo and is freed once. + unsafe { LocalFree(descriptor) }; + trusted +} + +fn trusted_owner(owner: PSID) -> io::Result { + // SAFETY: `owner` is a valid SID while its descriptor lives, which outlives this call. + let system = unsafe { + IsWellKnownSid(owner, WinLocalSystemSid) != 0 || IsWellKnownSid(owner, WinBuiltinAdministratorsSid) != 0 + }; + if system { + return Ok(true); + } + // SAFETY: both SIDs are valid for the call. + with_user_sid(|user| unsafe { EqualSid(owner, user) } != 0) +} + +fn with_user_sid(use_sid: impl FnOnce(PSID) -> T) -> io::Result { + let mut token: HANDLE = ptr::null_mut(); + // SAFETY: the current process's pseudo handle needs no closing; `token` is closed below. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let mut size = 0_u32; + // SAFETY: a null buffer of length 0 only asks for the size. + unsafe { GetTokenInformation(token, TokenUser, ptr::null_mut(), 0, &mut size) }; + // u64 words keep the TOKEN_USER's pointer aligned. + let mut buffer = vec![0_u64; usize::try_from(size).unwrap_or(0).div_ceil(8)]; + // SAFETY: `buffer` holds `size` bytes or more. + let filled = unsafe { GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), size, &mut size) }; + let result = if filled == 0 { + Err(io::Error::last_os_error()) + } else { + // SAFETY: GetTokenInformation wrote a TOKEN_USER at the start of `buffer`. + let user = unsafe { &*buffer.as_ptr().cast::() }; + Ok(use_sid(user.User.Sid)) + }; + // SAFETY: `token` was opened above and is closed once. + unsafe { CloseHandle(token) }; + result +} + +#[cfg(test)] +mod tests { + use std::os::windows::io::AsRawHandle; + + use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions}; + + use super::*; + + #[tokio::test] + async fn a_pipe_this_user_created_is_trusted() { + let name = format!(r"\\.\pipe\tablepro-owner-test-{}", std::process::id()); + let _server = ServerOptions::new().first_pipe_instance(true).create(&name).unwrap(); + let client = ClientOptions::new().open(&name).unwrap(); + + assert!(owned_by_user_or_system(client.as_raw_handle()).unwrap()); + } +} diff --git a/core/crates/ssh/src/in_process/prompts.rs b/core/crates/ssh/src/in_process/prompts.rs new file mode 100644 index 0000000000..f18f017ced --- /dev/null +++ b/core/crates/ssh/src/in_process/prompts.rs @@ -0,0 +1,270 @@ +use std::path::Path; +use std::sync::Arc; + +use secrecy::SecretString; +use tablepro_core::SshFailure; +use tablepro_core::credentials::{ + CredentialInteraction, CredentialPrompt, PromptField, PromptPurpose, PromptReason, PromptReply, +}; + +use tokio_util::sync::CancellationToken; + +use super::clock::PromptClock; + +// The questions a login asks the user, each one stopping the handshake clock. +pub(crate) struct Prompts { + interaction: CredentialInteraction, + clock: Arc, + destination: String, + // Set once the connect gives up, so a question still open cannot trust a key for it later. + abort: CancellationToken, +} + +impl Prompts { + pub fn new( + interaction: CredentialInteraction, + clock: Arc, + destination: String, + abort: CancellationToken, + ) -> Self { + Self { + interaction, + clock, + destination, + abort, + } + } + + async fn ask(&self, request: CredentialPrompt) -> Option { + let CredentialInteraction::Attended(prompter) = &self.interaction else { + return None; + }; + let _paused = self.clock.pause(); + tokio::select! { + () = self.abort.cancelled() => Some(PromptReply::Cancelled), + reply = prompter.prompt(request) => Some(reply), + } + } + + // Unattended, or a no, both leave the key untrusted, as the askpass bridge answers ssh. + pub async fn trust_host_key(&self, host: &str, algorithm: &str, fingerprint: &str) -> bool { + let request = CredentialPrompt { + purpose: PromptPurpose::SshHostKeyConfirmation { + host: host.to_owned(), + algorithm: algorithm.to_owned(), + fingerprint: fingerprint.to_owned(), + }, + target: self.destination.clone(), + fields: Vec::new(), + offer_remember: false, + }; + matches!(self.ask(request).await, Some(PromptReply::Submitted { .. })) + } + + pub async fn password(&self, user_host: &str, reason: PromptReason) -> Result, SshFailure> { + self.secret(PromptPurpose::SshPassword { reason }, user_host, "Password") + .await + } + + pub async fn passphrase(&self, path: &Path, reason: PromptReason) -> Result, SshFailure> { + let purpose = PromptPurpose::SshPassphrase { + path: path.to_owned(), + reason, + }; + self.secret(purpose, &self.destination, "Passphrase").await + } + + // `None` when nobody can be asked; a cancel ends the login. + async fn secret( + &self, + purpose: PromptPurpose, + target: &str, + label: &str, + ) -> Result, SshFailure> { + let request = CredentialPrompt { + purpose, + target: target.to_owned(), + fields: vec![PromptField { + label: label.to_owned(), + secret: true, + }], + offer_remember: false, + }; + match self.ask(request).await { + None => Ok(None), + Some(PromptReply::Submitted { values, .. }) => Ok(Some( + values.into_iter().next().unwrap_or_else(|| SecretString::from("")), + )), + Some(PromptReply::Cancelled) => Err(SshFailure::InteractionCancelled), + } + } + + pub async fn keyboard_interactive( + &self, + target: &str, + name: &str, + instructions: &str, + prompts: &[russh::client::Prompt], + ) -> Result>, SshFailure> { + let request = CredentialPrompt { + purpose: PromptPurpose::SshKeyboardInteractive { + name: name.to_owned(), + instructions: instructions.to_owned(), + }, + target: target.to_owned(), + fields: prompts + .iter() + .map(|prompt| PromptField { + label: prompt.prompt.trim().to_owned(), + secret: !prompt.echo, + }) + .collect(), + offer_remember: false, + }; + match self.ask(request).await { + None => Ok(None), + Some(PromptReply::Submitted { mut values, .. }) => { + values.resize_with(prompts.len(), || SecretString::from("")); + Ok(Some(values)) + } + Some(PromptReply::Cancelled) => Err(SshFailure::InteractionCancelled), + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use secrecy::ExposeSecret; + use tablepro_test_support::FakePrompter; + + use super::*; + + fn prompts(replies: impl IntoIterator) -> (Prompts, Arc) { + let prompter = Arc::new(FakePrompter::new(replies)); + let clock = Arc::new(PromptClock::new(Duration::from_secs(20))); + let prompts = Prompts::new( + CredentialInteraction::Attended(prompter.clone()), + clock, + "deploy@db.internal".to_owned(), + CancellationToken::new(), + ); + (prompts, prompter) + } + + fn submitted(values: &[&str]) -> PromptReply { + PromptReply::Submitted { + values: values.iter().map(|value| SecretString::from(*value)).collect(), + remember: false, + } + } + + #[tokio::test] + async fn each_question_asks_with_the_purpose_ssh_would_and_never_offers_to_remember() { + let (prompts, prompter) = prompts([PromptReply::Cancelled, submitted(&["s3cret"]), submitted(&[])]); + + assert!( + !prompts + .trust_host_key("[db.internal]:2222", "ED25519", "SHA256:abc") + .await + ); + let password = prompts.password("ops@jump1", PromptReason::Rejected).await.unwrap(); + assert!(prompts.trust_host_key("jump1", "ECDSA", "SHA256:def").await); + + assert_eq!(password.unwrap().expose_secret(), "s3cret"); + let requests = prompter.requests(); + assert_eq!( + requests[0].purpose, + PromptPurpose::SshHostKeyConfirmation { + host: "[db.internal]:2222".to_owned(), + algorithm: "ED25519".to_owned(), + fingerprint: "SHA256:abc".to_owned(), + } + ); + assert_eq!(requests[0].target, "deploy@db.internal"); + assert_eq!(requests[1].target, "ops@jump1", "the host asking, not the destination"); + assert_eq!( + requests[1].purpose, + PromptPurpose::SshPassword { + reason: PromptReason::Rejected + } + ); + assert!(requests.iter().all(|request| !request.offer_remember)); + } + + #[tokio::test] + async fn a_cancelled_secret_ends_the_login_and_unattended_asks_nobody() { + let (prompts, _) = prompts([PromptReply::Cancelled]); + let unattended = Prompts::new( + CredentialInteraction::Unattended, + Arc::new(PromptClock::new(Duration::from_secs(20))), + "db".to_owned(), + CancellationToken::new(), + ); + + assert_eq!( + prompts + .passphrase(Path::new("/keys/id_ed25519"), PromptReason::NotStored) + .await + .unwrap_err(), + SshFailure::InteractionCancelled + ); + assert!( + unattended + .password("db", PromptReason::NotStored) + .await + .unwrap() + .is_none() + ); + assert!(!unattended.trust_host_key("db", "ED25519", "SHA256:abc").await); + } + + #[tokio::test] + async fn a_question_open_when_the_connect_gives_up_is_a_no() { + let prompter = Arc::new(FakePrompter::new([submitted(&[])]).with_delay(Duration::from_secs(60))); + let abort = CancellationToken::new(); + let prompts = Prompts::new( + CredentialInteraction::Attended(prompter), + Arc::new(PromptClock::new(Duration::from_secs(20))), + "db".to_owned(), + abort.clone(), + ); + + let asking = prompts.trust_host_key("db", "ED25519", "SHA256:abc"); + tokio::pin!(asking); + tokio::select! { + _ = &mut asking => panic!("the prompt answered before the abort"), + () = tokio::time::sleep(Duration::from_millis(50)) => abort.cancel(), + } + + assert!(!asking.await); + } + + #[tokio::test] + async fn keyboard_interactive_asks_every_prompt_at_once_and_hides_what_the_server_hides() { + let (prompts, prompter) = prompts([submitted(&["123456"])]); + let questions = [ + russh::client::Prompt { + prompt: "Verification code: ".to_owned(), + echo: false, + }, + russh::client::Prompt { + prompt: "Team: ".to_owned(), + echo: true, + }, + ]; + + let answers = prompts + .keyboard_interactive("deploy@db.internal", "MFA", "Enter the code", &questions) + .await + .unwrap() + .unwrap(); + + assert_eq!(answers.len(), 2, "one answer per prompt, blank when none was given"); + let fields = &prompter.requests()[0].fields; + assert_eq!(fields[0].label, "Verification code:"); + assert!(fields[0].secret); + assert!(!fields[1].secret); + } +} diff --git a/core/crates/ssh/src/in_process/services.rs b/core/crates/ssh/src/in_process/services.rs new file mode 100644 index 0000000000..5425e28812 --- /dev/null +++ b/core/crates/ssh/src/in_process/services.rs @@ -0,0 +1,184 @@ +use std::path::{Path, PathBuf}; + +use tablepro_core::SshFailure; + +use super::known_hosts::{forget_host, lookup_name}; +use crate::{ForgetHostKeyError, SshConfig, SshDestination}; + +// OpenSSH's default identities, less the security keys nothing in process can sign with. +const DEFAULT_IDENTITIES: [&str; 3] = ["id_rsa", "id_ecdsa", "id_ed25519"]; + +#[cfg(windows)] +const OPENSSH_AGENT_PIPE: &str = r"\\.\pipe\openssh-ssh-agent"; + +// SSH in this process, where OpenSSH cannot run a ControlMaster (core ADR 0008). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InProcessSsh { + // Read first, and where a key the user trusts is written. + pub user_known_hosts: Option, + pub other_known_hosts: Vec, + // Tried when the connection names no key file, as OpenSSH does. + pub identity_files: Vec, + // A Unix socket, or on Windows a named pipe. + pub agent: Option, + // Windows: ask Pageant when nothing answers at `agent`. + pub pageant: bool, +} + +impl InProcessSsh { + pub fn system() -> Self { + let ssh_dir = std::env::home_dir() + .filter(|home| home.is_absolute()) + .map(|home| home.join(".ssh")); + let mut other_known_hosts: Vec = ssh_dir.iter().map(|dir| dir.join("known_hosts2")).collect(); + other_known_hosts.extend(global_known_hosts()); + Self { + user_known_hosts: ssh_dir.as_ref().map(|dir| dir.join("known_hosts")), + other_known_hosts, + identity_files: ssh_dir + .iter() + .flat_map(|dir| DEFAULT_IDENTITIES.iter().map(move |name| dir.join(name))) + .collect(), + agent: agent_from_environment(), + pageant: cfg!(windows), + } + } + + pub(crate) fn known_hosts_files(&self) -> impl Iterator { + self.user_known_hosts + .iter() + .chain(&self.other_known_hosts) + .map(PathBuf::as_path) + } + + // `host` and `known_hosts` come back from the app, so both are checked against this connection. + pub async fn forget_changed_host_key( + &self, + config: &SshConfig, + host: &str, + known_hosts: &Path, + ) -> Result<(), ForgetHostKeyError> { + let ours = config + .jump_hosts + .iter() + .chain([&config.destination]) + .any(|hop| hop_lookup_name(hop).eq_ignore_ascii_case(host)); + let read = self.known_hosts_files().any(|file| file == known_hosts); + if !ours || !read { + return Err(ForgetHostKeyError::NotThisConnection { + host: host.to_owned(), + known_hosts: known_hosts.to_owned(), + }); + } + let path = known_hosts.to_owned(); + let name = host.to_owned(); + let removed = tokio::task::spawn_blocking(move || forget_host(&path, &name)) + .await + .map_err(|error| crate::setup_task_failed(&error))?; + removed.map_err(|error| { + ForgetHostKeyError::Ssh(SshFailure::KnownHostsUnwritable { + path: known_hosts.to_owned(), + kind: error.kind(), + }) + }) + } +} + +pub(crate) fn hop_lookup_name(hop: &SshDestination) -> String { + lookup_name(hop.host(), hop.port().unwrap_or(22)) +} + +fn agent_from_environment() -> Option { + let named = std::env::var_os("SSH_AUTH_SOCK") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + #[cfg(windows)] + let named = named.or_else(|| Some(PathBuf::from(OPENSSH_AGENT_PIPE))); + named +} + +#[cfg(unix)] +fn global_known_hosts() -> Vec { + ["/etc/ssh/ssh_known_hosts", "/etc/ssh/ssh_known_hosts2"] + .into_iter() + .map(PathBuf::from) + .collect() +} + +// Win32-OpenSSH keeps its system files under %ProgramData%\ssh. +#[cfg(not(unix))] +fn global_known_hosts() -> Vec { + std::env::var_os("ProgramData") + .map(PathBuf::from) + .filter(|dir| dir.is_absolute()) + .map(|dir| { + let ssh = dir.join("ssh"); + vec![ssh.join("ssh_known_hosts"), ssh.join("ssh_known_hosts2")] + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SshAuth; + + fn config(destination: &str, jumps: &str) -> SshConfig { + SshConfig { + destination: SshDestination::parse_jump_list(destination).unwrap().remove(0), + jump_hosts: SshDestination::parse_jump_list(jumps).unwrap(), + auth: SshAuth::Agent, + } + } + + fn services(dir: &Path) -> InProcessSsh { + InProcessSsh { + user_known_hosts: Some(dir.join("known_hosts")), + other_known_hosts: vec![dir.join("global")], + identity_files: Vec::new(), + agent: None, + pageant: false, + } + } + + #[test] + fn the_system_files_are_absolute_and_the_user_file_comes_first() { + let system = InProcessSsh::system(); + + assert!(system.known_hosts_files().all(Path::is_absolute)); + if let Some(user) = &system.user_known_hosts { + assert_eq!(system.known_hosts_files().next(), Some(user.as_path())); + assert!(user.ends_with(Path::new(".ssh").join("known_hosts"))); + } + assert!(system.identity_files.iter().all(|file| file.is_absolute())); + } + + #[tokio::test] + async fn only_a_host_of_this_connection_in_a_file_it_reads_is_forgotten() { + let dir = tempfile::tempdir().unwrap(); + let services = services(dir.path()); + let known_hosts = dir.path().join("known_hosts"); + std::fs::write( + &known_hosts, + "[db.internal]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti\n\ + jump1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti\n", + ) + .unwrap(); + let config = config("deploy@db.internal:2222", "ops@jump1"); + + let elsewhere = services.forget_changed_host_key(&config, "other", &known_hosts).await; + let unread = services + .forget_changed_host_key(&config, "jump1", &dir.path().join("elsewhere")) + .await; + services + .forget_changed_host_key(&config, "jump1", &known_hosts) + .await + .unwrap(); + + assert!(matches!(elsewhere, Err(ForgetHostKeyError::NotThisConnection { .. }))); + assert!(matches!(unread, Err(ForgetHostKeyError::NotThisConnection { .. }))); + let left = std::fs::read_to_string(&known_hosts).unwrap(); + assert!(left.starts_with("[db.internal]:2222 "), "{left}"); + assert!(!left.contains("jump1"), "{left}"); + } +} diff --git a/core/crates/ssh/src/in_process/session.rs b/core/crates/ssh/src/in_process/session.rs new file mode 100644 index 0000000000..ff6c38384d --- /dev/null +++ b/core/crates/ssh/src/in_process/session.rs @@ -0,0 +1,408 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::Arc; + +use russh::Preferred; +use russh::client::{Config, Handle}; +use russh::keys::ssh_key::Algorithm; +use secrecy::SecretString; +use tablepro_core::credentials::CredentialInteraction; +use tablepro_core::{ + LivenessPolicy, NetworkEndpoint, SshDestinationPart, SshDestinationProblem, SshFailure, TimeoutPhase, Transport, + TransportError, TransportStream, +}; +use tablepro_net::DirectTcpTransport; +use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; + +use super::abortable::Abortable; +use super::agent::AgentSlot; +use super::auth::{Login, destination_methods, hop_methods, log_in}; +use super::clock::PromptClock; +use super::failure::{channel_failure, ended, from_russh}; +use super::forward::InProcessForward; +use super::handler::{HopHandler, HostKeyCheck, SessionEnd}; +use super::known_hosts::KnownTypes; +use super::prompts::Prompts; +use super::services::InProcessSsh; +use crate::{SshAuth, SshConfig, SshDestination}; + +const SSH_PORT: u16 = 22; + +// One SSH session in this process: the jump hosts' sessions, each carried +// over the one before, and the destination's last. +pub struct InProcessSession { + destination: String, + first_hop: NetworkEndpoint, + hops: Vec>>, + end: Arc, +} + +impl std::fmt::Debug for InProcessSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InProcessSession") + .field("destination", &self.destination) + .field("hops", &self.hops.len()) + .finish_non_exhaustive() + } +} + +struct Hop<'a> { + destination: &'a SshDestination, + user: String, + port: u16, +} + +impl<'a> Hop<'a> { + fn new(destination: &'a SshDestination) -> Result { + let user = match destination.user() { + Some(user) => user.to_owned(), + None => local_user().ok_or(SshDestinationProblem::Empty { + part: SshDestinationPart::User, + })?, + }; + Ok(Self { + destination, + user, + port: destination.port().unwrap_or(SSH_PORT), + }) + } + + fn host(&self) -> &str { + self.destination.host() + } +} + +// ssh logs in as the local user when none is named. +fn local_user() -> Option { + ["USER", "LOGNAME", "USERNAME"] + .into_iter() + .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty())) +} + +impl InProcessSession { + pub async fn connect( + config: &SshConfig, + services: &InProcessSsh, + liveness: &LivenessPolicy, + interaction: CredentialInteraction, + cancel: CancellationToken, + ) -> Result, TransportError> { + let clock = Arc::new(PromptClock::new(liveness.ssh_handshake_timeout)); + // Ends the half-built chain's streams and open questions when the connect does not finish. + let abort = CancellationToken::new(); + let unfinished = abort.clone().drop_guard(); + let prompts = Arc::new(Prompts::new( + interaction, + clock.clone(), + config.destination.to_string(), + abort.clone(), + )); + let connected = tokio::select! { + biased; + () = cancel.cancelled() => Err(SshFailure::InteractionCancelled.into()), + connected = Self::connect_chain(config, services, liveness, prompts, &abort) => connected, + () = clock.expired() => Err(TransportError::Timeout { phase: TimeoutPhase::SshHandshake }), + }; + if connected.is_ok() { + unfinished.disarm(); + } + connected.map(Arc::new) + } + + async fn connect_chain( + config: &SshConfig, + services: &InProcessSsh, + liveness: &LivenessPolicy, + prompts: Arc, + abort: &CancellationToken, + ) -> Result { + let route: Vec> = config + .jump_hosts + .iter() + .chain([&config.destination]) + .map(Hop::new) + .collect::>()?; + let first_hop = endpoint(&route[0])?; + let end = Arc::new(SessionEnd::default()); + let mut passphrase = match &config.auth { + SshAuth::PrivateKey { passphrase, .. } => passphrase.clone(), + SshAuth::Agent | SshAuth::Password { .. } | SshAuth::KeyboardInteractive => None, + }; + let mut keys = HashMap::new(); + let mut hops: Vec>> = Vec::with_capacity(route.len()); + for (index, hop) in route.iter().enumerate() { + let is_destination = index + 1 == route.len(); + let stream = match hops.last() { + None => DirectTcpTransport::new(first_hop.clone(), liveness).open().await?, + Some(previous) => open_next_hop(previous, hop, &end).await?, + }; + let stream = Box::new(Abortable::new(stream, abort.clone())); + let mut handle = handshake(stream, hop, services, liveness, &prompts, &end).await?; + let mut agent = AgentSlot::new(handle.agent_session_binding().cloned()); + let (methods, password) = match is_destination { + true => (destination_methods(&config.auth), stored_password(&config.auth)), + false => (hop_methods(&config.auth), None), + }; + let mut login = Login { + user: &hop.user, + host: hop.host(), + password, + agent_chosen: is_destination && matches!(config.auth, SshAuth::Agent), + passphrase: &mut passphrase, + keys: &mut keys, + offered: Vec::new(), + agent: &mut agent, + services, + prompts: &prompts, + end: &end, + }; + log_in(&mut handle, &methods, &mut login).await?; + hops.push(Arc::new(handle)); + } + Ok(Self { + destination: config.destination.to_string(), + first_hop, + hops, + end, + }) + } + + pub fn closed(&self) -> WaitForCancellationFutureOwned { + self.end.closed().clone().cancelled_owned() + } + + pub fn is_closed(&self) -> bool { + self.end.closed().is_cancelled() + } + + pub fn destination(&self) -> &str { + &self.destination + } + + pub fn first_hop_endpoint(&self) -> &NetworkEndpoint { + &self.first_hop + } + + pub async fn forward( + self: &Arc, + target: NetworkEndpoint, + liveness: &LivenessPolicy, + ) -> Result, TransportError> { + if self.is_closed() { + return Err(ended(&self.end)); + } + Ok(Arc::new(InProcessForward::new(self.clone(), target, liveness))) + } + + pub(crate) async fn open_channel( + &self, + target: &NetworkEndpoint, + ) -> Result, TransportError> { + let Some(destination) = self.hops.last() else { + return Err(ended(&self.end)); + }; + match destination + .channel_open_direct_tcpip(target.host(), u32::from(target.port()), "127.0.0.1", 0) + .await + { + Ok(channel) => Ok(Box::new(channel.into_stream())), + Err(_) if self.is_closed() => Err(ended(&self.end)), + Err(russh::Error::ChannelOpenFailure(reason)) => Err(SshFailure::ChannelOpenFailed { + target: target.to_string(), + detail: channel_failure(&reason), + } + .into()), + Err(error) => Err(SshFailure::ChannelOpenFailed { + target: target.to_string(), + detail: error.to_string(), + } + .into()), + } + } + + pub(crate) fn ended(&self) -> TransportError { + ended(&self.end) + } +} + +impl Drop for InProcessSession { + fn drop(&mut self) { + self.end.closed().cancel(); + let hops = std::mem::take(&mut self.hops); + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + return; + }; + // The destination says goodbye first, then each jump host, as ssh closes a chain. + runtime.spawn(async move { + for hop in hops.iter().rev() { + if let Err(error) = hop.disconnect(russh::Disconnect::ByApplication, "", "en").await { + tracing::debug!(%error, "an SSH session was already closed"); + } + } + }); + } +} + +// known_hosts files an RSA key as ssh-rsa, which signs as rsa-sha2-256 and -512 too. +fn same_key_type(held: &Algorithm, offered: &Algorithm) -> bool { + match (held, offered) { + (Algorithm::Rsa { .. }, Algorithm::Rsa { .. }) => true, + _ => held == offered, + } +} + +fn endpoint(hop: &Hop<'_>) -> Result { + NetworkEndpoint::new(hop.host(), hop.port).map_err(|_| { + SshFailure::from(SshDestinationProblem::Empty { + part: SshDestinationPart::Host, + }) + }) +} + +async fn open_next_hop( + previous: &Handle, + hop: &Hop<'_>, + end: &SessionEnd, +) -> Result, TransportError> { + let target = format!("{}:{}", hop.host(), hop.port); + match previous + .channel_open_direct_tcpip(hop.host(), u32::from(hop.port), "127.0.0.1", 0) + .await + { + Ok(channel) => Ok(Box::new(channel.into_stream())), + Err(russh::Error::ChannelOpenFailure(reason)) => Err(SshFailure::ChannelOpenFailed { + target, + detail: channel_failure(&reason), + } + .into()), + Err(error) => Err(from_russh(error, hop.host(), end)), + } +} + +// The host key types known_hosts holds for the hop go first, as OpenSSH orders them, so a host +// known by one type is not asked about as new because the server prefers another. +async fn handshake( + stream: Box, + hop: &Hop<'_>, + services: &InProcessSsh, + liveness: &LivenessPolicy, + prompts: &Arc, + end: &Arc, +) -> Result, TransportError> { + let check = Arc::new(HostKeyCheck::new(services, hop.host(), hop.port, prompts.clone())); + let known = check.known_types().await; + let handler = HopHandler { + check: check.clone(), + end: end.clone(), + }; + russh::client::connect_stream(Arc::new(russh_config(liveness, &known)), stream, handler) + .await + .map_err(|error| match check.take_refusal() { + Some(refusal) => refusal.into(), + None => from_russh(error, hop.host(), end), + }) +} + +fn stored_password(auth: &SshAuth) -> Option { + match auth { + SshAuth::Password { password } => Some(password.clone()), + SshAuth::Agent | SshAuth::PrivateKey { .. } | SshAuth::KeyboardInteractive => None, + } +} + +// ServerAliveInterval and ServerAliveCountMax as the OpenSSH transport passes them. +fn russh_config(liveness: &LivenessPolicy, known: &KnownTypes) -> Config { + let mut preferred = Preferred::default(); + let (first, rest): (Vec, Vec) = preferred + .key + .iter() + .cloned() + .partition(|offered| known.plain.iter().any(|held| same_key_type(held, offered))); + preferred.key = Cow::Owned(first.into_iter().chain(rest).collect()); + // A server's certificate is checked against @cert-authority lines, so it is asked for only where one names the host. + if known.authority { + preferred.host_key_certificates = preferred.key.clone(); + } + Config { + preferred, + keepalive_interval: Some(liveness.ssh_keepalive_interval), + keepalive_max: usize::try_from(liveness.ssh_keepalive_max).unwrap_or(usize::MAX), + inactivity_timeout: None, + nodelay: true, + ..Config::default() + } +} + +#[cfg(test)] +mod tests { + use russh::keys::ssh_key::EcdsaCurve; + + use super::*; + + #[test] + fn keepalives_follow_the_liveness_policy() { + let config = russh_config(&LivenessPolicy::DESKTOP, &KnownTypes::default()); + + assert_eq!( + config.keepalive_interval, + Some(LivenessPolicy::DESKTOP.ssh_keepalive_interval) + ); + assert_eq!(config.keepalive_max, 3); + assert_eq!(config.inactivity_timeout, None); + } + + #[test] + fn the_host_key_types_known_hosts_holds_are_asked_for_first() { + let p256 = Algorithm::Ecdsa { + curve: EcdsaCurve::NistP256, + }; + let rsa = Algorithm::Rsa { hash: None }; + + let known = |plain: Vec| KnownTypes { + plain, + authority: false, + }; + let ecdsa_known = russh_config(&LivenessPolicy::DESKTOP, &known(vec![p256.clone()])) + .preferred + .key; + let rsa_known = russh_config(&LivenessPolicy::DESKTOP, &known(vec![rsa])).preferred.key; + let unknown = russh_config(&LivenessPolicy::DESKTOP, &KnownTypes::default()).preferred; + let vouched = russh_config( + &LivenessPolicy::DESKTOP, + &KnownTypes { + plain: Vec::new(), + authority: true, + }, + ) + .preferred; + + assert_eq!(ecdsa_known.first(), Some(&p256)); + assert!( + rsa_known + .iter() + .take_while(|algorithm| matches!(algorithm, Algorithm::Rsa { .. })) + .count() + >= 2 + ); + assert_eq!(unknown.key.as_ref(), Preferred::default().key.as_ref()); + assert_eq!(ecdsa_known.len(), unknown.key.len()); + assert!( + unknown.host_key_certificates.is_empty(), + "no authority, no certificate asked for" + ); + assert_eq!(vouched.host_key_certificates.as_ref(), unknown.key.as_ref()); + } + + #[test] + fn a_hop_without_a_user_logs_in_as_the_local_user_on_port_22() { + let named = SshDestination::new("db.internal", Some(2222), Some("deploy".to_owned())).unwrap(); + let unnamed = SshDestination::new("jump1", None, None).unwrap(); + + let named = Hop::new(&named).unwrap(); + assert_eq!((named.user.as_str(), named.port), ("deploy", 2222)); + if let Some(local) = local_user() { + let unnamed = Hop::new(&unnamed).unwrap(); + assert_eq!((unnamed.user, unnamed.port), (local, 22)); + } + } +} diff --git a/core/crates/ssh/src/known_hosts.rs b/core/crates/ssh/src/known_hosts.rs index c16d35b632..596b886998 100644 --- a/core/crates/ssh/src/known_hosts.rs +++ b/core/crates/ssh/src/known_hosts.rs @@ -7,18 +7,7 @@ use tablepro_core::{SshDestinationPart, SshDestinationProblem, SshFailure}; use tokio::process::Command; use crate::route::{lookup, trace_route}; -use crate::{SshConfig, SshServices}; - -/// Why a key ssh reported as changed was left in known_hosts. -#[derive(Debug, thiserror::Error)] -pub enum ForgetHostKeyError { - /// `ssh -G` names no host of this connection by `host` that reads - /// `known_hosts`, so the report did not come from ssh's own check. - #[error("{host} in {} is not a host key this connection uses", .known_hosts.display())] - NotThisConnection { host: String, known_hosts: PathBuf }, - #[error(transparent)] - Ssh(#[from] SshFailure), -} +use crate::{ForgetHostKeyError, SshConfig, SshServices}; pub async fn forget_host_key(services: &SshServices, config: &SshConfig, known_hosts: &Path) -> Result<(), SshFailure> { let mut args: Vec = vec!["-G".into()]; diff --git a/core/crates/ssh/src/lib.rs b/core/crates/ssh/src/lib.rs index 16ff573f16..ba0f6c260f 100644 --- a/core/crates/ssh/src/lib.rs +++ b/core/crates/ssh/src/lib.rs @@ -1,9 +1,12 @@ -// The configuration, destination and prompt types are portable. The -// transport drives the system OpenSSH client over Unix sockets and an -// askpass helper, so it builds on Unix alone. +// Two transports: the system OpenSSH client over Unix sockets and an +// askpass helper, on Unix, and one in process on russh behind the +// `in-process` feature, on every target (core ADR 0008). mod config; mod destination; +mod forget_error; +#[cfg(feature = "in-process")] +mod in_process; mod prompt; #[cfg(unix)] @@ -41,12 +44,15 @@ mod sweep; pub use config::{SshAuth, SshConfig}; pub use destination::SshDestination; +pub use forget_error::ForgetHostKeyError; +#[cfg(feature = "in-process")] +pub use in_process::{InProcessForward, InProcessSession, InProcessSsh}; pub use prompt::AskpassPrompt; #[cfg(unix)] pub use forward::SshForwardTransport; #[cfg(unix)] -pub use known_hosts::{ForgetHostKeyError, forget_changed_host_key, forget_host_key}; +pub use known_hosts::{forget_changed_host_key, forget_host_key}; #[cfg(unix)] pub use runtime::SshRuntime; #[cfg(unix)] @@ -58,14 +64,14 @@ pub use session::SshSession; #[cfg(unix)] pub use sweep::sweep_stale_masters; -#[cfg(unix)] +#[cfg(any(unix, feature = "in-process"))] pub(crate) fn lock(mutex: &std::sync::Mutex) -> std::sync::MutexGuard<'_, T> { mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } // The panic hook has reported a panic, and a cancel comes only as the // runtime shuts down, so the error is logged, not shown. -#[cfg(unix)] +#[cfg(any(unix, feature = "in-process"))] pub(crate) fn setup_task_failed(error: &tokio::task::JoinError) -> tablepro_core::SshFailure { tracing::warn!(%error, "an SSH setup task stopped"); tablepro_core::SshFailure::Internal diff --git a/core/crates/ssh/tests/in_process_matrix.rs b/core/crates/ssh/tests/in_process_matrix.rs new file mode 100644 index 0000000000..42546be95f --- /dev/null +++ b/core/crates/ssh/tests/in_process_matrix.rs @@ -0,0 +1,451 @@ +#![cfg(unix)] +// The in-process transport against the OpenSSH server the system transport's tests use. The ssh-agent and +// ssh-keygen fixtures run on the host, so this runs where they do. + +use std::error::Error; +use std::sync::Arc; + +use secrecy::SecretString; +use tablepro_core::credentials::{CredentialInteraction, PromptPurpose, PromptReason, PromptReply}; +use tablepro_core::{LivenessPolicy, NetworkEndpoint, SshFailure, TransportError, TransportRoute}; +use tablepro_ssh::{InProcessSession, InProcessSsh, SshAuth, SshConfig, SshDestination}; +use tablepro_test_fixtures::{OpenSshFixture, OpenSshOptions, SshAgent, SshAuthVariant, SshKeyPair, SshKeyType}; +use tablepro_test_support::FakePrompter; +use tokio::io::AsyncReadExt; +use tokio_util::sync::CancellationToken; + +type TestResult = Result>; + +const PASSPHRASE: &str = "correct horse battery"; + +struct Server { + fixture: OpenSshFixture, + services: InProcessSsh, + _dir: tempfile::TempDir, +} + +impl Server { + // known_hosts already trusts the server, and no agent is reachable unless a case names one. + async fn start(variant: SshAuthVariant, options: OpenSshOptions, agent: Option<&SshAgent>) -> TestResult { + let fixture = OpenSshFixture::start_with(variant, options, None).await?; + let dir = tempfile::tempdir()?; + let known_hosts = dir.path().join("known_hosts"); + let host_key = fixture.host_public_key().await?; + std::fs::write( + &known_hosts, + format!("[{}]:{} {host_key}\n", fixture.host(), fixture.port()), + )?; + let services = InProcessSsh { + user_known_hosts: Some(known_hosts), + other_known_hosts: Vec::new(), + identity_files: Vec::new(), + agent: Some(agent.map_or_else(|| dir.path().join("no-agent.sock"), |agent| agent.socket().to_owned())), + pageant: false, + }; + Ok(Self { + fixture, + services, + _dir: dir, + }) + } + + fn key(&self) -> TestResult<&SshKeyPair> { + match self.fixture.variant() { + SshAuthVariant::PublicKey(key) => Ok(key), + other => Err(format!("the server trusts no key: {other:?}").into()), + } + } + + fn config(&self, auth: SshAuth) -> TestResult { + Ok(SshConfig { + destination: SshDestination::new( + self.fixture.host(), + Some(self.fixture.port()), + Some(self.fixture.password_credentials().username), + )?, + jump_hosts: Vec::new(), + auth, + }) + } + + // Every secret a case needs is stored, so a prompt is a failure. + async fn connect(&self, auth: SshAuth) -> TestResult, TransportError>> { + let prompter = Arc::new(FakePrompter::new(Vec::new())); + let session = self.connect_asking(auth, prompter.clone()).await?; + assert!(prompter.requests().is_empty(), "{:?}", prompter.requests()); + Ok(session) + } + + async fn connect_asking( + &self, + auth: SshAuth, + prompter: Arc, + ) -> TestResult, TransportError>> { + Ok(InProcessSession::connect( + &self.config(auth)?, + &self.services, + &LivenessPolicy::DESKTOP, + CredentialInteraction::Attended(prompter), + CancellationToken::new(), + ) + .await) + } + + fn password(&self) -> SshAuth { + SshAuth::Password { + password: SecretString::from(self.fixture.password_credentials().password), + } + } +} + +fn key_file(key: &SshKeyPair) -> SshAuth { + SshAuth::PrivateKey { + path: Some(key.private_key().to_owned()), + passphrase: key.passphrase().map(SecretString::from), + } +} + +fn typed(text: &str) -> PromptReply { + PromptReply::Submitted { + values: vec![SecretString::from(text)], + remember: false, + } +} + +// A forward to the server's own sshd answers with its banner, which proves the channel carries bytes. +async fn assert_tunnel_answers(session: &Arc) -> TestResult { + let target = NetworkEndpoint::new("127.0.0.1", OpenSshFixture::CONTAINER_PORT)?; + let transport = session.forward(target, &LivenessPolicy::DESKTOP).await?; + assert_eq!(transport.route(), TransportRoute::InProcess); + let mut stream = transport.open().await?; + let mut banner = [0_u8; 8]; + stream.read_exact(&mut banner).await?; + assert_eq!(&banner, b"SSH-2.0-"); + Ok(()) +} + +async fn key_signs_in(kind: SshKeyType, passphrase: Option<&str>, options: OpenSshOptions) -> TestResult { + let key = SshKeyPair::generate_with(kind, passphrase, "tablepro-in-process").await?; + let server = Server::start(SshAuthVariant::PublicKey(key), options, None).await?; + + let session = server.connect(key_file(server.key()?)).await??; + + assert_tunnel_answers(&session).await +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn a_stored_password_signs_in() -> TestResult { + let server = Server::start(SshAuthVariant::Password, OpenSshOptions::selected()?, None).await?; + + let session = server.connect(server.password()).await??; + + assert_tunnel_answers(&session).await +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn a_rejected_password_asks_again_and_unattended_is_refused() -> TestResult { + let server = Server::start(SshAuthVariant::Password, OpenSshOptions::selected()?, None).await?; + let wrong = SshAuth::Password { + password: SecretString::from("wrong"), + }; + let prompter = Arc::new(FakePrompter::new([typed( + &server.fixture.password_credentials().password, + )])); + + let session = server.connect_asking(wrong.clone(), prompter.clone()).await??; + let refused = InProcessSession::connect( + &server.config(wrong)?, + &server.services, + &LivenessPolicy::DESKTOP, + CredentialInteraction::Unattended, + CancellationToken::new(), + ) + .await; + + assert_tunnel_answers(&session).await?; + let purposes: Vec = prompter.requests().into_iter().map(|request| request.purpose).collect(); + assert_eq!( + purposes, + [PromptPurpose::SshPassword { + reason: PromptReason::Rejected + }] + ); + assert!( + matches!(&refused, Err(TransportError::Ssh(SshFailure::AuthenticationRejected { methods, .. })) if methods.contains(&"password".to_owned())), + "{refused:?}" + ); + Ok(()) +} + +// PAM asks through keyboard-interactive, and the stored password answers its one hidden prompt. +#[tokio::test] +#[ignore = "requires docker"] +async fn a_stored_password_answers_a_pam_prompt() -> TestResult { + let server = Server::start(SshAuthVariant::KeyboardInteractive, OpenSshOptions::selected()?, None).await?; + + let session = server.connect(server.password()).await??; + + assert_tunnel_answers(&session).await +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn ed25519_key_signs_in() -> TestResult { + key_signs_in(SshKeyType::Ed25519, None, OpenSshOptions::selected()?).await +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn ed25519_key_with_a_passphrase_signs_in() -> TestResult { + key_signs_in(SshKeyType::Ed25519, Some(PASSPHRASE), OpenSshOptions::selected()?).await +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn rsa_key_signs_in_with_rsa_sha2_256() -> TestResult { + let options = OpenSshOptions::selected()?.with_pubkey_accepted_algorithms("rsa-sha2-256"); + key_signs_in(SshKeyType::Rsa, Some(PASSPHRASE), options).await +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn rsa_key_signs_in_with_rsa_sha2_512() -> TestResult { + let options = OpenSshOptions::selected()?.with_pubkey_accepted_algorithms("rsa-sha2-512"); + key_signs_in(SshKeyType::Rsa, None, options).await +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn ecdsa_p256_key_with_a_passphrase_signs_in() -> TestResult { + key_signs_in(SshKeyType::EcdsaP256, Some(PASSPHRASE), OpenSshOptions::selected()?).await +} + +// The PEM format keeps no public half in the clear, so the passphrase opens it. +#[tokio::test] +#[ignore = "requires docker"] +async fn an_encrypted_pem_rsa_key_signs_in() -> TestResult { + let key = SshKeyPair::generate_with(SshKeyType::Rsa, Some(PASSPHRASE), "tablepro-in-process").await?; + let rewritten = std::process::Command::new("ssh-keygen") + .args(["-q", "-p", "-m", "PEM", "-P", PASSPHRASE, "-N", PASSPHRASE, "-f"]) + .arg(key.private_key()) + .status()?; + assert!(rewritten.success()); + let server = Server::start(SshAuthVariant::PublicKey(key), OpenSshOptions::selected()?, None).await?; + + let session = server.connect(key_file(server.key()?)).await??; + + assert_tunnel_answers(&session).await +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn a_missing_passphrase_is_asked_for_with_the_key_path() -> TestResult { + let key = SshKeyPair::generate_with(SshKeyType::Ed25519, Some(PASSPHRASE), "tablepro-in-process").await?; + let server = Server::start(SshAuthVariant::PublicKey(key), OpenSshOptions::selected()?, None).await?; + let path = server.key()?.private_key().to_owned(); + let auth = SshAuth::PrivateKey { + path: Some(path.clone()), + passphrase: None, + }; + let prompter = Arc::new(FakePrompter::new([typed("wrong"), typed(PASSPHRASE)])); + + let session = server.connect_asking(auth, prompter.clone()).await??; + + assert_tunnel_answers(&session).await?; + let purposes: Vec = prompter.requests().into_iter().map(|request| request.purpose).collect(); + assert_eq!( + purposes, + [ + PromptPurpose::SshPassphrase { + path: path.clone(), + reason: PromptReason::NotStored + }, + PromptPurpose::SshPassphrase { + path, + reason: PromptReason::Rejected + }, + ] + ); + Ok(()) +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn the_agent_signs_in() -> TestResult { + let agent = SshAgent::start().await?; + let key = SshKeyPair::generate("tablepro-in-process").await?; + agent.add(&key).await?; + let server = Server::start( + SshAuthVariant::PublicKey(key), + OpenSshOptions::selected()?, + Some(&agent), + ) + .await?; + + let session = server.connect(SshAuth::Agent).await??; + + assert_tunnel_answers(&session).await +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn a_missing_agent_is_named_when_nothing_else_signs_in() -> TestResult { + let key = SshKeyPair::generate("tablepro-in-process").await?; + let server = Server::start(SshAuthVariant::PublicKey(key), OpenSshOptions::selected()?, None).await?; + + let session = server.connect(SshAuth::Agent).await?; + + assert!( + matches!(&session, Err(TransportError::Ssh(SshFailure::AgentUnavailable { location })) if location.ends_with("no-agent.sock")), + "{session:?}" + ); + Ok(()) +} + +// The agent holds the chosen key, so it signs and the passphrase is never asked for (Linux ADR 0020). +#[tokio::test] +#[ignore = "requires docker"] +async fn a_key_file_the_agent_holds_asks_for_no_passphrase() -> TestResult { + let agent = SshAgent::start().await?; + let key = SshKeyPair::generate_with(SshKeyType::Ed25519, Some(PASSPHRASE), "tablepro-in-process").await?; + agent.add(&key).await?; + let server = Server::start( + SshAuthVariant::PublicKey(key), + OpenSshOptions::selected()?, + Some(&agent), + ) + .await?; + let auth = SshAuth::PrivateKey { + path: Some(server.key()?.private_key().to_owned()), + passphrase: None, + }; + + let session = server.connect(auth).await??; + + assert_tunnel_answers(&session).await +} + +// More agent keys than MaxAuthTries would end the login before the chosen one; only the chosen key is offered. +#[tokio::test] +#[ignore = "requires docker"] +async fn a_chosen_key_file_is_the_only_key_offered() -> TestResult { + let agent = SshAgent::start().await?; + for index in 1..=4 { + agent + .add(&SshKeyPair::generate(&format!("tablepro-decoy-{index}")).await?) + .await?; + } + let key = SshKeyPair::generate("tablepro-in-process").await?; + let options = OpenSshOptions::selected()?.with_max_auth_tries(3); + let server = Server::start(SshAuthVariant::PublicKey(key), options, Some(&agent)).await?; + + let session = server.connect(key_file(server.key()?)).await??; + + assert_tunnel_answers(&session).await +} + +// The server ends the login itself, and its disconnect, not a refusal, is what the connect reports. +#[tokio::test] +#[ignore = "requires docker"] +async fn an_agent_with_more_keys_than_max_auth_tries_is_named() -> TestResult { + let agent = SshAgent::start().await?; + for index in 1..=4 { + agent + .add(&SshKeyPair::generate(&format!("tablepro-decoy-{index}")).await?) + .await?; + } + let key = SshKeyPair::generate("tablepro-in-process").await?; + agent.add(&key).await?; + let options = OpenSshOptions::selected()?.with_max_auth_tries(3); + let server = Server::start(SshAuthVariant::PublicKey(key), options, Some(&agent)).await?; + + let session = server.connect(SshAuth::Agent).await?; + + assert!( + matches!(&session, Err(TransportError::Ssh(SshFailure::TooManyAuthenticationFailures { host })) if host == server.fixture.host()), + "{session:?}" + ); + Ok(()) +} + +// The server takes only certificates its authority signed, and the key's is beside the file. +async fn certified_key_signs_in(kind: SshKeyType, agent_holds_the_key: bool) -> TestResult { + let agent = SshAgent::start().await?; + let authority = SshKeyPair::generate("tablepro-user-ca").await?; + let variant = SshAuthVariant::UserCertificateAuthority(authority); + let server = Server::start(variant, OpenSshOptions::selected()?, Some(&agent)).await?; + let SshAuthVariant::UserCertificateAuthority(authority) = server.fixture.variant() else { + return Err("the server lost its certificate authority".into()); + }; + let key = SshKeyPair::generate_with(kind, None, "tablepro-in-process").await?; + if agent_holds_the_key { + agent.add(&key).await?; + } + let signed = std::process::Command::new("ssh-keygen") + .args(["-q", "-s"]) + .arg(authority.private_key()) + .args(["-I", "tablepro-in-process", "-n"]) + .arg(server.fixture.password_credentials().username) + .arg(key.public_key_file()) + .status()?; + assert!(signed.success()); + + let session = server.connect(key_file(&key)).await??; + + assert_tunnel_answers(&session).await +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn a_user_certificate_beside_the_key_file_signs_in() -> TestResult { + certified_key_signs_in(SshKeyType::Ed25519, false).await +} + +// russh's own certificate login names ssh-rsa-cert-v01, which OpenSSH 8.8 and later refuse. +#[tokio::test] +#[ignore = "requires docker"] +async fn a_user_certificate_beside_an_rsa_key_file_signs_in_with_rsa_sha2() -> TestResult { + certified_key_signs_in(SshKeyType::Rsa, false).await +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn a_user_certificate_beside_a_key_the_agent_holds_signs_in_through_the_agent() -> TestResult { + certified_key_signs_in(SshKeyType::Ed25519, true).await +} + +// sshd reaches itself on its own port, so one server is the jump host and the destination behind it. +#[tokio::test] +#[ignore = "requires docker"] +async fn a_stored_passphrase_opens_the_key_once_for_every_hop() -> TestResult { + let key = SshKeyPair::generate_with(SshKeyType::Ed25519, Some(PASSPHRASE), "tablepro-in-process").await?; + let server = Server::start(SshAuthVariant::PublicKey(key), OpenSshOptions::selected()?, None).await?; + let host_key = server.fixture.host_public_key().await?; + let known_hosts = server.services.user_known_hosts.clone().ok_or("no known_hosts")?; + let mut trusted = std::fs::read_to_string(&known_hosts)?; + trusted.push_str(&format!("[127.0.0.1]:{} {host_key}\n", OpenSshFixture::CONTAINER_PORT)); + std::fs::write(&known_hosts, trusted)?; + let user = server.fixture.password_credentials().username; + let config = SshConfig { + destination: SshDestination::new("127.0.0.1", Some(OpenSshFixture::CONTAINER_PORT), Some(user.clone()))?, + jump_hosts: vec![SshDestination::new( + server.fixture.host(), + Some(server.fixture.port()), + Some(user), + )?], + auth: key_file(server.key()?), + }; + + let session = InProcessSession::connect( + &config, + &server.services, + &LivenessPolicy::DESKTOP, + CredentialInteraction::Unattended, + CancellationToken::new(), + ) + .await?; + + assert_tunnel_answers(&session).await +} diff --git a/core/crates/ssh/tests/in_process_session.rs b/core/crates/ssh/tests/in_process_session.rs new file mode 100644 index 0000000000..a7956521a5 --- /dev/null +++ b/core/crates/ssh/tests/in_process_session.rs @@ -0,0 +1,362 @@ +// Host keys, jump hosts and the session's end for the in-process transport, against a real sshd. + +use std::error::Error; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use secrecy::SecretString; +use tablepro_core::credentials::{CredentialInteraction, PromptPurpose, PromptReply}; +use tablepro_core::{LivenessPolicy, NetworkEndpoint, SshFailure, TransportError}; +use tablepro_ssh::{ForgetHostKeyError, InProcessSession, InProcessSsh, SshAuth, SshConfig, SshDestination}; +use tablepro_test_fixtures::{OpenSshFixture, SshAuthVariant}; +use tablepro_test_support::FakePrompter; +use tokio::io::AsyncReadExt; +use tokio_util::sync::CancellationToken; + +type TestResult = Result>; + +struct Bastion { + fixture: OpenSshFixture, + services: InProcessSsh, + known_hosts: PathBuf, + _dir: tempfile::TempDir, +} + +impl Bastion { + async fn start() -> TestResult { + Self::start_with(SshAuthVariant::Password).await + } + + async fn start_with(variant: SshAuthVariant) -> TestResult { + let fixture = OpenSshFixture::start(variant).await?; + let dir = tempfile::tempdir()?; + let known_hosts = dir.path().join(".ssh").join("known_hosts"); + let services = InProcessSsh { + user_known_hosts: Some(known_hosts.clone()), + other_known_hosts: Vec::new(), + identity_files: Vec::new(), + agent: None, + pageant: false, + }; + Ok(Self { + fixture, + services, + known_hosts, + _dir: dir, + }) + } + + fn user(&self) -> String { + self.fixture.password_credentials().username + } + + fn name(&self) -> String { + format!("[{}]:{}", self.fixture.host(), self.fixture.port()) + } + + fn config(&self) -> TestResult { + Ok(SshConfig { + destination: SshDestination::new(self.fixture.host(), Some(self.fixture.port()), Some(self.user()))?, + jump_hosts: Vec::new(), + auth: SshAuth::Password { + password: SecretString::from(self.fixture.password_credentials().password), + }, + }) + } + + async fn connect( + &self, + config: &SshConfig, + liveness: &LivenessPolicy, + replies: Vec, + ) -> (Result, TransportError>, Arc) { + let prompter = Arc::new(FakePrompter::new(replies)); + let session = InProcessSession::connect( + config, + &self.services, + liveness, + CredentialInteraction::Attended(prompter.clone()), + CancellationToken::new(), + ) + .await; + (session, prompter) + } +} + +fn accept() -> PromptReply { + PromptReply::Submitted { + values: Vec::new(), + remember: false, + } +} + +fn purposes(prompter: &FakePrompter) -> Vec { + prompter.requests().into_iter().map(|request| request.purpose).collect() +} + +async fn banner(session: &Arc) -> TestResult<[u8; 8]> { + let target = NetworkEndpoint::new("127.0.0.1", OpenSshFixture::CONTAINER_PORT)?; + let transport = session.forward(target, &LivenessPolicy::DESKTOP).await?; + let mut stream = transport.open().await?; + let mut banner = [0_u8; 8]; + stream.read_exact(&mut banner).await?; + Ok(banner) +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn an_unknown_host_key_is_asked_about_once_then_recorded() -> TestResult { + let bastion = Bastion::start().await?; + let config = bastion.config()?; + + let (first, asked) = bastion.connect(&config, &LivenessPolicy::DESKTOP, vec![accept()]).await; + let (second, again) = bastion.connect(&config, &LivenessPolicy::DESKTOP, Vec::new()).await; + + assert!(matches!( + purposes(&asked).as_slice(), + [PromptPurpose::SshHostKeyConfirmation { host, algorithm, fingerprint }] + if *host == bastion.name() && !algorithm.is_empty() && fingerprint.starts_with("SHA256:") + )); + assert!(again.requests().is_empty(), "{:?}", again.requests()); + assert_eq!(&banner(&first?).await?, b"SSH-2.0-"); + assert_eq!(&banner(&second?).await?, b"SSH-2.0-"); + let recorded = std::fs::read_to_string(&bastion.known_hosts)?; + assert!(recorded.starts_with(&format!("{} ", bastion.name())), "{recorded}"); + Ok(()) +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn a_host_certificate_from_a_listed_authority_is_trusted_without_a_question() -> TestResult { + let bastion = Bastion::start_with(SshAuthVariant::HostCertificate).await?; + let authority = bastion + .fixture + .host_certificate() + .ok_or("the server has no host certificate")? + .known_hosts_line(bastion.fixture.host(), bastion.fixture.port()); + std::fs::create_dir_all(bastion.known_hosts.parent().ok_or("no parent")?)?; + std::fs::write(&bastion.known_hosts, authority)?; + + let session = InProcessSession::connect( + &bastion.config()?, + &bastion.services, + &LivenessPolicy::DESKTOP, + CredentialInteraction::Unattended, + CancellationToken::new(), + ) + .await?; + + assert_eq!(&banner(&session).await?, b"SSH-2.0-"); + Ok(()) +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn an_unattended_connect_to_an_unknown_host_is_refused_with_the_key_to_review() -> TestResult { + let bastion = Bastion::start().await?; + + let refused = InProcessSession::connect( + &bastion.config()?, + &bastion.services, + &LivenessPolicy::DESKTOP, + CredentialInteraction::Unattended, + CancellationToken::new(), + ) + .await; + + let Err(TransportError::Ssh(SshFailure::HostKeyUnknown { + host, + sha256_fingerprint, + trust_allowed: true, + declined: true, + .. + })) = &refused + else { + return Err(format!("expected an unknown host key, got {refused:?}").into()); + }; + assert_eq!(*host, bastion.name()); + assert!(sha256_fingerprint.starts_with("SHA256:")); + assert!(!bastion.known_hosts.exists()); + Ok(()) +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn a_changed_key_names_its_line_and_forgetting_it_lets_the_new_key_be_trusted() -> TestResult { + let bastion = Bastion::start().await?; + let config = bastion.config()?; + // An Ed25519 key that is not the server's, which offers Ed25519 first as russh prefers it. + let stale = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti"; + std::fs::create_dir_all(bastion.known_hosts.parent().ok_or("no parent")?)?; + std::fs::write(&bastion.known_hosts, format!("# kept\n{} {stale}\n", bastion.name()))?; + + let (changed, asked) = bastion.connect(&config, &LivenessPolicy::DESKTOP, Vec::new()).await; + let Err(TransportError::Ssh(SshFailure::HostKeyChanged { + host, + known_hosts, + line, + trust_allowed: true, + .. + })) = &changed + else { + return Err(format!("expected a changed host key, got {changed:?}").into()); + }; + assert!(asked.requests().is_empty()); + assert_eq!((known_hosts, *line), (&bastion.known_hosts, 2)); + let other = bastion + .services + .forget_changed_host_key(&config, "other.example", known_hosts) + .await; + assert!(matches!(other, Err(ForgetHostKeyError::NotThisConnection { .. }))); + bastion + .services + .forget_changed_host_key(&config, host, known_hosts) + .await?; + + let (session, asked) = bastion.connect(&config, &LivenessPolicy::DESKTOP, vec![accept()]).await; + + assert_eq!(&banner(&session?).await?, b"SSH-2.0-"); + assert_eq!(asked.requests().len(), 1); + assert!(std::fs::read_to_string(&bastion.known_hosts)?.starts_with("# kept\n")); + Ok(()) +} + +// sshd reaches itself on its own port, so one container is the jump host and the destination behind it. +#[tokio::test] +#[ignore = "requires docker"] +async fn a_jump_host_asks_for_its_own_key_and_password_and_never_gets_the_destinations() -> TestResult { + let bastion = Bastion::start().await?; + let password = bastion.fixture.password_credentials().password; + let config = SshConfig { + destination: SshDestination::new("127.0.0.1", Some(OpenSshFixture::CONTAINER_PORT), Some(bastion.user()))?, + jump_hosts: vec![SshDestination::new( + bastion.fixture.host(), + Some(bastion.fixture.port()), + Some(bastion.user()), + )?], + auth: SshAuth::Password { + password: SecretString::from(password.clone()), + }, + }; + let typed = PromptReply::Submitted { + values: vec![SecretString::from(password)], + remember: false, + }; + + let (session, prompter) = bastion + .connect(&config, &LivenessPolicy::DESKTOP, vec![accept(), typed, accept()]) + .await; + + let requests = prompter.requests(); + assert!( + matches!( + purposes(&prompter).as_slice(), + [ + PromptPurpose::SshHostKeyConfirmation { host: jump, .. }, + PromptPurpose::SshPassword { .. }, + PromptPurpose::SshHostKeyConfirmation { host: destination, .. }, + ] if *jump == bastion.name() && destination == "[127.0.0.1]:2222" + ), + "{requests:?}" + ); + assert_eq!( + requests[1].target, + format!("{}@{}", bastion.user(), bastion.fixture.host()) + ); + let session = session?; + assert_eq!( + session.first_hop_endpoint(), + &NetworkEndpoint::new(bastion.fixture.host(), bastion.fixture.port())? + ); + assert_eq!(&banner(&session).await?, b"SSH-2.0-"); + Ok(()) +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn keepalives_end_the_session_when_the_server_stops_answering() -> TestResult { + let bastion = Bastion::start().await?; + let liveness = LivenessPolicy { + ssh_keepalive_interval: Duration::from_secs(1), + ssh_keepalive_max: 2, + ..LivenessPolicy::DESKTOP + }; + let (session, _) = bastion.connect(&bastion.config()?, &liveness, vec![accept()]).await; + let session = session?; + + bastion.fixture.container().pause().await?; + let closed = tokio::time::timeout(Duration::from_secs(10), session.closed()).await; + bastion.fixture.container().unpause().await?; + + assert!( + closed.is_ok(), + "a paused server was not noticed within the keepalive window" + ); + let target = NetworkEndpoint::new("127.0.0.1", OpenSshFixture::CONTAINER_PORT)?; + assert!(matches!( + session.forward(target, &liveness).await, + Err(TransportError::Ssh(SshFailure::MasterExited { status: None, .. })) + )); + Ok(()) +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn the_session_ends_when_the_bastion_stops_and_a_reroute_shares_it_until_then() -> TestResult { + let bastion = Bastion::start().await?; + let (session, _) = bastion + .connect(&bastion.config()?, &LivenessPolicy::DESKTOP, vec![accept()]) + .await; + let session = session?; + let first = session + .forward(NetworkEndpoint::new("127.0.0.1", 1)?, &LivenessPolicy::DESKTOP) + .await?; + let refused = first.open().await.err(); + let rerouted = first + .reroute(NetworkEndpoint::new("127.0.0.1", OpenSshFixture::CONTAINER_PORT)?) + .await?; + let mut stream = rerouted.open().await?; + let mut banner = [0_u8; 8]; + stream.read_exact(&mut banner).await?; + + bastion.fixture.container().stop_with_timeout(Some(5)).await?; + tokio::time::timeout(Duration::from_secs(30), session.closed()).await?; + + assert!( + matches!(&refused, Some(TransportError::Ssh(SshFailure::ChannelOpenFailed { target, .. })) if target == "127.0.0.1:1"), + "{refused:?}" + ); + assert_eq!(&banner, b"SSH-2.0-"); + assert!(session.is_closed()); + assert!(rerouted.explain_closed_stream().await.is_some()); + Ok(()) +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn a_cancel_while_the_user_is_asked_ends_the_connect() -> TestResult { + let bastion = Bastion::start().await?; + let prompter = Arc::new(FakePrompter::new([accept()]).with_delay(Duration::from_secs(60))); + let cancel = CancellationToken::new(); + let cancelling = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(2)).await; + cancelling.cancel(); + }); + + let connected = InProcessSession::connect( + &bastion.config()?, + &bastion.services, + &LivenessPolicy::DESKTOP, + CredentialInteraction::Attended(prompter), + cancel, + ) + .await; + + assert!(matches!( + connected, + Err(TransportError::Ssh(SshFailure::InteractionCancelled)) + )); + Ok(()) +} diff --git a/core/crates/ssh/tests/in_process_windows.rs b/core/crates/ssh/tests/in_process_windows.rs new file mode 100644 index 0000000000..a5911073f2 --- /dev/null +++ b/core/crates/ssh/tests/in_process_windows.rs @@ -0,0 +1,180 @@ +#![cfg(windows)] +// The in-process transport against Windows' own OpenSSH Server and agent, which +// scripts/ci/start-windows-sshd.ps1 starts on the core workflow's Windows runners. + +use std::error::Error; +use std::path::PathBuf; +use std::sync::Arc; + +use secrecy::SecretString; +use tablepro_core::credentials::{CredentialInteraction, PromptPurpose, PromptReply}; +use tablepro_core::{LivenessPolicy, NetworkEndpoint, SshFailure, TransportError}; +use tablepro_ssh::{InProcessSession, InProcessSsh, SshAuth, SshConfig, SshDestination}; +use tablepro_test_support::FakePrompter; +use tokio::io::AsyncReadExt; +use tokio_util::sync::CancellationToken; + +type TestResult = Result>; + +const SSHD: &str = "127.0.0.1"; + +fn setting(name: &str) -> TestResult { + std::env::var(name).map_err(|_| format!("{name} is not set; run scripts/ci/start-windows-sshd.ps1").into()) +} + +struct Machine { + services: InProcessSsh, + user: String, + _dir: tempfile::TempDir, +} + +impl Machine { + fn new() -> TestResult { + let dir = tempfile::tempdir()?; + let system = InProcessSsh::system(); + Ok(Self { + services: InProcessSsh { + user_known_hosts: Some(dir.path().join("known_hosts")), + other_known_hosts: Vec::new(), + identity_files: Vec::new(), + ..system + }, + user: setting("TABLEPRO_TEST_SSHD_USER")?, + _dir: dir, + }) + } + + fn config(&self, auth: SshAuth) -> TestResult { + Ok(SshConfig { + destination: SshDestination::new(SSHD, Some(22), Some(self.user.clone()))?, + jump_hosts: Vec::new(), + auth, + }) + } + + async fn connect( + &self, + config: &SshConfig, + replies: Vec, + ) -> (Result, TransportError>, Arc) { + let prompter = Arc::new(FakePrompter::new(replies)); + let session = InProcessSession::connect( + config, + &self.services, + &LivenessPolicy::DESKTOP, + CredentialInteraction::Attended(prompter.clone()), + CancellationToken::new(), + ) + .await; + (session, prompter) + } +} + +fn accept() -> PromptReply { + PromptReply::Submitted { + values: Vec::new(), + remember: false, + } +} + +// sshd's own port answers with its banner, so a forward that carries bytes needs no database. +async fn assert_tunnel_answers(session: &Arc) -> TestResult { + let transport = session + .forward(NetworkEndpoint::new(SSHD, 22)?, &LivenessPolicy::DESKTOP) + .await?; + let mut stream = transport.open().await?; + let mut banner = [0_u8; 8]; + stream.read_exact(&mut banner).await?; + assert_eq!(&banner, b"SSH-2.0-"); + Ok(()) +} + +#[tokio::test] +#[ignore = "needs the OpenSSH Server scripts/ci/start-windows-sshd.ps1 starts"] +async fn a_password_signs_in_after_the_host_key_is_confirmed() -> TestResult { + let machine = Machine::new()?; + let config = machine.config(SshAuth::Password { + password: SecretString::from(setting("TABLEPRO_TEST_SSHD_PASSWORD")?), + })?; + + let (session, prompter) = machine.connect(&config, vec![accept()]).await; + + assert!(matches!( + prompter.requests().first().map(|request| &request.purpose), + Some(PromptPurpose::SshHostKeyConfirmation { host, .. }) if host == SSHD + )); + assert_tunnel_answers(&session?).await +} + +#[tokio::test] +#[ignore = "needs the OpenSSH Server scripts/ci/start-windows-sshd.ps1 starts"] +async fn a_key_file_with_a_passphrase_signs_in() -> TestResult { + let machine = Machine::new()?; + let config = machine.config(SshAuth::PrivateKey { + path: Some(PathBuf::from(setting("TABLEPRO_TEST_SSHD_LOCKED_KEY")?)), + passphrase: Some(SecretString::from(setting("TABLEPRO_TEST_SSHD_PASSPHRASE")?)), + })?; + + let (session, _) = machine.connect(&config, vec![accept()]).await; + + assert_tunnel_answers(&session?).await +} + +#[tokio::test] +#[ignore = "needs the OpenSSH Server scripts/ci/start-windows-sshd.ps1 starts"] +async fn the_windows_ssh_agent_signs_in() -> TestResult { + let machine = Machine::new()?; + let config = machine.config(SshAuth::Agent)?; + + let (session, _) = machine.connect(&config, vec![accept()]).await; + + assert_tunnel_answers(&session?).await +} + +#[tokio::test] +#[ignore = "needs the OpenSSH Server scripts/ci/start-windows-sshd.ps1 starts"] +async fn a_key_file_signs_in_through_a_jump_host() -> TestResult { + let machine = Machine::new()?; + let config = SshConfig { + destination: SshDestination::new("localhost", Some(22), Some(machine.user.clone()))?, + jump_hosts: vec![SshDestination::new(SSHD, Some(22), Some(machine.user.clone()))?], + auth: SshAuth::PrivateKey { + path: Some(PathBuf::from(setting("TABLEPRO_TEST_SSHD_KEY")?)), + passphrase: None, + }, + }; + + let (session, prompter) = machine.connect(&config, vec![accept(), accept()]).await; + + let hosts: Vec = prompter + .requests() + .into_iter() + .filter_map(|request| match request.purpose { + PromptPurpose::SshHostKeyConfirmation { host, .. } => Some(host), + _ => None, + }) + .collect(); + assert_eq!(hosts, [SSHD, "localhost"]); + assert_tunnel_answers(&session?).await +} + +#[tokio::test] +#[ignore = "needs the OpenSSH Server scripts/ci/start-windows-sshd.ps1 starts"] +async fn a_rejected_password_asks_again_and_a_cancel_ends_the_login() -> TestResult { + let machine = Machine::new()?; + let config = machine.config(SshAuth::Password { + password: SecretString::from("wrong"), + })?; + + let (session, prompter) = machine.connect(&config, vec![accept(), PromptReply::Cancelled]).await; + + assert!( + matches!(&session, Err(TransportError::Ssh(SshFailure::InteractionCancelled))), + "{session:?}" + ); + assert!(matches!( + prompter.requests().get(1).map(|request| &request.purpose), + Some(PromptPurpose::SshPassword { .. }) + )); + Ok(()) +} diff --git a/core/docs/decisions/0001-shared-core.md b/core/docs/decisions/0001-shared-core.md index 41aa630f83..bccdd7265a 100644 --- a/core/docs/decisions/0001-shared-core.md +++ b/core/docs/decisions/0001-shared-core.md @@ -1,6 +1,6 @@ # 0001: One shared Rust core, a native UI per platform -- **Status**: Accepted +- **Status**: Accepted; SSH on Windows amended by [0008](0008-in-process-ssh-on-windows.md) - **Date**: 2026-11-02 ## Context diff --git a/core/docs/decisions/0008-in-process-ssh-on-windows.md b/core/docs/decisions/0008-in-process-ssh-on-windows.md new file mode 100644 index 0000000000..d607c77adc --- /dev/null +++ b/core/docs/decisions/0008-in-process-ssh-on-windows.md @@ -0,0 +1,45 @@ +# 0008: SSH on Windows runs in process on russh + +- **Status**: Accepted +- **Date**: 2026-11-03 + +## Context + +The core's SSH transport runs the system `ssh` as a ControlMaster and asks it for Unix-socket forwards, with prompts through an askpass helper over a Unix socket ([Linux ADR 0013](../../../linux/docs/decisions/0013-openssh-client-transport.md)). It builds on Unix alone, and [0001](0001-shared-core.md) left it off Windows. The Windows app's `SshTunnels` fails every SSH connection with `ssh.open_ssh_client_missing`. + +Windows ships OpenSSH as a Feature on Demand from Windows 10 1809, which Microsoft lists as not installed by default on Windows 10 ([OpenSSH for Windows overview](https://learn.microsoft.com/en-us/windows-server/administration/openssh/openssh-overview)). Win32-OpenSSH does not implement the client's ControlMaster ([Project Scope](https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope)), so there is no master to add a forward to, and its askpass needs `SSH_ASKPASS_REQUIRE` from V8.6.0.0p1 on, newer than some inbox clients. + +sqlx (MySQL), the mongodb crate and scylla dial an address themselves, and on Windows neither sqlx 0.9 nor mongodb 3.9 dials a Unix socket. + +## Decision + +`tablepro-ssh` gains a transport that runs SSH in process on russh, behind its `in-process` feature and built on every target; the Windows app uses it, and Linux keeps the OpenSSH transport. + +## Rationale + +- russh is the maintained async SSH client in Rust, on tokio as the core is, with aws-lc-rs as its provider ([Linux ADR 0009](../../../linux/docs/decisions/0009-crypto-provider-and-sources.md)). +- Each stream is a `direct-tcpip` channel of one authenticated session: one login per connection, as many forwards and reroutes as the drivers ask for, and no port open on the machine. +- Nothing outside the app runs, so it works whether or not the OpenSSH client is installed, on x64 and Arm64 alike. +- Every question goes to the app's `CredentialPrompter` with the `PromptPurpose` the askpass bridge gives, so the session's host key review and retry work as they do on Linux. +- The iOS app, which cannot start a process, can use the same transport. + +## Consequences + +- What it does in place of OpenSSH: + - Host keys: `~/.ssh/known_hosts`, `known_hosts2` and the system file (`%ProgramData%\ssh\ssh_known_hosts` on Windows) are read with hashed names, patterns, `@revoked` and `@cert-authority`, and the key types they hold for a host are asked for first, as OpenSSH orders them. A key the user trusts is added to `~/.ssh/known_hosts`, which Win32-OpenSSH reads too. A changed key fails with `ssh.host_key_changed` naming the file and line, and `InProcessSsh::forget_changed_host_key` removes it as `ssh-keygen -R` would. + - Authentication tries the methods in the order the OpenSSH transport's `PreferredAuthentications` gives. A chosen key file is the only key offered, and the agent signs it when it holds it ([Linux ADR 0020](../../../linux/docs/decisions/0020-pin-only-a-key-file-the-agent-lacks.md)). A jump host tries the agent, the connection's key file and the default identities, then asks, and is never given the destination's password. + - The agent is `SSH_AUTH_SOCK`, else on Windows `\\.\pipe\openssh-ssh-agent`, then Pageant. Each hop opens its own agent connection and binds it to its session, as OpenSSH does for keys added with destination constraints. On Windows a pipe is used only when this user, LocalSystem or the Administrators own it, since any user can create it while the agent service is stopped, its default. + - Keepalives follow `LivenessPolicy`, as `ServerAliveInterval` and `ServerAliveCountMax` do for OpenSSH. + - It reads no `ssh_config`: the connection's fields are its whole configuration. A security key file cannot sign in process; an agent that holds one can. GSSAPI is not offered. + - New failures: `ssh.identity_file_unreadable`, `ssh.identity_file_invalid`, `ssh.agent_unavailable` and `ssh.known_hosts_unwritable`. +- A forward's route is `TransportRoute::InProcess`. MySQL and MongoDB relay it through `tablepro_net::PrivateListener`: a Unix socket in a directory only the user opens, and on Windows a loopback port that serves only connections this process opened, by the owning process `GetExtendedTcpTable` names. Cassandra's loopback relay checks the owner the same way, so it serves Windows too ([Linux ADR 0023](../../../linux/docs/decisions/0023-loopback-relay-for-cql.md)). +- sqlx checks a certificate against the host it dials, so MySQL with Verify full through SSH on Windows is refused with `config.server_name_override_unsupported`, as on any route whose host is not the server's name. +- rsa is back in the graph, under russh and ssh-key, for RSA identity files and host keys. The reasoning of 0009's Stage A holds: one signature per login, and RUSTSEC-2023-0071 is about private-key timing a server cannot observe from one login. `deny.toml` allows rsa under those crates only. +- `tablepro-ssh` depends on `tablepro-net` for the first hop's dial, with its keepalive and `TCP_USER_TIMEOUT`. +- 0013's "russh leaves the dependency graph" holds for the Linux app only: the feature is off in its build, so its binary and license notices carry no russh. + +## Alternatives considered + +- **Win32-OpenSSH.** With no ControlMaster, each forward is its own `ssh.exe` and login, or a `-L` or `-D` port that `ssh.exe` opens on loopback for every local user; the client may be missing and its askpass varies by version. +- **In process on every platform.** Linux would lose the user's `ssh_config` (ProxyJump, Match, Include, certificates, FIDO keys), which is why 0013 moved to OpenSSH. +- **libssh2 through the ssh2 crate**, as the macOS app does: a blocking C library with its own crypto (WinCNG or OpenSSL) against 0009, and a thread and socket pair per jump hop. diff --git a/core/docs/decisions/README.md b/core/docs/decisions/README.md index fbb4c9e53c..ab6cec80c7 100644 --- a/core/docs/decisions/README.md +++ b/core/docs/decisions/README.md @@ -8,9 +8,10 @@ The format, the statuses and how a later ADR changes an earlier one follow [the | # | Title | Status | Summary | |---|---|---|---| -| [0001](0001-shared-core.md) | One shared Rust core, a native UI per platform | Accepted | The UI-free crates move to `core/crates` and build on Linux, Windows and macOS; each app keeps a native UI and passes its platform services in. | +| [0001](0001-shared-core.md) | One shared Rust core, a native UI per platform | Accepted; SSH on Windows amended by 0008 | The UI-free crates move to `core/crates` and build on Linux, Windows and macOS; each app keeps a native UI and passes its platform services in. | | [0002](0002-uniffi-bindings.md) | One UniFFI API for Swift and C#, with a C ABI beside it | Accepted | UniFFI proc macros in the core; Swift through UniFFI's generator and C# through uniffi-bindgen-cs v0.11.1+v0.31.0, each with a thin layer for cancel, progress, streams and off-UI-thread calls; `extern "C"` and csbindgen for hot paths and as the fallback. | | [0003](0003-result-windows-for-native-grids.md) | The core owns the result set, and a native grid pulls windows of text | Accepted | A columnar result set in the core; one packed UTF-8 window per visible range with exact, locale-free text; one coalesced rows-arrived callback; Arrow only for bulk export. | | [0004](0004-driver-plugin-abi.md) | Downloadable drivers over a small C ABI of our own | Accepted | A static descriptor, minor-version negotiation, protobuf messages, Arrow C Data rows, a runtime per driver and `catch_unwind` at every export; signed per platform, never unloaded, linked statically on iOS. | | [0005](0005-sql-server-transport.md) | SQL Server on tiberius everywhere, with Kerberos added and ODBC as a Windows option | Accepted | tiberius on every platform with SSPI Negotiate added in a fork, Entra ID tokens from the core, and an optional Windows connection type over an installed ODBC Driver 18 for Always Encrypted; msodbcsql18 is never bundled. | | [0006](0006-connection-library.md) | The connection library holds a group tree, manual order and favourites | Accepted | Groups form a tree by id, connections and groups carry an explicit order, favourites are one ordered list; version 2 group names become top-level groups. | +| [0008](0008-in-process-ssh-on-windows.md) | SSH on Windows runs in process on russh | Accepted | russh behind `tablepro-ssh`'s `in-process` feature, on every target; Windows uses it, Linux keeps OpenSSH. Forwards are channels with no address, so MySQL and MongoDB relay them through a private listener that serves only this process. | diff --git a/deny.toml b/deny.toml index f7e39c77db..b7c703e980 100644 --- a/deny.toml +++ b/deny.toml @@ -15,7 +15,7 @@ version = 2 yanked = "deny" unmaintained = "workspace" ignore = [ - { id = "RUSTSEC-2023-0071", reason = "rsa is reached only through sqlx-mysql, which encrypts the MySQL password with the server's public key; no private-key operation runs in process, and OpenSSH signs SSH identities. The rsa ban below holds that scope. See docs/decisions/0009-crypto-provider-and-sources.md." }, + { id = "RUSTSEC-2023-0071", reason = "rsa is reached through sqlx-mysql, which encrypts the MySQL password with the server's public key, and through russh and ssh-key, which sign one login with an RSA identity file in the in-process SSH transport, off in the Linux app's build. Neither decrypts in process. The rsa ban below holds that scope. See linux/docs/decisions/0009-crypto-provider-and-sources.md and core/docs/decisions/0008-in-process-ssh-on-windows.md." }, ] [licenses] @@ -58,7 +58,7 @@ deny = [ { crate = "rust_decimal", reason = "decimals use tablepro_core::value::SqlDecimal; rust_decimal stops at 28 digits and has no NaN or infinity" }, { crate = "backoff", reason = "unmaintained" }, { crate = "instant", reason = "unmaintained" }, - { crate = "rsa", wrappers = ["sqlx-mysql"], reason = "RUSTSEC-2023-0071 is accepted only for sqlx-mysql's public-key encryption; any other user of rsa needs a new decision in ADR 0009" }, + { crate = "rsa", wrappers = ["sqlx-mysql", "russh", "ssh-key"], reason = "RUSTSEC-2023-0071 is accepted for sqlx-mysql's public-key encryption and the in-process SSH transport's RSA signatures (core ADR 0008); any other user of rsa needs a new decision" }, { crate = "tablepro-test-fixtures", wrappers = ["tablepro", "tablepro-driver-cassandra", "tablepro-driver-clickhouse", "tablepro-driver-mongodb", "tablepro-driver-mssql", "tablepro-driver-mysql", "tablepro-driver-postgres", "tablepro-driver-redis", "tablepro-session", "tablepro-ssh"], reason = "container fixtures are a dev-only leaf; extend the wrappers with each new dependent" }, { crate = "libsqlite3-sys", deny-multiple-versions = true, reason = "two SQLite builds in one binary conflict" }, ] diff --git a/linux/build-aux/debian/copyright b/linux/build-aux/debian/copyright index c7c133a944..9e50614e0a 100644 --- a/linux/build-aux/debian/copyright +++ b/linux/build-aux/debian/copyright @@ -2795,7 +2795,7 @@ License: Apache-2.0 or Expat . THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -Files: vendor/ctutils-0.4.5/* +Files: vendor/ctutils-0.4.8/* Copyright: 2025-2026 The RustCrypto Project Developers License: Apache-2.0 or Expat Copyright (c) 2025-2026 The RustCrypto Project Developers diff --git a/linux/data/licenses/THIRD-PARTY-NOTICE.md b/linux/data/licenses/THIRD-PARTY-NOTICE.md index e0db46c3a3..1d92675681 100644 --- a/linux/data/licenses/THIRD-PARTY-NOTICE.md +++ b/linux/data/licenses/THIRD-PARTY-NOTICE.md @@ -7506,7 +7506,7 @@ SOFTWARE. Used by: -- ctutils 0.4.5 +- ctutils 0.4.8 ~~~~text Copyright (c) 2025-2026 The RustCrypto Project Developers diff --git a/linux/data/licenses/third-party.json b/linux/data/licenses/third-party.json index 20a585533b..7207754d8e 100644 --- a/linux/data/licenses/third-party.json +++ b/linux/data/licenses/third-party.json @@ -1159,7 +1159,7 @@ "name": "MIT License", "text": "Copyright (c) 2025-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", "used_by": [ - { "crate": { "name": "ctutils", "version": "0.4.5" } } + { "crate": { "name": "ctutils", "version": "0.4.8" } } ] }, { @@ -2490,7 +2490,7 @@ { "name": "crypto-primes", "version": "0.7.2", "license": "Apache-2.0 OR MIT" }, { "name": "csv", "version": "1.4.0", "license": "Unlicense OR MIT" }, { "name": "csv-core", "version": "0.1.13", "license": "Unlicense OR MIT" }, - { "name": "ctutils", "version": "0.4.5", "license": "Apache-2.0 OR MIT" }, + { "name": "ctutils", "version": "0.4.8", "license": "Apache-2.0 OR MIT" }, { "name": "darling", "version": "0.24.1", "license": "MIT" }, { "name": "darling_core", "version": "0.24.1", "license": "MIT" }, { "name": "darling_macro", "version": "0.24.1", "license": "MIT" }, diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index d550f52c75..2d56887260 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -21,7 +21,7 @@ The library must: - be maintained and published on crates.io, used without a fork ([ADR 0005](decisions/0005-supply-chain-policy.md)); `deny.toml` allows no git source; - use rustls with aws-lc-rs, or no TLS of its own ([ADR 0009](decisions/0009-crypto-provider-and-sources.md)); -- either take a stream the driver hands it, or dial a TCP endpoint and a Unix socket path itself, because an SSH forward is a Unix socket ([Transports](../../core/ARCHITECTURE.md#transports)). A client that dials only TCP reaches the transport through a loopback relay, as the Cassandra driver's does ([ADR 0023](decisions/0023-loopback-relay-for-cql.md)); +- either take a stream the driver hands it, or dial a TCP endpoint and a Unix socket path itself, because an SSH forward is a Unix socket, and relay an in-process SSH channel, which has no address, through `tablepro_net::PrivateListener` ([Transports](../../core/ARCHITECTURE.md#transports)). A client that dials only TCP reaches the transport through a loopback relay, as the Cassandra driver's does ([ADR 0023](decisions/0023-loopback-relay-for-cql.md)); - have a way to stop a statement already sent: a cancel request, a KILL, an attention, an interrupt. Where the protocol has none, as CQL's has not, it drops the request and keeps the connection serving (`StatementStop::Abandon`); - let the driver keep a connection clean for the next caller, with no server-side state it did not ask for. diff --git a/linux/docs/decisions/0009-crypto-provider-and-sources.md b/linux/docs/decisions/0009-crypto-provider-and-sources.md index 3bd121bdff..9825dd9f5a 100644 --- a/linux/docs/decisions/0009-crypto-provider-and-sources.md +++ b/linux/docs/decisions/0009-crypto-provider-and-sources.md @@ -1,6 +1,6 @@ # 0009: One crypto provider, upstream sources and dependency bans -- **Status**: Accepted; sources and the PostgreSQL and ClickHouse TLS setup superseded by [0016](0016-driver-contract-v2.md), the ring ban amended by [0025](0025-bundled-duckdb.md) +- **Status**: Accepted; sources and the PostgreSQL and ClickHouse TLS setup superseded by [0016](0016-driver-contract-v2.md), the ring ban amended by [0025](0025-bundled-duckdb.md), the rsa ban amended by [core 0008](../../../core/docs/decisions/0008-in-process-ssh-on-windows.md) - **Date**: 2026-09-15 ## Context diff --git a/linux/docs/decisions/0013-openssh-client-transport.md b/linux/docs/decisions/0013-openssh-client-transport.md index d6c500bea6..06601f79fa 100644 --- a/linux/docs/decisions/0013-openssh-client-transport.md +++ b/linux/docs/decisions/0013-openssh-client-transport.md @@ -1,6 +1,6 @@ # 0013: SSH through the system OpenSSH client -- **Status**: Accepted; the forced options amended by 0019, the TCP port amended by 0023 +- **Status**: Accepted; the forced options amended by 0019, the TCP port amended by 0023, the russh consequence amended by core 0008 - **Date**: 2026-09-15 ## Context diff --git a/linux/docs/decisions/0023-loopback-relay-for-cql.md b/linux/docs/decisions/0023-loopback-relay-for-cql.md index f2fc34b956..0a9f4f1bb8 100644 --- a/linux/docs/decisions/0023-loopback-relay-for-cql.md +++ b/linux/docs/decisions/0023-loopback-relay-for-cql.md @@ -1,6 +1,6 @@ # 0023: A loopback relay for the CQL client -- **Status**: Accepted +- **Status**: Accepted; the Linux-only relay amended by core 0008 - **Date**: 2026-10-23 ## Context diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index 3e4d007c88..39bb835d81 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -57,10 +57,10 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0006](0006-meson-cargo-build.md) | Meson drives Cargo | Accepted; where the version lives amended by 0017, the release tarball and the place of the Cargo workspace amended by core 0001 | Meson owns install, data files and the build-time config; Cargo stays the Rust compiler. | | [0007](0007-gsettings-preferences.md) | Preferences and window geometry in GSettings | Accepted; the schema id and path per profile amended 2026-10-15 | A schema with ranges and enums replaces preferences.json and window.json; keyed data stays in files. | | [0008](0008-flatpak-sandbox-permissions.md) | Flatpak sandbox permissions | Accepted; the Flathub submission deferred by 0018 | Network, display, home and removable drives for SQLite, the SSH agent and the Kerberos KCM socket; portals for the rest; two Flathub lint exceptions. | -| [0009](0009-crypto-provider-and-sources.md) | One crypto provider, upstream sources and dependency bans | Accepted; sources and the PostgreSQL and ClickHouse TLS setup superseded by 0016, the ring ban amended by 0023 | aws-lc-rs only; deny.toml enforces sources, licenses, bans and advisory ignores. | +| [0009](0009-crypto-provider-and-sources.md) | One crypto provider, upstream sources and dependency bans | Accepted; sources and the PostgreSQL and ClickHouse TLS setup superseded by 0016, the ring ban amended by 0023, the rsa ban amended by core 0008 | aws-lc-rs only; deny.toml enforces sources, licenses, bans and advisory ignores. | | [0010](0010-ddl-plans-and-table-definitions.md) | DDL plans against table definitions | Accepted | Each engine plans structure changes through its `DdlDialect` against the table's definition; the UI offers only what the plan accepts. | | [0012](0012-window-per-session.md) | One window per connection session, built from gtk-rs subclasses | Accepted; what a window holds superseded by 0028 | One `TableProApplication` per profile through D-Bus uniqueness; a window holds at most one session and returns to the picker on disconnect; the application, the window and its workspace are subclasses, not Relm4 components. | -| [0013](0013-openssh-client-transport.md) | SSH through the system OpenSSH client | Accepted; the forced options amended by 0019, the TCP port amended by 0023 | ControlMaster per connection, Unix-socket forwards, prompts through an askpass helper. | +| [0013](0013-openssh-client-transport.md) | SSH through the system OpenSSH client | Accepted; the forced options amended by 0019, the TCP port amended by 0023, the russh consequence amended by core 0008 | ControlMaster per connection, Unix-socket forwards, prompts through an askpass helper. | | [0014](0014-lossless-values-and-dialects.md) | Lossless values and engine dialects | Accepted | A value reaches the user and goes back without loss; every statement is spelled by the dialect of the connection that runs it. | | [0015](0015-gnome-50-platform-floor.md) | GNOME 50 platform floor | Accepted; the runtime row amended by 0018 | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88; AdwShortcutsDialog replaces the shortcuts window. | | [0016](0016-driver-contract-v2.md) | Driver contract v2 | Accepted; the no-fork rule for tiberius superseded by core 0005 | Owned calls with engine stops, phase-aware write outcomes, server-side row limits; PostgreSQL on tokio-postgres; no forks. | @@ -70,7 +70,7 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0020](0020-pin-only-a-key-file-the-agent-lacks.md) | Pin only a key file the agent does not hold | Accepted | `IdentitiesOnly=yes` only for a key file the agent lacks, so an agent-held certificate or encrypted PEM key still signs. | | [0021](0021-engines-without-sql.md) | Engines without SQL keep the driver contract and say what they hold | Accepted; amended by 0026 | A key-value engine states `DataModel::KeyValue`, lists keys with `scan_keys` and reads them with `read_key`, has its own `SqlGrammar` variant, and is held read-only by the driver's check where the server cannot. | | [0022](0022-ai-assistant-and-mcp-server.md) | An AI assistant and a local MCP server | Accepted | An MCP server on `127.0.0.1`, off until turned on, with a token and a per-connection External Clients level, every client write approved in the app; an AI assistant with no provider until the user adds one and a per-connection AI policy. | -| [0023](0023-loopback-relay-for-cql.md) | A loopback relay for the CQL client | Accepted | Each scylla session dials a 127.0.0.1 relay that serves only this user and carries every connection over `Transport::open`, so CQL reaches a server through an SSH forward. | +| [0023](0023-loopback-relay-for-cql.md) | A loopback relay for the CQL client | Accepted; the Linux-only relay amended by core 0008 | Each scylla session dials a 127.0.0.1 relay that serves only this user and carries every connection over `Transport::open`, so CQL reaches a server through an SSH forward. | | [0024](0024-multi-cursor-on-text-marks.md) | Multiple cursors on text marks, in the app | Accepted | The SQL editor's view, a GtkSourceView subclass, keeps extra cursors as GtkTextMark pairs, runs GTK's own commands once per cursor and repeats committed text from `end-user-action`, so one undo takes back every cursor's edit. | | [0025](0025-bundled-duckdb.md) | DuckDB built from the duckdb crate's bundled sources | Accepted; the crate version superseded by 0031 | No PPA or COPR target but Ubuntu 26.10 ships libduckdb, so DuckDB 1.5.6 is compiled from the crate on every channel, with no extension fetched or loaded on its own. | | [0026](0026-documents-in-the-table-tab.md) | Document collections open in the table tab, and their statements are shell calls | Accepted | A document engine states `DataModel::Document`, lists collections as tables, opens them in the table tab, and every builder writes a mongosh call with bound values through a document arm chosen by the grammar. | diff --git a/linux/docs/drivers/cassandra.md b/linux/docs/drivers/cassandra.md index 9692d8ba4b..4896e0266e 100644 --- a/linux/docs/drivers/cassandra.md +++ b/linux/docs/drivers/cassandra.md @@ -9,7 +9,7 @@ The default port is 9042. With TLS on, use the port the server encrypts: 9042 wh scylla dials a TCP address itself and takes no stream, while an SSH forward is a Unix socket. So each scylla session dials a loopback relay of its own ([ADR 0023](../decisions/0023-loopback-relay-for-cql.md)): - The relay listens on `127.0.0.1` on a port the kernel picks, for as long as the session lives. -- It serves a connection only after `/proc/net/tcp` shows the connecting socket belongs to this user, and carries it over a stream from `Transport::open`. Every byte to the server goes through the transport, direct, SSH or the tests' `FaultTransport`. +- It serves a connection only after `/proc/net/tcp` shows the connecting socket belongs to this user, or on Windows after `GetExtendedTcpTable` names this process as its owner, and carries it over a stream from `Transport::open`. Every byte to the server goes through the transport, direct, SSH or the tests' `FaultTransport`. - A stream the transport fails to open closes the connection, and the connect reports the transport's own error. The session stays on the node behind the transport, as cqlsh does with its host: diff --git a/linux/docs/drivers/mongodb.md b/linux/docs/drivers/mongodb.md index 9ef03a6421..7f8ab448be 100644 --- a/linux/docs/drivers/mongodb.md +++ b/linux/docs/drivers/mongodb.md @@ -12,11 +12,11 @@ What the connection names decides how the crate reaches it: |---|---|---| | A host, directly | That one host, with `directConnection`, as mongosh does with one host: a replica set member is used as named, never swapped for the names the set gives its members, which the app may not be able to reach | The crate's, from files the driver writes (below) | | DNS Seed List on | The servers the host's SRV records name, with the options its TXT record sets (`replicaSet`, `authSource`, `loadBalanced`), and the set's discovery | The crate's | -| SSH | A private Unix socket the driver listens on; each connection the crate opens there is a new `Transport::open()` stream through the forward, one host, `directConnection` | The driver's, around each stream | +| SSH | A private socket the driver listens on (`tablepro_net::PrivateListener`: a Unix socket, or on Windows a loopback port that serves only this process); each connection the crate opens there is a new `Transport::open()` stream through the forward, one host, `directConnection` | The driver's, around each stream | A seed list is a DNS lookup the forward cannot carry, so DNS Seed List over SSH is refused before anything is opened (`ConfigError::SeedListOverSsh`). The session's reachability check does not wait on a seed list's host, which may have no address of its own. The seed list's port is the records' to give, so the connect dialog hides the Port row while DNS Seed List is on. -The relay socket sits in a private directory under `$XDG_RUNTIME_DIR` (the system temporary directory without one), mode 0700, and is removed with the connection. A stream the forward fails to open closes the crate's connection, and the transport's own error (the SSH master exited, the destination refused) is what the call reports. +On Unix the relay socket sits in a private directory under `$XDG_RUNTIME_DIR` (the system temporary directory without one), mode 0700, and is removed with the connection. A stream the forward fails to open closes the crate's connection, and the transport's own error (the SSH master exited, the destination refused) is what the call reports. ## Clients diff --git a/linux/docs/drivers/mysql.md b/linux/docs/drivers/mysql.md index 563e12a829..b8bc4c5f3f 100644 --- a/linux/docs/drivers/mysql.md +++ b/linux/docs/drivers/mysql.md @@ -21,8 +21,9 @@ sqlx dials its own sockets and takes no stream, so the driver passes it the tran - A direct route is the host and port, dialled by sqlx. - An SSH forward is the Unix socket OpenSSH listens on. sqlx connects to the socket, and its `host` carries the name TLS checks: the service's own, or the override. +- An in-process SSH channel has no address ([core ADR 0008](../../../core/docs/decisions/0008-in-process-ssh-on-windows.md)), so the driver relays it through a `tablepro_net::PrivateListener` that carries each connection over `Transport::open`. On Unix that is a Unix socket, dialled as above. On Windows it is a loopback port that serves only this process, and sqlx checks the certificate against `127.0.0.1`, so Verify full through it is refused with `ServerNameOverrideUnsupported`. -Nothing relays bytes in between. The whole login, dial included, is bounded by the connect and login timeouts together (25 s). sqlx 0.9 sets no `TCP_NODELAY` and no keepalive on its own dial, so on a direct route a dead peer is found by the probe and by each call's deadline, not by the kernel. +On the other routes nothing relays bytes in between. The whole login, dial included, is bounded by the connect and login timeouts together (25 s). sqlx 0.9 sets no `TCP_NODELAY` and no keepalive on its own dial, so on a direct route a dead peer is found by the probe and by each call's deadline, not by the kernel. ## Session statements diff --git a/scripts/ci/detect-changed-paths.sh b/scripts/ci/detect-changed-paths.sh index bf1f9315dc..0cc7d4ca68 100755 --- a/scripts/ci/detect-changed-paths.sh +++ b/scripts/ci/detect-changed-paths.sh @@ -92,7 +92,7 @@ case "$SUITE" in linux|linux-docs) # The Linux app builds the shared core in the Cargo workspace whose files are at the top. PATTERNS=('linux/' 'core/' 'Cargo\.(toml|lock)$' '(rust-toolchain|rustfmt|clippy|deny)\.toml$' - '\.config/nextest\.toml$' 'scripts/ci/detect-changed-paths\.sh$' + '\.config/nextest\.toml$' 'scripts/ci/(detect-changed-paths\.sh|start-windows-sshd\.ps1)$' '\.github/workflows/(ci|build-linux|core|linux-packages|release-linux)\.yml$') # linux-docs decides the docs links job, which reads every path. The other Linux jobs skip # Markdown, except the files a Meson test reads and the notices the build installs. diff --git a/scripts/ci/start-windows-sshd.ps1 b/scripts/ci/start-windows-sshd.ps1 new file mode 100644 index 0000000000..81689db9c9 --- /dev/null +++ b/scripts/ci/start-windows-sshd.ps1 @@ -0,0 +1,73 @@ +# Starts Windows' own OpenSSH Server on a hosted runner for the in-process +# SSH tests, and writes what they need to $env:GITHUB_ENV. +# usage: start-windows-sshd.ps1 -Out + +param([Parameter(Mandatory)][string]$Out) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$client = Get-WindowsCapability -Online -Name 'OpenSSH.Client*' +$server = Get-WindowsCapability -Online -Name 'OpenSSH.Server*' +"OpenSSH client: $($client.State); server: $($server.State)" +if ($server.State -ne 'Installed') { + Add-WindowsCapability -Online -Name $server.Name | Out-Null +} +$openssh = Join-Path $env:WINDIR 'System32\OpenSSH' +& (Join-Path $openssh 'ssh.exe') -V + +New-Item -ItemType Directory -Force $Out | Out-Null +$user = 'tpssh' +$password = 'Tp-' + [guid]::NewGuid().ToString('N') +"::add-mask::$password" +$secure = ConvertTo-SecureString $password -AsPlainText -Force +New-LocalUser -Name $user -Password $secure -PasswordNeverExpires -AccountNeverExpires | Out-Null + +$keygen = Join-Path $openssh 'ssh-keygen.exe' +$plain = Join-Path $Out 'id_ed25519' +$locked = Join-Path $Out 'id_ecdsa' +$passphrase = 'correct horse battery' +& $keygen -q -t ed25519 -N '' -C tablepro-windows -f $plain +if ($LASTEXITCODE -ne 0) { throw "ssh-keygen failed with $LASTEXITCODE" } +& $keygen -q -t ecdsa -b 256 -N $passphrase -C tablepro-windows-locked -f $locked +if ($LASTEXITCODE -ne 0) { throw "ssh-keygen failed with $LASTEXITCODE" } + +# One key file for every user, ahead of the default config's Match block, +# owned and writable only by SYSTEM and Administrators as sshd requires. +$programData = Join-Path $env:ProgramData 'ssh' +New-Item -ItemType Directory -Force $programData | Out-Null +$authorized = Join-Path $programData 'tablepro_authorized_keys' +(Get-Content "$plain.pub", "$locked.pub") | Set-Content -Encoding ascii $authorized +& icacls.exe $authorized /inheritance:r /grant 'SYSTEM:F' /grant 'BUILTIN\Administrators:F' /grant "${user}:R" | Out-Null + +$config = Join-Path $programData 'sshd_config' +if (-not (Test-Path $config)) { + Copy-Item (Join-Path $openssh 'sshd_config_default') $config +} +$lines = @( + "AuthorizedKeysFile __PROGRAMDATA__/ssh/tablepro_authorized_keys", + 'PasswordAuthentication yes', + 'AllowTcpForwarding yes' +) +Set-Content -Encoding ascii $config ($lines + (Get-Content $config)) +Set-Service sshd -StartupType Manual +Restart-Service sshd + +Set-Service ssh-agent -StartupType Manual +Start-Service ssh-agent +& (Join-Path $openssh 'ssh-add.exe') $plain +if ($LASTEXITCODE -ne 0) { throw "ssh-add failed with $LASTEXITCODE" } + +$deadline = (Get-Date).AddSeconds(30) +while (-not (Test-NetConnection -ComputerName 127.0.0.1 -Port 22 -InformationLevel Quiet)) { + if ((Get-Date) -gt $deadline) { throw 'sshd did not listen on port 22' } + Start-Sleep -Seconds 1 +} + +@( + "TABLEPRO_TEST_SSHD_USER=$user", + "TABLEPRO_TEST_SSHD_PASSWORD=$password", + "TABLEPRO_TEST_SSHD_KEY=$plain", + "TABLEPRO_TEST_SSHD_LOCKED_KEY=$locked", + "TABLEPRO_TEST_SSHD_PASSPHRASE=$passphrase" +) | Add-Content -Encoding utf8 $env:GITHUB_ENV diff --git a/scripts/ci/test_detect_changed_paths.py b/scripts/ci/test_detect_changed_paths.py index b904797f96..e75552aff9 100644 --- a/scripts/ci/test_detect_changed_paths.py +++ b/scripts/ci/test_detect_changed_paths.py @@ -78,6 +78,7 @@ def test_linux_runs_on_the_linux_tree_and_what_its_jobs_read(self): ".github/workflows/build-linux.yml", ".github/workflows/linux-packages.yml", ".github/workflows/release-linux.yml", + "scripts/ci/start-windows-sshd.ps1", ]: with self.subTest(path=path): self.assertEqual(self.verdict_after("linux", path), "true") From f15cda23d8d2e6c6b9ce8fb789be54512723f9ca Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 14:54:47 +0700 Subject: [PATCH 02/13] fix(core-ssh): retry keyboard-interactive, open encrypted PKCS#8 keys and log the Windows sshd --- .github/workflows/core.yml | 6 ++ core/crates/ssh/src/in_process/auth.rs | 61 +++++++++++++++++---- core/crates/ssh/src/in_process/identity.rs | 25 +++++++++ core/crates/ssh/tests/in_process_matrix.rs | 21 +++++-- core/crates/ssh/tests/in_process_windows.rs | 25 +++++++-- scripts/ci/start-windows-sshd.ps1 | 5 +- 6 files changed, 121 insertions(+), 22 deletions(-) diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 11e0cc8cea..4bac473e57 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -77,3 +77,9 @@ jobs: - name: SSH tunnels through Windows' OpenSSH Server if: runner.os == 'Windows' run: cargo test --locked --target "$TARGET" --features tablepro-ssh/in-process -p tablepro-ssh --test in_process_windows -- --ignored + - name: The OpenSSH Server's log + if: failure() && runner.os == 'Windows' + shell: pwsh + run: | + Get-Content (Join-Path $env:ProgramData 'ssh\sshd_config') | Where-Object { $_ -notmatch '^\s*(#|$)' } + Get-Content (Join-Path $env:ProgramData 'ssh\logs\sshd.log') -Tail 300 -ErrorAction Continue diff --git a/core/crates/ssh/src/in_process/auth.rs b/core/crates/ssh/src/in_process/auth.rs index a8c591aed7..f8e7ff518d 100644 --- a/core/crates/ssh/src/in_process/auth.rs +++ b/core/crates/ssh/src/in_process/auth.rs @@ -247,22 +247,48 @@ async fn password(handle: &mut Handle, login: &mut Login<'_>) -> Res Ok(last) } +// A rejected exchange starts again, as a wrong password does, while someone can answer. async fn keyboard_interactive(handle: &mut Handle, login: &mut Login<'_>) -> Result { + let mut last = Step::NotTried; + for _ in 0..TRIES { + let (step, asked) = keyboard_exchange(handle, login).await?; + match step { + Step::Refused(remaining) => { + let again = asked && remaining.contains(&MethodKind::KeyboardInteractive); + last = Step::Refused(remaining); + if !again { + break; + } + } + other => return Ok(other), + } + } + Ok(last) +} + +// One exchange, and whether a person gave its answers. +async fn keyboard_exchange( + handle: &mut Handle, + login: &mut Login<'_>, +) -> Result<(Step, bool), TransportError> { + let mut asked = false; let mut response = handle .authenticate_keyboard_interactive_start(login.user, None) .await .map_err(|error| login.failed(error))?; - for _ in 0..KEYBOARD_ROUNDS { + let mut rounds = 0; + loop { let (name, instructions, prompts) = match response { - KeyboardInteractiveAuthResponse::Success => return Ok(Step::Done), + KeyboardInteractiveAuthResponse::Success => return Ok((Step::Done, asked)), KeyboardInteractiveAuthResponse::Failure { remaining_methods, partial_success, } => { - return Ok(match partial_success { + let step = match partial_success { true => Step::Partial(remaining_methods), false => Step::Refused(remaining_methods), - }); + }; + return Ok((step, asked)); } KeyboardInteractiveAuthResponse::InfoRequest { name, @@ -270,6 +296,14 @@ async fn keyboard_interactive(handle: &mut Handle, login: &mut Login prompts, } => (name, instructions, prompts), }; + rounds += 1; + if rounds > KEYBOARD_ROUNDS { + return Err(SshFailure::Protocol { + status: None, + detail: format!("keyboard-interactive asked more than {KEYBOARD_ROUNDS} times"), + } + .into()); + } let answers = if prompts.is_empty() { Vec::new() } else if let Some(stored) = stored_password_answer(&prompts, login) { @@ -280,7 +314,10 @@ async fn keyboard_interactive(handle: &mut Handle, login: &mut Login .keyboard_interactive(&login.user_host(), &name, &instructions, &prompts) .await? { - Some(answers) => answers, + Some(answers) => { + asked = true; + answers + } // russh takes nothing but answers until the exchange ends, so nobody to ask answers blank. None => prompts.iter().map(|_| SecretString::from("")).collect(), } @@ -291,7 +328,6 @@ async fn keyboard_interactive(handle: &mut Handle, login: &mut Login .await .map_err(|error| login.failed(error))?; } - Ok(Step::NotTried) } // PAM asks for the password through keyboard-interactive, so the stored one answers its one hidden prompt. @@ -323,7 +359,7 @@ async fn agent( .collect(); let mut last = Step::NotTried; for identity in wanted { - login.offered.push(Offered::of(&identity)); + let offered = Offered::of(&identity); let hash = rsa_hash(handle, identity_algorithm(&identity)).await; let result = match identity { AgentIdentity::PublicKey { key, .. } => handle.authenticate_publickey_with(user, key, hash, agent).await, @@ -334,7 +370,11 @@ async fn agent( } }; let result = match result { - Ok(result) => result, + // A key the agent could not sign with was not offered, so its file may still be. + Ok(result) => { + login.offered.push(offered); + result + } Err(error) if handle.is_closed() => { tracing::debug!(%error, "the session ended while the agent signed"); return Err(from_russh(russh::Error::Disconnect, host, end)); @@ -372,10 +412,11 @@ async fn key_file( if let Some(certificate) = &certificate && !login.offered.contains(&Offered::Certificate(public.key_data().clone())) { - login.offered.push(Offered::Certificate(public.key_data().clone())); match agent_certificate(handle, login, certificate).await? { - Step::Refused(remaining) if remaining.contains(&MethodKind::PublicKey) => {} Step::NotTried => {} + Step::Refused(remaining) if remaining.contains(&MethodKind::PublicKey) => { + login.offered.push(Offered::Certificate(public.key_data().clone())); + } other => return Ok(other), } } diff --git a/core/crates/ssh/src/in_process/identity.rs b/core/crates/ssh/src/in_process/identity.rs index 9ca5707ce6..b56c5f9a0d 100644 --- a/core/crates/ssh/src/in_process/identity.rs +++ b/core/crates/ssh/src/in_process/identity.rs @@ -64,6 +64,7 @@ pub(crate) async fn load( match decode(&text, None).await { Ok(key) => return usable(key, path, chosen), Err(russh::keys::Error::KeyIsEncrypted) => {} + Err(_) if encrypted(&text) => {} Err(error) => return unusable(&error, path, chosen), } let mut reason = PromptReason::NotStored; @@ -114,6 +115,18 @@ fn sign(key: &PrivateKey, hash_alg: Option, data: &[u8]) -> Result bool { + if text.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") { + return true; + } + text.trim_start().starts_with("PuTTY-User-Key-File-") + && text + .lines() + .filter_map(|line| line.strip_prefix("Encryption:")) + .any(|cipher| cipher.trim() != "none") +} + async fn decode( text: &Arc>, passphrase: Option, @@ -235,6 +248,18 @@ mod tests { key.public_key().key_data().verify(&data, &signature).unwrap(); } + #[test] + fn pkcs8_and_putty_files_say_when_they_are_encrypted() { + let pkcs8 = "-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIIB\n-----END ENCRYPTED PRIVATE KEY-----\n"; + let putty = "PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: aes256-cbc\nComment: deploy\n"; + let plain_putty = "PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\nComment: deploy\n"; + + assert!(encrypted(pkcs8)); + assert!(encrypted(putty)); + assert!(!encrypted(plain_putty)); + assert!(!encrypted("-----BEGIN PRIVATE KEY-----\n")); + } + #[tokio::test] async fn the_public_half_of_an_encrypted_key_reads_without_its_passphrase() { let dir = tempfile::tempdir().unwrap(); diff --git a/core/crates/ssh/tests/in_process_matrix.rs b/core/crates/ssh/tests/in_process_matrix.rs index 42546be95f..5c600e1241 100644 --- a/core/crates/ssh/tests/in_process_matrix.rs +++ b/core/crates/ssh/tests/in_process_matrix.rs @@ -222,16 +222,15 @@ async fn ecdsa_p256_key_with_a_passphrase_signs_in() -> TestResult { key_signs_in(SshKeyType::EcdsaP256, Some(PASSPHRASE), OpenSshOptions::selected()?).await } -// The PEM format keeps no public half in the clear, so the passphrase opens it. -#[tokio::test] -#[ignore = "requires docker"] -async fn an_encrypted_pem_rsa_key_signs_in() -> TestResult { +// PEM and PKCS#8 keep no public half in the clear, so the passphrase opens them. +async fn rewritten_rsa_key_signs_in(format: &str) -> TestResult { let key = SshKeyPair::generate_with(SshKeyType::Rsa, Some(PASSPHRASE), "tablepro-in-process").await?; let rewritten = std::process::Command::new("ssh-keygen") - .args(["-q", "-p", "-m", "PEM", "-P", PASSPHRASE, "-N", PASSPHRASE, "-f"]) + .args(["-q", "-p", "-m", format, "-P", PASSPHRASE, "-N", PASSPHRASE, "-f"]) .arg(key.private_key()) .status()?; assert!(rewritten.success()); + std::fs::remove_file(key.public_key_file())?; let server = Server::start(SshAuthVariant::PublicKey(key), OpenSshOptions::selected()?, None).await?; let session = server.connect(key_file(server.key()?)).await??; @@ -239,6 +238,18 @@ async fn an_encrypted_pem_rsa_key_signs_in() -> TestResult { assert_tunnel_answers(&session).await } +#[tokio::test] +#[ignore = "requires docker"] +async fn an_encrypted_pem_rsa_key_signs_in() -> TestResult { + rewritten_rsa_key_signs_in("PEM").await +} + +#[tokio::test] +#[ignore = "requires docker"] +async fn an_encrypted_pkcs8_rsa_key_signs_in() -> TestResult { + rewritten_rsa_key_signs_in("PKCS8").await +} + #[tokio::test] #[ignore = "requires docker"] async fn a_missing_passphrase_is_asked_for_with_the_key_path() -> TestResult { diff --git a/core/crates/ssh/tests/in_process_windows.rs b/core/crates/ssh/tests/in_process_windows.rs index a5911073f2..87ddb87320 100644 --- a/core/crates/ssh/tests/in_process_windows.rs +++ b/core/crates/ssh/tests/in_process_windows.rs @@ -70,6 +70,17 @@ impl Machine { } } +// What the user was asked says which login step the server refused. +fn explained( + session: Result, TransportError>, + prompter: &FakePrompter, +) -> TestResult> { + session.map_err(|error| { + let asked: Vec = prompter.requests().into_iter().map(|request| request.purpose).collect(); + format!("{error:?}, after asking {asked:?}").into() + }) +} + fn accept() -> PromptReply { PromptReply::Submitted { values: Vec::new(), @@ -99,11 +110,12 @@ async fn a_password_signs_in_after_the_host_key_is_confirmed() -> TestResult { let (session, prompter) = machine.connect(&config, vec![accept()]).await; + let session = explained(session, &prompter)?; assert!(matches!( prompter.requests().first().map(|request| &request.purpose), Some(PromptPurpose::SshHostKeyConfirmation { host, .. }) if host == SSHD )); - assert_tunnel_answers(&session?).await + assert_tunnel_answers(&session).await } #[tokio::test] @@ -115,9 +127,9 @@ async fn a_key_file_with_a_passphrase_signs_in() -> TestResult { passphrase: Some(SecretString::from(setting("TABLEPRO_TEST_SSHD_PASSPHRASE")?)), })?; - let (session, _) = machine.connect(&config, vec![accept()]).await; + let (session, prompter) = machine.connect(&config, vec![accept()]).await; - assert_tunnel_answers(&session?).await + assert_tunnel_answers(&explained(session, &prompter)?).await } #[tokio::test] @@ -126,9 +138,9 @@ async fn the_windows_ssh_agent_signs_in() -> TestResult { let machine = Machine::new()?; let config = machine.config(SshAuth::Agent)?; - let (session, _) = machine.connect(&config, vec![accept()]).await; + let (session, prompter) = machine.connect(&config, vec![accept()]).await; - assert_tunnel_answers(&session?).await + assert_tunnel_answers(&explained(session, &prompter)?).await } #[tokio::test] @@ -145,6 +157,7 @@ async fn a_key_file_signs_in_through_a_jump_host() -> TestResult { }; let (session, prompter) = machine.connect(&config, vec![accept(), accept()]).await; + let session = explained(session, &prompter)?; let hosts: Vec = prompter .requests() @@ -155,7 +168,7 @@ async fn a_key_file_signs_in_through_a_jump_host() -> TestResult { }) .collect(); assert_eq!(hosts, [SSHD, "localhost"]); - assert_tunnel_answers(&session?).await + assert_tunnel_answers(&session).await } #[tokio::test] diff --git a/scripts/ci/start-windows-sshd.ps1 b/scripts/ci/start-windows-sshd.ps1 index 81689db9c9..f043b87e21 100644 --- a/scripts/ci/start-windows-sshd.ps1 +++ b/scripts/ci/start-windows-sshd.ps1 @@ -44,10 +44,13 @@ $config = Join-Path $programData 'sshd_config' if (-not (Test-Path $config)) { Copy-Item (Join-Path $openssh 'sshd_config_default') $config } +# The log goes to __PROGRAMDATA__/ssh/logs/sshd.log, which the workflow prints when a test fails. $lines = @( "AuthorizedKeysFile __PROGRAMDATA__/ssh/tablepro_authorized_keys", 'PasswordAuthentication yes', - 'AllowTcpForwarding yes' + 'AllowTcpForwarding yes', + 'SyslogFacility LOCAL0', + 'LogLevel DEBUG2' ) Set-Content -Encoding ascii $config ($lines + (Get-Content $config)) Set-Service sshd -StartupType Manual From b90c4461fe005ec07a2dab01e5fa920e2fc4be86 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 15:13:19 +0700 Subject: [PATCH 03/13] fix(core-ssh): ask for a key's passphrase only once the server takes the key --- core/crates/ssh/src/in_process/auth.rs | 102 +++++++++++++-------- core/crates/ssh/src/in_process/forward.rs | 14 ++- core/crates/ssh/src/in_process/identity.rs | 51 +++++++++++ 3 files changed, 128 insertions(+), 39 deletions(-) diff --git a/core/crates/ssh/src/in_process/auth.rs b/core/crates/ssh/src/in_process/auth.rs index f8e7ff518d..ea25e1ad11 100644 --- a/core/crates/ssh/src/in_process/auth.rs +++ b/core/crates/ssh/src/in_process/auth.rs @@ -3,7 +3,6 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use russh::client::{AuthResult, Handle, KeyboardInteractiveAuthResponse}; -use russh::keys::PrivateKeyWithHashAlg; use russh::keys::agent::AgentIdentity; use russh::keys::ssh_key::public::KeyData; use russh::keys::ssh_key::{Algorithm, Certificate, HashAlg, PrivateKey, PublicKey}; @@ -15,7 +14,7 @@ use tablepro_core::{SshFailure, TransportError}; use super::agent::{AgentSlot, identity_algorithm, signs_for}; use super::failure::from_russh; use super::handler::{HopHandler, SessionEnd}; -use super::identity::{self, KeySigner, TRIES}; +use super::identity::{self, FileSignError, FileSigner, TRIES}; use super::prompts::Prompts; use super::services::InProcessSsh; use crate::SshAuth; @@ -89,14 +88,14 @@ impl From for Step { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Offered { Key(KeyData), - Certificate(KeyData), + Certificate(Box), } impl Offered { fn of(identity: &AgentIdentity) -> Self { match identity { AgentIdentity::PublicKey { key, .. } => Self::Key(key.key_data().clone()), - AgentIdentity::Certificate { certificate, .. } => Self::Certificate(certificate.public_key().clone()), + AgentIdentity::Certificate { certificate, .. } => Self::Certificate(Box::new(certificate.clone())), } } } @@ -405,64 +404,95 @@ async fn key_file( login: &mut Login<'_>, ) -> Result { let certificate = identity::certificate_beside(path).await; + let public = identity::public_half(path).await; // A key the agent holds signs there with no passphrase, certificates included (Linux ADR 0020). - if let Some(public) = identity::public_half(path).await - && login.agent.holds(login.services, &public).await + if let Some(public) = &public + && login.agent.holds(login.services, public).await { if let Some(certificate) = &certificate - && !login.offered.contains(&Offered::Certificate(public.key_data().clone())) + && !login + .offered + .contains(&Offered::Certificate(Box::new(certificate.clone()))) { match agent_certificate(handle, login, certificate).await? { Step::NotTried => {} Step::Refused(remaining) if remaining.contains(&MethodKind::PublicKey) => { - login.offered.push(Offered::Certificate(public.key_data().clone())); + login.offered.push(Offered::Certificate(Box::new(certificate.clone()))); } other => return Ok(other), } } - let step = agent(handle, login, Some(&public)).await?; + let step = agent(handle, login, Some(public)).await?; if !matches!(step, Step::NotTried) { return Ok(step); } } - let key = match login.keys.get(path) { - Some(key) => key.clone(), - None => { - let Some(key) = identity::load(path, chosen, &mut *login.passphrase, login.prompts).await? else { - return Ok(Step::NotTried); - }; - let key = Arc::new(key); - login.keys.insert(path.to_owned(), key.clone()); - key - } + let (user, host, end) = (login.user, login.host, login.end); + let mut signer = FileSigner { + path, + chosen, + stored: &mut *login.passphrase, + prompts: login.prompts, + keys: &mut *login.keys, + }; + // A PEM or PKCS#8 file with no `.pub` beside it shows its public half only once opened. + let public = match public { + Some(public) => public, + None => match signer.key().await? { + Some(key) => key.public_key().clone(), + None => return Ok(Step::NotTried), + }, }; - let data = key.public_key().key_data().clone(); if let Some(certificate) = certificate - && !login.offered.contains(&Offered::Certificate(data.clone())) + && !login + .offered + .contains(&Offered::Certificate(Box::new(certificate.clone()))) { - login.offered.push(Offered::Certificate(data.clone())); let hash = rsa_hash(handle, certificate.public_key().algorithm()).await; + let offered = Offered::Certificate(Box::new(certificate.clone())); let result = handle - .authenticate_certificate_with(login.user, certificate, hash, &mut KeySigner(key.clone())) + .authenticate_certificate_with(user, certificate, hash, &mut signer) .await; - match result { - Ok(result) => match Step::from(result) { - Step::Refused(remaining) if remaining.contains(&MethodKind::PublicKey) => {} - other => return Ok(other), - }, - Err(error) => tracing::debug!(%error, "the certificate beside the key file did not sign"), + match signed(result, host, end)? { + Step::NotTried => {} + Step::Refused(remaining) if remaining.contains(&MethodKind::PublicKey) => login.offered.push(offered), + other => return Ok(other), } } - if login.offered.contains(&Offered::Key(data.clone())) { + let offered = Offered::Key(public.key_data().clone()); + if login.offered.contains(&offered) { return Ok(Step::NotTried); } - login.offered.push(Offered::Key(data)); - let hash = rsa_hash(handle, key.algorithm()).await; + let hash = rsa_hash(handle, public.algorithm()).await; + let mut signer = FileSigner { + path, + chosen, + stored: &mut *login.passphrase, + prompts: login.prompts, + keys: &mut *login.keys, + }; let result = handle - .authenticate_publickey(login.user, PrivateKeyWithHashAlg::new(key, hash)) - .await - .map_err(|error| login.failed(error))?; - Ok(Step::from(result)) + .authenticate_publickey_with(user, public, hash, &mut signer) + .await; + let step = signed(result, host, end)?; + if !matches!(step, Step::NotTried) { + login.offered.push(offered); + } + Ok(step) +} + +// A key nobody could open was never offered; a cancel or an unusable file ends the login. +fn signed(result: Result, host: &str, end: &SessionEnd) -> Result { + match result { + Ok(result) => Ok(Step::from(result)), + Err(FileSignError::Failure(failure)) => Err(failure.into()), + Err(FileSignError::Unanswered) => Ok(Step::NotTried), + Err(FileSignError::Send(_)) => Err(from_russh(russh::Error::Disconnect, host, end)), + Err(FileSignError::Agent(error)) => { + tracing::debug!(%error, "the key file did not sign"); + Ok(Step::NotTried) + } + } } // The agent signs with the key a certificate kept on disk certifies. diff --git a/core/crates/ssh/src/in_process/forward.rs b/core/crates/ssh/src/in_process/forward.rs index b741d17854..64bc58ff57 100644 --- a/core/crates/ssh/src/in_process/forward.rs +++ b/core/crates/ssh/src/in_process/forward.rs @@ -5,6 +5,7 @@ use tablepro_core::{ LivenessPolicy, NetworkEndpoint, TimeoutPhase, Transport, TransportError, TransportKind, TransportRoute, TransportStream, }; +use tokio::sync::oneshot; use super::session::InProcessSession; @@ -46,9 +47,16 @@ impl Transport for InProcessForward { if self.session.is_closed() { return Err(self.session.ended()); } - let opening = self.session.open_channel(&self.service); - match tokio::time::timeout(self.liveness.ssh_channel_open_timeout, opening).await { - Ok(opened) => opened, + // russh keeps a channel confirmed after its opener stopped waiting, so the open finishes on a + // task of its own and a late channel is dropped there, which closes it. + let (opened, waiting) = oneshot::channel(); + let (session, service) = (self.session.clone(), self.service.clone()); + tokio::spawn(async move { + let _late = opened.send(session.open_channel(&service).await); + }); + match tokio::time::timeout(self.liveness.ssh_channel_open_timeout, waiting).await { + Ok(Ok(opened)) => opened, + Ok(Err(_)) => Err(self.session.ended()), Err(_) => Err(TransportError::Timeout { phase: TimeoutPhase::SshChannelOpen, }), diff --git a/core/crates/ssh/src/in_process/identity.rs b/core/crates/ssh/src/in_process/identity.rs index b56c5f9a0d..08a1e9927c 100644 --- a/core/crates/ssh/src/in_process/identity.rs +++ b/core/crates/ssh/src/in_process/identity.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -107,6 +108,56 @@ impl russh::Signer for KeySigner { } } +// Opens the key file only once the server says it would take the key, so a passphrase is asked for +// only then, as OpenSSH asks. +pub(crate) struct FileSigner<'a> { + pub path: &'a Path, + pub chosen: bool, + pub stored: &'a mut Option, + pub prompts: &'a Prompts, + pub keys: &'a mut HashMap>, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum FileSignError { + #[error(transparent)] + Send(#[from] russh::SendError), + #[error(transparent)] + Failure(#[from] SshFailure), + #[error("nobody could give the key's passphrase")] + Unanswered, + #[error(transparent)] + Agent(#[from] russh::AgentAuthError), +} + +impl FileSigner<'_> { + pub async fn key(&mut self) -> Result>, SshFailure> { + if let Some(key) = self.keys.get(self.path) { + return Ok(Some(key.clone())); + } + let Some(key) = load(self.path, self.chosen, self.stored, self.prompts).await? else { + return Ok(None); + }; + let key = Arc::new(key); + self.keys.insert(self.path.to_owned(), key.clone()); + Ok(Some(key)) + } +} + +impl russh::Signer for FileSigner<'_> { + type Error = FileSignError; + + async fn auth_sign( + &mut self, + identity: &AgentIdentity, + hash_alg: Option, + to_sign: Vec, + ) -> Result, Self::Error> { + let key = self.key().await?.ok_or(FileSignError::Unanswered)?; + Ok(KeySigner(key).auth_sign(identity, hash_alg, to_sign).await?) + } +} + fn sign(key: &PrivateKey, hash_alg: Option, data: &[u8]) -> Result { use russh::keys::signature::Signer; Ok(match key.key_data() { From e60dce452b0ee362e35820389534f5c05b073ff7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 15:42:22 +0700 Subject: [PATCH 04/13] ci(core): let the Windows test user log in to sshd and run the tunnel tests one at a time --- .github/workflows/core.yml | 3 ++- scripts/ci/start-windows-sshd.ps1 | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 4bac473e57..21c143c957 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -76,7 +76,8 @@ jobs: run: scripts/ci/start-windows-sshd.ps1 -Out (Join-Path $env:RUNNER_TEMP 'tablepro-ssh') - name: SSH tunnels through Windows' OpenSSH Server if: runner.os == 'Windows' - run: cargo test --locked --target "$TARGET" --features tablepro-ssh/in-process -p tablepro-ssh --test in_process_windows -- --ignored + # One login at a time: the Arm64 runner is slow to start each session's process. + run: cargo test --locked --target "$TARGET" --features tablepro-ssh/in-process -p tablepro-ssh --test in_process_windows -- --ignored --test-threads=1 - name: The OpenSSH Server's log if: failure() && runner.os == 'Windows' shell: pwsh diff --git a/scripts/ci/start-windows-sshd.ps1 b/scripts/ci/start-windows-sshd.ps1 index f043b87e21..0f27d53ed5 100644 --- a/scripts/ci/start-windows-sshd.ps1 +++ b/scripts/ci/start-windows-sshd.ps1 @@ -22,6 +22,11 @@ $password = 'Tp-' + [guid]::NewGuid().ToString('N') "::add-mask::$password" $secure = ConvertTo-SecureString $password -AsPlainText -Force New-LocalUser -Name $user -Password $secure -PasswordNeverExpires -AccountNeverExpires | Out-Null +# The default sshd_config lets in only the Administrators and the OpenSSH Users groups. +if (-not (Get-LocalGroup -Name 'OpenSSH Users' -ErrorAction SilentlyContinue)) { + New-LocalGroup -Name 'OpenSSH Users' | Out-Null +} +Add-LocalGroupMember -Group 'OpenSSH Users' -Member $user $keygen = Join-Path $openssh 'ssh-keygen.exe' $plain = Join-Path $Out 'id_ed25519' @@ -67,6 +72,11 @@ while (-not (Test-NetConnection -ComputerName 127.0.0.1 -Port 22 -InformationLev Start-Sleep -Seconds 1 } +# The first login makes the user's profile, which takes longer than a channel open may on a slow +# runner, so Windows' own client logs in once first. +& (Join-Path $openssh 'ssh.exe') -i $plain -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL "$user@127.0.0.1" 'exit 0' +if ($LASTEXITCODE -ne 0) { throw "the first login failed with $LASTEXITCODE" } + @( "TABLEPRO_TEST_SSHD_USER=$user", "TABLEPRO_TEST_SSHD_PASSWORD=$password", From f4fcb50a0639c75095719ae329076101bfe62a3e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 16:25:01 +0700 Subject: [PATCH 05/13] fix(core-ssh): stop a late channel open from keeping the session alive --- core/crates/ssh/src/in_process/forward.rs | 12 ++++-- core/crates/ssh/src/in_process/session.rs | 47 ++++++++++++++++------- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/core/crates/ssh/src/in_process/forward.rs b/core/crates/ssh/src/in_process/forward.rs index 64bc58ff57..c91828a819 100644 --- a/core/crates/ssh/src/in_process/forward.rs +++ b/core/crates/ssh/src/in_process/forward.rs @@ -48,11 +48,17 @@ impl Transport for InProcessForward { return Err(self.session.ended()); } // russh keeps a channel confirmed after its opener stopped waiting, so the open finishes on a - // task of its own and a late channel is dropped there, which closes it. + // task of its own, which drops a late channel to close it and gives up when the session ends. + let opener = self.session.opener()?; let (opened, waiting) = oneshot::channel(); - let (session, service) = (self.session.clone(), self.service.clone()); + let service = self.service.clone(); tokio::spawn(async move { - let _late = opened.send(session.open_channel(&service).await); + tokio::select! { + () = opener.closed().cancelled() => {} + result = opener.open(&service) => { + let _late = opened.send(result); + } + } }); match tokio::time::timeout(self.liveness.ssh_channel_open_timeout, waiting).await { Ok(Ok(opened)) => opened, diff --git a/core/crates/ssh/src/in_process/session.rs b/core/crates/ssh/src/in_process/session.rs index ff6c38384d..ff955edf51 100644 --- a/core/crates/ssh/src/in_process/session.rs +++ b/core/crates/ssh/src/in_process/session.rs @@ -194,19 +194,44 @@ impl InProcessSession { Ok(Arc::new(InProcessForward::new(self.clone(), target, liveness))) } - pub(crate) async fn open_channel( - &self, - target: &NetworkEndpoint, - ) -> Result, TransportError> { - let Some(destination) = self.hops.last() else { - return Err(ended(&self.end)); - }; - match destination + // What a channel open needs, held apart from the session so a late open never keeps it alive. + pub(crate) fn opener(&self) -> Result { + match self.hops.last() { + Some(destination) => Ok(ChannelOpener { + destination: destination.clone(), + end: self.end.clone(), + }), + None => Err(ended(&self.end)), + } + } + + pub(crate) fn ended(&self) -> TransportError { + ended(&self.end) + } +} + +pub(crate) struct ChannelOpener { + destination: Arc>, + end: Arc, +} + +impl ChannelOpener { + pub fn closed(&self) -> &CancellationToken { + self.end.closed() + } + + pub fn ended(&self) -> TransportError { + ended(&self.end) + } + + pub async fn open(&self, target: &NetworkEndpoint) -> Result, TransportError> { + match self + .destination .channel_open_direct_tcpip(target.host(), u32::from(target.port()), "127.0.0.1", 0) .await { Ok(channel) => Ok(Box::new(channel.into_stream())), - Err(_) if self.is_closed() => Err(ended(&self.end)), + Err(_) if self.end.closed().is_cancelled() => Err(self.ended()), Err(russh::Error::ChannelOpenFailure(reason)) => Err(SshFailure::ChannelOpenFailed { target: target.to_string(), detail: channel_failure(&reason), @@ -219,10 +244,6 @@ impl InProcessSession { .into()), } } - - pub(crate) fn ended(&self) -> TransportError { - ended(&self.end) - } } impl Drop for InProcessSession { From 499c3da082a4ecdf9a86d45b1e1b84909f6a04fe Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 16:48:04 +0700 Subject: [PATCH 06/13] docs(core-ssh): name the in-process transport's timeouts and failures --- linux/docs/error-handling.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/linux/docs/error-handling.md b/linux/docs/error-handling.md index d6aa9f40da..d67f56e88f 100644 --- a/linux/docs/error-handling.md +++ b/linux/docs/error-handling.md @@ -8,7 +8,7 @@ Errors are typed enums, built with `thiserror` wherever they cross a crate bound |---|---|---| | `tablepro-core` | `DriverError`, `ConfigError`, `TransportError`, `SshFailure`, `CertificateFileProblem`, `SecretError` | The contract every driver, transport and keyring speaks. | | `tablepro-net` | `tls::TlsSetupError` | Wraps a `ConfigError`, a rustls error or a PEM encoding error while a TLS config is built; drivers turn it into `DriverError`. | -| `tablepro-ssh` | `SshFailure`, inside `TransportError::Ssh` | Classified from OpenSSH's exit status and stderr. | +| `tablepro-ssh` | `SshFailure`, inside `TransportError::Ssh` | Classified from OpenSSH's exit status and stderr, or in the in-process transport from russh's errors and the server's disconnect ([core ADR 0008](../../core/docs/decisions/0008-in-process-ssh-on-windows.md)). | | `drivers/` | `DriverError` only | The client library's error never leaves the crate. | | `tablepro-storage` | `StorageError`, `KeyringError`, `CredentialError` | `KeyringError::Secret` wraps core's `SecretError`. | | `tablepro-linux-platform` | `SettingsError` | GSettings. The Secret Service's errors map to core's `SecretError`. | @@ -97,8 +97,8 @@ A driver maps the codes the app acts on to their own variants and passes everyth |---|---|---| | `Connect` | `LivenessPolicy::connect_timeout`, 10 s | No TCP connection, or the socket did not open. | | `Login` | `login_timeout`, 15 s | Connected, but the login or TLS handshake did not finish. | -| `SshHandshake` | `ssh_handshake_timeout`, 20 s, paused while a prompt is open | OpenSSH did not authenticate in time. | -| `SshChannelOpen` | `ssh_channel_open_timeout`, 10 s | `ssh -O forward` did not answer in time. | +| `SshHandshake` | `ssh_handshake_timeout`, 20 s, paused while a prompt is open | The SSH login, every jump host included, did not finish in time. | +| `SshChannelOpen` | `ssh_channel_open_timeout`, 10 s | `ssh -O forward`, or the in-process transport's channel open, did not answer in time. | | `LockWait` | `WorkspaceSettings::lock_timeout`, 10 s | Another session holds a lock this statement needs. | | `Statement` | The call's deadline, or the workspace statement timeout of 60 s | The statement ran too long. `server_cancelled` says whether the server confirmed the stop. | | `Probe` | `probe_timeout`, 5 s | The server did not answer a liveness check. | From 6a44bf0742fa4ad34448b7408abd7bd0de45b232 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 3 Nov 2026 23:47:39 +0700 Subject: [PATCH 07/13] fix(core-ssh): sign a certificate on disk through an agent that holds only its key --- core/crates/ssh/src/in_process/auth.rs | 28 ++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/core/crates/ssh/src/in_process/auth.rs b/core/crates/ssh/src/in_process/auth.rs index ea25e1ad11..a2e4237fc1 100644 --- a/core/crates/ssh/src/in_process/auth.rs +++ b/core/crates/ssh/src/in_process/auth.rs @@ -11,7 +11,7 @@ use secrecy::{ExposeSecret, SecretString}; use tablepro_core::credentials::PromptReason; use tablepro_core::{SshFailure, TransportError}; -use super::agent::{AgentSlot, identity_algorithm, signs_for}; +use super::agent::{Agent, AgentSlot, identity_algorithm, signs_for}; use super::failure::from_russh; use super::handler::{HopHandler, SessionEnd}; use super::identity::{self, FileSignError, FileSigner, TRIES}; @@ -495,6 +495,30 @@ fn signed(result: Result, host: &str, end: &SessionEn } } +// An agent that holds only the plain key does not know the certificate on disk, so it is asked to +// sign as that key, which is the signature a certificate login carries, as OpenSSH asks it. +struct AsPlainKey<'a>(&'a mut Agent); + +impl russh::Signer for AsPlainKey<'_> { + type Error = russh::AgentAuthError; + + async fn auth_sign( + &mut self, + identity: &AgentIdentity, + hash_alg: Option, + to_sign: Vec, + ) -> Result, Self::Error> { + let plain = match identity { + AgentIdentity::Certificate { certificate, comment } => AgentIdentity::PublicKey { + key: PublicKey::from(certificate.public_key().clone()), + comment: comment.clone(), + }, + other @ AgentIdentity::PublicKey { .. } => other.clone(), + }; + russh::Signer::auth_sign(self.0, &plain, hash_alg, to_sign).await + } +} + // The agent signs with the key a certificate kept on disk certifies. async fn agent_certificate( handle: &mut Handle, @@ -507,7 +531,7 @@ async fn agent_certificate( }; let hash = rsa_hash(handle, certificate.public_key().algorithm()).await; match handle - .authenticate_certificate_with(user, certificate.clone(), hash, agent) + .authenticate_certificate_with(user, certificate.clone(), hash, &mut AsPlainKey(agent)) .await { Ok(result) => Ok(Step::from(result)), From 7965b0d936cd94bba29934a420e8923c191229a0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 07:31:10 +0700 Subject: [PATCH 08/13] feat(windows): connect through in-process SSH --- .github/workflows/windows.yml | 12 + Cargo.lock | 1 + core/ARCHITECTURE.md | 2 +- core/crates/ffi/Cargo.toml | 5 +- core/crates/ffi/src/core_handle.rs | 335 +++++- core/crates/ffi/src/error.rs | 112 +- core/crates/ffi/src/host_key.rs | 101 ++ core/crates/ffi/src/lib.rs | 5 +- core/crates/ffi/src/prompter.rs | 268 +++++ core/crates/ffi/src/run/run_event.rs | 4 +- core/crates/ffi/src/session.rs | 4 +- core/crates/ffi/tests/library.rs | 168 ++- core/crates/net/Cargo.toml | 4 +- core/crates/net/src/tls/pem_check.rs | 46 +- core/crates/ssh/src/in_process/identity.rs | 142 ++- .../src/credentials/keyring_credentials.rs | 21 +- linux/changelog.d/4008.fixed.1.md | 1 + linux/changelog.d/4008.fixed.md | 1 + scripts/ci/detect-changed-paths.sh | 2 +- windows/ci/rotate-ssh-test-key.ps1 | 40 + windows/ci/ssh-postgres.ps1 | 33 + windows/docs/decisions/0004-app-shell.md | 2 +- .../src/TablePro.App.Model/ConnectionDraft.cs | 9 +- .../ConnectionForm/ConnectionFormWindow.xaml | 14 +- .../ConnectionFormWindow.xaml.cs | 21 +- windows/src/TablePro.App/MainWindow.xaml.cs | 4 + .../Services/CredentialDialogs.cs | 176 +++ .../TablePro.App/Strings/en-US/Resources.resw | 90 ++ .../src/TablePro.App/Views/OpenConnection.cs | 70 +- .../TablePro.App/Views/QueryTabView.xaml.cs | 36 +- .../TablePro.App/Views/TableTabView.xaml.cs | 6 + .../TablePro.App/Views/WorkspaceView.xaml.cs | 125 +- windows/src/TablePro.Interop/CoreCall.cs | 2 +- windows/src/TablePro.Interop/CoreError.cs | 11 +- .../TablePro.Interop/CredentialPrompter.cs | 113 ++ .../src/TablePro.Interop/DatabaseSession.cs | 6 +- .../Generated/tablepro_ffi.cs | 1059 ++++++++++++++--- windows/src/TablePro.Interop/Mapping.cs | 10 +- .../TablePro.Interop/TablePro.Interop.csproj | 1 + windows/src/TablePro.Interop/TableProCore.cs | 42 +- windows/tests/Shared/SshTestEnvironment.cs | 29 + .../ConnectionDraftTests.cs | 18 + .../SshConnectionTests.cs | 129 ++ .../TablePro.App.UITests.csproj | 1 + .../ConnectionLibraryTests.cs | 2 +- .../CredentialPrompterTests.cs | 70 ++ .../SshConnectionTests.cs | 161 +++ .../TablePro.Interop.Tests.csproj | 1 + 48 files changed, 3164 insertions(+), 351 deletions(-) create mode 100644 core/crates/ffi/src/host_key.rs create mode 100644 core/crates/ffi/src/prompter.rs create mode 100644 linux/changelog.d/4008.fixed.1.md create mode 100644 linux/changelog.d/4008.fixed.md create mode 100644 windows/ci/rotate-ssh-test-key.ps1 create mode 100644 windows/ci/ssh-postgres.ps1 create mode 100644 windows/src/TablePro.App/Services/CredentialDialogs.cs create mode 100644 windows/src/TablePro.Interop/CredentialPrompter.cs create mode 100644 windows/tests/Shared/SshTestEnvironment.cs create mode 100644 windows/tests/TablePro.App.UITests/SshConnectionTests.cs create mode 100644 windows/tests/TablePro.Interop.Tests/CredentialPrompterTests.cs create mode 100644 windows/tests/TablePro.Interop.Tests/SshConnectionTests.cs diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 7ef3eaa82c..4d8e53fd62 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -65,6 +65,14 @@ jobs: - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x + - name: Start Windows' OpenSSH Server + if: matrix.name == 'x64' + shell: pwsh + run: scripts/ci/start-windows-sshd.ps1 -Out (Join-Path $env:RUNNER_TEMP 'tablepro-ssh') + - name: Prepare PostgreSQL for the SSH connection tests + if: matrix.name == 'x64' + shell: pwsh + run: windows/ci/ssh-postgres.ps1 - name: Test the interop library # The test build runs cargo for the core first (windows/AGENTS.md, Commands). working-directory: windows @@ -100,6 +108,10 @@ jobs: name: windows-screenshots-${{ matrix.name }} path: screenshots if-no-files-found: ignore + - name: The OpenSSH Server's log + if: failure() && matrix.name == 'x64' + shell: pwsh + run: Get-Content (Join-Path $env:ProgramData 'ssh\logs\sshd.log') -Tail 300 -ErrorAction Continue - name: Restore uniffi-bindgen-cs if: matrix.bindings diff --git a/Cargo.lock b/Cargo.lock index 8ca5b70f1f..74352934b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7067,6 +7067,7 @@ dependencies = [ "csbindgen", "futures", "percent-encoding", + "russh", "secrecy", "tablepro-core", "tablepro-driver-cassandra", diff --git a/core/ARCHITECTURE.md b/core/ARCHITECTURE.md index 18a16eb302..feac9566a1 100644 --- a/core/ARCHITECTURE.md +++ b/core/ARCHITECTURE.md @@ -72,7 +72,7 @@ A value or a trait the app passes in: |---|---|---| | `StoragePaths` | `tablepro_linux_platform::storage_paths`, GLib's XDG directories | `HostConfig.storage_dir`, one folder for every root | | `SecretVault` and `SecretItems`, labelled through `SecretLabeler` and `SecretItemLabeler` | `tablepro_linux_platform::SecretStore`, the Secret Service through oo7 | `Core::with_system_secrets`, the platform's store the core reaches itself (below); `HostSecrets`, a callback interface, for a host with a store of its own | -| `SshTunnels` | `tablepro_ssh::SshServices`, the system OpenSSH client | none yet, so a route through SSH fails as with no client installed; `tablepro_ssh::InProcessSsh`, behind `tablepro-session`'s `in-process-ssh` feature, is the transport for it ([ADR 0008](docs/decisions/0008-in-process-ssh-on-windows.md)) | +| `SshTunnels` | `tablepro_ssh::SshServices`, the system OpenSSH client | `tablepro_ssh::InProcessSsh`, behind `tablepro-session`'s `in-process-ssh` feature ([ADR 0008](docs/decisions/0008-in-process-ssh-on-windows.md)) | | `ProcessHold` | `gio::Application::hold` | none | A `cfg` split inside a crate: diff --git a/core/crates/ffi/Cargo.toml b/core/crates/ffi/Cargo.toml index 7be03a23df..2775ff2357 100644 --- a/core/crates/ffi/Cargo.toml +++ b/core/crates/ffi/Cargo.toml @@ -21,8 +21,8 @@ tablepro-driver-mysql.workspace = true tablepro-driver-postgres.workspace = true tablepro-driver-redis.workspace = true tablepro-driver-sqlite.workspace = true -tablepro-session.workspace = true -tablepro-ssh.workspace = true +tablepro-session = { workspace = true, features = ["in-process-ssh"] } +tablepro-ssh = { workspace = true, features = ["in-process"] } tablepro-storage.workspace = true async-trait.workspace = true chrono.workspace = true @@ -42,6 +42,7 @@ windows-sys.workspace = true [dev-dependencies] csbindgen.workspace = true +russh.workspace = true tempfile.workspace = true [lints] diff --git a/core/crates/ffi/src/core_handle.rs b/core/crates/ffi/src/core_handle.rs index 6a9140e038..295f1f7ca9 100644 --- a/core/crates/ffi/src/core_handle.rs +++ b/core/crates/ffi/src/core_handle.rs @@ -1,23 +1,21 @@ use std::io::Read; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use futures::future::BoxFuture; use secrecy::{ExposeSecret, SecretString}; -use tablepro_core::credentials::{CredentialInteraction, SecretItems, SecretKind, SecretVault}; +use tablepro_core::credentials::{CredentialInteraction, CredentialPrompter, SecretItems, SecretKind, SecretVault}; use tablepro_core::{ ConnectEnvironment, Credentials, DatabaseDriver, DriverError, DriverRegistry, FileEndpoint, LivenessPolicy, - SafeModeLevel, SshFailure, TransportError, WorkspaceSettings, + SafeModeLevel, WorkspaceSettings, }; -use tablepro_session::connect::{SshTunnel, SshTunnels}; use tablepro_session::runtime::Tasks; use tablepro_session::{ ConnectError, ConnectRequest, ConnectServices, ConnectionMetadata, Connector, CredentialSource, DefaultDeadlines, DriverConnector, NoStoredCredentials, RefreshedRequest, RequestTarget, Session, SessionDeps, resolve_saved, }; -use tablepro_ssh::{SshAuth, SshConfig}; +use tablepro_ssh::{InProcessSsh, SshAuth}; use tablepro_storage::{ConnectionStore, CredentialDemand, KeyringCredentials, SavedConnection, StoragePaths}; -use tokio_util::sync::CancellationToken; use uuid::Uuid; use crate::cancel_token::{CancelToken, token}; @@ -25,6 +23,7 @@ use crate::drivers::{self, DriverInfo}; use crate::error::CoreError; use crate::host::{HostConfig, HostSecretStore, HostSecrets}; use crate::library::{ConnectionLibrary, ConnectionRecord, ConnectionSecrets, apply, blank, parse_id}; +use crate::prompter::{HostPrompter, HostPrompterBridge}; use crate::runtime::{self, on_runtime}; use crate::session::DatabaseSession; @@ -38,7 +37,8 @@ pub struct Core { vault: Arc, items: Arc, store: Option, - tasks: Tasks, + pub(crate) tasks: Tasks, + pub(crate) ssh: Arc, } /// A database file to open. @@ -144,11 +144,12 @@ impl Core { .await } - // Secrets typed now come first; Credential Manager supplies the rest. + // Typed secrets come first, then Credential Manager; with no prompter an SSH login fails where it would ask. pub async fn connect_saved( &self, id: String, secrets: ConnectionSecrets, + prompter: Option>, cancel: Option>, ) -> Result, CoreError> { let id = parse_id(&id)?; @@ -158,6 +159,7 @@ impl Core { let deadlines = self.deadlines.clone(); let vault = self.vault.clone(); let tasks = self.tasks.clone(); + let prompter = self.prompter(prompter); let cancel = token(cancel); on_runtime(&self.tasks, async move { let reader = store.clone(); @@ -180,7 +182,7 @@ impl Core { ) .await?; let pending = connector - .establish(refreshed.request, CredentialInteraction::Unattended, cancel.clone()) + .establish(refreshed.request, interaction(prompter.as_ref()), cancel.clone()) .await?; if cancel.is_cancelled() { let report = pending.discard().await; @@ -197,7 +199,7 @@ impl Core { credentials, deadlines, tasks: tasks.clone(), - prompter: None, + prompter, }, ); let stamped = tasks @@ -215,6 +217,7 @@ impl Core { &self, record: ConnectionRecord, secrets: ConnectionSecrets, + prompter: Option>, cancel: Option>, ) -> Result<(), CoreError> { let id = match record.id.trim() { @@ -227,6 +230,7 @@ impl Core { let registry = self.registry.clone(); let connector = self.connector.clone(); let vault = self.vault.clone(); + let prompter = self.prompter(prompter); let cancel = token(cancel); on_runtime(&self.tasks, async move { let credentials = Prefilled::new(&saved, vault, &secrets); @@ -239,7 +243,7 @@ impl Core { ) .await?; let pending = connector - .establish(refreshed.request, CredentialInteraction::Unattended, cancel) + .establish(refreshed.request, interaction(prompter.as_ref()), cancel) .await?; let report = pending.discard().await; tracing::debug!(?report, "closed the test connection"); @@ -253,9 +257,10 @@ impl Core { fn build(config: HostConfig, vault: Arc, items: Arc, tasks: Tasks) -> Arc { let liveness = LivenessPolicy::DESKTOP; let deadlines = Arc::new(DefaultDeadlines::new(liveness)); + let ssh = Arc::new(InProcessSsh::system()); let services = ConnectServices { environment: ConnectEnvironment::new(config.client_version), - ssh: Arc::new(NoSshTransport), + ssh: ssh.clone(), liveness, }; let store = config @@ -270,9 +275,14 @@ impl Core { items, store, tasks, + ssh, }) } + fn prompter(&self, host: Option>) -> Option> { + host.map(|host| Arc::new(HostPrompterBridge::new(host, self.tasks.clone())) as Arc) + } + pub fn secret_vault(&self) -> Arc { self.vault.clone() } @@ -326,17 +336,16 @@ fn system_secrets(_tasks: &Tasks) -> Result { // Secrets the form or a prompt holds go into the request first; the rest come from the vault. struct Prefilled { - typed: Vec<(SecretKind, SecretString)>, + typed: Arc>>, keyring: KeyringCredentials, } impl Prefilled { fn new(saved: &SavedConnection, vault: Arc, secrets: &ConnectionSecrets) -> Self { let typed = secrets.typed(); - let kinds: Vec = typed.iter().map(|(kind, _)| *kind).collect(); Self { - typed, - keyring: KeyringCredentials::for_saved(vault, saved).keeping_session_secrets(&kinds), + typed: Arc::new(Mutex::new(typed)), + keyring: KeyringCredentials::for_saved(vault, saved), } } } @@ -348,14 +357,29 @@ impl CredentialSource for Prefilled { interaction: CredentialInteraction, demand: CredentialDemand, ) -> BoxFuture<'static, Result> { - let mut request = request; - fill(&mut request, &self.typed); - let typed: Vec = self.typed.iter().map(|(kind, _)| *kind).collect(); - // A typed secret may be empty, for a server that takes none, which the keyring would read as missing. - if needed(&request).iter().all(|kind| typed.contains(kind)) { - return Box::pin(async move { Ok(RefreshedRequest::unchanged(request)) }); - } - self.keyring.refresh(request, interaction, demand) + let typed = self.typed.clone(); + let keyring = self.keyring.clone(); + Box::pin(async move { + let mut request = request; + let kinds = { + let held = typed.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + fill(&mut request, &held); + held.iter().map(|(kind, _)| *kind).collect::>() + }; + let refreshed = keyring + .keeping_session_secrets(&kinds) + .refresh(request, interaction, demand) + .await?; + // The session's reconnect and editor requests are snapshots, so keep later prompt answers here. + let mut held = typed.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + for kind in &refreshed.typed { + if let Some(secret) = request_secret(&refreshed.request, *kind) { + held.retain(|(existing, _)| existing != kind); + held.push((*kind, secret.clone())); + } + } + Ok(refreshed) + }) } fn remember( @@ -366,6 +390,18 @@ impl CredentialSource for Prefilled { } } +fn request_secret(request: &ConnectRequest, kind: SecretKind) -> Option<&SecretString> { + let RequestTarget::Network { credentials, ssh, .. } = &request.target else { + return None; + }; + match (kind, credentials, ssh.as_ref().map(|ssh| &ssh.auth)) { + (SecretKind::DatabasePassword, Credentials::Password { password, .. }, _) => Some(password), + (SecretKind::SshPassword, _, Some(SshAuth::Password { password })) => Some(password), + (SecretKind::SshPassphrase, _, Some(SshAuth::PrivateKey { passphrase, .. })) => passphrase.as_ref(), + _ => None, + } +} + fn fill(request: &mut ConnectRequest, typed: &[(SecretKind, SecretString)]) { let RequestTarget::Network { credentials, ssh, .. } = &mut request.target else { return; @@ -383,22 +419,6 @@ fn fill(request: &mut ConnectRequest, typed: &[(SecretKind, SecretString)]) { } } -fn needed(request: &ConnectRequest) -> Vec { - let RequestTarget::Network { credentials, ssh, .. } = &request.target else { - return Vec::new(); - }; - let mut kinds = Vec::new(); - if matches!(credentials, Credentials::Password { .. }) { - kinds.push(SecretKind::DatabasePassword); - } - match ssh.as_ref().map(|ssh| &ssh.auth) { - Some(SshAuth::Password { .. }) => kinds.push(SecretKind::SshPassword), - Some(SshAuth::PrivateKey { .. }) => kinds.push(SecretKind::SshPassphrase), - _ => {} - } - kinds -} - // A missing file is no error here: it fails only once it is opened. async fn file_engine( registry: &DriverRegistry, @@ -438,6 +458,13 @@ fn file_error(path: &Path, kind: std::io::ErrorKind) -> CoreError { }) } +// The saved secrets resolve before the login unattended, as the app asks for a missing one itself. +fn interaction(prompter: Option<&Arc>) -> CredentialInteraction { + prompter.map_or(CredentialInteraction::Unattended, |prompter| { + CredentialInteraction::Attended(prompter.clone()) + }) +} + fn file_name(path: &Path) -> String { path.file_name().map_or_else( || path.display().to_string(), @@ -445,22 +472,218 @@ fn file_name(path: &Path) -> String { ) } -// No SSH transport is wired in yet, so SSH fails as it would with no client installed. -#[derive(Debug)] -struct NoSshTransport; +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Mutex; -impl SshTunnels for NoSshTransport { - fn open( - &self, - _config: &SshConfig, - _liveness: LivenessPolicy, - _interaction: CredentialInteraction, - _cancel: CancellationToken, - ) -> BoxFuture<'static, Result, TransportError>> { - Box::pin(async { - Err(TransportError::Ssh(SshFailure::OpenSshClientMissing { - program: PathBuf::from("ssh"), - })) - }) + use async_trait::async_trait; + use tablepro_core::credentials::{ + CredentialPrompt, PromptPurpose, PromptReason, PromptReply, SecretError, SecretLookup, + }; + use tablepro_storage::{SavedSshAuth, SavedSshConfig}; + + use super::*; + + #[derive(Default)] + struct Vault(Mutex>); + + #[async_trait] + impl SecretVault for Vault { + async fn store(&self, id: Uuid, kind: SecretKind, secret: &SecretString, _: &str) -> Result<(), SecretError> { + self.0.lock().unwrap().insert((id, kind), secret.clone()); + Ok(()) + } + + async fn load(&self, id: Uuid, kind: SecretKind) -> Result { + Ok(self + .0 + .lock() + .unwrap() + .get(&(id, kind)) + .cloned() + .map_or(SecretLookup::NotStored, SecretLookup::Found)) + } + + async fn relabel(&self, _: Uuid, _: SecretKind, _: &str) -> Result<(), SecretError> { + Ok(()) + } + + async fn delete_kind(&self, id: Uuid, kind: SecretKind) -> Result<(), SecretError> { + self.0.lock().unwrap().remove(&(id, kind)); + Ok(()) + } + + async fn delete_connection(&self, id: Uuid) -> Result<(), SecretError> { + self.0.lock().unwrap().retain(|(connection, _), _| *connection != id); + Ok(()) + } + } + + struct Answer { + value: &'static str, + asked: Mutex>, + } + + impl CredentialPrompter for Answer { + fn prompt(&self, prompt: CredentialPrompt) -> BoxFuture<'static, PromptReply> { + self.asked.lock().unwrap().push(prompt); + let value = self.value; + Box::pin(async move { + PromptReply::Submitted { + values: vec![SecretString::from(value)], + remember: false, + } + }) + } + } + + fn saved(kind: SecretKind) -> SavedConnection { + let mut saved = blank(Uuid::new_v4()); + saved.driver_id = "postgres".to_owned(); + saved.host = "db.internal".to_owned(); + saved.port = 5432; + saved.database = "shop".to_owned(); + saved.username = "app".to_owned(); + saved.ssh = Some(SavedSshConfig { + host: "bastion.internal".to_owned(), + port: Some(22), + username: Some("deploy".to_owned()), + jump_hosts: Vec::new(), + auth: match kind { + SecretKind::SshPassphrase => SavedSshAuth::PrivateKey { + path: Some(PathBuf::from("/keys/id_ed25519")), + has_passphrase: true, + }, + _ => SavedSshAuth::Password, + }, + }); + saved + } + + fn secrets(value: &str) -> ConnectionSecrets { + ConnectionSecrets { + database_password: Some(value.to_owned()), + ssh_password: Some(value.to_owned()), + ssh_passphrase: Some(value.to_owned()), + } + } + + fn value(request: &ConnectRequest, kind: SecretKind) -> &str { + let RequestTarget::Network { + credentials: Credentials::Password { password, .. }, + ssh: Some(ssh), + .. + } = &request.target + else { + panic!("expected a password connection through SSH"); + }; + match (kind, &ssh.auth) { + (SecretKind::DatabasePassword, _) => password.expose_secret(), + (SecretKind::SshPassword, SshAuth::Password { password }) => password.expose_secret(), + ( + SecretKind::SshPassphrase, + SshAuth::PrivateKey { + passphrase: Some(passphrase), + .. + }, + ) => passphrase.expose_secret(), + _ => panic!("secret kind does not match the SSH method"), + } + } + + #[tokio::test] + async fn explicitly_empty_form_secrets_survive_initial_resolution_and_reconnect() { + for kind in SecretKind::ALL { + let saved = saved(kind); + let vault = Arc::new(Vault::default()); + vault + .store(saved.id, kind, &SecretString::from("stored before"), "saved") + .await + .unwrap(); + let credentials = Prefilled::new(&saved, vault, &secrets("")); + let first = resolve_saved( + &saved, + &drivers::registry(), + &credentials, + CredentialInteraction::Unattended, + CredentialDemand::default(), + ) + .await + .unwrap(); + assert_eq!(value(&first.request, kind), ""); + + let reconnect = credentials + .refresh( + first.request.for_reconnect(), + CredentialInteraction::Unattended, + CredentialDemand::default(), + ) + .await + .unwrap(); + assert_eq!(value(&reconnect.request, kind), ""); + } + } + + #[tokio::test] + async fn a_reentered_secret_replaces_the_form_value_in_later_refreshes() { + for kind in SecretKind::ALL { + for replacement in ["corrected", ""] { + let saved = saved(kind); + let credentials = Prefilled::new(&saved, Arc::new(Vault::default()), &secrets("original")); + let first = resolve_saved( + &saved, + &drivers::registry(), + &credentials, + CredentialInteraction::Unattended, + CredentialDemand::default(), + ) + .await + .unwrap(); + assert_eq!(value(&first.request, kind), "original"); + let answer = Arc::new(Answer { + value: replacement, + asked: Mutex::new(Vec::new()), + }); + + let corrected = credentials + .refresh( + first.request.for_reconnect(), + CredentialInteraction::Attended(answer.clone()), + CredentialDemand::reenter(kind), + ) + .await + .unwrap(); + assert_eq!(value(&corrected.request, kind), replacement); + assert_eq!(corrected.typed, [kind]); + assert!(corrected.remember.is_empty()); + { + let asked = answer.asked.lock().unwrap(); + assert_eq!(asked.len(), 1); + assert!(matches!( + asked[0].purpose, + PromptPurpose::DatabasePassword { + reason: PromptReason::Rejected + } | PromptPurpose::SshPassword { + reason: PromptReason::Rejected + } | PromptPurpose::SshPassphrase { + reason: PromptReason::Rejected, + .. + } + )); + } + + let reconnect = credentials + .refresh( + first.request.for_reconnect(), + CredentialInteraction::Unattended, + CredentialDemand::default(), + ) + .await + .unwrap(); + assert_eq!(value(&reconnect.request, kind), replacement); + assert_eq!(answer.asked.lock().unwrap().len(), 1); + } + } } } diff --git a/core/crates/ffi/src/error.rs b/core/crates/ffi/src/error.rs index 9042205c86..3fa4bb96b9 100644 --- a/core/crates/ffi/src/error.rs +++ b/core/crates/ffi/src/error.rs @@ -1,7 +1,7 @@ use std::path::Path; use std::sync::Arc; -use tablepro_core::{DriverError, ServerDiagnostics, TransportError}; +use tablepro_core::{DriverError, ServerDiagnostics, SshFailure, TransportError}; use tablepro_session::{ConnectError, OperationError}; use crate::host::SecretKindTag; @@ -13,7 +13,7 @@ pub enum CoreError { #[error("cancelled")] Cancelled, #[error("{}", info.message)] - Failed { info: ErrorInfo }, + Failed { info: BoxedErrorInfo }, } /// An error as the app words it: by `code`, the innermost of `codes`, @@ -31,6 +31,25 @@ pub struct ErrorInfo { pub path: Option, /// The secret a keyring error is about, so the app can ask for it. pub secret: Option, + pub host_key: Option, +} + +// Error details stay off the stack while bindings keep the same record values. +pub type BoxedErrorInfo = Box; + +uniffi::custom_type!(BoxedErrorInfo, ErrorInfo, { + lower: |value| *value, + try_lift: |value| Ok(Box::new(value)), +}); + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct HostKeyFailure { + pub host: String, + pub algorithm: String, + pub fingerprint: String, + pub known_hosts: String, + pub line: u64, + pub trust_allowed: bool, } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] @@ -46,14 +65,15 @@ pub struct ServerMessage { impl CoreError { pub(crate) fn failed(code: &str, message: impl Into) -> Self { Self::Failed { - info: ErrorInfo { + info: Box::new(ErrorInfo { code: code.to_owned(), codes: vec![code.to_owned()], message: message.into(), server: None, path: None, secret: None, - }, + host_key: None, + }), } } @@ -81,6 +101,7 @@ impl ErrorInfo { server: server_message(cause), path: file_path(cause).map(|path| path.display().to_string()), secret: None, + host_key: host_key_failure(cause), } } @@ -92,10 +113,33 @@ impl ErrorInfo { server: None, path: None, secret: None, + host_key: None, } } } +fn host_key_failure(error: &DriverError) -> Option { + let DriverError::Transport(TransportError::Ssh(SshFailure::HostKeyChanged { + host, + algorithm, + sha256_fingerprint, + known_hosts, + line, + trust_allowed, + })) = error + else { + return None; + }; + Some(HostKeyFailure { + host: host.clone(), + algorithm: algorithm.clone(), + fingerprint: sha256_fingerprint.clone(), + known_hosts: known_hosts.display().to_string(), + line: u64::try_from(*line).unwrap_or(u64::MAX), + trust_allowed: *trust_allowed, + }) +} + fn driver_codes(error: &DriverError, codes: &mut Vec) { codes.push(error.code().to_owned()); match error { @@ -146,7 +190,7 @@ impl From for CoreError { match error { DriverError::Cancelled => Self::Cancelled, error => Self::Failed { - info: ErrorInfo::from_driver(&error), + info: ErrorInfo::from_driver(&error).into(), }, } } @@ -157,10 +201,10 @@ impl From<&OperationError> for CoreError { match error { OperationError::Cancelled | OperationError::Driver(DriverError::Cancelled) => Self::Cancelled, OperationError::Driver(driver) => Self::Failed { - info: ErrorInfo::wrapping(&[error.code()], driver, error.to_string()), + info: ErrorInfo::wrapping(&[error.code()], driver, error.to_string()).into(), }, other => Self::Failed { - info: ErrorInfo::plain(&[other.code()], other.to_string()), + info: ErrorInfo::plain(&[other.code()], other.to_string()).into(), }, } } @@ -183,16 +227,16 @@ impl From for CoreError { match &error { ConnectError::Cancelled | ConnectError::Driver(DriverError::Cancelled) => Self::Cancelled, ConnectError::Driver(driver) => Self::Failed { - info: ErrorInfo::wrapping(&[error.code()], driver, error.to_string()), + info: ErrorInfo::wrapping(&[error.code()], driver, error.to_string()).into(), }, ConnectError::Keyring(keyring) => Self::Failed { - info: ErrorInfo { + info: Box::new(ErrorInfo { secret: Some(keyring.kind().into()), ..ErrorInfo::plain(&[error.code(), keyring.code()], error.to_string()) - }, + }), }, other => Self::Failed { - info: ErrorInfo::plain(&[other.code()], other.to_string()), + info: ErrorInfo::plain(&[other.code()], other.to_string()).into(), }, } } @@ -208,7 +252,7 @@ mod tests { fn info(error: CoreError) -> ErrorInfo { match error { - CoreError::Failed { info } => info, + CoreError::Failed { info } => *info, CoreError::Cancelled => panic!("cancelled"), } } @@ -239,6 +283,50 @@ mod tests { ); } + #[test] + fn a_changed_host_key_carries_the_facts_needed_for_an_explicit_recovery() { + let known_hosts = PathBuf::from("/keys/known_hosts"); + for trust_allowed in [false, true] { + let error = ConnectError::Driver(DriverError::from(TransportError::Ssh(SshFailure::HostKeyChanged { + host: "[db.internal]:2222".to_owned(), + algorithm: "ED25519".to_owned(), + sha256_fingerprint: "SHA256:new-key".to_owned(), + known_hosts: known_hosts.clone(), + line: 7, + trust_allowed, + }))); + + let info = info(error.into()); + + assert_eq!(info.code, "ssh.host_key_changed"); + assert_eq!( + info.host_key, + Some(HostKeyFailure { + host: "[db.internal]:2222".to_owned(), + algorithm: "ED25519".to_owned(), + fingerprint: "SHA256:new-key".to_owned(), + known_hosts: known_hosts.display().to_string(), + line: 7, + trust_allowed, + }) + ); + } + } + + #[test] + fn a_revoked_host_key_has_no_recovery_action() { + let info = info( + DriverError::from(TransportError::Ssh(SshFailure::HostKeyRevoked { + host: "db.internal".to_owned(), + detail: "revoked".to_owned(), + })) + .into(), + ); + + assert_eq!(info.code, "ssh.host_key_revoked"); + assert!(info.host_key.is_none()); + } + #[test] fn the_server_s_words_come_through_as_they_came() { let mut diagnostics = ServerDiagnostics::new( diff --git a/core/crates/ffi/src/host_key.rs b/core/crates/ffi/src/host_key.rs new file mode 100644 index 0000000000..c8f14d7424 --- /dev/null +++ b/core/crates/ffi/src/host_key.rs @@ -0,0 +1,101 @@ +use std::path::Path; + +use tablepro_core::{DriverError, TransportError}; +use tablepro_session::connect::ssh_config; +use tablepro_ssh::{ForgetHostKeyError, InProcessSsh}; +use uuid::Uuid; + +use crate::core_handle::Core; +use crate::error::CoreError; +use crate::library::{ConnectionRecord, apply, blank}; +use crate::runtime::on_runtime; + +#[uniffi::export] +impl Core { + pub async fn forget_changed_host_key( + &self, + record: ConnectionRecord, + host: String, + known_hosts: String, + ) -> Result<(), CoreError> { + let ssh = self.ssh.clone(); + on_runtime(&self.tasks, async move { + forget(&ssh, &record, &host, Path::new(&known_hosts)).await + }) + .await + } +} + +async fn forget( + ssh: &InProcessSsh, + record: &ConnectionRecord, + host: &str, + known_hosts: &Path, +) -> Result<(), CoreError> { + let mut saved = blank(Uuid::new_v4()); + apply(record, record.name.trim().to_owned(), None, &mut saved); + let settings = saved + .ssh + .ok_or_else(|| CoreError::failed("ssh.host_key_not_this_connection", "the connection has no SSH settings"))?; + let config = ssh_config(&settings).map_err(|error| DriverError::from(TransportError::Ssh(error.into())))?; + ssh.forget_changed_host_key(&config, host, known_hosts) + .await + .map_err(|error| match error { + ForgetHostKeyError::Ssh(failure) => DriverError::from(TransportError::Ssh(failure)).into(), + error @ ForgetHostKeyError::NotThisConnection { .. } => { + CoreError::failed("ssh.host_key_not_this_connection", error.to_string()) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::library::{SshAuthTag, SshSettings}; + + #[tokio::test] + async fn a_host_or_file_outside_this_connection_cannot_be_forgotten() { + let dir = tempfile::tempdir().unwrap(); + let known_hosts = dir.path().join("known_hosts"); + let contents = + "[db.internal]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqti\n"; + std::fs::write(&known_hosts, contents).unwrap(); + let services = InProcessSsh { + user_known_hosts: Some(known_hosts.clone()), + other_known_hosts: Vec::new(), + identity_files: Vec::new(), + agent: None, + pageant: false, + }; + let mut record = ConnectionRecord::from(&blank(Uuid::new_v4())); + record.ssh = Some(SshSettings { + host: "db.internal".to_owned(), + port: Some(2222), + username: Some("deploy".to_owned()), + jump_hosts: Vec::new(), + auth: SshAuthTag::Agent, + }); + + let wrong_host = forget(&services, &record, "other", &known_hosts).await.unwrap_err(); + let wrong_file = forget(&services, &record, "[db.internal]:2222", &dir.path().join("other")) + .await + .unwrap_err(); + assert_eq!(wrong_host.code(), "ssh.host_key_not_this_connection"); + assert_eq!(wrong_file.code(), "ssh.host_key_not_this_connection"); + assert_eq!(std::fs::read_to_string(&known_hosts).unwrap(), contents); + + forget(&services, &record, "[db.internal]:2222", &known_hosts) + .await + .unwrap(); + assert_eq!(std::fs::read_to_string(&known_hosts).unwrap(), ""); + + record.ssh = None; + assert_eq!( + forget(&services, &record, "[db.internal]:2222", &known_hosts) + .await + .unwrap_err() + .code(), + "ssh.host_key_not_this_connection" + ); + } +} diff --git a/core/crates/ffi/src/lib.rs b/core/crates/ffi/src/lib.rs index 9780251e28..7d809c6bdf 100644 --- a/core/crates/ffi/src/lib.rs +++ b/core/crates/ffi/src/lib.rs @@ -10,7 +10,9 @@ mod drivers; mod editor; mod error; mod host; +mod host_key; mod library; +mod prompter; mod results; mod run; mod runtime; @@ -24,12 +26,13 @@ pub use connection_url::{ParsedConnectionUrl, parse_connection_url}; pub use core_handle::{Core, FileOpenRequest}; pub use drivers::{DataModelTag, DriverInfo, EndpointInfo, EngineCategoryTag}; pub use editor::{QueryEditor, RunSettings}; -pub use error::{CoreError, ErrorInfo, ServerMessage}; +pub use error::{BoxedErrorInfo, CoreError, ErrorInfo, HostKeyFailure, ServerMessage}; pub use host::{HostConfig, HostSecretError, HostSecretStore, HostSecrets, SecretItemTag, SecretKindTag}; pub use library::{ ConnectionLibrary, ConnectionRecord, ConnectionSecrets, GroupRecord, ImportSummary, LibrarySnapshot, SafeModeTag, SshAuthTag, SshSettings, TagRecord, TlsModeTag, TlsSettings, }; +pub use prompter::{HostPrompter, PromptAnswer, PromptFieldInfo, PromptPurposeTag, PromptReasonTag, PromptRequest}; pub use results::{ResultColumnInfo, ResultSet, ValueKind}; pub use run::{QueryRun, RunEvent, RunListener, RunOutcome, TransactionStateTag, TruncationTag}; pub use session::{ColumnSort, DatabaseSession, FailureCauseTag, SessionHealthTag, TableQuery}; diff --git a/core/crates/ffi/src/prompter.rs b/core/crates/ffi/src/prompter.rs new file mode 100644 index 0000000000..cd5f96fc59 --- /dev/null +++ b/core/crates/ffi/src/prompter.rs @@ -0,0 +1,268 @@ +use std::sync::Arc; + +use futures::future::BoxFuture; +use secrecy::SecretString; +use tablepro_core::credentials::{CredentialPrompt, CredentialPrompter, PromptPurpose, PromptReason, PromptReply}; +use tablepro_session::runtime::Tasks; + +// The app asks the user and returns the answer; the core calls it on a thread it lets block. +#[uniffi::export(callback_interface)] +pub trait HostPrompter: Send + Sync { + fn prompt(&self, prompt: PromptRequest) -> PromptAnswer; +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct PromptRequest { + pub purpose: PromptPurposeTag, + // What the question is about, such as user@host, for the dialog's text. + pub target: String, + pub fields: Vec, + pub offer_remember: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum PromptPurposeTag { + DatabasePassword { + reason: PromptReasonTag, + }, + SshPassword { + reason: PromptReasonTag, + }, + SshPassphrase { + path: String, + reason: PromptReasonTag, + }, + SshKeyboardInteractive { + name: String, + instructions: String, + }, + SshHostKey { + host: String, + algorithm: String, + fingerprint: String, + }, + SshConfirmation { + text: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum PromptReasonTag { + NotStored, + KeyringLocked, + KeyringUnavailable { detail: String }, + Rejected, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct PromptFieldInfo { + pub label: String, + pub secret: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum PromptAnswer { + // One value per field; a host key or a confirmation is trusted with none. + Submitted { values: Vec, remember: bool }, + Cancelled, +} + +pub(crate) struct HostPrompterBridge { + host: Arc, + tasks: Tasks, +} + +impl HostPrompterBridge { + pub(crate) fn new(host: Box, tasks: Tasks) -> Self { + Self { + host: Arc::from(host), + tasks, + } + } +} + +impl std::fmt::Debug for HostPrompterBridge { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HostPrompterBridge").finish_non_exhaustive() + } +} + +impl CredentialPrompter for HostPrompterBridge { + fn prompt(&self, request: CredentialPrompt) -> BoxFuture<'static, PromptReply> { + let host = self.host.clone(); + let tasks = self.tasks.clone(); + Box::pin(async move { + let request = PromptRequest::from(request); + match tasks.spawn_blocking_task(move || host.prompt(request)).await { + Ok(answer) => answer.into(), + Err(failure) => { + tracing::warn!(%failure, "the app's prompt failed, so it counts as cancelled"); + PromptReply::Cancelled + } + } + }) + } +} + +impl From for PromptRequest { + fn from(prompt: CredentialPrompt) -> Self { + Self { + purpose: prompt.purpose.into(), + target: prompt.target, + fields: prompt + .fields + .into_iter() + .map(|field| PromptFieldInfo { + label: field.label, + secret: field.secret, + }) + .collect(), + offer_remember: prompt.offer_remember, + } + } +} + +impl From for PromptPurposeTag { + fn from(purpose: PromptPurpose) -> Self { + match purpose { + PromptPurpose::DatabasePassword { reason } => Self::DatabasePassword { reason: reason.into() }, + PromptPurpose::SshPassword { reason } => Self::SshPassword { reason: reason.into() }, + PromptPurpose::SshPassphrase { path, reason } => Self::SshPassphrase { + path: path.display().to_string(), + reason: reason.into(), + }, + PromptPurpose::SshKeyboardInteractive { name, instructions } => { + Self::SshKeyboardInteractive { name, instructions } + } + PromptPurpose::SshHostKeyConfirmation { + host, + algorithm, + fingerprint, + } => Self::SshHostKey { + host, + algorithm, + fingerprint, + }, + PromptPurpose::SshConfirmation { text } => Self::SshConfirmation { text }, + } + } +} + +impl From for PromptReasonTag { + fn from(reason: PromptReason) -> Self { + match reason { + PromptReason::NotStored => Self::NotStored, + PromptReason::KeyringLocked => Self::KeyringLocked, + PromptReason::KeyringUnavailable { detail } => Self::KeyringUnavailable { detail }, + PromptReason::Rejected => Self::Rejected, + } + } +} + +impl From for PromptReply { + fn from(answer: PromptAnswer) -> Self { + match answer { + PromptAnswer::Submitted { values, remember } => Self::Submitted { + values: values.into_iter().map(SecretString::from).collect(), + remember, + }, + PromptAnswer::Cancelled => Self::Cancelled, + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Mutex; + + use secrecy::ExposeSecret; + use tablepro_core::credentials::PromptField; + + use super::*; + use crate::runtime; + + struct Answering { + asked: Arc>>, + answer: PromptAnswer, + } + + impl HostPrompter for Answering { + fn prompt(&self, prompt: PromptRequest) -> PromptAnswer { + self.asked.lock().unwrap().push(prompt); + self.answer.clone() + } + } + + struct Failing; + + impl HostPrompter for Failing { + fn prompt(&self, _: PromptRequest) -> PromptAnswer { + panic!("the app's callback threw"); + } + } + + fn bridge(host: impl HostPrompter + 'static) -> HostPrompterBridge { + HostPrompterBridge::new(Box::new(host), runtime::tasks().unwrap()) + } + + fn passphrase() -> CredentialPrompt { + CredentialPrompt { + purpose: PromptPurpose::SshPassphrase { + path: PathBuf::from("/keys/id_ed25519"), + reason: PromptReason::Rejected, + }, + target: "deploy@bastion".to_owned(), + fields: vec![PromptField { + label: "Passphrase".to_owned(), + secret: true, + }], + offer_remember: false, + } + } + + #[tokio::test] + async fn the_app_sees_the_question_and_its_answer_comes_back() { + let asked = Arc::new(Mutex::new(Vec::new())); + let prompter = bridge(Answering { + asked: asked.clone(), + answer: PromptAnswer::Submitted { + values: vec!["correct horse".to_owned()], + remember: true, + }, + }); + + let reply = prompter.prompt(passphrase()).await; + + let PromptReply::Submitted { values, remember } = reply else { + panic!("cancelled") + }; + assert_eq!( + values.iter().map(|value| value.expose_secret()).collect::>(), + ["correct horse"] + ); + assert!(remember); + assert_eq!( + *asked.lock().unwrap(), + [PromptRequest { + purpose: PromptPurposeTag::SshPassphrase { + path: PathBuf::from("/keys/id_ed25519").display().to_string(), + reason: PromptReasonTag::Rejected, + }, + target: "deploy@bastion".to_owned(), + fields: vec![PromptFieldInfo { + label: "Passphrase".to_owned(), + secret: true, + }], + offer_remember: false, + }] + ); + } + + #[tokio::test] + async fn a_callback_that_fails_counts_as_cancelled() { + let reply = bridge(Failing).prompt(passphrase()).await; + + assert!(matches!(reply, PromptReply::Cancelled)); + } +} diff --git a/core/crates/ffi/src/run/run_event.rs b/core/crates/ffi/src/run/run_event.rs index 606734e307..4fa2939599 100644 --- a/core/crates/ffi/src/run/run_event.rs +++ b/core/crates/ffi/src/run/run_event.rs @@ -1,6 +1,6 @@ use tablepro_core::{DriverError, RunEnd, RunSummary, ServerNotice, TransactionState, Truncation}; -use crate::error::{CoreError, ErrorInfo}; +use crate::error::{BoxedErrorInfo, CoreError}; use crate::results::ResultColumnInfo; /// One thing that happened in a run, in the order it happened. Rows that @@ -85,7 +85,7 @@ pub enum TruncationTag { #[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] pub enum RunOutcome { Completed { elapsed_ms: u64 }, - Failed { info: ErrorInfo }, + Failed { info: BoxedErrorInfo }, Cancelled, TimedOut { server_cancelled: bool }, } diff --git a/core/crates/ffi/src/session.rs b/core/crates/ffi/src/session.rs index f79fe2ddfe..79c00f9e2f 100644 --- a/core/crates/ffi/src/session.rs +++ b/core/crates/ffi/src/session.rs @@ -11,7 +11,7 @@ use tablepro_session::{FailureCause, MonitorSignal, OperationError, OperationKin use crate::cancel_token::{CancelToken, token}; use crate::catalog::{ColumnEntry, ObjectEntry, ObjectScopeArg, SchemaEntry, TableName}; use crate::editor::QueryEditor; -use crate::error::{CoreError, ErrorInfo}; +use crate::error::{BoxedErrorInfo, CoreError}; use crate::runtime::on_runtime; #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Record)] @@ -39,7 +39,7 @@ pub enum SessionHealthTag { Offline, Failed { cause: FailureCauseTag, - reason: Option, + reason: Option, }, } diff --git a/core/crates/ffi/tests/library.rs b/core/crates/ffi/tests/library.rs index 1d7495fa74..91910d7090 100644 --- a/core/crates/ffi/tests/library.rs +++ b/core/crates/ffi/tests/library.rs @@ -7,11 +7,15 @@ use std::collections::HashMap; use std::path::Path; use std::sync::{Arc, Mutex}; +use russh::keys::HashAlg; +use russh::keys::ssh_key::PrivateKey; +use russh::keys::ssh_key::private::Ed25519Keypair; use tablepro_ffi::{ - ConnectionRecord, ConnectionSecrets, Core, CoreError, FileOpenRequest, HostConfig, HostSecretError, HostSecrets, - ObjectScopeArg, RunOutcome, RunSettings, SafeModeTag, SecretItemTag, SecretKindTag, TableName, TlsModeTag, - TlsSettings, + ConnectionRecord, ConnectionSecrets, Core, CoreError, FileOpenRequest, HostConfig, HostPrompter, HostSecretError, + HostSecrets, ObjectScopeArg, PromptAnswer, PromptPurposeTag, PromptRequest, RunOutcome, RunSettings, SafeModeTag, + SecretItemTag, SecretKindTag, SshAuthTag, SshSettings, TableName, TlsModeTag, TlsSettings, }; +use tokio::net::TcpListener; type Stored = Arc>>; @@ -221,7 +225,7 @@ async fn a_saved_sqlite_file_connects_lists_its_tables_and_becomes_recent() { .unwrap(); let session = core - .connect_saved(saved.id.clone(), ConnectionSecrets::default(), None) + .connect_saved(saved.id.clone(), ConnectionSecrets::default(), None, None) .await .unwrap(); let objects = session.objects(ObjectScopeArg::DefaultSchema, None).await.unwrap(); @@ -260,6 +264,7 @@ async fn a_test_logs_in_and_a_missing_file_fails_by_code() { record("test", "sqlite", &path.display().to_string()), ConnectionSecrets::default(), None, + None, ) .await .unwrap(); @@ -268,6 +273,7 @@ async fn a_test_logs_in_and_a_missing_file_fails_by_code() { record("gone", "sqlite", &dir.path().join("gone.sqlite").display().to_string()), ConnectionSecrets::default(), None, + None, ) .await .unwrap_err(); @@ -286,7 +292,7 @@ async fn a_network_login_with_no_saved_password_says_so() { let saved = library.save(shop, ConnectionSecrets::default()).await.unwrap(); let error = core - .connect_saved(saved.id, ConnectionSecrets::default(), None) + .connect_saved(saved.id, ConnectionSecrets::default(), None, None) .await .unwrap_err(); @@ -315,6 +321,7 @@ async fn a_password_typed_at_connect_is_used_when_none_is_saved() { ..ConnectionSecrets::default() }, None, + None, ) .await .unwrap_err(); @@ -323,6 +330,157 @@ async fn a_password_typed_at_connect_is_used_when_none_is_saved() { assert_ne!(error.code(), "keyring.secret_not_stored"); } +struct Refusing; + +impl russh::server::Handler for Refusing { + type Error = russh::Error; +} + +#[derive(Clone, Default)] +struct Declining(Arc>>); + +impl HostPrompter for Declining { + fn prompt(&self, prompt: PromptRequest) -> PromptAnswer { + self.0.lock().unwrap().push(prompt); + PromptAnswer::Cancelled + } +} + +// None when the server stops before the connects are done. +async fn against_ssh_server(key: PrivateKey, connect: impl FnOnce(u16) -> F) -> Option { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let config = Arc::new(russh::server::Config { + keys: vec![key], + ..russh::server::Config::default() + }); + let serving = async move { + while let Ok((stream, _)) = listener.accept().await { + if let Ok(session) = russh::server::run_stream(config.clone(), stream, Refusing).await { + session.await.ok(); + } + } + }; + tokio::select! { + done = connect(port) => Some(done), + () = serving => None, + } +} + +fn through_ssh(port: u16) -> ConnectionRecord { + let mut shop = record("shop", "postgres", "shop"); + shop.host = "127.0.0.1".to_owned(); + shop.port = 5432; + shop.ssh = Some(SshSettings { + host: "127.0.0.1".to_owned(), + port: Some(port), + username: Some("tester".to_owned()), + jump_hosts: Vec::new(), + auth: SshAuthTag::Password, + }); + shop +} + +#[tokio::test] +async fn an_ssh_login_asks_the_app_about_a_host_key_it_does_not_know() { + let dir = tempfile::tempdir().unwrap(); + let core = core(dir.path(), MemorySecrets::default()); + let key = PrivateKey::from(Ed25519Keypair::from_seed(&[7; 32])); + let fingerprint = key.public_key().fingerprint(HashAlg::Sha256).to_string(); + let prompter = Declining::default(); + let secrets = ConnectionSecrets { + database_password: Some(String::new()), + ssh_password: Some("typed".to_owned()), + ssh_passphrase: None, + }; + + let prompts = &prompter; + let (port, unattended, declined) = against_ssh_server(key, |port| async move { + let unattended = core + .test_connection(through_ssh(port), secrets.clone(), None, None) + .await; + let declined = core + .test_connection( + through_ssh(port), + secrets.clone(), + Some(Box::new(prompts.clone())), + None, + ) + .await; + (port, unattended, declined) + }) + .await + .unwrap(); + + assert_eq!(unattended.unwrap_err().code(), "ssh.host_key_unknown"); + assert_eq!(declined.unwrap_err().code(), "ssh.host_key_unknown"); + let asked = prompter.0.lock().unwrap(); + assert_eq!(asked.len(), 1, "{asked:?}"); + assert_eq!( + asked[0].purpose, + PromptPurposeTag::SshHostKey { + host: format!("[127.0.0.1]:{port}"), + algorithm: "ED25519".to_owned(), + fingerprint, + } + ); +} + +#[tokio::test] +async fn an_empty_database_password_survives_a_separately_stored_ssh_password() { + let dir = tempfile::tempdir().unwrap(); + let core = core(dir.path(), MemorySecrets::default()); + let key = PrivateKey::from(Ed25519Keypair::from_seed(&[8; 32])); + against_ssh_server(key, |port| async move { + let saved = core + .library() + .unwrap() + .save( + through_ssh(port), + ConnectionSecrets { + ssh_password: Some("saved ssh password".to_owned()), + ..ConnectionSecrets::default() + }, + ) + .await + .unwrap(); + let error = core + .connect_saved( + saved.id, + ConnectionSecrets { + database_password: Some(String::new()), + ..ConnectionSecrets::default() + }, + None, + None, + ) + .await + .unwrap_err(); + assert_eq!(error.code(), "ssh.host_key_unknown"); + + let mut with_key = through_ssh(port); + with_key.ssh.as_mut().unwrap().auth = SshAuthTag::PrivateKey { + path: Some(dir.path().join("id_ed25519").display().to_string()), + has_passphrase: false, + }; + let error = core + .test_connection( + with_key, + ConnectionSecrets { + database_password: Some(String::new()), + ..ConnectionSecrets::default() + }, + None, + None, + ) + .await + .unwrap_err(); + assert_ne!(error.code(), "keyring.secret_not_stored"); + }) + .await + .unwrap(); +} + #[tokio::test] async fn a_tag_goes_on_several_connections_and_a_rename_reaches_all_of_them() { let dir = tempfile::tempdir().unwrap(); diff --git a/core/crates/net/Cargo.toml b/core/crates/net/Cargo.toml index 29cb5f294f..a059175a66 100644 --- a/core/crates/net/Cargo.toml +++ b/core/crates/net/Cargo.toml @@ -19,8 +19,8 @@ thiserror.workspace = true tokio = { workspace = true, features = ["net", "rt"] } tracing.workspace = true -[target.'cfg(target_os = "linux")'.dependencies] -rustix = { workspace = true, features = ["process"] } +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true, features = ["fs", "process"] } [target.'cfg(windows)'.dependencies] windows-sys.workspace = true diff --git a/core/crates/net/src/tls/pem_check.rs b/core/crates/net/src/tls/pem_check.rs index dfca3fde0c..01f63d9fce 100644 --- a/core/crates/net/src/tls/pem_check.rs +++ b/core/crates/net/src/tls/pem_check.rs @@ -76,7 +76,7 @@ fn read_pem_file(path: &Path) -> Result, CertificateFileProblem> { if !std::fs::metadata(path).map_err(unreadable)?.is_file() { return Err(CertificateFileProblem::NotRegularFile); } - let file = File::open(path).map_err(unreadable)?; + let file = open_pem_file(path).map_err(unreadable)?; // What was opened may not be what was looked at a moment before. if !file.metadata().map_err(unreadable)?.is_file() { return Err(CertificateFileProblem::NotRegularFile); @@ -93,6 +93,21 @@ fn read_pem_file(path: &Path) -> Result, CertificateFileProblem> { Ok(bytes) } +#[cfg(unix)] +fn open_pem_file(path: &Path) -> std::io::Result { + use rustix::fs::{Mode, OFlags, open}; + + // A FIFO swapped in after metadata must not wait for a writer. + open(path, OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NONBLOCK, Mode::empty()) + .map(File::from) + .map_err(Into::into) +} + +#[cfg(not(unix))] +fn open_pem_file(path: &Path) -> std::io::Result { + File::open(path) +} + fn parse_certificates(bytes: &[u8]) -> Result>, CertificateFileProblem> { let certificates = CertificateDer::pem_slice_iter(bytes) .collect::, _>>() @@ -119,3 +134,32 @@ fn certificate_problem(error: pem::Error) -> CertificateFileProblem { } } } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn a_fifo_at_open_time_does_not_wait_for_a_writer() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("certificate.pem"); + assert!( + std::process::Command::new("mkfifo") + .arg("-m") + .arg("600") + .arg(&path) + .status() + .unwrap() + .success() + ); + + // Bypass the path check to cover a FIFO replacing a regular file before open. + let file = open_pem_file(&path).unwrap(); + + assert!(!file.metadata().unwrap().is_file()); + assert!(matches!( + read_pem_file(&path), + Err(CertificateFileProblem::NotRegularFile) + )); + } +} diff --git a/core/crates/ssh/src/in_process/identity.rs b/core/crates/ssh/src/in_process/identity.rs index 08a1e9927c..cb0d7d2229 100644 --- a/core/crates/ssh/src/in_process/identity.rs +++ b/core/crates/ssh/src/in_process/identity.rs @@ -1,4 +1,6 @@ use std::collections::HashMap; +use std::fs::File; +use std::io::{self, Read}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -16,17 +18,69 @@ use super::prompts::Prompts; // OpenSSH's NumberOfPasswordPrompts default, which also bounds a key's passphrase. pub(crate) const TRIES: usize = 3; +// Keys and certificates need far less space; match the bound for TLS identity files. +const MAX_IDENTITY_BYTES: u64 = 1024 * 1024; + +async fn read_identity(path: &Path) -> io::Result> { + let path = path.to_owned(); + tokio::task::spawn_blocking(move || read_identity_blocking(&path)) + .await + .map_err(io::Error::other)? +} + +fn read_identity_blocking(path: &Path) -> io::Result> { + if !std::fs::metadata(path)?.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "not a regular identity file", + )); + } + let file = open_identity(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "not a regular identity file", + )); + } + if metadata.len() > MAX_IDENTITY_BYTES { + return Err(io::Error::new(io::ErrorKind::InvalidData, "identity file is too large")); + } + let mut text = Zeroizing::new(String::new()); + // The file can grow after its metadata was read. + file.take(MAX_IDENTITY_BYTES + 1).read_to_string(&mut text)?; + if text.len() as u64 > MAX_IDENTITY_BYTES { + return Err(io::Error::new(io::ErrorKind::InvalidData, "identity file is too large")); + } + Ok(text) +} + +#[cfg(unix)] +fn open_identity(path: &Path) -> io::Result { + use rustix::fs::{Mode, OFlags, open}; + + // A FIFO swapped in after metadata must not wait for a writer on the blocking pool. + open(path, OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NONBLOCK, Mode::empty()) + .map(File::from) + .map_err(Into::into) +} + +#[cfg(not(unix))] +fn open_identity(path: &Path) -> io::Result { + File::open(path) +} + // The key's public half without its passphrase: the `.pub` beside it, or the // copy an OpenSSH key file keeps in the clear. pub(crate) async fn public_half(path: &Path) -> Option { let mut beside = path.as_os_str().to_owned(); beside.push(".pub"); - if let Ok(text) = tokio::fs::read_to_string(PathBuf::from(beside)).await + if let Ok(text) = read_identity(&PathBuf::from(beside)).await && let Ok(key) = PublicKey::from_openssh(text.trim()) { return Some(key); } - let text = Zeroizing::new(tokio::fs::read_to_string(path).await.ok()?); + let text = read_identity(path).await.ok()?; PrivateKey::from_openssh(text.as_bytes()) .ok() .map(|key| key.public_key().clone()) @@ -36,7 +90,7 @@ pub(crate) async fn public_half(path: &Path) -> Option { pub(crate) async fn certificate_beside(path: &Path) -> Option { let mut beside = path.as_os_str().to_owned(); beside.push("-cert.pub"); - let text = tokio::fs::read_to_string(PathBuf::from(beside)).await.ok()?; + let text = read_identity(&PathBuf::from(beside)).await.ok()?; Certificate::from_openssh(text.trim()).ok() } @@ -49,8 +103,8 @@ pub(crate) async fn load( stored: &mut Option, prompts: &Prompts, ) -> Result, SshFailure> { - let text = match tokio::fs::read_to_string(path).await { - Ok(text) => Arc::new(Zeroizing::new(text)), + let text = match read_identity(path).await { + Ok(text) => Arc::new(text), Err(error) if chosen => { return Err(SshFailure::IdentityFileUnreadable { path: path.to_owned(), @@ -342,6 +396,84 @@ mod tests { assert!(load(&garbage, false, &mut None, &prompts).await.unwrap().is_none()); } + #[tokio::test] + async fn oversized_private_keys_are_refused_before_decoding() { + let dir = tempfile::tempdir().unwrap(); + let path = write_key(dir.path(), None); + let mut contents = std::fs::read_to_string(&path).unwrap(); + contents.extend(std::iter::repeat_n('\n', MAX_IDENTITY_BYTES as usize)); + std::fs::write(&path, contents).unwrap(); + let (prompts, prompter) = prompts(Vec::new()); + + assert!(matches!( + load(&path, true, &mut None, &prompts).await, + Err(SshFailure::IdentityFileUnreadable { + kind: io::ErrorKind::InvalidData, + .. + }) + )); + assert!(load(&path, false, &mut None, &prompts).await.unwrap().is_none()); + assert!(public_half(&path).await.is_none()); + assert!(prompter.requests().is_empty()); + } + + #[tokio::test] + async fn oversized_optional_public_keys_and_certificates_are_skipped() { + use russh::keys::ssh_key::certificate::{Builder, CertType}; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("id_ed25519"); + let public_path = dir.path().join("id_ed25519.pub"); + let certificate_path = dir.path().join("id_ed25519-cert.pub"); + let key = key(); + let public = key.public_key().to_openssh().unwrap(); + let mut builder = Builder::new(vec![7; 16], key.public_key().key_data().clone(), 100, 200).unwrap(); + builder.cert_type(CertType::User).unwrap(); + builder.valid_principal("deploy").unwrap(); + let certificate = builder.sign(&key).unwrap().to_openssh().unwrap(); + std::fs::write(&public_path, &public).unwrap(); + std::fs::write(&certificate_path, &certificate).unwrap(); + assert!(public_half(&path).await.is_some()); + assert!(certificate_beside(&path).await.is_some()); + + let padding = " ".repeat(MAX_IDENTITY_BYTES as usize); + std::fs::write(&public_path, format!("{public}{padding}")).unwrap(); + std::fs::write(&certificate_path, format!("{certificate}{padding}")).unwrap(); + + assert!(public_half(&path).await.is_none()); + assert!(certificate_beside(&path).await.is_none()); + } + + #[cfg(unix)] + #[tokio::test] + async fn a_fifo_or_device_is_refused_without_reading_it() { + let dir = tempfile::tempdir().unwrap(); + let fifo = dir.path().join("identity"); + assert!( + std::process::Command::new("mkfifo") + .arg("-m") + .arg("600") + .arg(&fifo) + .status() + .unwrap() + .success() + ); + let (prompts, _) = prompts(Vec::new()); + + for path in [fifo.as_path(), Path::new("/dev/zero")] { + assert!(matches!( + load(path, true, &mut None, &prompts).await, + Err(SshFailure::IdentityFileUnreadable { + kind: io::ErrorKind::InvalidInput, + .. + }) + )); + } + // Opening the FIFO alone also cannot wait if it replaces a regular file after metadata. + let opened = open_identity(&fifo).unwrap(); + assert!(!opened.metadata().unwrap().is_file()); + } + #[tokio::test] async fn an_encrypted_key_with_nobody_to_ask_is_not_used() { let dir = tempfile::tempdir().unwrap(); diff --git a/core/crates/storage/src/credentials/keyring_credentials.rs b/core/crates/storage/src/credentials/keyring_credentials.rs index 5e02a2c7c3..4ea7de05c6 100644 --- a/core/crates/storage/src/credentials/keyring_credentials.rs +++ b/core/crates/storage/src/credentials/keyring_credentials.rs @@ -82,7 +82,7 @@ impl KeyringCredentials { return self.ask(prompter, kind, PromptReason::Rejected, true).await.map(Some); } if self.session_only.contains(&kind) - && let Some(secret) = in_use.filter(|secret| !secret.expose_secret().is_empty()) + && let Some(secret) = in_use { return Ok(Some(ResolvedSecret::known(secret.clone()))); } @@ -598,6 +598,25 @@ mod tests { assert!(vault.loads().is_empty(), "the stored copy was read"); } + #[tokio::test] + async fn an_explicit_empty_session_secret_wins_over_the_stored_copy() { + let (credentials, vault) = credentials(Vault::holding(SecretKind::DatabasePassword, "old"), None); + let credentials = credentials.keeping_session_secrets(&[SecretKind::DatabasePassword]); + let empty = SecretString::from(""); + let resolved = credentials + .resolve( + SecretKind::DatabasePassword, + Some(&empty), + &CredentialInteraction::Unattended, + CredentialDemand::default(), + ) + .await + .unwrap() + .unwrap(); + assert!(resolved.secret.expose_secret().is_empty()); + assert!(vault.loads().is_empty()); + } + #[tokio::test] async fn other_kinds_still_come_from_the_keyring() { let (credentials, _) = credentials( diff --git a/linux/changelog.d/4008.fixed.1.md b/linux/changelog.d/4008.fixed.1.md new file mode 100644 index 0000000000..0fed5e51e8 --- /dev/null +++ b/linux/changelog.d/4008.fixed.1.md @@ -0,0 +1 @@ +Connections could hang if a certificate file was replaced by a named pipe while being opened. diff --git a/linux/changelog.d/4008.fixed.md b/linux/changelog.d/4008.fixed.md new file mode 100644 index 0000000000..31b54c8329 --- /dev/null +++ b/linux/changelog.d/4008.fixed.md @@ -0,0 +1 @@ +An empty password typed for a session is kept when reconnecting, even when an older password is stored. diff --git a/scripts/ci/detect-changed-paths.sh b/scripts/ci/detect-changed-paths.sh index e43678f06b..ef0c7a42a8 100755 --- a/scripts/ci/detect-changed-paths.sh +++ b/scripts/ci/detect-changed-paths.sh @@ -103,7 +103,7 @@ case "$SUITE" in windows) # The .NET tests run the whole core through its FFI crate, so any core or workspace change runs them. PATTERNS=('windows/' 'core/' 'Cargo\.(toml|lock)$' 'rust-toolchain\.toml$' - 'scripts/ci/detect-changed-paths\.sh$' '\.github/workflows/(ci|windows)\.yml$') + 'scripts/ci/(detect-changed-paths\.sh|start-windows-sshd\.ps1)$' '\.github/workflows/(ci|windows)\.yml$') SKIP='(windows|core)/.*\.md$' ;; *) echo "detect-changed-paths.sh: expected macos, plugins, ios, oracle, hana, release, shipped, swiftlint, linux, linux-docs or windows, got '$SUITE'" >&2 diff --git a/windows/ci/rotate-ssh-test-key.ps1 b/windows/ci/rotate-ssh-test-key.ps1 new file mode 100644 index 0000000000..23259900c1 --- /dev/null +++ b/windows/ci/rotate-ssh-test-key.ps1 @@ -0,0 +1,40 @@ +param([Parameter(Mandatory)][ValidateSet('Rotate', 'Restore')][string]$Action) + +$ErrorActionPreference = 'Stop' +$directory = Join-Path $env:ProgramData 'ssh' +$config = Join-Path $directory 'sshd_config' +$backup = Join-Path $directory 'tablepro_ui_sshd_config' +$key = Join-Path $directory 'tablepro_ui_host_key' +$keygen = Join-Path $env:WINDIR 'System32\OpenSSH\ssh-keygen.exe' + +if ($Action -eq 'Rotate') { + if (Test-Path $backup) { throw 'The previous SSH key rotation was not restored.' } + Copy-Item $config $backup + Remove-Item $key, "$key.pub" -ErrorAction SilentlyContinue + & $keygen -q -t ed25519 -N '' -f $key + if ($LASTEXITCODE -ne 0) { throw 'Generating the replacement SSH host key failed.' } + & icacls.exe $key /setowner SYSTEM /inheritance:r /grant 'SYSTEM:F' /grant 'BUILTIN\Administrators:F' | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Setting the SSH host key permissions failed.' } + $lines = Get-Content $config | Where-Object { $_ -notmatch '^\s*HostKey\s' } + Set-Content -Encoding ascii $config (@("HostKey `"$($key.Replace('\', '/'))`"") + $lines) +} +elseif (Test-Path $backup) { + Copy-Item $backup $config -Force +} +else { + return +} + +# Existing child sessions must close too, so the app sees a lost tunnel and reconnects to the new key. +Stop-Service sshd +Get-Process sshd* -ErrorAction SilentlyContinue | Stop-Process -Force +Start-Service sshd + +if ($Action -eq 'Rotate') { + $fingerprint = & $keygen -lf "$key.pub" -E sha256 + if ($LASTEXITCODE -ne 0) { throw 'Reading the replacement SSH fingerprint failed.' } + ($fingerprint -split '\s+')[1] +} +else { + Remove-Item $backup, $key, "$key.pub" -ErrorAction SilentlyContinue +} diff --git a/windows/ci/ssh-postgres.ps1 b/windows/ci/ssh-postgres.ps1 new file mode 100644 index 0000000000..eae69a43b5 --- /dev/null +++ b/windows/ci/ssh-postgres.ps1 @@ -0,0 +1,33 @@ +$ErrorActionPreference = 'Stop' + +# The x64 runner ships PostgreSQL; a missing fixture must fail setup rather than skip its tests. +if (-not $env:PGBIN -or -not (Test-Path (Join-Path $env:PGBIN 'psql.exe'))) { + throw 'The runner must provide PostgreSQL in PGBIN.' +} +$service = Get-Service 'postgresql-x64-*' | Select-Object -First 1 +Set-Service $service.Name -StartupType Manual +Start-Service $service.Name +$env:PGPASSWORD = 'root' +$password = 'Tp-' + [guid]::NewGuid().ToString('N') +"::add-mask::$password" +$psql = Join-Path $env:PGBIN 'psql.exe' +& $psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 -c "CREATE ROLE tablepro_ssh LOGIN PASSWORD '$password'" +if ($LASTEXITCODE -ne 0) { throw 'Creating the SSH test database user failed.' } +& $psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 -c 'CREATE DATABASE tablepro_ssh OWNER tablepro_ssh' +if ($LASTEXITCODE -ne 0) { throw 'Creating the SSH test database failed.' } +$env:PGPASSWORD = $password +& $psql -h 127.0.0.1 -U tablepro_ssh -d tablepro_ssh -v ON_ERROR_STOP=1 -c "CREATE TABLE ssh_proof (answer integer PRIMARY KEY); INSERT INTO ssh_proof VALUES (42)" +if ($LASTEXITCODE -ne 0) { throw 'Seeding the SSH test database failed.' } +Remove-Item Env:PGPASSWORD + +# Separate ports give both themes their own first-use host trust prompt without changing known_hosts. +$config = Join-Path $env:ProgramData 'ssh\sshd_config' +$ports = @('Port 22', 'Port 22220', 'Port 22221', 'Port 22222') +Set-Content -Encoding ascii $config ($ports + (Get-Content $config)) +Restart-Service sshd + +@( + 'TABLEPRO_TEST_SSH_POSTGRES_PORT=5432', + "TABLEPRO_TEST_SSH_POSTGRES_PASSWORD=$password", + "TABLEPRO_TEST_SSH_ROTATE_SCRIPT=$(Join-Path $PSScriptRoot 'rotate-ssh-test-key.ps1')" +) | Add-Content -Encoding utf8 $env:GITHUB_ENV diff --git a/windows/docs/decisions/0004-app-shell.md b/windows/docs/decisions/0004-app-shell.md index c6116784f7..efcaa5bce6 100644 --- a/windows/docs/decisions/0004-app-shell.md +++ b/windows/docs/decisions/0004-app-shell.md @@ -156,7 +156,7 @@ Facts the CI runs on `windows-2025` and `windows-11-arm` established, which the - The core gained the library and connecting through it: `Core::library`, `connect_saved`, `test_connection`, `parse_connection_url`, `DatabaseSession::table_query` and `sorted_query` with `sql_syntax::with_order_by` and `sort_reference`, `ResultSet::sorted` over `value::order`, and `Core::with_system_secrets`, which keeps secrets in Credential Manager on Windows (`core/ARCHITECTURE.md`, Platforms). - Then what the shell's second pass needed: `DatabaseDriver::category` (`EngineCategory`) and `DriverInfo.category`; in the library, tags (`create_tag`, `update_tag`, `delete_tag`, `tag_connections`, and the tags in the snapshot), `rename_connection`, `add_favorites` and `remove_favorites` with a place, `move_to_group` with a place and `move_groups`; `ErrorInfo.secret`; `table_query` with an offset and `can_seek`, `table_row_count`; `health`, `health_change` and `reconnect` on the session; `QueryEditor::is_lost`; and the transaction in `RunEvent::Finished`. Each has its test in `core/crates/ffi/tests`. - `tests/TablePro.App.Model.Tests` covers settings, the session file with the window and the pane, the library tree with tags and search, selection by connection, drops, the engine families, closed tabs, the pager and the workspace bars, the form's fields and the object folders on any machine. `tests/TablePro.App.UITests` drives the unpackaged build through UI Automation (FlaUI): first run, Settings, the form and its engine families, connecting to a SQLite file, opening, sorting and paging a table with a width set through Transform and the header's ItemStatus, a query and its sort, a script's rows sorted where they are, a failed connect, a password asked for, session restore, the rail's mark, the pane's width and the window's place across a relaunch, Reopen closed tab, an open transaction's bars, and rename, tags, a drop and selection mode on the Connections page, in light and dark, with screenshots. -- SSH fields are saved, but the core has no SSH transport on Windows yet, so such a connection fails with `ssh.open_ssh_client_missing`. +- SSH connections use the core's in-process transport (core ADR 0008). Host-key, passphrase and interactive login prompts use native ContentDialogs. A changed host key requires confirmation to remove its old entry, then separate confirmation of the replacement key on retry. - Not built yet: importing from other apps (the importers move to the core first), tab tear-out, and a page size picker and Go to page in the pager. - Unverified: Native AOT publishing of the app, and the app on a real Windows 10 machine. diff --git a/windows/src/TablePro.App.Model/ConnectionDraft.cs b/windows/src/TablePro.App.Model/ConnectionDraft.cs index 02dad4435e..b7d2435e74 100644 --- a/windows/src/TablePro.App.Model/ConnectionDraft.cs +++ b/windows/src/TablePro.App.Model/ConnectionDraft.cs @@ -28,6 +28,8 @@ public sealed record FieldError(FormField Field, FieldProblem Problem); // Numbers stay text as typed, so Validate can name the field that does not parse. public sealed class ConnectionDraft { + private SshSettings? _savedSsh; + public Guid? Id { get; set; } public string EngineId { get; set; } = string.Empty; @@ -99,6 +101,7 @@ public static ConnectionDraft From(SavedConnection connection, DatabaseEngine en ArgumentNullException.ThrowIfNull(engine); return new ConnectionDraft { + _savedSsh = connection.Ssh, Id = connection.Id, EngineId = connection.EngineId, IsFileEngine = engine.DefaultPort is null, @@ -238,9 +241,11 @@ public SavedConnection ToConnection() SshHost.Trim(), ParsePort(SshPort), Blank(SshUserName), - [], + _savedSsh?.JumpHosts ?? [], SshAuth, - SshAuth == SshAuthMethod.PrivateKey ? Blank(SshKeyPath) : null) + SshAuth == SshAuthMethod.PrivateKey ? Blank(SshKeyPath) : null, + SshAuth == SshAuthMethod.PrivateKey && _savedSsh is { KeyHasPassphrase: true } + && string.Equals(_savedSsh.KeyPath, Blank(SshKeyPath), StringComparison.Ordinal)) : null, QueryTimeoutSeconds: string.IsNullOrWhiteSpace(Timeout) ? null diff --git a/windows/src/TablePro.App/ConnectionForm/ConnectionFormWindow.xaml b/windows/src/TablePro.App/ConnectionForm/ConnectionFormWindow.xaml index 2d2b44a8f0..13dc7116af 100644 --- a/windows/src/TablePro.App/ConnectionForm/ConnectionFormWindow.xaml +++ b/windows/src/TablePro.App/ConnectionForm/ConnectionFormWindow.xaml @@ -136,19 +136,19 @@ - + - + - + - + - + @@ -157,12 +157,12 @@ - +