From 4c0e7a464e7e268c3756d84567a7403fde45a05e Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 16 Oct 2025 11:50:59 +0200 Subject: [PATCH] MVP rewrite --- apps/desktop/desktop_native/Cargo.lock | 774 +++++++++++++++--- apps/desktop/desktop_native/Cargo.toml | 3 +- apps/desktop/desktop_native/napi/Cargo.toml | 1 + apps/desktop/desktop_native/napi/index.d.ts | 26 + apps/desktop/desktop_native/napi/src/lib.rs | 204 +++++ .../desktop_native/ssh_agent/Cargo.toml | 54 ++ .../ssh_agent/src/agent/agent.rs | 102 +++ .../desktop_native/ssh_agent/src/agent/mod.rs | 3 + .../ssh_agent/src/agent/platform/mod.rs | 66 ++ .../ssh_agent/src/agent/ui_requester.rs | 100 +++ .../desktop_native/ssh_agent/src/lib.rs | 4 + .../ssh_agent/src/memory/mod.rs | 119 +++ .../src/protocol/async_stream_wrapper.rs | 45 + .../ssh_agent/src/protocol/connection.rs | 64 ++ .../ssh_agent/src/protocol/key_store.rs | 33 + .../ssh_agent/src/protocol/mod.rs | 8 + .../ssh_agent/src/protocol/protocol.rs | 121 +++ .../ssh_agent/src/protocol/replies.rs | 131 +++ .../ssh_agent/src/protocol/requests.rs | 334 ++++++++ .../ssh_agent/src/protocol/types.rs | 380 +++++++++ .../ssh_agent/src/transport/mod.rs | 4 + .../transport/named_pipe_listener_stream.rs | 102 +++ .../ssh_agent/src/transport/peer_info.rs | 76 ++ .../src/transport/unix_listener_stream.rs | 101 +++ .../autofill/main/main-ssh-agent.service.ts | 110 ++- .../autofill/services/ssh-agent.service.ts | 3 +- .../default-process-reload.service.ts | 1 + 27 files changed, 2834 insertions(+), 135 deletions(-) create mode 100644 apps/desktop/desktop_native/ssh_agent/Cargo.toml create mode 100644 apps/desktop/desktop_native/ssh_agent/src/agent/agent.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/agent/mod.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/agent/platform/mod.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/agent/ui_requester.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/lib.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/memory/mod.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/protocol/async_stream_wrapper.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/protocol/connection.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/protocol/key_store.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/protocol/mod.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/protocol/protocol.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/protocol/replies.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/protocol/requests.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/protocol/types.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/transport/mod.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/transport/named_pipe_listener_stream.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/transport/peer_info.rs create mode 100644 apps/desktop/desktop_native/ssh_agent/src/transport/unix_listener_stream.rs diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 9020e08362e..f7301785fd2 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -23,10 +23,20 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "generic-array", ] +[[package]] +name = "aead" +version = "0.6.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac8202ab55fcbf46ca829833f347a82a2a4ce0596f0304ac322c2d100030cd56" +dependencies = [ + "crypto-common 0.2.0-rc.4", + "inout 0.2.0-rc.6", +] + [[package]] name = "aes" version = "0.8.4" @@ -34,7 +44,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "aes" +version = "0.9.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e713c57c2a2b19159e7be83b9194600d7e8eb3b7c2cd67e671adf47ce189a05" +dependencies = [ + "cfg-if", + "cipher 0.5.0-rc.1", "cpufeatures", "zeroize", ] @@ -45,14 +67,29 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] +[[package]] +name = "aes-gcm" +version = "0.11.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686ba04dc80c816104c96cd7782b748f6ad58c5dd4ee619ff3258cf68e83d54" +dependencies = [ + "aead 0.6.0-rc.2", + "aes 0.9.0-rc.1", + "cipher 0.5.0-rc.1", + "ctr 0.10.0-rc.1", + "ghash 0.6.0-rc.2", + "subtle", + "zeroize", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -369,6 +406,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b59d472eab27ade8d770dcb11da7201c11234bef9f82ce7aa517be028d462b" + [[package]] name = "base64" version = "0.22.1" @@ -396,9 +439,20 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" dependencies = [ - "blowfish", - "pbkdf2", - "sha2", + "blowfish 0.9.1", + "pbkdf2 0.12.2", + "sha2 0.10.9", +] + +[[package]] +name = "bcrypt-pbkdf" +version = "0.11.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1bf369918379398613de5b595f0f843a9c0eaef8266d33a54fb7f82c69f846e" +dependencies = [ + "blowfish 0.10.0-rc.1", + "pbkdf2 0.13.0-rc.1", + "sha2 0.11.0-rc.2", ] [[package]] @@ -424,15 +478,15 @@ dependencies = [ "anyhow", "byteorder", "ecdsa", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "futures", "p256", "p384", "p521", - "rsa", + "rsa 0.9.6", "russh-cryptovec", - "ssh-encoding", - "ssh-key", + "ssh-encoding 0.2.0", + "ssh-key 0.6.7", "thiserror 1.0.69", "tokio", "tokio-util", @@ -442,16 +496,16 @@ dependencies = [ name = "bitwarden_chromium_importer" version = "0.0.0" dependencies = [ - "aes", - "aes-gcm", + "aes 0.8.4", + "aes-gcm 0.10.3", "anyhow", "async-trait", "base64", - "cbc", + "cbc 0.1.2", "hex", "homedir", "oo7", - "pbkdf2", + "pbkdf2 0.12.2", "rand 0.9.1", "rusqlite", "security-framework", @@ -472,6 +526,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.11.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9ef36a6fcdb072aa548f3da057640ec10859eb4e91ddf526ee648d50c76a949" +dependencies = [ + "hybrid-array", + "zeroize", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -481,6 +545,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.4.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e59c1aab3e6c5e56afe1b7e8650be9b5a791cb997bdea449194ae62e4bf8c73" +dependencies = [ + "hybrid-array", +] + [[package]] name = "blocking" version = "1.6.1" @@ -501,7 +574,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" dependencies = [ "byteorder", - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "blowfish" +version = "0.10.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4f049baa079f3b50e74ad3b1fb0585db8ec51f08939671bd6fb4d65886b758" +dependencies = [ + "byteorder", + "cipher 0.5.0-rc.1", ] [[package]] @@ -554,7 +637,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.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dbf9e5b071e9de872e32b73f485e8f644ff47c7011d95476733e7482ee3e5c3" +dependencies = [ + "cipher 0.5.0-rc.1", ] [[package]] @@ -585,18 +677,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures", ] +[[package]] +name = "chacha20" +version = "0.10.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd162f2b8af3e0639d83f28a637e4e55657b7a74508dba5a9bf4da523d5c9e9" +dependencies = [ + "cfg-if", + "cipher 0.5.0-rc.1", + "cpufeatures", + "zeroize", +] + [[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.6", + "inout 0.1.4", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.5.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e12a13eb01ded5d32ee9658d94f553a19e804204f2dc811df69ab4d9e0cb8c7" +dependencies = [ + "block-buffer 0.11.0-rc.5", + "crypto-common 0.2.0-rc.4", + "inout 0.2.0-rc.6", "zeroize", ] @@ -681,6 +797,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e" + [[package]] name = "convert_case" version = "0.6.0" @@ -733,6 +855,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.0-rc.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4113edbc9f68c0a64d5b911f803eb245d04bb812680fd56776411f69c670f3e0" +dependencies = [ + "num-traits", + "rand_core 0.9.3", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -744,6 +879,26 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "crypto-primes" +version = "0.7.0-pre.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f2523fbb68811c8710829417ad488086720a6349e337c38d12fa81e09e50bf" +dependencies = [ + "crypto-bigint 0.7.0-rc.8", + "libm", + "rand_core 0.9.3", +] + [[package]] name = "ctor" version = "0.2.9" @@ -760,7 +915,16 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27e41d01c6f73b9330177f5cf782ae5b581b5f2c7840e298e0275ceee5001434" +dependencies = [ + "cipher 0.5.0-rc.1", ] [[package]] @@ -772,13 +936,28 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", - "digest", - "fiat-crypto", + "digest 0.10.7", + "fiat-crypto 0.2.9", "rustc_version", "subtle", "zeroize", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f9200d1d13637f15a6acb71e758f64624048d85b31a5fdbfd8eca1e2687d0b7" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.11.0-rc.3", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", +] + [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -868,8 +1047,19 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d8dd2f26c86b27a2a8ea2767ec7f9df7a89516e4794e54ac01ee618dda3aa4" +dependencies = [ + "const-oid 0.10.1", + "pem-rfc7468 1.0.0-rc.3", "zeroize", ] @@ -882,11 +1072,20 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "des" +version = "0.9.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f51594a70805988feb1c85495ddec0c2052e4fbe59d9c0bb7f94bfc164f4f90" +dependencies = [ + "cipher 0.5.0-rc.1", +] + [[package]] name = "desktop_core" version = "0.0.0" dependencies = [ - "aes", + "aes 0.8.4", "anyhow", "arboard", "ashpd", @@ -894,11 +1093,11 @@ dependencies = [ "bitwarden-russh", "byteorder", "bytes", - "cbc", + "cbc 0.1.2", "core-foundation", "desktop_objc", "dirs", - "ed25519", + "ed25519 2.2.3", "futures", "homedir", "interprocess", @@ -906,17 +1105,17 @@ dependencies = [ "libc", "oo7", "pin-project", - "pkcs8", + "pkcs8 0.10.2", "rand 0.9.1", - "rsa", + "rsa 0.9.6", "russh-cryptovec", "scopeguard", "secmem-proc", "security-framework", "security-framework-sys", - "sha2", - "ssh-encoding", - "ssh-key", + "sha2 0.10.9", + "ssh-encoding 0.2.0", + "ssh-key 0.6.7", "sysinfo", "thiserror 2.0.12", "tokio", @@ -947,6 +1146,7 @@ dependencies = [ "napi-derive", "serde", "serde_json", + "ssh_agent", "tokio", "tokio-stream", "tokio-util", @@ -989,9 +1189,21 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac89f8a64533a9b0eaa73a68e424db0fb1fd6271c74cc0125336a05f090568d" +dependencies = [ + "block-buffer 0.11.0-rc.5", + "const-oid 0.10.1", + "crypto-common 0.2.0-rc.4", "subtle", ] @@ -1055,12 +1267,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", - "digest", + "der 0.7.10", + "digest 0.10.7", "elliptic-curve", "rfc6979", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", ] [[package]] @@ -1069,42 +1281,64 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef49c0b20c0ad088893ad2a790a29c06a012b3f05bcfc66661fd22a94b32129" +dependencies = [ + "signature 3.0.0-rc.4", ] [[package]] name = "ed25519-dalek" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "rand_core 0.6.4", "serde", - "sha2", - "signature", + "sha2 0.10.9", + "signature 2.2.0", "subtle", "zeroize", ] +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" +dependencies = [ + "curve25519-dalek 5.0.0-pre.1", + "ed25519 3.0.0-rc.1", + "sha2 0.11.0-rc.2", + "subtle", +] + [[package]] name = "elliptic-curve" version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", - "crypto-bigint", - "digest", + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", "ff", "generic-array", "group", "hkdf", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", "subtle", "zeroize", ] @@ -1219,6 +1453,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "fixedbitset" version = "0.4.2" @@ -1408,7 +1648,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f88107cb02ed63adcc4282942e60c4d09d80208d33b360ce7c729ce6dae1739" +dependencies = [ + "polyval 0.7.0-rc.2", ] [[package]] @@ -1502,7 +1751,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", ] [[package]] @@ -1517,6 +1775,16 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "hybrid-array" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f471e0a81b2f90ffc0cb2f951ae04da57de8baa46fa99112b062a5173a5088d0" +dependencies = [ + "typenum", + "zeroize", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -1640,10 +1908,20 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", + "block-padding 0.3.3", "generic-array", ] +[[package]] +name = "inout" +version = "0.2.0-rc.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1603f76010ff924b616c8f44815a42eb10fb0b93d308b41deaa8da6d4251fd4b" +dependencies = [ + "block-padding 0.4.0-rc.4", + "hybrid-array", +] + [[package]] name = "interprocess" version = "2.2.1" @@ -1819,7 +2097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -2088,6 +2366,28 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -2201,11 +2501,11 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6cb23d3ec3527d65a83be1c1795cb883c52cfa57147d42acc797127df56fc489" dependencies = [ - "aes", + "aes 0.8.4", "ashpd", - "cbc", - "cipher", - "digest", + "cbc 0.1.2", + "cipher 0.4.4", + "digest 0.10.7", "endi", "futures-util", "getrandom 0.3.3", @@ -2214,10 +2514,10 @@ dependencies = [ "md-5", "num", "num-bigint-dig", - "pbkdf2", + "pbkdf2 0.12.2", "rand 0.9.1", "serde", - "sha2", + "sha2 0.10.9", "subtle", "tokio", "zbus", @@ -2278,7 +2578,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2290,7 +2590,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2299,12 +2599,12 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" dependencies = [ - "base16ct", + "base16ct 0.2.0", "ecdsa", "elliptic-curve", "primeorder", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2348,10 +2648,19 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", + "digest 0.10.7", "hmac", ] +[[package]] +name = "pbkdf2" +version = "0.13.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3fc18bb4460ac250ba6b75dfa7cf9d0b2273e3e623f660bd6ce2c3e902342e" +dependencies = [ + "digest 0.11.0-rc.3", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2361,6 +2670,15 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e58fab693c712c0d4e88f8eb3087b6521d060bcaf76aeb20cb192d809115ba" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -2426,9 +2744,19 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der 0.8.0-rc.9", + "spki 0.8.0-rc.4", ] [[package]] @@ -2437,13 +2765,13 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" dependencies = [ - "aes", - "cbc", - "der", - "pbkdf2", + "aes 0.8.4", + "cbc 0.1.2", + "der 0.7.10", + "pbkdf2 0.12.2", "scrypt", - "sha2", - "spki", + "sha2 0.10.9", + "spki 0.7.3", ] [[package]] @@ -2452,10 +2780,20 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", + "der 0.7.10", "pkcs5", "rand_core 0.6.4", - "spki", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0-rc.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93eac55f10aceed84769df670ea4a32d2ffad7399400d41ee1c13b1cd8e1b478" +dependencies = [ + "der 0.8.0-rc.9", + "spki 0.8.0-rc.4", ] [[package]] @@ -2493,7 +2831,18 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "poly1305" +version = "0.9.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb78a635f75d76d856374961deecf61031c0b6f928c83dc9c0924ab6c019c298" +dependencies = [ + "cpufeatures", + "universal-hash 0.6.0-rc.2", + "zeroize", ] [[package]] @@ -2505,7 +2854,18 @@ dependencies = [ "cfg-if", "cpufeatures", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffd40cc99d0fbb02b4b3771346b811df94194bc103983efa0203c8893755085" +dependencies = [ + "cfg-if", + "cpufeatures", + "universal-hash 0.6.0-rc.2", ] [[package]] @@ -2713,17 +3073,37 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", - "pkcs1", - "pkcs8", + "pkcs1 0.7.5", + "pkcs8 0.10.2", "rand_core 0.6.4", - "sha2", - "signature", - "spki", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8c26d4f6d0d2689c1cc822ac369edb64b4a090bc53141ae563bfa19c797300" +dependencies = [ + "const-oid 0.10.1", + "crypto-bigint 0.7.0-rc.8", + "crypto-primes", + "digest 0.11.0-rc.3", + "pkcs1 0.8.0-rc.4", + "pkcs8 0.11.0-rc.7", + "rand_core 0.9.3", + "sha2 0.11.0-rc.2", + "signature 3.0.0-rc.4", + "spki 0.8.0-rc.4", "subtle", "zeroize", ] @@ -2821,7 +3201,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -2862,9 +3242,9 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "pbkdf2", + "pbkdf2 0.12.2", "salsa20", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2873,14 +3253,24 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "subtle", "zeroize", ] +[[package]] +name = "sec1" +version = "0.8.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dff52f6118bc9f0ac974a54a639d499ac26a6cad7a6e39bc0990c19625e793b" +dependencies = [ + "base16ct 0.3.0", + "hybrid-array", +] + [[package]] name = "secmem-proc" version = "0.3.7" @@ -2971,6 +3361,16 @@ dependencies = [ "syn", ] +[[package]] +name = "serdect" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3ef0e35b322ddfaecbc60f34ab448e157e48531288ee49fafbb053696b8ffe2" +dependencies = [ + "base16ct 0.3.0", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2979,18 +3379,29 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.0-rc.3", ] [[package]] @@ -3023,10 +3434,20 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc280a6ff65c79fbd6622f64d7127f32b85563bca8c53cd2e9141d6744a9056d" +dependencies = [ + "digest 0.11.0-rc.3", + "rand_core 0.9.3", +] + [[package]] name = "simplelog" version = "0.12.2" @@ -3088,7 +3509,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8baeff88f34ed0691978ec34440140e1572b68c7dd4a495fd14a3dc1944daa80" +dependencies = [ + "base64ct", + "der 0.8.0-rc.9", ] [[package]] @@ -3097,17 +3528,37 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" dependencies = [ - "aes", - "aes-gcm", - "cbc", - "chacha20", - "cipher", - "ctr", - "poly1305", - "ssh-encoding", + "aes 0.8.4", + "aes-gcm 0.10.3", + "cbc 0.1.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "ctr 0.9.2", + "poly1305 0.8.0", + "ssh-encoding 0.2.0", "subtle", ] +[[package]] +name = "ssh-cipher" +version = "0.3.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481f53252058ad302f9dff47a3ca03c5e30e34e49226d9549a7e9d16cb210700" +dependencies = [ + "aead 0.6.0-rc.2", + "aes 0.9.0-rc.1", + "aes-gcm 0.11.0-rc.1", + "cbc 0.2.0-rc.1", + "chacha20 0.10.0-rc.2", + "cipher 0.5.0-rc.1", + "ctr 0.10.0-rc.1", + "des", + "poly1305 0.9.0-rc.2", + "ssh-encoding 0.3.0-rc.2", + "subtle", + "zeroize", +] + [[package]] name = "ssh-encoding" version = "0.2.0" @@ -3115,8 +3566,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" dependencies = [ "base64ct", - "pem-rfc7468", - "sha2", + "pem-rfc7468 0.7.0", + "sha2 0.10.9", +] + +[[package]] +name = "ssh-encoding" +version = "0.3.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f1447aab1592c131dec60f7d8cc0b2fb4042d0bf2c90c40f972c2c046b25d1b" +dependencies = [ + "base64ct", + "crypto-bigint 0.7.0-rc.8", + "digest 0.11.0-rc.3", + "pem-rfc7468 1.0.0-rc.3", + "subtle", + "zeroize", ] [[package]] @@ -3125,20 +3590,73 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" dependencies = [ - "bcrypt-pbkdf", - "ed25519-dalek", + "bcrypt-pbkdf 0.10.0", + "ed25519-dalek 2.2.0", "num-bigint-dig", "rand_core 0.6.4", - "rsa", - "sec1", - "sha2", - "signature", - "ssh-cipher", - "ssh-encoding", + "rsa 0.9.6", + "sec1 0.7.3", + "sha2 0.10.9", + "signature 2.2.0", + "ssh-cipher 0.2.0", + "ssh-encoding 0.2.0", "subtle", "zeroize", ] +[[package]] +name = "ssh-key" +version = "0.7.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7307406fcbbeb6933b5c8cc84ec0fefee80fec53ba5b88b96674c0a75495090a" +dependencies = [ + "bcrypt-pbkdf 0.11.0-rc.1", + "ed25519-dalek 3.0.0-pre.1", + "home", + "rand_core 0.9.3", + "rsa 0.10.0-rc.8", + "sec1 0.8.0-rc.10", + "sha2 0.11.0-rc.2", + "signature 3.0.0-rc.4", + "ssh-cipher 0.3.0-rc.3", + "ssh-encoding 0.3.0-rc.2", + "subtle", + "zeroize", +] + +[[package]] +name = "ssh_agent" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64", + "block-padding 0.4.0-rc.4", + "byteorder", + "bytes", + "ed25519 2.2.3", + "ed25519-dalek 2.2.0", + "futures", + "homedir", + "inout 0.2.0-rc.6", + "log", + "num_enum", + "p256", + "p384", + "p521", + "rsa 0.10.0-rc.8", + "sha2 0.10.9", + "signature 3.0.0-rc.4", + "ssh-encoding 0.3.0-rc.2", + "ssh-key 0.7.0-rc.3", + "sysinfo", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "windows 0.61.1", + "windows-future", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -3653,7 +4171,17 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.6.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55be643b40a21558f44806b53ee9319595bc7ca6896372e4e08e5d7d83c9cd6" +dependencies = [ + "crypto-common 0.2.0-rc.4", "subtle", ] diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 0a637b12de9..f86ad7ec1a5 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -7,6 +7,7 @@ members = [ "macos_provider", "napi", "proxy", + "ssh_agent", "windows_plugin_authenticator" ] @@ -53,8 +54,8 @@ security-framework = "=3.5.0" security-framework-sys = "=2.15.0" serde = "=1.0.209" serde_json = "=1.0.127" -sha2 = "=0.10.8" simplelog = "=0.12.2" +sha2 = "=0.10.9" ssh-encoding = "=0.2.0" ssh-key = { version = "=0.6.7", default-features = false } sysinfo = "=0.35.0" diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 5e2e42b463f..d274eab9572 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -19,6 +19,7 @@ autotype = { path = "../autotype" } base64 = { workspace = true } bitwarden_chromium_importer = { path = "../bitwarden_chromium_importer" } desktop_core = { path = "../core" } +ssh_agent = { path = "../ssh_agent" } hex = { workspace = true } napi = { workspace = true, features = ["async"] } napi-derive = { workspace = true } diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 030bf4c964d..f5000c78414 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -236,3 +236,29 @@ export declare namespace autotype { export function getForegroundWindowTitle(): string export function typeInput(input: Array, keyboardShortcut: Array): void } +export declare namespace sshagent_v2 { + export interface PrivateKey { + privateKey: string + name: string + cipherId: string + } + export interface SshKey { + privateKey: string + publicKey: string + keyFingerprint: string + } + export interface SshUiRequest { + cipherId?: string + isList: boolean + processName: string + isForwarding: boolean + namespace?: string + } + export function serve(callback: (err: Error | null, arg: SshUiRequest) => any): Promise + export function stop(agentState: SshAgentState): void + export function isRunning(agentState: SshAgentState): boolean + export function setKeys(agentState: SshAgentState, newKeys: Array): void + export function lock(agentState: SshAgentState): void + export function clearKeys(agentState: SshAgentState): void + export class SshAgentState { } +} diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 327c7c1c8e5..1376884a44d 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -1053,3 +1053,207 @@ pub mod autotype { }) } } + +#[napi] +pub mod sshagent_v2 { + use std::sync::Arc; + + use napi::{ + bindgen_prelude::Promise, + threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction}, + }; + use ssh_agent::agent::ui_requester; + use ssh_agent::{ + self, + agent::{ui_requester::UiRequestMessage, BitwardenDesktopAgent}, + memory::UnlockedSshItem, + protocol::types::KeyPair, + transport::unix_listener_stream::UnixListenerStream, + }; + use tokio::{self, sync::Mutex}; + use tracing::{error, info}; + + #[napi] + pub struct SshAgentState { + agent: BitwardenDesktopAgent, + } + + #[napi(object)] + pub struct PrivateKey { + pub private_key: String, + pub name: String, + pub cipher_id: String, + } + + #[napi(object)] + pub struct SshKey { + pub private_key: String, + pub public_key: String, + pub key_fingerprint: String, + } + + #[napi(object)] + pub struct SshUIRequest { + pub cipher_id: Option, + pub is_list: bool, + pub process_name: String, + pub is_forwarding: bool, + pub namespace: Option, + } + + #[allow(clippy::unused_async)] // FIXME: Remove unused async! + #[napi] + pub async fn serve( + callback: ThreadsafeFunction, + ) -> napi::Result { + let (auth_request_tx, mut auth_request_rx) = + tokio::sync::mpsc::channel::(32); + let (auth_response_tx, auth_response_rx) = + tokio::sync::broadcast::channel::<(u32, bool)>(32); + let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx)); + let ui_requester = + ui_requester::UiRequester::new(auth_request_tx, Arc::new(Mutex::new(auth_response_rx))); + + tokio::spawn(async move { + let _ = ui_requester; + + while let Some(request) = auth_request_rx.recv().await { + let cloned_response_tx_arc = auth_response_tx_arc.clone(); + let cloned_callback = callback.clone(); + tokio::spawn(async move { + let auth_response_tx_arc = cloned_response_tx_arc; + let callback = cloned_callback; + + let js_request = match request.clone() { + UiRequestMessage::ListRequest { + request_id: _, + connection_info, + } => SshUIRequest { + cipher_id: None, + is_list: true, + process_name: "".to_string(), + is_forwarding: connection_info.is_forwarding(), + namespace: None, + }, + UiRequestMessage::AuthRequest { + request_id, + connection_info, + cipher_id, + } => SshUIRequest { + cipher_id: Some(cipher_id), + is_list: false, + process_name: "".to_string(), + is_forwarding: connection_info.is_forwarding(), + namespace: None, + }, + UiRequestMessage::SignRequest { + request_id, + connection_info, + cipher_id, + namespace, + } => SshUIRequest { + cipher_id: Some(cipher_id), + is_list: false, + process_name: "".to_string(), + is_forwarding: connection_info.is_forwarding(), + namespace: Some(namespace), + }, + }; + + let promise_result: Result, napi::Error> = + callback.call_async(Ok(js_request)).await; + match promise_result { + Ok(promise_result) => match promise_result.await { + Ok(result) => { + let _ = auth_response_tx_arc + .lock() + .await + .send((request.id(), result)) + .expect("should be able to send auth response to agent"); + } + Err(e) => { + error!(error = %e, "Calling UI callback promise was rejected"); + let _ = auth_response_tx_arc + .lock() + .await + .send((request.id(), false)) + .expect("should be able to send auth response to agent"); + } + }, + Err(e) => { + error!(error = %e, "Calling UI callback could not create promise"); + let _ = auth_response_tx_arc + .lock() + .await + .send((request.id(), false)) + .expect("should be able to send auth response to agent"); + } + } + }); + } + }); + + let agent = BitwardenDesktopAgent::new(ui_requester); + let agent_copy = agent.clone(); + tokio::spawn(async move { + UnixListenerStream::listen("/home/quexten/.ssh-sock".to_string(), agent_copy) + .await + .unwrap(); + }); + + Ok(SshAgentState { agent: agent }) + } + + #[napi] + pub fn stop(agent_state: &mut SshAgentState) -> napi::Result<()> { + agent_state.agent.stop(); + Ok(()) + } + + #[napi] + pub fn is_running(agent_state: &mut SshAgentState) -> bool { + // let bitwarden_agent_state = agent_state.state.clone(); + // bitwarden_agent_state.is_running() + true + } + + #[napi] + pub fn set_keys( + agent_state: &mut SshAgentState, + new_keys: Vec, + ) -> napi::Result<()> { + agent_state.agent.set_keys( + new_keys + .iter() + .filter_map(|k| { + let private_key = + ssh_agent::protocol::types::PrivateKey::try_from(k.private_key.clone()) + .ok()?; + Some(UnlockedSshItem::new( + KeyPair::new(private_key, k.name.clone()), + k.cipher_id.clone(), + )) + }) + .collect(), + ); + Ok(()) + } + + #[napi] + pub fn lock(agent_state: &mut SshAgentState) -> napi::Result<()> { + // let bitwarden_agent_state = &mut agent_state.state; + // bitwarden_agent_state + // .lock() + // .map_err(|e| napi::Error::from_reason(e.to_string())) + Ok(()) + } + + #[napi] + pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> { + // let bitwarden_agent_state = &mut agent_state.state; + // bitwarden_agent_state + // .clear_keys() + // .map_err(|e| napi::Error::from_reason(e.to_string())) + Ok(()) + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/Cargo.toml b/apps/desktop/desktop_native/ssh_agent/Cargo.toml new file mode 100644 index 00000000000..c1a3d07b756 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "ssh_agent" +edition = { workspace = true } +license = { workspace = true } +version = { workspace = true } +publish = { workspace = true } + +[features] +default = [ + "dep:windows", +] +manual_test = [] + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +bytes = { workspace = true } +byteorder = { workspace = true } +block-padding = { version = "=0.4.0-rc.4" } +ed25519 = { workspace = true, features = ["pkcs8"] } +ed25519-dalek = { version = "2.2.0", features = ["rand_core"] } +futures = { workspace = true } +inout = { version = "=0.2.0-rc.6" } +homedir = { workspace = true } +log = { workspace = true } +rsa = { version = "=0.10.0-rc.8", features = ["sha2"] } +sha2 = "0.10.9" +ssh-encoding = "=0.3.0-rc.2" +ssh-key = { version = "=0.7.0-rc.3", features = [ + "encryption", + "ed25519", + "rsa", + "ecdsa", + "getrandom", +] } +num_enum = "0.7.4" +signature = "3.0.0-rc.4" +sysinfo = { workspace = true, features = ["windows"] } +tracing-subscriber.workspace = true +tracing = { workspace = true } +p256 = "0.13.2" +p384 = "0.13.1" +p521 = "0.13.3" +tokio = { workspace = true, features = ["io-util", "sync", "macros", "net"] } +tokio-util = { workspace = true, features = ["codec"] } + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = [ + "Win32_System_Pipes", +], optional = true } +windows-future = { workspace = true } + +[lints] +workspace = true diff --git a/apps/desktop/desktop_native/ssh_agent/src/agent/agent.rs b/apps/desktop/desktop_native/ssh_agent/src/agent/agent.rs new file mode 100644 index 00000000000..c0b8aff0bf8 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/agent/agent.rs @@ -0,0 +1,102 @@ +use std::sync::{Arc, Mutex}; + +use futures::Stream; +use tokio_util::sync::CancellationToken; + +use crate::{ + agent::ui_requester::{self, UiRequester}, + memory::UnlockedSshItem, + protocol::{ + self, + key_store::Agent, + protocol::serve_listener, + types::{KeyPair, PublicKeyWithName}, + }, + transport::peer_info::PeerInfo, +}; + +#[derive(Clone)] +pub struct BitwardenDesktopAgent { + cancellation_token: CancellationToken, + key_store: Arc>, + ui_requester: UiRequester, +} + +impl BitwardenDesktopAgent { + pub fn new(ui_requester: UiRequester) -> Self { + Self { + cancellation_token: CancellationToken::new(), + key_store: Arc::new(Mutex::new(crate::memory::KeyStore::new())), + ui_requester, + } + } + + pub async fn serve(&self, listener: L) + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Sync + Unpin + 'static, + L: Stream> + Unpin, + { + serve_listener(listener, self.cancellation_token.clone(), self) + .await + .unwrap(); + } + + pub fn stop(&self) { + self.cancellation_token.cancel(); + } + + pub fn set_keys(&self, keys: Vec) { + self.key_store + .lock() + .expect("Failed to lock key store") + .set_unlocked(keys); + } +} + +impl Agent for &BitwardenDesktopAgent { + async fn list_keys(&self) -> Result, anyhow::Error> { + Ok(self + .key_store + .lock() + .expect("Failed to lock key store") + .list_keys()) + } + + async fn find_ssh_item( + &self, + public_key: &protocol::types::PublicKey, + ) -> Result, anyhow::Error> { + Ok(self + .key_store + .lock() + .expect("Failed to lock key store") + .get_unlocked_keypair(public_key)) + } + + async fn request_can_list( + &self, + connection_info: &protocol::connection::ConnectionInfo, + ) -> Result { + Ok(self.ui_requester.request_list(connection_info).await) + } + + async fn request_can_sign( + &self, + public_key: &protocol::types::PublicKey, + connection_info: &protocol::connection::ConnectionInfo, + ) -> Result { + let id = self + .key_store + .lock() + .expect("Failed to lock key store") + .get_cipher_id(public_key); + if let Some(cipher_id) = id { + return Ok(self + .ui_requester + .request_sign(connection_info, cipher_id, "unknown".to_string()) + .await); + } else { + return Ok(false); + } + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/agent/mod.rs b/apps/desktop/desktop_native/ssh_agent/src/agent/mod.rs new file mode 100644 index 00000000000..efd3ba6465f --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/agent/mod.rs @@ -0,0 +1,3 @@ +pub mod agent; +pub mod ui_requester; +pub use agent::BitwardenDesktopAgent; diff --git a/apps/desktop/desktop_native/ssh_agent/src/agent/platform/mod.rs b/apps/desktop/desktop_native/ssh_agent/src/agent/platform/mod.rs new file mode 100644 index 00000000000..cc4070ba685 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/agent/platform/mod.rs @@ -0,0 +1,66 @@ +use tracing::info; +use homedir::my_home; + +use crate::{agent::{self, BitwardenDesktopAgent}, transport::unix_listener_stream::UnixListenerStream}; + +struct PlatformListener { +} + +impl PlatformListener { + pub fn spawn_listeners(agent: BitwardenDesktopAgent) { + #[cfg(target_os = "linux")] + { + Self::spawn_linux_listeners(agent); + } + + #[cfg(target_os = "macos")] + { + Self::spawn_macos_listeners(agent); + } + } + + fn spawn_linux_listeners(agent: BitwardenDesktopAgent) { + let ssh_agent_directory = match my_home() { + Ok(Some(home)) => home, + _ => { + info!("Could not determine home directory"); + return; + } + }; + + let is_flatpak = std::env::var("container") == Ok("flatpak".to_string()); + let path = if !is_flatpak { + ssh_agent_directory + .join(".bitwarden-ssh-agent.sock") + .to_str() + .expect("Path should be valid") + .to_owned() + } else { + ssh_agent_directory + .join(".var/app/com.bitwarden.desktop/data/.bitwarden-ssh-agent.sock") + .to_str() + .expect("Path should be valid") + .to_owned() + }; + + tokio::spawn(UnixListenerStream::listen(path, agent)); + } + + fn spawn_macos_listeners(agent: BitwardenDesktopAgent) { + let ssh_agent_directory = match my_home() { + Ok(Some(home)) => home, + _ => { + info!("Could not determine home directory"); + return; + } + }; + + let path = ssh_agent_directory + .join(".bitwarden-ssh-agent.sock") + .to_str() + .expect("Path should be valid") + .to_owned(); + + tokio::spawn(UnixListenerStream::listen(path, agent)); + } +} \ No newline at end of file diff --git a/apps/desktop/desktop_native/ssh_agent/src/agent/ui_requester.rs b/apps/desktop/desktop_native/ssh_agent/src/agent/ui_requester.rs new file mode 100644 index 00000000000..c050a072ece --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/agent/ui_requester.rs @@ -0,0 +1,100 @@ +use std::sync::{atomic::AtomicU32, Arc}; + +use tokio::sync::Mutex; + +use crate::protocol::connection::ConnectionInfo; + +const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +#[derive(Clone)] +pub struct UiRequester { + show_ui_request_tx: tokio::sync::mpsc::Sender, + get_ui_response_rx: Arc>>, +} + +const REQUEST_ID_COUNTER: AtomicU32 = AtomicU32::new(0); + +#[derive(Clone, Debug)] +pub enum UiRequestMessage { + ListRequest { + request_id: u32, + connection_info: ConnectionInfo, + }, + AuthRequest { + request_id: u32, + connection_info: ConnectionInfo, + cipher_id: String, + }, + SignRequest { + request_id: u32, + connection_info: ConnectionInfo, + cipher_id: String, + namespace: String, + }, +} + +impl UiRequestMessage { + pub fn id(&self) -> u32 { + match self { + UiRequestMessage::ListRequest { request_id, .. } => *request_id, + UiRequestMessage::AuthRequest { request_id, .. } => *request_id, + UiRequestMessage::SignRequest { request_id, .. } => *request_id, + } + } +} + +impl UiRequester { + pub fn new( + show_ui_request_tx: tokio::sync::mpsc::Sender, + get_ui_response_rx: Arc>>, + ) -> Self { + Self { + show_ui_request_tx, + get_ui_response_rx, + } + } + + pub async fn request_list(&self, connection_info: &ConnectionInfo) -> bool { + let request_id = REQUEST_ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.request(UiRequestMessage::ListRequest { + request_id, + connection_info: connection_info.clone(), + }) + .await + } + + pub async fn request_sign( + &self, + connection_info: &ConnectionInfo, + cipher_id: String, + namespace: String, + ) -> bool { + let request_id = REQUEST_ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.request(UiRequestMessage::SignRequest { + request_id, + connection_info: connection_info.clone(), + cipher_id, + namespace, + }) + .await + } + + async fn request(&self, request: UiRequestMessage) -> bool { + let mut rx_channel = self.get_ui_response_rx.lock().await.resubscribe(); + self.show_ui_request_tx + .send(request.clone()) + .await + .expect("Should send request to ui"); + + tokio::time::timeout(TIMEOUT, async move { + while let Ok((id, response)) = rx_channel.recv().await { + if id == request.id() { + return response; + } + } + false + }) + .await + .unwrap_or(false) + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/lib.rs b/apps/desktop/desktop_native/ssh_agent/src/lib.rs new file mode 100644 index 00000000000..c73e1c78129 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/lib.rs @@ -0,0 +1,4 @@ +pub mod agent; +pub mod memory; +pub mod protocol; +pub mod transport; diff --git a/apps/desktop/desktop_native/ssh_agent/src/memory/mod.rs b/apps/desktop/desktop_native/ssh_agent/src/memory/mod.rs new file mode 100644 index 00000000000..4794513765f --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/memory/mod.rs @@ -0,0 +1,119 @@ +//! This module implements memory storage for the SSH agent. The current +//! implementation caches the keys in memory, and ideally uses platform secure memory APIs. + +use crate::protocol::types::{KeyPair, PublicKeyWithName}; + +struct LockedSshItem { + public_key: PublicKeyWithName, + cipher_id: String, +} + +#[derive(Clone)] +pub struct UnlockedSshItem { + pub(crate) key_pair: KeyPair, + cipher_id: String, +} + +impl UnlockedSshItem { + pub fn new(key_pair: KeyPair, cipher_id: String) -> Self { + Self { + key_pair, + cipher_id, + } + } +} + +struct LockedKeyStore { + keys: Vec, +} + +struct UnlockedKeyStore { + keys: Vec, +} + +pub(crate) enum KeyStore { + Locked(LockedKeyStore), + Unlocked(UnlockedKeyStore), +} + +impl KeyStore { + pub fn new() -> Self { + KeyStore::Locked(LockedKeyStore { keys: vec![] }) + } + + pub fn lock(&mut self) { + if let KeyStore::Unlocked(unlocked) = self { + let keys = unlocked + .keys + .iter() + .map(|kp| LockedSshItem { + public_key: PublicKeyWithName::new( + kp.key_pair.public_key().clone(), + kp.key_pair.name().to_string(), + ), + cipher_id: kp.cipher_id.clone(), + }) + .collect(); + *self = KeyStore::Locked(LockedKeyStore { keys }); + } + } + + pub fn set_unlocked(&mut self, keys: Vec) { + *self = KeyStore::Unlocked(UnlockedKeyStore { keys }); + } + + pub fn list_keys(&self) -> Vec { + match self { + KeyStore::Locked(locked) => locked + .keys + .iter() + .map(|item| item.public_key.clone()) + .collect(), + KeyStore::Unlocked(unlocked) => unlocked + .keys + .iter() + .map(|item| { + PublicKeyWithName::new( + item.key_pair.public_key().clone(), + item.key_pair.name().to_string(), + ) + }) + .collect(), + } + } + + pub fn get_unlocked_keypair( + &self, + public_key: &crate::protocol::types::PublicKey, + ) -> Option { + if let KeyStore::Unlocked(unlocked) = self { + for item in &unlocked.keys { + if *item.key_pair.public_key() == *public_key { + return Some(item.clone()); + } + } + } + None + } + + pub fn get_cipher_id(&self, public_key: &crate::protocol::types::PublicKey) -> Option { + if let KeyStore::Locked(locked) = self { + for item in &locked.keys { + if item.public_key.key == *public_key { + return Some(item.cipher_id.clone()); + } + } + } else if let KeyStore::Unlocked(unlocked) = self { + for item in &unlocked.keys { + if *item.key_pair.public_key() == *public_key { + return Some(item.cipher_id.clone()); + } + } + } + None + } + + pub fn is_locked(&self) -> bool { + matches!(self, KeyStore::Locked(_)) + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/protocol/async_stream_wrapper.rs b/apps/desktop/desktop_native/ssh_agent/src/protocol/async_stream_wrapper.rs new file mode 100644 index 00000000000..aa7ddbba659 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/protocol/async_stream_wrapper.rs @@ -0,0 +1,45 @@ +use ssh_encoding::Decode; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +use crate::protocol::replies::ReplyFrame; + +pub(crate) struct AsyncStreamWrapper +where + PeerStream: AsyncRead + AsyncWrite + Send + Sync + Unpin, +{ + stream: PeerStream, +} + +impl AsyncStreamWrapper +where + PeerStream: AsyncRead + AsyncWrite + Send + Sync + Unpin, +{ + pub fn new(stream: PeerStream) -> Self { + Self { stream } + } + + pub async fn read_u32(&mut self) -> Result { + let mut buf = [0u8; 4]; + self.stream.read_exact(&mut buf).await?; + u32::decode(&mut buf.as_slice()).map_err(|e| anyhow::anyhow!("Failed to decode u32: {}", e)) + } + + pub async fn read_vec(&mut self, len: usize) -> Result, anyhow::Error> { + let mut buf = vec![0u8; len]; + self.stream.read_exact(&mut buf).await?; + Ok(buf) + } + + pub async fn read_message(&mut self) -> Result, anyhow::Error> { + // An SSH agent message consists of a 32 bit integer denoting the length, followed by that many bytes + let length = self.read_u32().await? as usize; + self.read_vec(length).await + } + + pub async fn write_reply(&mut self, data: &ReplyFrame) -> Result<(), anyhow::Error> { + let raw_frame: Vec = data.into(); + self.stream.write_u32(raw_frame.len() as u32).await?; + self.stream.write_all(&raw_frame).await?; + Ok(()) + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/protocol/connection.rs b/apps/desktop/desktop_native/ssh_agent/src/protocol/connection.rs new file mode 100644 index 00000000000..171e5a45fd2 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/protocol/connection.rs @@ -0,0 +1,64 @@ +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + +use crate::{ + protocol::types::{PublicKey, SessionId}, + transport::peer_info::PeerInfo, +}; + +// The connection id is global and increasing throughout the lifetime of the desktop app +static CONNECTION_COUNTER: AtomicU32 = AtomicU32::new(0); + +#[derive(Clone, Debug)] +pub struct ConnectionInfo { + id: u32, + peer_info: PeerInfo, + + is_forwarding: bool, + host_key: Option, + session_identifier: Option, +} + +impl ConnectionInfo { + pub fn new(peer_info: PeerInfo) -> Self { + let id = CONNECTION_COUNTER.fetch_add(1, Ordering::SeqCst); + Self { + id, + peer_info, + is_forwarding: false, + host_key: None, + session_identifier: None, + } + } + + pub fn id(&self) -> u32 { + self.id + } + + pub fn peer_info(&self) -> &PeerInfo { + &self.peer_info + } + + pub fn is_forwarding(&self) -> bool { + self.is_forwarding + } + + pub fn set_forwarding(&mut self) { + self.is_forwarding = true; + } + + pub fn host_key(&self) -> Option<&PublicKey> { + self.host_key.as_ref() + } + + pub fn set_host_key(&mut self, host_key: PublicKey) { + self.host_key = Some(host_key); + } + + pub fn session_identifier(&self) -> Option<&SessionId> { + self.session_identifier.as_ref() + } + + pub fn set_session_identifier(&mut self, session_id: SessionId) { + self.session_identifier = Some(session_id); + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/protocol/key_store.rs b/apps/desktop/desktop_native/ssh_agent/src/protocol/key_store.rs new file mode 100644 index 00000000000..764f5def3f6 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/protocol/key_store.rs @@ -0,0 +1,33 @@ +use crate::{ + memory::UnlockedSshItem, + protocol::{ + connection::ConnectionInfo, + types::{KeyPair, PublicKey, PublicKeyWithName}, + }, +}; + +pub(crate) trait Agent: Send + Sync { + async fn request_can_list( + &self, + connection_info: &ConnectionInfo, + ) -> Result; + async fn list_keys(&self) -> Result, anyhow::Error>; + async fn request_can_sign( + &self, + public_key: &PublicKey, + connection_info: &ConnectionInfo, + ) -> Result; + async fn find_ssh_item( + &self, + public_key: &PublicKey, + ) -> Result, anyhow::Error>; +} + +#[cfg(test)] +const PRIVATE_ED25519_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBDUDO7ChZIednIJxGA95T/ZTyREftahrFEJM/eeC8mmAAAAKByJoOYciaD +mAAAAAtzc2gtZWQyNTUxOQAAACBDUDO7ChZIednIJxGA95T/ZTyREftahrFEJM/eeC8mmA +AAAEBQK5JpycFzP/4rchfpZhbdwxjTwHNuGx2/kvG4i6xfp0NQM7sKFkh52cgnEYD3lP9l +PJER+1qGsUQkz954LyaYAAAAHHF1ZXh0ZW5ATWFjQm9vay1Qcm8tMTYubG9jYWwB +-----END OPENSSH PRIVATE KEY-----"; diff --git a/apps/desktop/desktop_native/ssh_agent/src/protocol/mod.rs b/apps/desktop/desktop_native/ssh_agent/src/protocol/mod.rs new file mode 100644 index 00000000000..c880ba28389 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/protocol/mod.rs @@ -0,0 +1,8 @@ +//! An implementation of ssh agent +pub(crate) mod async_stream_wrapper; +pub(crate) mod connection; +pub(crate) mod key_store; +pub(crate) mod protocol; +pub(crate) mod replies; +pub(crate) mod requests; +pub mod types; diff --git a/apps/desktop/desktop_native/ssh_agent/src/protocol/protocol.rs b/apps/desktop/desktop_native/ssh_agent/src/protocol/protocol.rs new file mode 100644 index 00000000000..6872b62c4c2 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/protocol/protocol.rs @@ -0,0 +1,121 @@ +use futures::{Stream, StreamExt}; +use tokio::{ + io::{AsyncRead, AsyncWrite}, + select, +}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info}; + +use crate::{ + protocol::{ + async_stream_wrapper::AsyncStreamWrapper, + connection::ConnectionInfo, + key_store::Agent, + replies::{AgentFailure, IdentitiesReply, SshSignReply}, + requests::Request, + }, + transport::peer_info::PeerInfo, +}; + +pub async fn serve_listener( + mut listener: Listener, + cancellation_token: CancellationToken, + agent: impl Agent, +) -> Result<(), anyhow::Error> +where + PeerStream: AsyncRead + AsyncWrite + Send + Sync + Unpin + 'static, + Listener: Stream> + Unpin, +{ + loop { + select! { + _ = cancellation_token.cancelled() => { + break; + } + Some(Ok((stream, peer_info))) = listener.next() => { + let mut stream = AsyncStreamWrapper::new(stream); + let connection_info = ConnectionInfo::new(peer_info); + info!("Accepted connection {} from {:?}", connection_info.id(), connection_info.peer_info()); + if let Err(e) = handle_connection(&agent, &mut stream, &connection_info).await { + error!("Error handling request: {e}"); + } + } + } + } + Ok(()) +} + +async fn handle_connection( + agent: &impl Agent, + stream: &mut AsyncStreamWrapper, + connection: &ConnectionInfo, +) -> Result<(), anyhow::Error> { + loop { + let span = tracing::info_span!("Connection", connection_id = connection.id()); + span.in_scope(|| info!("Waiting for request")); + + let request = match stream.read_message().await { + Ok(request) => request, + Err(_) => { + span.in_scope(|| info!("Connection closed")); + break; + } + }; + + span.in_scope(|| info!("Request {:x?}", request)); + let Ok(request) = Request::try_from(request.as_slice()) else { + span.in_scope(|| error!("Failed to parse request")); + stream.write_reply(&AgentFailure::new().into()).await?; + continue; + }; + + let response = match request { + Request::IdentitiesRequest => { + span.in_scope(|| info!("Received IdentitiesRequest")); + + let Ok(true) = agent.request_can_list(connection).await else { + span.in_scope(|| error!("List keys request denied by UI")); + return stream.write_reply(&AgentFailure::new().into()).await; + }; + + IdentitiesReply::new(agent.list_keys().await?) + .encode() + .map_err(|e| anyhow::anyhow!("Failed to encode identities reply: {e}")) + } + Request::SignRequest(sign_request) => { + span.in_scope(|| info!("Received SignRequest {:?}", sign_request)); + + let Ok(true) = agent + .request_can_sign(sign_request.public_key(), connection) + .await + else { + span.in_scope(|| error!("Sign request denied by UI")); + return stream.write_reply(&AgentFailure::new().into()).await; + }; + + let ssh_item = agent + .find_ssh_item(sign_request.public_key()) + .await + .ok() + .flatten(); + + if let Some(ssh_item) = ssh_item { + SshSignReply::new( + &ssh_item.key_pair.private_key(), + &sign_request.payload_to_sign(), + sign_request.signing_scheme(), + ) + .encode() + } else { + Ok(AgentFailure::new().into()) + } + .map_err(|e| anyhow::anyhow!("Failed to create sign reply: {e}")) + } + }?; + + let encoded: Vec = (&response).into(); + span.in_scope(|| info!("Sending response")); + span.in_scope(|| info!("Response {:x?}", encoded)); + stream.write_reply(&response).await?; + } + Ok(()) +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/protocol/replies.rs b/apps/desktop/desktop_native/ssh_agent/src/protocol/replies.rs new file mode 100644 index 00000000000..06648e74b84 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/protocol/replies.rs @@ -0,0 +1,131 @@ +use num_enum::{IntoPrimitive, TryFromPrimitive}; +use ssh_encoding::Encode; + +use crate::protocol::types::{ + KeyPair, PrivateKey, PublicKey, PublicKeyWithName, RsaSigningScheme, Signature, +}; + +/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-protocol-messages` +/// The different types of replies that the SSH agent can send to a client. +#[allow(non_camel_case_types)] +#[derive(Debug, Eq, PartialEq, TryFromPrimitive, IntoPrimitive, Default)] +#[repr(u8)] +pub enum ReplyType { + /// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-generic-server-responses` + /// Generic response indicating failure + /// Unsupported extensions must be replied to with SSH_AGENT_FAILURE. + SSH_AGENT_FAILURE = 5, + /// Generic response indicating success + SSH_AGENT_SUCCESS = 6, + + /// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-extension-mechanism` + /// Failure within an extension are replied to with this message. + SSH_AGENT_EXTENSION_FAILURE = 28, + /// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-requesting-a-list-of-keys` + /// Response to `RequestType::SSH_AGENTC_REQUEST_IDENTITIES` + SSH_AGENT_IDENTITIES_ANSWER = 12, + /// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-private-key-operations`` + /// Response to `RequestType::SSH_AGENTC_SIGN_REQUEST` + SSH_AGENT_SIGN_RESPONSE = 14, + /// Invalid reply type + #[default] + SSH_AGENT_INVALID = 0, +} + +/// A reply is structured as a single byte indicating the type, followed by a +/// payload that is structured according to the type. +pub struct ReplyFrame { + /// The serialized frame structured as + /// reply_type|payload + raw_frame: Vec, +} + +impl ReplyFrame { + pub fn new(reply: ReplyType, payload: Vec) -> Self { + let mut raw_frame = Vec::new(); + Into::::into(reply) + .encode(&mut raw_frame) + .expect("Encoding into Vec cannot fail"); + raw_frame.extend_from_slice(&payload); + Self { raw_frame } + } +} + +impl Into> for &ReplyFrame { + fn into(self) -> Vec { + self.raw_frame.clone() + } +} + +pub(crate) struct IdentitiesReply { + keys: Vec, +} + +impl IdentitiesReply { + pub fn new(keys: Vec) -> Self { + Self { keys } + } + + /// https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-requesting-a-list-of-keys + /// The reply to a request is structured as: + /// + /// byte SSH_AGENT_IDENTITIES_ANSWER + /// uint32 nkeys + /// [ + /// string public key blob + /// string comment (a utf-8 string) + /// ... (nkeys times) + /// ] + pub fn encode(&self) -> Result { + Ok(ReplyFrame::new(ReplyType::SSH_AGENT_IDENTITIES_ANSWER, { + let mut reply_message = Vec::new(); + (self.keys.len() as u32).encode(&mut reply_message)?; + for key in &self.keys { + key.key.encode(&mut reply_message)?; + key.name.encode(&mut reply_message)?; + } + reply_message + })) + } +} + +pub(crate) struct SshSignReply(Signature); + +impl SshSignReply { + pub fn new( + private_key: &PrivateKey, + data: &[u8], + requested_signing_scheme: Option, + ) -> Self { + Self( + // Note, this should take into account the extension / signing scheme. + private_key.sign(data, requested_signing_scheme).unwrap(), + ) + } + + /// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-private-key-operations` + /// A reply to a sign request is structured as: + /// + /// byte SSH_AGENT_SIGN_RESPONSE + /// string signature blob + pub fn encode(&self) -> Result { + Ok(ReplyFrame::new(ReplyType::SSH_AGENT_SIGN_RESPONSE, { + let mut reply_payload = Vec::new(); + self.0.encode().unwrap().encode(&mut reply_payload)?; + reply_payload + })) + } +} + +pub(crate) struct AgentFailure; +impl AgentFailure { + pub fn new() -> Self { + Self {} + } +} + +impl From for ReplyFrame { + fn from(_value: AgentFailure) -> Self { + ReplyFrame::new(ReplyType::SSH_AGENT_EXTENSION_FAILURE, Vec::new()) + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/protocol/requests.rs b/apps/desktop/desktop_native/ssh_agent/src/protocol/requests.rs new file mode 100644 index 00000000000..930c0a1d99a --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/protocol/requests.rs @@ -0,0 +1,334 @@ +//! This file contains parsing logic for requests sent to the SSH agent. +//! Parsers must include test vectors recorded from real SSH operations. + +use byteorder::ReadBytesExt; +use bytes::{Buf, Bytes}; +use log::info; +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +use crate::protocol::types::{PublicKey, RsaSigningScheme, SessionId, Signature}; + +/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-protocol-messages` +/// The different types of requests that a client can send to the SSH agent. +#[allow(non_camel_case_types)] +#[derive(Debug, Eq, PartialEq, TryFromPrimitive, IntoPrimitive, Default)] +#[repr(u8)] +pub(crate) enum RequestType { + /// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-requesting-a-list-of-keys` + /// Request the list of keys the agent is holding + SSH_AGENTC_REQUEST_IDENTITIES = 11, + /// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-private-key-operations` + /// Sign an authentication request or SSHSIG request + SSH_AGENTC_SIGN_REQUEST = 13, + /// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-extension-mechanism` + /// Handle vendor specific extensions such as session binding + SSH_AGENTC_EXTENSION = 27, + /// An invalid request + #[default] + SSH_AGENTC_INVALID = 0, +} + +/// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-signature-flags` +/// +/// There are currently two flags defined which control which signature method +/// are used for RSA. These have no effect on other key types. If neither of these is defined, +/// RSA is used with SHA1, however this is deprecated and should not be used. +#[allow(non_camel_case_types)] +#[derive(Debug, Eq, PartialEq, TryFromPrimitive, IntoPrimitive)] +#[repr(u8)] +pub(crate) enum SshSignFlags { + /// Sign with SHA256 if RSA is used + SSH_AGENT_RSA_SHA2_256 = 2, + /// Sign with SHA512 if RSA is used + SSH_AGENT_RSA_SHA2_512 = 4, +} + +#[derive(Debug)] +pub(crate) enum Request { + /// Request the list of keys the agent is holding + IdentitiesRequest, + /// Sign an authentication request or SSHSIG request + SignRequest(SshSignRequest), +} + +impl TryFrom<&[u8]> for Request { + type Error = anyhow::Error; + + // A protocol message consists of + // + // uint32 length + // byte type + // byte[length-1] contents + // + // The length is already stripped of in the `async_stream_wrapper::read_message`, so + // the message is just type|contents. + fn try_from(message: &[u8]) -> Result { + if message.is_empty() { + return Err(anyhow::anyhow!("Empty request")); + } + + let r#type = RequestType::try_from_primitive(message[0])?; + let contents = message[1..].to_vec(); + + match r#type { + RequestType::SSH_AGENTC_REQUEST_IDENTITIES => Ok(Request::IdentitiesRequest), + RequestType::SSH_AGENTC_SIGN_REQUEST => { + Ok(Request::SignRequest(contents.as_slice().try_into()?)) + } + RequestType::SSH_AGENTC_EXTENSION => { + // Only support session bind for now + let _extension_request: SessionBindRequest = contents.as_slice().try_into()?; + info!("Received extension request: {:?}", _extension_request); + Err(anyhow::anyhow!("Unsupported extension request")) + } + _ => Err(anyhow::anyhow!("Unsupported request type: {:?}", r#type)), + } + } +} + +/// A sign request requests the agent to sign a blob of data with a specific key. The key is +/// referenced by its public key blob. The payload usually has a specific structure for auth +/// requests or SSHSIG requests. There are also flags supported that control signing behavior. +#[derive(Debug)] +pub(crate) struct SshSignRequest { + public_key: PublicKey, + payload_to_sign: Vec, + parsed_sign_request: ParsedSignRequest, + flags: u32, +} + +impl SshSignRequest { + pub fn is_flag_set(&self, flag: SshSignFlags) -> bool { + (self.flags & (flag as u32)) != 0 + } + + pub fn signing_scheme(&self) -> Option { + if self.is_flag_set(SshSignFlags::SSH_AGENT_RSA_SHA2_256) { + Some(RsaSigningScheme::Pkcs1v15Sha256) + } else if self.is_flag_set(SshSignFlags::SSH_AGENT_RSA_SHA2_512) { + Some(RsaSigningScheme::Pkcs1v15Sha512) + } else { + None + } + } + + pub fn public_key(&self) -> &PublicKey { + &self.public_key + } + + pub fn payload_to_sign(&self) -> &[u8] { + &self.payload_to_sign + } + + pub fn parsed_payload(&self) -> &ParsedSignRequest { + &self.parsed_sign_request + } +} + +impl TryFrom<&[u8]> for SshSignRequest { + type Error = anyhow::Error; + + /// `https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#name-private-key-operations` + /// A private key operation is structured as follows: + /// + /// byte SSH_AGENTC_SIGN_REQUEST + /// string key blob + /// string data + /// uint32 flags + /// + /// In this case, the message already has the leading byte stripped off by the previous parsing code. + fn try_from(mut message: &[u8]) -> Result { + let public_key_blob = read_bytes(&mut message)?.to_vec(); + let data = read_bytes(&mut message)?; + let flags = message + .read_u32::() + .map_err(|e| anyhow::anyhow!("Failed to read flags from sign request: {e}"))?; + + Ok(SshSignRequest { + public_key: public_key_blob.try_into()?, + payload_to_sign: data.clone(), + parsed_sign_request: data.as_slice().try_into()?, + flags, + }) + } +} + +#[derive(Debug)] +pub(crate) enum ParsedSignRequest { + SshSigRequest { namespace: String }, + SignRequest {}, +} + +impl<'a> TryFrom<&'a [u8]> for ParsedSignRequest { + type Error = anyhow::Error; + + fn try_from(data: &'a [u8]) -> Result { + let mut data = Bytes::copy_from_slice(data); + let magic_header = "SSHSIG"; + let header = data.split_to(magic_header.len()); + + // sshsig; based on https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig + if header == magic_header.as_bytes() { + let _version = data.get_u32(); + + // read until null byte + let namespace = data + .into_iter() + .take_while(|&x| x != 0) + .collect::>(); + let namespace = + String::from_utf8(namespace).map_err(|_| anyhow::anyhow!("Invalid namespace"))?; + + Ok(ParsedSignRequest::SshSigRequest { namespace }) + } else { + Ok(ParsedSignRequest::SignRequest {}) + } + } +} + +fn read_bool(data: &mut &[u8]) -> Result { + let byte = data + .read_u8() + .map_err(|e| anyhow::anyhow!("Failed to read bool: {e}"))?; + match byte { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(anyhow::anyhow!("Invalid boolean value")), + } +} + +/// A helper function to read a length prefixed byte array +pub(super) fn read_bytes(data: &mut &[u8]) -> Result, anyhow::Error> { + let length = data + .read_u32::() + .map_err(|e| anyhow::anyhow!("Failed to read length: {e}"))?; + let mut buf = vec![ + 0; + length + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid length"))? + ]; + std::io::Read::read_exact(data, &mut buf) + .map_err(|e| anyhow::anyhow!("Failed to read exact bytes: {e}"))?; + Ok(buf) +} + +enum Extension { + SessionBind, + Unsupported, +} + +impl From for Extension { + fn from(value: String) -> Self { + match value.as_str() { + "session-bind@openssh.com" => Extension::SessionBind, + _ => Extension::Unsupported, + } + } +} + +/// https://www.openssh.com/agent-restrict.html +/// byte SSH_AGENTC_EXTENSION (0x1b) +/// string session-bind@openssh.com +/// string hostkey +/// string session identifier +/// string signature +/// bool is_forwarding +#[derive(Debug)] +struct SessionBindRequest { + host_key: PublicKey, + session_id: SessionId, + signature: Signature, + is_forwarding: bool, +} + +impl TryFrom<&[u8]> for SessionBindRequest { + type Error = anyhow::Error; + + fn try_from(mut message: &[u8]) -> Result { + let extension_name = String::from_utf8(read_bytes(&mut message)?) + .map_err(|_| anyhow::anyhow!("Invalid extension name"))?; + match Extension::from(extension_name) { + Extension::SessionBind => { + let host_key = read_bytes(&mut message)?.try_into()?; + let session_id = read_bytes(&mut message)?; + let signature = read_bytes(&mut message)?; + let is_forwarding = read_bool(&mut message)?; + + Ok(SessionBindRequest { + host_key, + session_id: SessionId::from(session_id), + signature: Signature::try_from(signature.as_slice())?, + is_forwarding, + }) + } + Extension::Unsupported => Err(anyhow::anyhow!("Unsupported extension")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Protocol Request Messages + const TEST_VECTOR_REQUEST_LIST: &[u8] = &[11]; + const TEST_VECTOR_REQUEST_SIGN: &[u8] = &[ + 13, 0, 0, 0, 51, 0, 0, 0, 11, 115, 115, 104, 45, 101, 100, 50, 53, 53, 49, 57, 0, 0, 0, 32, + 29, 223, 117, 159, 179, 182, 138, 116, 19, 26, 175, 28, 112, 116, 125, 161, 73, 110, 213, + 155, 210, 209, 216, 151, 51, 134, 209, 95, 89, 119, 233, 120, 0, 0, 0, 146, 0, 0, 0, 32, + 181, 207, 94, 63, 132, 40, 223, 192, 113, 235, 146, 168, 148, 99, 10, 232, 43, 52, 136, + 115, 113, 29, 242, 9, 69, 130, 8, 140, 111, 100, 189, 9, 50, 0, 0, 0, 3, 103, 105, 116, 0, + 0, 0, 14, 115, 115, 104, 45, 99, 111, 110, 110, 101, 99, 116, 105, 111, 110, 0, 0, 0, 9, + 112, 117, 98, 108, 105, 99, 107, 101, 121, 1, 0, 0, 0, 11, 115, 115, 104, 45, 101, 100, 50, + 53, 53, 49, 57, 0, 0, 0, 51, 0, 0, 0, 11, 115, 115, 104, 45, 101, 100, 50, 53, 53, 49, 57, + 0, 0, 0, 32, 29, 223, 117, 159, 179, 182, 138, 116, 19, 26, 175, 28, 112, 116, 125, 161, + 73, 110, 213, 155, 210, 209, 216, 151, 51, 134, 209, 95, 89, 119, 233, 120, 0, 0, 0, 0, + ]; + + // Inner messages for the sign request + const TEST_VECTOR_REQUEST_SIGN_AUTHENTICATE: &[u8] = &[ + 0, 0, 0, 32, 181, 207, 94, 63, 132, 40, 223, 192, 113, 235, 146, 168, 148, 99, 10, 232, 43, + 52, 136, 115, 113, 29, 242, 9, 69, 130, 8, 140, 111, 100, 189, 9, 50, 0, 0, 0, 3, 103, 105, + 116, 0, 0, 0, 14, 115, 115, 104, 45, 99, 111, 110, 110, 101, 99, 116, 105, 111, 110, 0, 0, + 0, 9, 112, 117, 98, 108, 105, 99, 107, 101, 121, 1, 0, 0, 0, 11, 115, 115, 104, 45, 101, + 100, 50, 53, 53, 49, 57, 0, 0, 0, 51, 0, 0, 0, 11, 115, 115, 104, 45, 101, 100, 50, 53, 53, + 49, 57, 0, 0, 0, 32, 29, 223, 117, 159, 179, 182, 138, 116, 19, 26, 175, 28, 112, 116, 125, + 161, 73, 110, 213, 155, 210, 209, 216, 151, 51, 134, 209, 95, 89, 119, 233, 120, + ]; + const TEST_VECTOR_REQUEST_SIGN_SSHSIG_GIT: &[u8] = &[ + 83, 83, 72, 83, 73, 71, 0, 0, 0, 3, 103, 105, 116, 0, 0, 0, 0, 0, 0, 0, 6, 115, 104, 97, + 53, 49, 50, 0, 0, 0, 64, 30, 64, 7, 140, 213, 231, 218, 138, 18, 144, 116, 7, 182, 82, 23, + 205, 39, 91, 32, 189, 66, 61, 26, 22, 93, 175, 87, 211, 52, 127, 62, 223, 177, 70, 125, 65, + 44, 147, 16, 177, 89, 5, 162, 230, 184, 137, 234, 155, 152, 93, 161, 105, 254, 223, 93, + 178, 118, 238, 176, 38, 145, 49, 56, 92, + ]; + + #[test] + fn test_parse_identities_request() { + let req = Request::try_from(TEST_VECTOR_REQUEST_LIST).expect("Should parse"); + assert!(matches!(req, Request::IdentitiesRequest)); + } + + #[test] + fn test_parse_sign_request() { + let req = Request::try_from(TEST_VECTOR_REQUEST_SIGN).expect("Should parse"); + assert!(matches!(req, Request::SignRequest { .. })); + } + + #[test] + fn test_parse_sign_authenticate_request() { + let req = ParsedSignRequest::try_from(TEST_VECTOR_REQUEST_SIGN_AUTHENTICATE) + .expect("Should parse"); + assert!(matches!(req, ParsedSignRequest::SignRequest {})); + } + + #[test] + fn test_parse_sign_sshsig_git_request() { + let req = + ParsedSignRequest::try_from(TEST_VECTOR_REQUEST_SIGN_SSHSIG_GIT).expect("Should parse"); + assert!( + matches!(req, ParsedSignRequest::SshSigRequest { namespace } if namespace == "git".to_string()) + ); + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/protocol/types.rs b/apps/desktop/desktop_native/ssh_agent/src/protocol/types.rs new file mode 100644 index 00000000000..a983125ae08 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/protocol/types.rs @@ -0,0 +1,380 @@ +use std::fmt::Debug; +use std::fmt::Formatter; + +use base64::prelude::BASE64_STANDARD; +use base64::Engine as _; +use rsa::Pkcs1v15Sign; +use ssh_key::private::KeypairData; +use ssh_key::{Algorithm, EcdsaCurve, HashAlg}; + +use rsa::sha2::{self, Digest}; +use signature::Signer; +use ssh_encoding::Encode; +use ssh_key::private::{EcdsaKeypair, Ed25519Keypair, RsaKeypair}; + +use crate::protocol::requests::read_bytes; + +#[derive(Clone)] +pub struct PublicKeyWithName { + pub key: PublicKey, + pub name: String, +} + +impl PublicKeyWithName { + pub fn new(key: PublicKey, name: String) -> Self { + Self { key, name } + } +} + +/// A named SSH key pair consisting of a public and private key, and a name (comment). +#[derive(Debug, Clone)] +pub struct KeyPair { + private_key: PrivateKey, + public_key: PublicKey, + name: String, +} + +impl KeyPair { + pub fn new(private_key: PrivateKey, name: String) -> Self { + KeyPair { + public_key: private_key.public_key(), + private_key, + name, + } + } + + pub fn public_key(&self) -> &PublicKey { + &self.public_key + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn private_key(&self) -> &PrivateKey { + &self.private_key + } +} + +/// A detached SSH signature, containing the signature scheme and blob. +pub struct Signature(ssh_key::Signature); + +impl Signature { + pub(crate) fn encode(&self) -> Result, ssh_encoding::Error> { + let mut buffer = Vec::new(); + self.0.algorithm().as_str().encode(&mut buffer)?; + self.0.as_bytes().encode(&mut buffer)?; + Ok(buffer) + } + + pub(crate) fn verify( + &self, + public_key: &PublicKey, + data: &[u8], + ) -> Result { + let public_key_parsed = + ssh_key::PublicKey::from_bytes(public_key.blob()).map_err(|e| anyhow::anyhow!(e))?; + + match self.0.algorithm() { + Algorithm::Ed25519 => { + let verifying_key = public_key_parsed + .key_data() + .ed25519() + .ok_or(anyhow::anyhow!("Public key is not Ed25519"))?; + let signature = &ed25519_dalek::Signature::from_slice(self.0.as_bytes())?; + ed25519_dalek::VerifyingKey::from_bytes(&verifying_key.0) + .map_err(|e| anyhow::anyhow!("Failed to parse Ed25519 key: {e}"))? + .verify_strict(data, signature)?; + return Ok(true); + } + Algorithm::Rsa { hash: Some(alg) } => { + let verifying_key: Result = public_key_parsed + .key_data() + .rsa() + .ok_or(anyhow::anyhow!("Public key is not RSA"))? + .try_into(); + let verifying_key = + verifying_key.map_err(|e| anyhow::anyhow!("Failed to parse RSA key: {e}"))?; + + match alg { + HashAlg::Sha256 => verifying_key.verify( + Pkcs1v15Sign::new::(), + sha2::Sha256::digest(data).as_slice(), + self.0.as_bytes(), + ), + HashAlg::Sha512 => verifying_key.verify( + Pkcs1v15Sign::new::(), + sha2::Sha512::digest(data).as_slice(), + self.0.as_bytes(), + ), + _ => return Ok(false), + } + .map_err(|e| anyhow::anyhow!("RSA signature verification failed: {e}"))?; + return Ok(true); + } + Algorithm::Ecdsa { curve } => { + let sec1_bytes = public_key_parsed + .key_data() + .ecdsa() + .unwrap() + .as_sec1_bytes(); + match curve { + EcdsaCurve::NistP256 => { + use p256::ecdsa::signature::Verifier; + p256::ecdsa::VerifyingKey::from_sec1_bytes(sec1_bytes)? + .verify( + data, + &p256::ecdsa::Signature::from_slice(self.0.as_bytes())?, + ) + .map_err(|e| { + anyhow::anyhow!("ECDSA P-256 signature verification failed: {e}") + })?; + return Ok(true); + } + EcdsaCurve::NistP384 => { + use p384::ecdsa::signature::Verifier; + p384::ecdsa::VerifyingKey::from_sec1_bytes(sec1_bytes)? + .verify( + data, + &p384::ecdsa::Signature::from_slice(self.0.as_bytes())?, + ) + .map_err(|e| { + anyhow::anyhow!("ECDSA P-384 signature verification failed: {e}") + })?; + return Ok(true); + } + EcdsaCurve::NistP521 => { + use p521::ecdsa::signature::Verifier; + p521::ecdsa::VerifyingKey::from_sec1_bytes(sec1_bytes)? + .verify( + data, + &p521::ecdsa::Signature::from_slice(self.0.as_bytes())?, + ) + .map_err(|e| { + anyhow::anyhow!("ECDSA P-521 signature verification failed: {e}") + })?; + return Ok(true); + } + _ => return Ok(false), + } + } + _ => return Ok(false), + } + } +} + +impl Debug for Signature { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "SshSignature(\"{} {}\")", + self.0.algorithm().as_str(), + BASE64_STANDARD.encode(self.0.as_bytes()) + ) + } +} + +impl TryFrom<&[u8]> for Signature { + type Error = anyhow::Error; + fn try_from(bytes: &[u8]) -> Result { + let mut buffer = bytes; + let alg = Algorithm::new( + &String::from_utf8_lossy(read_bytes(&mut buffer).unwrap().as_slice()).to_string(), + )?; + let sig = read_bytes(&mut buffer).unwrap(); + Ok(Signature(ssh_key::Signature::new(alg, sig)?)) + } +} + +#[derive(Clone)] +pub(super) struct SessionId(Vec); + +impl From> for SessionId { + fn from(v: Vec) -> Self { + SessionId(v) + } +} + +impl Debug for SessionId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "SessionId(\"{}\")", BASE64_STANDARD.encode(&self.0)) + } +} + +#[allow(unused)] +pub enum RsaSigningScheme { + Pkcs1v15Sha512, + Pkcs1v15Sha256, + // Sha1 is not supported because it is deprecated +} + +impl RsaSigningScheme { + fn to_hash_alg(&self) -> HashAlg { + match self { + RsaSigningScheme::Pkcs1v15Sha512 => HashAlg::Sha512, + RsaSigningScheme::Pkcs1v15Sha256 => HashAlg::Sha256, + } + } +} + +#[derive(Clone)] +pub enum PrivateKey { + Ed25519(Ed25519Keypair), + Rsa(RsaKeypair), + Ecdsa(EcdsaKeypair), +} + +impl Debug for PrivateKey { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + PrivateKey::Ed25519(_key) => write!(f, "Ed25519()"), + PrivateKey::Rsa(_key) => write!(f, "Rsa()"), + PrivateKey::Ecdsa(_key) => write!(f, "Ecdsa()"), + } + } +} + +impl PrivateKey { + fn public_key(&self) -> PublicKey { + let private_key = match self { + PrivateKey::Ed25519(key) => ssh_key::private::PrivateKey::from(key.to_owned()), + PrivateKey::Rsa(key) => ssh_key::private::PrivateKey::from(key.to_owned()), + PrivateKey::Ecdsa(key) => ssh_key::private::PrivateKey::from(key.to_owned()), + }; + + private_key + .public_key() + .to_bytes() + .map(PublicKey::try_from) + .expect("Key is always valid") + .expect("Key is always valid") + } + + pub(crate) fn sign( + &self, + data: &[u8], + scheme: Option, + ) -> Result { + let private_key = match self { + PrivateKey::Ed25519(key) => ssh_key::private::PrivateKey::from(key.clone()), + PrivateKey::Rsa(key) => ssh_key::private::PrivateKey::from(key.clone()), + PrivateKey::Ecdsa(key) => ssh_key::private::PrivateKey::from(key.clone()), + }; + let result: Result = + if let KeypairData::Rsa(keypair) = private_key.key_data() { + (keypair, scheme.map(|s| s.to_hash_alg())).try_sign(data) + } else { + private_key.try_sign(data) + }; + result.map(Signature).map_err(|e| anyhow::anyhow!(e)) + } +} + +impl TryFrom for PrivateKey { + type Error = anyhow::Error; + + fn try_from(pem: String) -> Result { + let parsed_key = parse_key_safe(&pem)?; + Self::try_from(parsed_key) + } +} + +impl TryFrom for PrivateKey { + type Error = anyhow::Error; + + fn try_from(key: ssh_key::private::PrivateKey) -> Result { + match key.algorithm() { + ssh_key::Algorithm::Ed25519 => { + Ok(Self::Ed25519(key.key_data().ed25519().unwrap().to_owned())) + } + ssh_key::Algorithm::Rsa { hash: _ } => { + Ok(Self::Rsa(key.key_data().rsa().unwrap().to_owned())) + } + ssh_key::Algorithm::Ecdsa { curve: _ } => { + Ok(Self::Ecdsa(key.key_data().ecdsa().unwrap().to_owned())) + } + _ => Err(anyhow::anyhow!("Unsupported key type")), + } + } +} + +#[derive(Clone, PartialEq)] +pub struct PublicKey { + alg: String, + blob: Vec, +} + +impl PublicKey { + pub(super) fn encode( + &self, + writer: &mut impl ssh_encoding::Writer, + ) -> Result<(), ssh_encoding::Error> { + let mut buf = Vec::new(); + self.alg().as_bytes().encode(&mut buf)?; + self.blob().encode(&mut buf)?; + buf.encode(writer)?; + Ok(()) + } + fn try_read_from(mut bytes: &[u8]) -> Result { + let alg = String::from_utf8_lossy(read_bytes(&mut bytes)?.as_slice()).to_string(); + let blob = read_bytes(&mut bytes)?; + Ok(PublicKey { alg, blob }) + } +} + +impl TryFrom for ssh_key::PublicKey { + type Error = anyhow::Error; + fn try_from(key: PublicKey) -> Result { + ssh_key::PublicKey::from_bytes(&key.blob).map_err(|e| anyhow::anyhow!(e)) + } +} + +impl TryFrom> for PublicKey { + type Error = anyhow::Error; + fn try_from(bytes: Vec) -> Result { + PublicKey::try_read_from(&bytes) + } +} + +impl Debug for PublicKey { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "SshPublicKey(\"{} {}\")", self.alg(), self.blob_b64()) + } +} + +impl PublicKey { + fn alg(&self) -> &str { + &self.alg + } + + fn blob(&self) -> &[u8] { + &self.blob + } + + fn blob_b64(&self) -> String { + BASE64_STANDARD.encode(self.blob()) + } +} + +fn parse_key_safe(pem: &str) -> Result { + match ssh_key::private::PrivateKey::from_openssh(pem) { + Ok(key) => match key.public_key().to_bytes() { + Ok(_) => Ok(key), + Err(e) => Err(anyhow::Error::msg(format!( + "Failed to parse public key: {e}" + ))), + }, + Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {e}"))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_keypair_creation() { + let private_key = PrivateKey::try_from(PRIVATE_ED25519_KEY.to_string()) + .expect("Test key is always valid"); + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/transport/mod.rs b/apps/desktop/desktop_native/ssh_agent/src/transport/mod.rs new file mode 100644 index 00000000000..74fbc4af514 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/transport/mod.rs @@ -0,0 +1,4 @@ +#[cfg(windows)] +mod named_pipe_listener_stream; +pub mod peer_info; +pub mod unix_listener_stream; diff --git a/apps/desktop/desktop_native/ssh_agent/src/transport/named_pipe_listener_stream.rs b/apps/desktop/desktop_native/ssh_agent/src/transport/named_pipe_listener_stream.rs new file mode 100644 index 00000000000..cb10e873a33 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/transport/named_pipe_listener_stream.rs @@ -0,0 +1,102 @@ +use futures::Stream; +use std::os::windows::prelude::AsRawHandle as _; +use std::{ + io, + pin::Pin, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + task::{Context, Poll}, +}; +use tokio::{ + net::windows::named_pipe::{NamedPipeServer, ServerOptions}, + select, +}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info}; +use windows::Win32::{Foundation::HANDLE, System::Pipes::GetNamedPipeClientProcessId}; + +use crate::ssh_agent::peerinfo::{self, models::PeerInfo}; + +const PIPE_NAME: &str = r"\\.\pipe\openssh-ssh-agent"; + +#[pin_project::pin_project] +pub struct NamedPipeServerStream { + rx: tokio::sync::mpsc::Receiver<(NamedPipeServer, PeerInfo)>, +} + +impl NamedPipeServerStream { + // FIXME: Remove unwraps! They panic and terminate the whole application. + #[allow(clippy::unwrap_used)] + pub fn new(cancellation_token: CancellationToken, is_running: Arc) -> Self { + let (tx, rx) = tokio::sync::mpsc::channel(16); + tokio::spawn(async move { + info!("Creating named pipe server on {}", PIPE_NAME); + let mut listener = match ServerOptions::new().create(PIPE_NAME) { + Ok(pipe) => pipe, + Err(e) => { + error!(error = %e, "Encountered an error creating the first pipe. The system's openssh service must likely be disabled"); + cancellation_token.cancel(); + is_running.store(false, Ordering::Relaxed); + return; + } + }; + loop { + info!("Waiting for connection"); + select! { + _ = cancellation_token.cancelled() => { + info!("[SSH Agent Native Module] Cancellation token triggered, stopping named pipe server"); + break; + } + _ = listener.connect() => { + info!("[SSH Agent Native Module] Incoming connection"); + let handle = HANDLE(listener.as_raw_handle()); + let mut pid = 0; + unsafe { + if let Err(e) = GetNamedPipeClientProcessId(handle, &mut pid) { + error!(error = %e, pid, "Faile to get named pipe client process id"); + continue + } + }; + + let peer_info = peerinfo::gather::get_peer_info(pid); + let peer_info = match peer_info { + Err(e) => { + error!(error = %e, pid = %pid, "Failed getting process info"); + continue + }, + Ok(info) => info, + }; + + tx.send((listener, peer_info)).await.unwrap(); + + listener = match ServerOptions::new().create(PIPE_NAME) { + Ok(pipe) => pipe, + Err(e) => { + error!(error = %e, "Encountered an error creating a new pipe"); + cancellation_token.cancel(); + is_running.store(false, Ordering::Relaxed); + return; + } + }; + } + } + } + }); + Self { rx } + } +} + +impl Stream for NamedPipeServerStream { + type Item = io::Result<(NamedPipeServer, PeerInfo)>; + + fn poll_next( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>> { + let this = self.project(); + + this.rx.poll_recv(cx).map(|v| v.map(Ok)) + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/transport/peer_info.rs b/apps/desktop/desktop_native/ssh_agent/src/transport/peer_info.rs new file mode 100644 index 00000000000..6cd74702ea1 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/transport/peer_info.rs @@ -0,0 +1,76 @@ +use std::fmt::Debug; + +use sysinfo::{Pid, System}; + +/// Peerinfo represents the information of a peer process connecting over a socket. +/// This can be later extended to include more information (icon, app name) for the corresponding application. +#[derive(Clone)] +pub struct PeerInfo { + uid: u32, + pid: u32, + process_name: String, + peer_type: PeerType, +} + +#[derive(Clone, Copy, Debug)] +pub enum PeerType { + #[cfg(windows)] + NamedPipe, + UnixSocket, +} + +impl PeerInfo { + pub fn new(pid: u32, peer_type: PeerType) -> Self { + Self::from_pid(pid, peer_type).unwrap_or_else(|_| PeerInfo::unknown()) + } + + fn from_pid(peer_pid: u32, peer_type: PeerType) -> Result { + let mut system = System::new(); + system.refresh_processes( + sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(peer_pid)]), + true, + ); + if let Some(process) = system.process(Pid::from_u32(peer_pid)) { + return Ok(Self { + uid: **process.user_id().ok_or(())?, + pid: peer_pid, + process_name: process.name().to_str().ok_or(())?.to_string(), + peer_type, + }); + } else { + Err(()) + } + } + + pub fn unknown() -> Self { + Self { + uid: 0, + pid: 0, + process_name: "Unknown application".to_string(), + peer_type: PeerType::UnixSocket, + } + } + + pub fn uid(&self) -> u32 { + self.uid + } + + pub fn pid(&self) -> u32 { + self.pid + } + + pub fn process_name(&self) -> &str { + &self.process_name + } +} + +impl Debug for PeerInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PeerInfo") + .field("uid", &self.uid) + .field("pid", &self.pid) + .field("process_name", &self.process_name) + .field("peer_type", &self.peer_type) + .finish() + } +} diff --git a/apps/desktop/desktop_native/ssh_agent/src/transport/unix_listener_stream.rs b/apps/desktop/desktop_native/ssh_agent/src/transport/unix_listener_stream.rs new file mode 100644 index 00000000000..25125af8d46 --- /dev/null +++ b/apps/desktop/desktop_native/ssh_agent/src/transport/unix_listener_stream.rs @@ -0,0 +1,101 @@ +use futures::Stream; +use std::os::unix::fs::PermissionsExt; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::{fs, io}; +use tokio::net::{UnixListener, UnixStream}; +use tracing::{error, info}; + +use crate::agent::agent::BitwardenDesktopAgent; +use crate::transport::peer_info::{PeerInfo, PeerType}; + +pub struct UnixListenerStream { + inner: tokio::net::UnixListener, +} + +impl UnixListenerStream { + fn new(listener: tokio::net::UnixListener) -> Self { + Self { inner: listener } + } + + /// Start listening on the given Unix socket path. + /// This will return only once the lister stops. Returning will attempt to clean up the socket file. + pub async fn listen( + ssh_path: String, + agent: BitwardenDesktopAgent, + ) -> Result<(), anyhow::Error> { + info!(socket = %ssh_path, "Starting SSH Unix listener"); + + // Remove existing socket file if it exists + let socket_path = std::path::Path::new(&ssh_path); + if let Err(e) = std::fs::remove_file(socket_path) { + error!(error = %e, socket = %ssh_path, "Could not remove existing socket file"); + if e.kind() != std::io::ErrorKind::NotFound { + return Err(anyhow::Error::new(e)); + } + } + + match UnixListener::bind(socket_path) { + Ok(listener) => { + // Only the current user should be able to access the socket + if let Err(e) = fs::set_permissions(socket_path, fs::Permissions::from_mode(0o600)) + { + error!(error = %e, socket = ?socket_path, "Could not set socket permissions"); + return Err(anyhow::Error::new(e)); + } + + let stream = Self::new(listener); + agent.serve(stream).await; + } + Err(e) => { + error!(error = %e, socket = %ssh_path, "Unable to start start agent server"); + } + } + + let _ = std::fs::remove_file(socket_path); + info!(socket = %ssh_path, "SSH Unix listener stopped"); + + Ok(()) + } +} + +impl Stream for UnixListenerStream { + type Item = io::Result<(UnixStream, PeerInfo)>; + + fn poll_next( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>> { + match self.inner.poll_accept(cx) { + Poll::Ready(Ok((stream, _))) => { + let pid = match stream.peer_cred() { + Ok(peer) => match peer.pid() { + Some(pid) => pid, + None => { + return Poll::Ready(Some(Ok((stream, PeerInfo::unknown())))); + } + }, + Err(_) => return Poll::Ready(Some(Ok((stream, PeerInfo::unknown())))), + }; + Poll::Ready(Some(Ok(( + stream, + PeerInfo::new(pid as u32, PeerType::UnixSocket), + )))) + } + Poll::Ready(Err(err)) => Poll::Ready(Some(Err(err))), + Poll::Pending => Poll::Pending, + } + } +} + +impl AsRef for UnixListenerStream { + fn as_ref(&self) -> &tokio::net::UnixListener { + &self.inner + } +} + +impl AsMut for UnixListenerStream { + fn as_mut(&mut self) -> &mut tokio::net::UnixListener { + &mut self.inner + } +} diff --git a/apps/desktop/src/autofill/main/main-ssh-agent.service.ts b/apps/desktop/src/autofill/main/main-ssh-agent.service.ts index 595ef778bcf..2163ee7a462 100644 --- a/apps/desktop/src/autofill/main/main-ssh-agent.service.ts +++ b/apps/desktop/src/autofill/main/main-ssh-agent.service.ts @@ -5,7 +5,7 @@ import { concatMap, delay, filter, firstValueFrom, from, race, take, timer } fro import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { sshagent } from "@bitwarden/desktop-napi"; +import { sshagent, sshagent_v2 } from "@bitwarden/desktop-napi"; class AgentResponse { requestId: number; @@ -19,7 +19,8 @@ export class MainSshAgentService { private requestResponses: AgentResponse[] = []; private request_id = 0; - private agentState: sshagent.SshAgentState; + private agentStateV1: sshagent.SshAgentState; + private agentStateV2: sshagent_v2.SshAgentState; constructor( private logService: LogService, @@ -30,11 +31,15 @@ export class MainSshAgentService { }); ipcMain.handle("sshagent.isloaded", async (event: any) => { - return this.agentState != null; + return this.agentStateV1 != null && this.agentStateV2 != null; }); } init() { + this.init_v2(); + } + + init_v1() { // handle sign request passing to UI sshagent .serve(async (err: Error, sshUiRequest: sshagent.SshUiRequest) => { @@ -83,7 +88,7 @@ export class MainSshAgentService { return response.accepted; }) .then((agentState: sshagent.SshAgentState) => { - this.agentState = agentState; + this.agentStateV1 = agentState; this.logService.info("SSH agent started"); }) .catch((e) => { @@ -93,8 +98,8 @@ export class MainSshAgentService { ipcMain.handle( "sshagent.setkeys", async (event: any, keys: { name: string; privateKey: string; cipherId: string }[]) => { - if (this.agentState != null && (await sshagent.isRunning(this.agentState))) { - sshagent.setKeys(this.agentState, keys); + if (this.agentStateV1 != null && (await sshagent.isRunning(this.agentStateV1))) { + sshagent.setKeys(this.agentStateV1, keys); } }, ); @@ -106,14 +111,99 @@ export class MainSshAgentService { ); ipcMain.handle("sshagent.lock", async (event: any) => { - if (this.agentState != null && (await sshagent.isRunning(this.agentState))) { - sshagent.lock(this.agentState); + if (this.agentStateV1 != null && (await sshagent.isRunning(this.agentStateV1))) { + sshagent.lock(this.agentStateV1); } }); ipcMain.handle("sshagent.clearkeys", async (event: any) => { - if (this.agentState != null) { - sshagent.clearKeys(this.agentState); + if (this.agentStateV1 != null) { + sshagent.clearKeys(this.agentStateV1); + } + }); + + } + + init_v2() { + // handle sign request passing to UI + sshagent_v2 + .serve(async (err: Error, sshUiRequest: sshagent_v2.SshUiRequest) => { + // clear all old (> SIGN_TIMEOUT) requests + this.requestResponses = this.requestResponses.filter( + (response) => response.timestamp > new Date(Date.now() - this.SIGN_TIMEOUT), + ); + + this.request_id += 1; + const id_for_this_request = this.request_id; + this.messagingService.send("sshagent.signrequest", { + cipherId: sshUiRequest.cipherId, + isListRequest: sshUiRequest.isList, + requestId: id_for_this_request, + processName: sshUiRequest.processName, + isAgentForwarding: sshUiRequest.isForwarding, + namespace: sshUiRequest.namespace, + }); + + const result = await firstValueFrom( + race( + from([false]).pipe(delay(this.SIGN_TIMEOUT)), + + //poll for response + timer(0, this.REQUEST_POLL_INTERVAL).pipe( + concatMap(() => from(this.requestResponses)), + filter((response) => response.requestId == id_for_this_request), + take(1), + concatMap(() => from([true])), + ), + ), + ); + + if (!result) { + return false; + } + + const response = this.requestResponses.find( + (response) => response.requestId == id_for_this_request, + ); + + this.requestResponses = this.requestResponses.filter( + (response) => response.requestId != id_for_this_request, + ); + + return response.accepted; + }) + .then((agentState: sshagent_v2.SshAgentState) => { + this.agentStateV2 = agentState; + this.logService.info("SSH agent started"); + }) + .catch((e) => { + this.logService.error("SSH agent encountered an error: ", e); + }); + + ipcMain.handle( + "sshagent.setkeys", + async (event: any, keys: { name: string; privateKey: string; cipherId: string }[]) => { + if (this.agentStateV2 != null && (await sshagent_v2.isRunning(this.agentStateV2))) { + sshagent_v2.setKeys(this.agentStateV2, keys); + } + }, + ); + ipcMain.handle( + "sshagent.signrequestresponse", + async (event: any, { requestId, accepted }: { requestId: number; accepted: boolean }) => { + this.requestResponses.push({ requestId, accepted, timestamp: new Date() }); + }, + ); + + ipcMain.handle("sshagent.lock", async (event: any) => { + if (this.agentStateV2 != null && (await sshagent_v2.isRunning(this.agentStateV2))) { + sshagent_v2.lock(this.agentStateV2); + } + }); + + ipcMain.handle("sshagent.clearkeys", async (event: any) => { + if (this.agentStateV2 != null) { + sshagent_v2.clearKeys(this.agentStateV2); } }); } diff --git a/apps/desktop/src/autofill/services/ssh-agent.service.ts b/apps/desktop/src/autofill/services/ssh-agent.service.ts index d5aed7f3289..c573caebd1d 100644 --- a/apps/desktop/src/autofill/services/ssh-agent.service.ts +++ b/apps/desktop/src/autofill/services/ssh-agent.service.ts @@ -46,7 +46,7 @@ export class SshAgentService implements OnDestroy { private authorizedSshKeys: Record = {}; - private isFeatureFlagEnabled = false; + private isFeatureFlagEnabled = true; private destroy$ = new Subject(); @@ -272,6 +272,7 @@ export class SshAgentService implements OnDestroy { cipherId: cipher.id, }; }); + //this.logService.info(`Setting ${keys.length} SSH keys in agent renderer`); await ipc.platform.sshAgent.setKeys(keys); }), takeUntil(this.destroy$), diff --git a/libs/common/src/key-management/services/default-process-reload.service.ts b/libs/common/src/key-management/services/default-process-reload.service.ts index bc5739167ce..5591876add1 100644 --- a/libs/common/src/key-management/services/default-process-reload.service.ts +++ b/libs/common/src/key-management/services/default-process-reload.service.ts @@ -33,6 +33,7 @@ export class DefaultProcessReloadService implements ProcessReloadServiceAbstract ) {} async startProcessReload(authService: AuthService): Promise { + return; const accounts = await firstValueFrom(this.accountService.accounts$); if (accounts != null) { const keys = Object.keys(accounts);