diff --git a/AGENTS.md b/AGENTS.md index 2ddf796..64f390f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,11 @@ derived from a password-store repository. This includes filesystem layout, `.gpg-id` handling, GPG-compatible encryption and key handling, Git, synchronization, conflict handling, entry parsing, password generation, OTP, QR payloads, server/application identities, HTTPS Git credentials, and -secure-secret-storage orchestration. Git remotes are HTTPS-only; reject other -schemes before entering the Git transport. +secure-secret-storage orchestration. Git remotes are typed HTTPS or optional +SSH endpoints; reject local, helper, executable, and unknown transports before +entering Git transport code. Builds without the `ssh` feature must still parse +SSH endpoints and return a typed unsupported-transport error before connection +or repository mutation. The CLI, Ratatui, Iced, Swift, SwiftUI, AutoFill, and watchOS code may collect input, invoke the Rust API, and present Rust-provided state. They must not diff --git a/Cargo.lock b/Cargo.lock index aba8236..7ba42ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,7 +32,17 @@ checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "bytes", "crypto-common 0.1.7", - "generic-array", + "generic-array 0.14.7", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] @@ -55,6 +65,7 @@ dependencies = [ "cipher 0.5.2", "cpubits", "cpufeatures 0.3.0", + "zeroize", ] [[package]] @@ -63,14 +74,29 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", + "aead 0.5.2", "aes 0.8.4", "cipher 0.4.4", - "ctr", - "ghash", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.2", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", + "zeroize", +] + [[package]] name = "aes-kw" version = "0.2.1" @@ -242,12 +268,12 @@ dependencies = [ "chrono", "clap", "cryptographic-message-syntax", - "der", + "der 0.7.10", "dialoguer", "difference", "digest 0.10.7", "dirs", - "elliptic-curve", + "elliptic-curve 0.13.8", "env_logger", "figment", "filetime", @@ -262,10 +288,10 @@ dependencies = [ "oid-registry", "once_cell", "p12", - "p256", + "p256 0.13.2", "pem", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "plist", "rand 0.8.7", "rasn", @@ -282,10 +308,10 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.10.9", - "signature", + "signature 2.2.0", "simple-file-manifest", "spake2", - "spki", + "spki 0.7.3", "subtle", "tempfile", "thiserror 2.0.19", @@ -318,7 +344,7 @@ dependencies = [ "fscommon", "getrandom 0.2.17", "gpt", - "md5", + "md5 0.7.0", "plist", "serde", "serde_bytes", @@ -370,9 +396,9 @@ dependencies = [ "scroll", "serde", "serde-xml-rs", - "sha1", + "sha1 0.10.7", "sha2 0.10.9", - "signature", + "signature 2.2.0", "thiserror 2.0.19", "url", "x509-certificate", @@ -432,12 +458,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", - "blake2", + "blake2 0.10.6", "cpufeatures 0.2.17", - "password-hash", + "password-hash 0.5.0", "zeroize", ] +[[package]] +name = "argon2" +version = "0.6.0-rc.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5" +dependencies = [ + "base64ct", + "blake2 0.11.0-rc.6", + "cpufeatures 0.3.0", + "password-hash 0.6.1", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -775,6 +813,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.22.1" @@ -812,6 +856,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "bcrypt-pbkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" +dependencies = [ + "blowfish 0.10.0", + "pbkdf2", + "sha2 0.11.0", +] + [[package]] name = "bisync" version = "0.3.0" @@ -924,6 +979,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "blake2" +version = "0.11.0-rc.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "blake2b_simd" version = "1.0.4" @@ -947,7 +1011,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -957,6 +1021,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -971,7 +1036,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -1024,6 +1089,16 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + [[package]] name = "bstr" version = "1.13.0" @@ -1372,6 +1447,7 @@ dependencies = [ "cipher 0.5.2", "cpufeatures 0.3.0", "rand_core 0.10.1", + "zeroize", ] [[package]] @@ -1407,6 +1483,7 @@ dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", "inout 0.2.2", + "zeroize", ] [[package]] @@ -1898,19 +1975,36 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "subtle", "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect 0.4.3", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", + "generic-array 0.14.7", "rand_core 0.6.4", "typenum", ] @@ -1921,7 +2015,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1937,7 +2033,7 @@ dependencies = [ "pem", "reqwest 0.12.28", "ring", - "signature", + "signature 2.2.0", "x509-certificate", ] @@ -1969,6 +2065,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "ctrlc" version = "3.5.2" @@ -1987,6 +2092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -2005,13 +2111,30 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", "rand_core 0.6.4", "rustc_version", "subtle", "zeroize", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", + "rustc_version", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -2029,13 +2152,13 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" dependencies = [ - "crypto-bigint", - "elliptic-curve", - "pkcs8", + "crypto-bigint 0.5.5", + "elliptic-curve 0.13.8", + "pkcs8 0.10.2", "rand_core 0.6.4", "serdect 0.3.0", - "sha3", - "signature", + "sha3 0.10.9", + "signature 2.2.0", "subtle", "zeroize", ] @@ -2135,7 +2258,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" dependencies = [ - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -2169,6 +2292,17 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deltae" version = "0.3.2" @@ -2182,7 +2316,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", - "pem-rfc7468", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -2266,6 +2411,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "dialoguer" version = "0.11.0" @@ -2398,10 +2552,10 @@ dependencies = [ "digest 0.10.7", "num-bigint-dig", "num-traits", - "pkcs8", - "rfc6979", + "pkcs8 0.10.2", + "rfc6979 0.4.0", "sha2 0.10.9", - "signature", + "signature 2.2.0", "zeroize", ] @@ -2423,10 +2577,10 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" dependencies = [ - "aead", + "aead 0.5.2", "cipher 0.4.4", "cmac", - "ctr", + "ctr 0.9.2", "subtle", ] @@ -2436,12 +2590,27 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", - "spki", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der 0.8.1", + "digest 0.11.3", + "elliptic-curve 0.14.1", + "rfc6979 0.6.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", ] [[package]] @@ -2450,8 +2619,18 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "signature 3.0.0", ] [[package]] @@ -2460,8 +2639,8 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", "rand_core 0.6.4", "serde", "sha2 0.10.9", @@ -2469,6 +2648,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.17.0" @@ -2481,18 +2676,18 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "base64ct", - "crypto-bigint", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", - "generic-array", - "group", - "hkdf", - "pem-rfc7468", - "pkcs8", + "ff 0.13.1", + "generic-array 0.14.7", + "group 0.13.0", + "hkdf 0.12.4", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", "serde_json", "serdect 0.2.0", "subtle", @@ -2500,6 +2695,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hkdf 0.13.0", + "hybrid-array", + "pem-rfc7468 1.0.0", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -2521,6 +2738,18 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -2578,7 +2807,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2696,12 +2925,28 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.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 = "field-offset" version = "0.3.6" @@ -3122,6 +3367,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic-array" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e" +dependencies = [ + "generic-array 0.14.7", + "rustversion", + "typenum", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -3178,7 +3434,17 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", + "zeroize", ] [[package]] @@ -3568,7 +3834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1" dependencies = [ "bstr", - "hashbrown 0.17.1", + "hashbrown 0.15.5", ] [[package]] @@ -4243,11 +4509,22 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -4312,9 +4589,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -4467,6 +4744,15 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + [[package]] name = "hmac" version = "0.12.1" @@ -4530,7 +4816,10 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ + "ctutils", + "subtle", "typenum", + "zeroize", ] [[package]] @@ -4985,7 +5274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "block-padding 0.3.3", - "generic-array", + "generic-array 0.14.7", ] [[package]] @@ -5011,6 +5300,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "internal-russh-num-bigint" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.10.2", + "rand_core 0.10.1", +] + [[package]] name = "io-extras" version = "0.19.0" @@ -5059,6 +5360,7 @@ dependencies = [ "keepass", "keyring-core", "nix 0.31.3", + "percent-encoding", "pgp", "qrcode", "rand 0.8.7", @@ -5066,14 +5368,16 @@ dependencies = [ "regex", "reqwest 0.13.4", "rqrr", + "russh", "secret-service", "security-framework 3.7.0", "serde", - "sha1", + "sha1 0.10.7", "sha2 0.10.9", "shlex 1.3.0", "smallvec", "tempfile", + "tokio", "toml 0.9.12+spec-1.1.0", "url", "windows-native-keyring-store", @@ -5159,7 +5463,7 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5331,11 +5635,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", - "ecdsa", - "elliptic-curve", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", "once_cell", "sha2 0.10.9", - "signature", + "signature 2.2.0", ] [[package]] @@ -5358,6 +5662,16 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + [[package]] name = "keepass" version = "0.13.20" @@ -5392,6 +5706,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -5718,6 +6042,12 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" +[[package]] +name = "md5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" + [[package]] name = "memchr" version = "2.8.3" @@ -5817,6 +6147,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sha3 0.11.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -6496,9 +6851,9 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" dependencies = [ - "aead", + "aead 0.5.2", "cipher 0.4.4", - "ctr", + "ctr 0.9.2", "subtle", ] @@ -6592,7 +6947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -6612,12 +6967,12 @@ checksum = "d4873306de53fe82e7e484df31e1e947d61514b6ea2ed6cd7b45d63006fd9224" dependencies = [ "cbc 0.1.2", "cipher 0.4.4", - "des", + "des 0.8.1", "getrandom 0.2.17", "hmac 0.12.1", "lazy_static", "rc2", - "sha1", + "sha1 0.10.7", "yasna", ] @@ -6627,38 +6982,99 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", "sha2 0.10.9", ] +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.0", +] + [[package]] name = "p384" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", "sha2 0.10.9", ] +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "fiat-crypto 0.3.0", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.0", +] + [[package]] name = "p521" version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" dependencies = [ - "base16ct", - "ecdsa", - "elliptic-curve", - "primeorder", + "base16ct 0.2.0", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", "rand_core 0.6.4", "sha2 0.10.9", ] +[[package]] +name = "p521" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" +dependencies = [ + "base16ct 1.0.0", + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.0", +] + +[[package]] +name = "pageant" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3adadc44070da6f464b0918655a12f5792c156e088d8c4082d13e27d94c3e791" +dependencies = [ + "base16ct 1.0.0", + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.10.2", + "sha2 0.11.0", + "thiserror 2.0.19", + "tokio", + "windows 0.62.2", + "windows-strings 0.5.1", +] + [[package]] name = "palette" version = "0.7.6" @@ -6748,12 +7164,31 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + [[package]] name = "pear" version = "0.2.9" @@ -6796,6 +7231,15 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -6861,15 +7305,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" dependencies = [ - "aead", + "aead 0.5.2", "aes 0.8.4", - "aes-gcm", + "aes-gcm 0.10.3", "aes-kw", - "argon2", + "argon2 0.5.3", "base64 0.22.1", "bitfields", "block-padding 0.3.3", - "blowfish", + "blowfish 0.9.1", "buffer-redux", "byteorder", "bytes", @@ -6879,21 +7323,21 @@ dependencies = [ "cipher 0.4.4", "const-oid 0.9.6", "crc24", - "curve25519-dalek", + "curve25519-dalek 4.1.3", "cx448", "derive_builder", "derive_more", - "des", + "des 0.8.1", "digest 0.10.7", "dsa", "eax", - "ecdsa", - "ed25519-dalek", - "elliptic-curve", + "ecdsa 0.16.9", + "ed25519-dalek 2.2.0", + "elliptic-curve 0.13.8", "flate2", - "generic-array", + "generic-array 0.14.7", "hex", - "hkdf", + "hkdf 0.12.4", "idea", "k256", "log", @@ -6904,18 +7348,18 @@ dependencies = [ "num-traits", "num_enum", "ocb3", - "p256", - "p384", - "p521", + "p256 0.13.2", + "p384 0.13.1", + "p521 0.13.3", "rand 0.8.7", "replace_with", "ripemd", "rsa", - "sha1", + "sha1 0.10.7", "sha1-checked", "sha2 0.10.9", - "sha3", - "signature", + "sha3 0.10.9", + "signature 2.2.0", "smallvec", "snafu 0.9.2", "subtle", @@ -6924,6 +7368,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + [[package]] name = "phf" version = "0.11.3" @@ -7025,9 +7479,26 @@ 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 = "pkcs5" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" +dependencies = [ + "aes 0.9.2", + "aes-gcm 0.11.1", + "cbc 0.2.1", + "der 0.8.1", + "pbkdf2", + "rand_core 0.10.1", + "scrypt", + "sha2 0.11.0", + "spki 0.8.0", ] [[package]] @@ -7036,8 +7507,20 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.1", + "pkcs5", + "rand_core 0.10.1", + "spki 0.8.0", ] [[package]] @@ -7092,6 +7575,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash 0.6.1", + "zeroize", +] + [[package]] name = "polyval" version = "0.6.2" @@ -7101,7 +7595,19 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", + "zeroize", ] [[package]] @@ -7149,13 +7655,40 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "primeorder" version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.13.8", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve 0.14.1", + "once_cell", + "primefield", + "serdect 0.4.3", + "wnaf", ] [[package]] @@ -7324,7 +7857,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7819,6 +8352,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint 0.7.5", + "hmac 0.13.0", +] + [[package]] name = "rfd" version = "0.17.2" @@ -7904,10 +8447,10 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] @@ -7922,6 +8465,99 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "russh" +version = "0.63.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bab1b87d915817d5d9cc352637cd40d5f0b298a48c6309af9156a4addc3031" +dependencies = [ + "aes 0.9.2", + "bitflags 2.13.1", + "block-padding 0.4.2", + "byteorder", + "bytes", + "cbc 0.2.1", + "cipher 0.5.2", + "crypto-bigint 0.7.5", + "ctr 0.10.1", + "curve25519-dalek 5.0.0", + "data-encoding", + "delegate", + "der 0.8.1", + "digest 0.11.3", + "ecdsa 0.17.0", + "ed25519-dalek 3.0.0", + "elliptic-curve 0.14.1", + "enum_dispatch", + "futures", + "generic-array 1.4.5", + "getrandom 0.4.3", + "ghash 0.6.0", + "hex-literal", + "hmac 0.13.0", + "inout 0.2.2", + "internal-russh-num-bigint", + "keccak 0.2.2", + "log", + "md5 0.8.1", + "ml-kem", + "module-lattice", + "num-bigint", + "p256 0.14.0", + "p384 0.14.0", + "p521 0.14.0", + "pageant", + "pbkdf2", + "pkcs5", + "pkcs8 0.11.0", + "polyval 0.7.3", + "rand 0.10.2", + "rand_core 0.10.1", + "ring", + "russh-cryptovec", + "russh-util", + "salsa20", + "scrypt", + "sec1 0.8.1", + "sha1 0.11.0", + "sha2 0.11.0", + "sha3 0.12.0", + "signature 3.0.0", + "spki 0.8.0", + "ssh-encoding", + "ssh-key", + "subtle", + "thiserror 2.0.19", + "tokio", + "typenum", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438" +dependencies = [ + "log", + "nix 0.31.3", + "ssh-encoding", + "windows-sys 0.61.2", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "rust-argon2" version = "3.0.0" @@ -7987,7 +8623,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8077,7 +8713,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8179,6 +8815,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "pbkdf2", + "salsa20", + "sha2 0.11.0", +] + [[package]] name = "sctk-adwaita" version = "0.10.1" @@ -8198,15 +8846,29 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", + "base16ct 0.2.0", + "der 0.7.10", + "generic-array 0.14.7", + "pkcs8 0.10.2", "serdect 0.2.0", "subtle", "zeroize", ] +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.1", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -8225,9 +8887,9 @@ dependencies = [ "aes 0.8.4", "cbc 0.1.2", "futures-util", - "generic-array", + "generic-array 0.14.7", "getrandom 0.2.17", - "hkdf", + "hkdf 0.12.4", "num", "once_cell", "serde", @@ -8412,7 +9074,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" dependencies = [ - "base16ct", + "base16ct 0.2.0", "serde", ] @@ -8422,7 +9084,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" dependencies = [ - "base16ct", + "base16ct 0.2.0", + "serde", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", "serde", ] @@ -8437,6 +9109,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha1-checked" version = "0.10.0" @@ -8444,7 +9127,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" dependencies = [ "digest 0.10.7", - "sha1", + "sha1 0.10.7", "zeroize", ] @@ -8477,7 +9160,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ "digest 0.10.7", - "keccak", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.2", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.2", + "sponge-cursor", ] [[package]] @@ -8539,6 +9243,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -8787,8 +9501,8 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5482afe85a0b6ce956c945401598dbc527593c77ba51d0a87a586938b1b893a" dependencies = [ - "curve25519-dalek", - "hkdf", + "curve25519-dalek 4.1.3", + "hkdf 0.12.4", "rand_core 0.6.4", "sha2 0.10.9", ] @@ -8815,7 +9529,80 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.1", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + +[[package]] +name = "ssh-cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597" +dependencies = [ + "aead 0.6.1", + "aes 0.9.2", + "aes-gcm 0.11.1", + "chacha20", + "cipher 0.5.2", + "ctutils", + "des 0.9.0", + "poly1305", + "ssh-encoding", + "zeroize", +] + +[[package]] +name = "ssh-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e" +dependencies = [ + "base64ct", + "bytes", + "ctutils", + "digest 0.11.3", + "pem-rfc7468 1.0.0", + "zeroize", +] + +[[package]] +name = "ssh-key" +version = "0.7.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3" +dependencies = [ + "argon2 0.6.0-rc.8", + "bcrypt-pbkdf", + "ctutils", + "ed25519-dalek 3.0.0", + "hex", + "hmac 0.13.0", + "p256 0.14.0", + "p384 0.14.0", + "p521 0.14.0", + "rand_core 0.10.1", + "sec1 0.8.1", + "sha1 0.11.0", + "sha2 0.11.0", + "signature 3.0.0", + "ssh-cipher", + "ssh-encoding", + "zeroize", ] [[package]] @@ -8983,7 +9770,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9227,10 +10014,22 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "tracing", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -9494,7 +10293,7 @@ dependencies = [ "rustls", "rustls-native-certs 0.7.3", "rustls-pki-types", - "sha1", + "sha1 0.10.7", "thiserror 1.0.69", "utf-8", ] @@ -9771,6 +10570,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -10389,7 +11198,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -10865,6 +11674,17 @@ dependencies = [ "wayland-protocols-wlr", ] +[[package]] +name = "wnaf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", +] + [[package]] name = "writeable" version = "0.6.3" @@ -10928,7 +11748,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "rand_core 0.6.4", "serde", "zeroize", @@ -10953,12 +11773,12 @@ dependencies = [ "bcder", "bytes", "chrono", - "der", + "der 0.7.10", "hex", "pem", "ring", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "thiserror 1.0.69", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index e049179..8aa507d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ keyring-core = "1.0" keepass = "0.13.20" muda = "0.19" pgp = { version = "0.20", default-features = false } +percent-encoding = "2.3" qrcode = { version = "0.14", default-features = false } rand = "0.8" regex = "1.13" @@ -51,6 +52,7 @@ reqwest = { version = "0.13", default-features = false, features = ["blocking", rfd = { version = "0.17", default-features = false } rqrr = { version = "0.10", default-features = false } rpassword = "7.5" +russh = { version = "0.63.1", default-features = false, features = ["ring"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] } security-framework = "3.7" secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] } @@ -61,6 +63,7 @@ sha2 = "0.10" shlex = "1.3" simple-file-manifest = "0.11" toml = "0.9" +tokio = { version = "1.53.1", default-features = false, features = ["io-util", "net", "rt-multi-thread", "sync", "time"] } uniffi = "0.32" url = { version = "2.5", default-features = false } windows-native-keyring-store = { version = "1.1", default-features = false } diff --git a/README.md b/README.md index c703709..c2593d4 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,10 @@ and the first-party `pass` command behavior. `pass-otp` compatibility adds The compatibility target includes init, list/show, find/grep, insert/edit, generate, remove, move/copy, and Git-backed commit and synchronization flows. -Remote synchronization is HTTPS-only and uses server/application credentials -kept in the operating system's secure store. +Remote endpoints are typed in the storage crate as credential-free HTTPS or +feature-gated SSH. HTTPS synchronization uses server/application credentials +kept in the operating system's secure store; SSH transport dependencies and +runtime are compiled only with the optional `ssh` Cargo feature. The central `ironstorage` Rust crate owns repository access, Git, GPG-compatible encryption and key handling, entries, OTP, synchronization, @@ -24,7 +26,7 @@ and `gpg`—are forbidden; compatibility is implemented in Rust. IronStorage is MIT licensed. Dependency licenses and the completed packaging review are tracked in [`DEPENDENCIES.md`](DEPENDENCIES.md). -The shared TOML schema, path rules, editor precedence, and HTTPS remote format +The shared TOML schema, path rules, editor precedence, and Git remote formats are documented in [`docs/configuration.md`](docs/configuration.md). Embedded Git, HTTPS synchronization, merge behavior, and commit signing are documented in [`docs/git-synchronization.md`](docs/git-synchronization.md). diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 67fccdf..f28ce61 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -1939,15 +1939,16 @@ mod tests { assert!(stderr.is_empty()); let remote = &config.git_remotes()[0]; + let (server_id, application_id) = remote.https_credentials().expect("HTTPS remote"); secrets.create( &SecretReference::https_git_credential( - remote.server_id().as_str(), - remote.application_id().as_str(), + server_id.as_str(), + application_id.as_str(), "fixture-account", )?, SecretBytes::new(b"fixture-token".to_vec()), )?; - let credential = secrets.credential(remote.server_id(), remote.application_id())?; + let credential = secrets.credential(server_id, application_id)?; assert_eq!(credential.username(), "fixture-account"); assert_eq!(credential.password(), b"fixture-token"); Ok(()) diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 057027b..cb11389 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -7601,7 +7601,7 @@ mod tests { assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl)); assert_eq!( error.to_string(), - "Git remotes must use credential-free HTTPS URLs" + "the Git remote URL is invalid or forbidden" ); assert!(DesktopGitRequest::Pull.requires_authentication()); diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 71e3b46..eb29062 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -24,6 +24,7 @@ full = [ "dep:keepass", "dep:keyring-core", "dep:pgp", + "dep:percent-encoding", "dep:qrcode", "dep:rand", "dep:regex", @@ -38,6 +39,7 @@ full = [ "dep:windows-native-keyring-store", "dep:zbus-secret-service-keyring-store", ] +ssh = ["dep:russh", "dep:tokio"] watch = [] [dependencies] @@ -54,16 +56,19 @@ image = { workspace = true, optional = true } keyring-core = { workspace = true, optional = true } keepass = { workspace = true, optional = true } pgp = { workspace = true, optional = true } +percent-encoding = { workspace = true, optional = true } qrcode = { workspace = true, optional = true } rand = { workspace = true, optional = true } regex = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } rqrr = { workspace = true, optional = true } +russh = { workspace = true, optional = true } serde = { workspace = true, optional = true } sha1.workspace = true sha2.workspace = true shlex = { workspace = true, optional = true } toml = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } url = { workspace = true, optional = true } zeroize.workspace = true diff --git a/crates/storage/src/config.rs b/crates/storage/src/config.rs index 5a6861c..c5e0a6a 100644 --- a/crates/storage/src/config.rs +++ b/crates/storage/src/config.rs @@ -420,26 +420,9 @@ impl Config { .ok_or(ConfigError::InvalidField { field: "git" })?; match remote { Some(remote) => { - let mut configured = toml::Table::new(); - configured.insert( - "name".to_owned(), - toml::Value::String(remote.name().as_str().to_owned()), - ); - configured.insert( - "url".to_owned(), - toml::Value::String(remote.url().to_string()), - ); - configured.insert( - "server_id".to_owned(), - toml::Value::String(remote.server_id().as_str().to_owned()), - ); - configured.insert( - "application_id".to_owned(), - toml::Value::String(remote.application_id().as_str().to_owned()), - ); git.insert( "remotes".to_owned(), - toml::Value::Array(vec![toml::Value::Table(configured)]), + toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote))]), ); } None => { @@ -511,27 +494,10 @@ impl Config { toml::Value::String(path_text(key_material, "key_material")?), ); if let Some(remote) = remote { - let mut configured = toml::Table::new(); - configured.insert( - "name".to_owned(), - toml::Value::String(remote.name().as_str().to_owned()), - ); - configured.insert( - "url".to_owned(), - toml::Value::String(remote.url().to_string()), - ); - configured.insert( - "server_id".to_owned(), - toml::Value::String(remote.server_id().as_str().to_owned()), - ); - configured.insert( - "application_id".to_owned(), - toml::Value::String(remote.application_id().as_str().to_owned()), - ); let mut git = toml::Table::new(); git.insert( "remotes".to_owned(), - toml::Value::Array(vec![toml::Value::Table(configured)]), + toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote))]), ); root.insert("git".to_owned(), toml::Value::Table(git)); } @@ -768,6 +734,29 @@ impl Config { } } +fn git_remote_document(remote: &GitRemote) -> toml::Table { + let mut configured = toml::Table::new(); + configured.insert( + "name".to_owned(), + toml::Value::String(remote.name().as_str().to_owned()), + ); + configured.insert( + "url".to_owned(), + toml::Value::String(remote.url().to_owned()), + ); + if let Some((server_id, application_id)) = remote.https_credentials() { + configured.insert( + "server_id".to_owned(), + toml::Value::String(server_id.as_str().to_owned()), + ); + configured.insert( + "application_id".to_owned(), + toml::Value::String(application_id.as_str().to_owned()), + ); + } + configured +} + /// Deterministic path context for configuration loading. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ConfigLoader { @@ -876,12 +865,264 @@ identifier_type!(RemoteName); identifier_type!(ServerId); identifier_type!(ApplicationId); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RemoteTransport { + Https, + Ssh, +} + +impl fmt::Display for RemoteTransport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Https => "HTTPS", + Self::Ssh => "SSH", + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RemoteEndpoint { + Https(Url), + Ssh(SshEndpoint), +} + +impl RemoteEndpoint { + pub fn parse(value: &str) -> Result { + if value.is_empty() || value.chars().any(char::is_control) { + return Err(InvalidRemoteEndpoint); + } + if value.contains("://") { + let parsed = Url::parse(value).map_err(|_| InvalidRemoteEndpoint)?; + return match parsed.scheme() { + "https" + if parsed.host_str().is_some() + && parsed.username().is_empty() + && parsed.password().is_none() + && parsed.query().is_none() + && parsed.fragment().is_none() => + { + Ok(Self::Https(parsed)) + } + "ssh" => parse_ssh_url(parsed).map(Self::Ssh), + _ => Err(InvalidRemoteEndpoint), + }; + } + parse_scp_remote(value).map(Self::Ssh) + } + + pub const fn transport(&self) -> RemoteTransport { + match self { + Self::Https(_) => RemoteTransport::Https, + Self::Ssh(_) => RemoteTransport::Ssh, + } + } + + pub const fn as_https(&self) -> Option<&Url> { + match self { + Self::Https(url) => Some(url), + Self::Ssh(_) => None, + } + } + + pub const fn as_ssh(&self) -> Option<&SshEndpoint> { + match self { + Self::Https(_) => None, + Self::Ssh(endpoint) => Some(endpoint), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SshEndpoint { + user: Option, + host: String, + port: u16, + path: SshRepositoryPath, +} + +impl SshEndpoint { + pub fn user(&self) -> Option<&str> { + self.user.as_deref() + } + + pub fn host(&self) -> &str { + &self.host + } + + pub const fn port(&self) -> u16 { + self.port + } + + pub const fn path(&self) -> &SshRepositoryPath { + &self.path + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SshRepositoryPath { + Absolute(String), + Relative(String), + Tilde(String), +} + +impl SshRepositoryPath { + pub fn as_str(&self) -> &str { + match self { + Self::Absolute(path) | Self::Relative(path) | Self::Tilde(path) => path, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct InvalidRemoteEndpoint; + +impl fmt::Display for InvalidRemoteEndpoint { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("invalid or forbidden Git remote endpoint") + } +} + +impl Error for InvalidRemoteEndpoint {} + +fn parse_ssh_url(url: Url) -> Result { + if url.password().is_some() || url.query().is_some() || url.fragment().is_some() { + return Err(InvalidRemoteEndpoint); + } + let user = parse_ssh_user(url.username())?; + let host = url.host_str().ok_or(InvalidRemoteEndpoint)?.to_owned(); + let port = url.port().unwrap_or(22); + if port == 0 { + return Err(InvalidRemoteEndpoint); + } + let path = percent_encoding::percent_decode_str(url.path()) + .decode_utf8() + .map_err(|_| InvalidRemoteEndpoint)?; + let path = if let Some(tilde) = path.strip_prefix("/~") { + parse_ssh_path(format!("~{tilde}"), true)? + } else { + parse_ssh_path(path.into_owned(), false)? + }; + Ok(SshEndpoint { + user, + host, + port, + path, + }) +} + +fn parse_scp_remote(value: &str) -> Result { + if value.contains(['?', '#']) || value.to_ascii_lowercase().starts_with("ext::") { + return Err(InvalidRemoteEndpoint); + } + let separator = if let Some(bracket) = value.find('[') { + let close = value[bracket + 1..] + .find(']') + .map(|index| bracket + index + 1) + .ok_or(InvalidRemoteEndpoint)?; + if value.as_bytes().get(close + 1) != Some(&b':') { + return Err(InvalidRemoteEndpoint); + } + close + 1 + } else { + value.find(':').ok_or(InvalidRemoteEndpoint)? + }; + let (authority, path) = value.split_at(separator); + let path = path.strip_prefix(':').ok_or(InvalidRemoteEndpoint)?; + if path.contains(':') { + return Err(InvalidRemoteEndpoint); + } + let (user, host) = match authority.split_once('@') { + Some((user, host)) if !host.contains('@') => (parse_ssh_user(user)?, host), + Some(_) => return Err(InvalidRemoteEndpoint), + None => (None, authority), + }; + if host.is_empty() + || host.starts_with('-') + || (host.len() == 1 + && host.as_bytes()[0].is_ascii_alphabetic() + && path.starts_with(['/', '\\'])) + { + return Err(InvalidRemoteEndpoint); + } + let bracketed = host.starts_with('[') && host.ends_with(']'); + if host.contains(['[', ']']) && !bracketed || host.contains(':') && !bracketed { + return Err(InvalidRemoteEndpoint); + } + let host = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host); + let host = url::Host::parse(host) + .map_err(|_| InvalidRemoteEndpoint)? + .to_string(); + Ok(SshEndpoint { + user, + host, + port: 22, + path: parse_ssh_path(path.to_owned(), true)?, + }) +} + +fn parse_ssh_user(value: &str) -> Result, InvalidRemoteEndpoint> { + if value.is_empty() { + return Ok(None); + } + if value.starts_with('-') + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(InvalidRemoteEndpoint); + } + Ok(Some(value.to_owned())) +} + +fn parse_ssh_path( + path: String, + allow_relative: bool, +) -> Result { + if path.is_empty() + || path == "/" + || path.contains(['?', '#']) + || path.chars().any(char::is_control) + { + return Err(InvalidRemoteEndpoint); + } + if path.starts_with('/') { + return Ok(SshRepositoryPath::Absolute(path)); + } + if let Some(tilde_path) = path.strip_prefix('~') { + let user = tilde_path.split('/').next().unwrap_or_default(); + if !user.is_empty() + && !user + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(InvalidRemoteEndpoint); + } + return Ok(SshRepositoryPath::Tilde(path)); + } + if !allow_relative || path.starts_with('-') { + return Err(InvalidRemoteEndpoint); + } + Ok(SshRepositoryPath::Relative(path)) +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct GitRemote { name: RemoteName, - url: Url, - server_id: ServerId, - application_id: ApplicationId, + url: String, + endpoint: RemoteEndpoint, + credentials: GitRemoteCredentials, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum GitRemoteCredentials { + Https { + server_id: ServerId, + application_id: ApplicationId, + }, + Ssh, } impl GitRemote { @@ -894,8 +1135,18 @@ impl GitRemote { let mut remotes = validate_remotes(vec![RawGitRemote { name: name.into(), url: url.into(), - server_id: server_id.into(), - application_id: application_id.into(), + server_id: Some(server_id.into()), + application_id: Some(application_id.into()), + }])?; + Ok(remotes.remove(0)) + } + + pub fn ssh(name: impl Into, url: impl Into) -> Result { + let mut remotes = validate_remotes(vec![RawGitRemote { + name: name.into(), + url: url.into(), + server_id: None, + application_id: None, }])?; Ok(remotes.remove(0)) } @@ -904,16 +1155,22 @@ impl GitRemote { &self.name } - pub fn url(&self) -> &Url { + pub fn url(&self) -> &str { &self.url } - pub fn server_id(&self) -> &ServerId { - &self.server_id + pub const fn endpoint(&self) -> &RemoteEndpoint { + &self.endpoint } - pub fn application_id(&self) -> &ApplicationId { - &self.application_id + pub const fn https_credentials(&self) -> Option<(&ServerId, &ApplicationId)> { + match &self.credentials { + GitRemoteCredentials::Https { + server_id, + application_id, + } => Some((server_id, application_id)), + GitRemoteCredentials::Ssh => None, + } } } @@ -1067,7 +1324,7 @@ impl fmt::Display for ConfigError { } Self::InvalidRemoteUrl { name } => write!( formatter, - "Git remote {name} must be an HTTPS URL without embedded credentials, query, or fragment" + "Git remote {name} must be an allowed HTTPS or SSH endpoint without embedded credentials, query, or fragment" ), } } @@ -1153,8 +1410,8 @@ struct RawGit { struct RawGitRemote { name: String, url: String, - server_id: String, - application_id: String, + server_id: Option, + application_id: Option, } fn validate_config( @@ -1413,41 +1670,52 @@ fn validate_remotes(raw: Vec) -> Result, ConfigErro let mut remotes = Vec::with_capacity(raw.len()); for remote in raw { let name = RemoteName(validate_identifier("git.remotes.name", remote.name)?); - let server_id = ServerId(validate_identifier( - "git.remotes.server_id", - remote.server_id, - )?); - let application_id = ApplicationId(validate_identifier( - "git.remotes.application_id", - remote.application_id, - )?); if !names.insert(name.clone()) { return Err(ConfigError::DuplicateRemote { name: name.0.clone(), }); } - if !references.insert((server_id.clone(), application_id.clone())) { - return Err(ConfigError::DuplicateCredentialReference); - } - let url = Url::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl { - name: name.0.clone(), - })?; - if url.scheme() != "https" - || url.host_str().is_none() - || !url.username().is_empty() - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() - { - return Err(ConfigError::InvalidRemoteUrl { + let endpoint = + RemoteEndpoint::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl { name: name.0.clone(), - }); - } + })?; + let credentials = match (&endpoint, remote.server_id, remote.application_id) { + (RemoteEndpoint::Https(_), Some(server_id), Some(application_id)) => { + let server_id = ServerId(validate_identifier("git.remotes.server_id", server_id)?); + let application_id = ApplicationId(validate_identifier( + "git.remotes.application_id", + application_id, + )?); + if !references.insert((server_id.clone(), application_id.clone())) { + return Err(ConfigError::DuplicateCredentialReference); + } + GitRemoteCredentials::Https { + server_id, + application_id, + } + } + (RemoteEndpoint::Https(_), None, _) => { + return Err(ConfigError::MissingField { + field: "git.remotes.server_id", + }); + } + (RemoteEndpoint::Https(_), _, None) => { + return Err(ConfigError::MissingField { + field: "git.remotes.application_id", + }); + } + (RemoteEndpoint::Ssh(_), None, None) => GitRemoteCredentials::Ssh, + (RemoteEndpoint::Ssh(_), _, _) => { + return Err(ConfigError::InvalidField { + field: "git.remotes.https_credentials", + }); + } + }; remotes.push(GitRemote { name, - url, - server_id, - application_id, + url: remote.url, + endpoint, + credentials, }); } Ok(remotes) diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index c01fad5..3fb333d 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -24,7 +24,7 @@ use sha1::{Digest as _, Sha1}; use zeroize::Zeroize as _; use crate::{ - config::{ApplicationId, GitRemote, ServerId}, + config::{ApplicationId, GitRemote, RemoteEndpoint, RemoteTransport, ServerId}, crypto::{KeyHandle, KeyStore, SecretProvider}, mutation::{TreeCommit, TreeCommitError, TreeCommitter}, recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter}, @@ -384,6 +384,9 @@ pub enum GitError { DirtyWorktree, InvalidRemoteName, ForbiddenRemoteUrl, + UnsupportedRemoteTransport { + transport: RemoteTransport, + }, RemoteNotFound { name: String, }, @@ -429,7 +432,13 @@ impl fmt::Display for GitError { Self::DirtyWorktree => formatter.write_str("the worktree has uncommitted changes"), Self::InvalidRemoteName => formatter.write_str("the Git remote name is invalid"), Self::ForbiddenRemoteUrl => { - formatter.write_str("Git remotes must use credential-free HTTPS URLs") + formatter.write_str("the Git remote URL is invalid or forbidden") + } + Self::UnsupportedRemoteTransport { transport } => { + write!( + formatter, + "this build does not support {transport} Git remotes" + ) } Self::RemoteNotFound { name } => write!(formatter, "Git remote not found: {name}"), Self::CredentialsUnavailable => { @@ -861,7 +870,7 @@ impl GitRepository { control: &GitOperationControl, ) -> Result, GitError> { control.report(GitProgressPhase::Validating)?; - validate_https_remote(configured.url().as_str())?; + require_https_remote(configured)?; ensure_clone_parent(parent)?; let temporary = private_temporary_directory(parent, "probe")?; let result = (|| { @@ -874,7 +883,7 @@ impl GitRepository { ) .map_err(invalid)?; let mut repository = Self::open(&store, identity)?; - repository.add_remote(configured.name().as_str(), configured.url().as_str())?; + repository.add_remote(configured.name().as_str(), configured.url())?; repository.fetch_with_transport_controlled( configured, credentials, @@ -951,7 +960,7 @@ impl GitRepository { control: &GitOperationControl, ) -> Result { control.report(GitProgressPhase::Validating)?; - validate_https_remote(configured.url().as_str())?; + require_https_remote(configured)?; if let Some(branch) = branch { validate_remote_name(branch)?; } @@ -984,7 +993,7 @@ impl GitRepository { ) .map_err(invalid)?; let mut repository = Self::open(&store, identity.clone())?; - repository.add_remote(configured.name().as_str(), configured.url().as_str())?; + repository.add_remote(configured.name().as_str(), configured.url())?; repository.pull_with_transport_controlled( configured, branch, @@ -1167,13 +1176,15 @@ impl GitRepository { name: name.to_owned(), })?; let text = url.to_bstring().to_str_lossy().into_owned(); - validate_https_remote(&text)?; + RemoteEndpoint::parse(&text).map_err(|_| GitError::ForbiddenRemoteUrl)?; Ok(text) } pub fn add_remote(&mut self, name: &str, url: &str) -> Result<(), GitError> { validate_remote_name(name)?; - validate_https_remote(url)?; + ensure_remote_transport_available( + &RemoteEndpoint::parse(url).map_err(|_| GitError::ForbiddenRemoteUrl)?, + )?; if self.remotes().iter().any(|existing| existing == name) { return Err(GitError::InvalidRepository(format!( "remote {name} already exists" @@ -1197,7 +1208,9 @@ impl GitRepository { pub fn set_remote_url(&mut self, name: &str, url: &str) -> Result<(), GitError> { validate_remote_name(name)?; - validate_https_remote(url)?; + ensure_remote_transport_available( + &RemoteEndpoint::parse(url).map_err(|_| GitError::ForbiddenRemoteUrl)?, + )?; if !self.remotes().iter().any(|existing| existing == name) { return Err(GitError::RemoteNotFound { name: name.to_owned(), @@ -1248,7 +1261,9 @@ impl GitRepository { )); } validate_remote_name(&parts[1..parts.len() - 1].join("."))?; - validate_https_remote(value)?; + ensure_remote_transport_available( + &RemoteEndpoint::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?, + )?; } if value.contains(['\n', '\r', '\0']) { return Err(GitError::InvalidRepository( @@ -1293,12 +1308,12 @@ impl GitRepository { control.checkpoint(GitProgressPhase::Validating)?; let name = configured.name().as_str(); let actual_url = self.remote_url(name)?; - if actual_url != configured.url().as_str() { + if !same_remote_endpoint(&actual_url, configured.url())? { return Err(GitError::ForbiddenRemoteUrl); } + let (_, server_id, application_id) = require_https_remote(configured)?; control.checkpoint(GitProgressPhase::Authenticating)?; - let credential = - credentials.credential(configured.server_id(), configured.application_id())?; + let credential = credentials.credential(server_id, application_id)?; let received_pack = transport.fetch_controlled(self, configured, &credential, control)?; Ok(FetchOutcome { remote: name.to_owned(), @@ -1333,7 +1348,7 @@ impl GitRepository { .map_err(|_| GitError::CredentialsUnavailable)? .to_owned(); let username = credential.username().to_owned(); - let expected_origin = configured.url().clone(); + let expected_origin = require_https_remote(configured)?.0.clone(); let remote = self .repository .find_fetch_remote(Some(name.into())) @@ -1576,10 +1591,10 @@ impl GitRepository { } let name = configured.name().as_str(); let actual_url = self.remote_url(name)?; - if actual_url != configured.url().as_str() { + if !same_remote_endpoint(&actual_url, configured.url())? { return Err(GitError::ForbiddenRemoteUrl); } - let url = validate_https_remote(&actual_url)?; + let (url, server_id, application_id) = require_https_remote(configured)?; let branch = branch.map_or_else( || self.current_branch(), |branch| { @@ -1594,10 +1609,9 @@ impl GitRepository { .map_err(|_| GitError::UnbornHead)? .detach(); control.checkpoint(GitProgressPhase::Authenticating)?; - let credential = - credentials.credential(configured.server_id(), configured.application_id())?; + let credential = credentials.credential(server_id, application_id)?; let advertisement = - transport.advertise_receive_pack_controlled(&url, &credential, control)?; + transport.advertise_receive_pack_controlled(url, &credential, control)?; let advertised = parse_receive_pack_advertisement(&advertisement)?; let old = advertised.refs.get(&reference).copied(); if let Some(old) = old { @@ -1635,7 +1649,7 @@ impl GitRepository { let mut request = encode_pkt_line(command.as_bytes())?; request.extend_from_slice(b"0000"); request.extend_from_slice(&pack); - let response = transport.receive_pack_controlled(&url, &credential, request, control)?; + let response = transport.receive_pack_controlled(url, &credential, request, control)?; parse_receive_pack_result(&response, &reference)?; self.update_remote_tracking(name, &branch, new)?; Ok(PushOutcome { @@ -1706,7 +1720,7 @@ impl GitRepository { let name = configured.name().as_str(); let actual_url = if self.remotes().iter().any(|remote| remote == name) { let actual = self.remote_url(name)?; - if actual != configured.url().as_str() { + if !same_remote_endpoint(&actual, configured.url())? { return Err(GitError::ForbiddenRemoteUrl); } actual @@ -1756,7 +1770,7 @@ impl GitRepository { let status = self.status()?; let name = configured.name().as_str(); let actual_url = self.remote_url(name)?; - if actual_url != configured.url().as_str() { + if !same_remote_endpoint(&actual_url, configured.url())? { return Err(GitError::ForbiddenRemoteUrl); } let local_id = self @@ -3375,18 +3389,34 @@ fn private_temporary_directory(parent: &Path, purpose: &str) -> Result Result { - let parsed = url::Url::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?; - if parsed.scheme() != "https" - || parsed.host_str().is_none() - || !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.query().is_some() - || parsed.fragment().is_some() - { - return Err(GitError::ForbiddenRemoteUrl); +fn require_https_remote( + remote: &GitRemote, +) -> Result<(&url::Url, &ServerId, &ApplicationId), GitError> { + let url = remote + .endpoint() + .as_https() + .ok_or(GitError::UnsupportedRemoteTransport { + transport: RemoteTransport::Ssh, + })?; + let (server_id, application_id) = remote + .https_credentials() + .ok_or(GitError::ForbiddenRemoteUrl)?; + Ok((url, server_id, application_id)) +} + +fn same_remote_endpoint(actual: &str, configured: &str) -> Result { + let actual = RemoteEndpoint::parse(actual).map_err(|_| GitError::ForbiddenRemoteUrl)?; + let configured = RemoteEndpoint::parse(configured).map_err(|_| GitError::ForbiddenRemoteUrl)?; + Ok(actual == configured) +} + +fn ensure_remote_transport_available(endpoint: &RemoteEndpoint) -> Result<(), GitError> { + if endpoint.transport() == RemoteTransport::Ssh && !cfg!(feature = "ssh") { + return Err(GitError::UnsupportedRemoteTransport { + transport: RemoteTransport::Ssh, + }); } - Ok(parsed) + Ok(()) } fn same_https_origin(expected: &url::Url, requested: &url::Url) -> bool { @@ -3485,7 +3515,8 @@ fn validate_local_config_security(config: &gix_config::File) -> Result<(), GitEr )); } if let Ok(url) = config.raw_value_by("remote", Some(name.into()), "url") { - validate_https_remote(url.to_str().map_err(|_| GitError::ForbiddenRemoteUrl)?)?; + RemoteEndpoint::parse(url.to_str().map_err(|_| GitError::ForbiddenRemoteUrl)?) + .map_err(|_| GitError::ForbiddenRemoteUrl)?; } } } diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index af2fc3f..4c48a25 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -593,15 +593,7 @@ impl MobileAuthentication { drop(status); let repository_title = remote.map_or_else( || "Local Password Store".to_owned(), - |remote| { - remote - .url() - .path_segments() - .and_then(Iterator::last) - .unwrap_or("Password Store") - .trim_end_matches(".git") - .to_owned() - }, + remote_repository_title, ); Ok(MobilePreferences { sync_configured: remote.is_some(), @@ -611,12 +603,23 @@ impl MobileAuthentication { |remote| remote.url().to_string(), ), server_title: remote - .and_then(|remote| remote.url().host_str()) + .and_then(|remote| { + remote + .endpoint() + .as_https() + .and_then(url::Url::host_str) + .or_else(|| remote.endpoint().as_ssh().map(|ssh| ssh.host())) + }) .unwrap_or("Git Sync Not Configured") .to_owned(), server_identity: remote.map_or_else( || "Add an HTTPS remote when you want to sync".to_owned(), - |remote| remote.server_id().as_str().to_owned(), + |remote| { + remote.https_credentials().map_or_else( + || "SSH identity and host trust".to_owned(), + |(server_id, _)| server_id.as_str().to_owned(), + ) + }, ), application_account: remote.map(application_account).transpose()?.flatten(), default_key_title: key @@ -673,7 +676,10 @@ impl MobileAuthentication { .ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?; let store = preference_secret_store()?; store.unlock().map_err(preference_secret_error)?; - let result = store.delete_https_git_credential(remote.server_id(), remote.application_id()); + let (server_id, application_id) = remote.https_credentials().ok_or_else(|| { + config_detail("The configured Git remote does not use HTTPS credentials.") + })?; + let result = store.delete_https_git_credential(server_id, application_id); let lock_result = store.lock(); if !matches!(result, Ok(()) | Err(SecretStoreError::Missing)) { return Err(preference_secret_error(result.expect_err("checked error"))); @@ -1558,9 +1564,12 @@ fn preference_secret_store() -> Result Result, MobileAuthenticationError> { + let Some((server_id, application_id)) = remote.https_credentials() else { + return Ok(None); + }; let store = preference_secret_store()?; store.unlock().map_err(preference_secret_error)?; - let result = store.https_git_credential_account(remote.server_id(), remote.application_id()); + let result = store.https_git_credential_account(server_id, application_id); let lock_result = store.lock(); let account = match result { Ok(account) => Some(account), @@ -1572,6 +1581,26 @@ fn application_account(remote: &GitRemote) -> Result, MobileAuthe .map(|()| account) } +fn remote_repository_title(remote: &GitRemote) -> String { + let path = remote.endpoint().as_https().map_or_else( + || { + remote + .endpoint() + .as_ssh() + .map(|ssh| ssh.path().as_str()) + .unwrap_or_default() + }, + |url| url.path(), + ); + path.trim_end_matches('/') + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Password Store") + .trim_end_matches(".git") + .to_owned() +} + fn preference_secret_error(error: SecretStoreError) -> MobileAuthenticationError { MobileAuthenticationError::new( MobileAuthenticationErrorKind::SecureStorage, diff --git a/crates/storage/src/mobile_onboarding.rs b/crates/storage/src/mobile_onboarding.rs index 8989c05..e682ade 100644 --- a/crates/storage/src/mobile_onboarding.rs +++ b/crates/storage/src/mobile_onboarding.rs @@ -147,7 +147,7 @@ impl MobileOnboardingOperation { let actual = git .remote_url(request.remote.name().as_str()) .map_err(MobileOnboardingError::from_git)?; - if actual != request.remote.url().as_str() + if actual != request.remote.url() || git.current_branch().ok().as_deref() != Some(branch) { return Err(MobileOnboardingError::different_existing_clone()); @@ -219,11 +219,8 @@ impl MobileOnboardingOperation { .map_err(|_| MobileOnboardingError::inaccessible_repository())?; let mut git = GitRepository::open(&repository, config.git_identity().clone()) .map_err(MobileOnboardingError::from_git)?; - git.add_remote( - request.remote.name().as_str(), - request.remote.url().as_str(), - ) - .map_err(MobileOnboardingError::from_git)?; + git.add_remote(request.remote.name().as_str(), request.remote.url()) + .map_err(MobileOnboardingError::from_git)?; if let Err(error) = config.update_mobile_remote(Some(&request.remote)) { let _ = git.remove_remote(request.remote.name().as_str()); return Err(MobileOnboardingError::from_config(error)); @@ -481,7 +478,10 @@ impl GitCredentialProvider for MobileOnboardingRequest { server: &crate::config::ServerId, application: &crate::config::ApplicationId, ) -> Result { - if server != self.remote.server_id() || application != self.remote.application_id() { + let Some((expected_server, expected_application)) = self.remote.https_credentials() else { + return Err(GitError::CredentialsUnavailable); + }; + if server != expected_server || application != expected_application { return Err(GitError::CredentialsUnavailable); } GitCredential::new(self.account.clone(), self.token.expose().to_vec()) @@ -985,8 +985,12 @@ fn default_key( } fn store_application_token(request: &MobileOnboardingRequest) -> Result<(), MobileOnboardingError> { + let (server_id, application_id) = request + .remote + .https_credentials() + .ok_or_else(MobileOnboardingError::token_configuration)?; let credential = request - .credential(request.remote.server_id(), request.remote.application_id()) + .credential(server_id, application_id) .map_err(MobileOnboardingError::from_git)?; store_git_credential(&request.remote, &credential) } @@ -995,6 +999,9 @@ fn store_git_credential( remote: &GitRemote, credential: &GitCredential, ) -> Result<(), MobileOnboardingError> { + let (server_id, application_id) = remote + .https_credentials() + .ok_or_else(MobileOnboardingError::token_configuration)?; let store = NativeSecretStore::system( SecretCachePolicy::Disabled, SecretProtectionPolicy::device_unlocked(), @@ -1003,8 +1010,8 @@ fn store_git_credential( store.unlock().map_err(MobileOnboardingError::from_secret)?; store .store_https_git_credential( - remote.server_id(), - remote.application_id(), + server_id, + application_id, credential.username(), SecretBytes::new(credential.password().to_vec()), ) @@ -1069,7 +1076,7 @@ mod tests { ) .expect("valid request"); assert_eq!( - request.remote().url().as_str(), + request.remote().url(), "https://example.test/gitea/team/passwords.git" ); assert!(!format!("{request:?}").contains("DO-NOT-RENDER")); diff --git a/crates/storage/tests/config_contract.rs b/crates/storage/tests/config_contract.rs index 8560117..da7f81a 100644 --- a/crates/storage/tests/config_contract.rs +++ b/crates/storage/tests/config_contract.rs @@ -7,7 +7,10 @@ use ironstorage::{ authentication::{ AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT, MAX_AUTHENTICATION_TIMEOUT, }, - config::{ConfigError, ConfigLoader, EditorSource, MobileAppearance}, + config::{ + ConfigError, ConfigLoader, EditorSource, MobileAppearance, RemoteEndpoint, RemoteTransport, + SshRepositoryPath, + }, desktop::DesktopStorage, git::GitIdentity, mobile::MobileTab, @@ -84,12 +87,10 @@ fn explicit_relative_configuration_resolves_deterministically() -> TestResult { assert_eq!(config.git_remotes().len(), 1); let remote = &config.git_remotes()[0]; assert_eq!(remote.name().as_str(), "origin"); - assert_eq!( - remote.url().as_str(), - "https://git.example.test/alice/store.git" - ); - assert_eq!(remote.server_id().as_str(), "personal-git"); - assert_eq!(remote.application_id().as_str(), "ironstorage-cli"); + assert_eq!(remote.url(), "https://git.example.test/alice/store.git"); + let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials"); + assert_eq!(server_id.as_str(), "personal-git"); + assert_eq!(application_id.as_str(), "ironstorage-cli"); assert_eq!( config.clipboard_timeout().duration(), DEFAULT_CLIPBOARD_TIMEOUT @@ -564,10 +565,9 @@ fn missing_and_invalid_required_fields_are_typed() -> TestResult { } #[test] -fn git_configuration_rejects_non_https_and_embedded_credentials() -> TestResult { +fn git_configuration_rejects_forbidden_transports_and_embedded_credentials() -> TestResult { let fixture = ConfigurationFixture::new()?; for url in [ - "ssh://git@example.test/store.git", "git://example.test/store.git", "file:///tmp/store.git", "../store.git", @@ -602,6 +602,159 @@ application_id = "application" Ok(()) } +#[test] +fn ssh_remote_endpoints_parse_to_one_typed_contract() -> TestResult { + let absolute = RemoteEndpoint::parse("ssh://git@example.test:2222/repos/store.git")?; + assert_eq!(absolute.transport(), RemoteTransport::Ssh); + let ssh = absolute.as_ssh().expect("SSH endpoint"); + assert_eq!(ssh.user(), Some("git")); + assert_eq!(ssh.host(), "example.test"); + assert_eq!(ssh.port(), 2222); + assert_eq!( + ssh.path(), + &SshRepositoryPath::Absolute("/repos/store.git".to_owned()) + ); + + assert_eq!( + RemoteEndpoint::parse("ssh://git@example.test/repos/store.git")?, + RemoteEndpoint::parse("git@example.test:/repos/store.git")? + ); + assert_eq!( + RemoteEndpoint::parse("ssh://git@example.test/~alice/store.git")?, + RemoteEndpoint::parse("git@example.test:~alice/store.git")? + ); + + let relative = RemoteEndpoint::parse("git@example.test:team/store.git")?; + assert_eq!( + relative.as_ssh().expect("SSH endpoint").path(), + &SshRepositoryPath::Relative("team/store.git".to_owned()) + ); + let ipv6 = RemoteEndpoint::parse("ssh://git@[2001:db8::1]:2200/store.git")?; + assert_eq!(ipv6.as_ssh().expect("IPv6 endpoint").port(), 2200); + let ipv4 = RemoteEndpoint::parse("git@192.0.2.10:team/store.git")?; + assert_eq!(ipv4.as_ssh().expect("IPv4 endpoint").host(), "192.0.2.10"); + + let unicode = RemoteEndpoint::parse("git@bücher.example:team/密码.git")?; + let unicode = unicode.as_ssh().expect("Unicode endpoint"); + assert_eq!(unicode.host(), "xn--bcher-kva.example"); + assert_eq!(unicode.path().as_str(), "team/密码.git"); + + let inert = "team/repo';touch${IFS}pwned.git"; + assert_eq!( + RemoteEndpoint::parse(&format!("git@example.test:{inert}"))? + .as_ssh() + .expect("literal path") + .path() + .as_str(), + inert + ); + Ok(()) +} + +#[test] +fn remote_credentials_are_explicitly_transport_specific() -> TestResult { + let fixture = ConfigurationFixture::new()?; + fixture.write_explicit( + r#" +vault = "vault" +default_key = "alice" +key_material = "keys" + +[[git.remotes]] +name = "origin" +url = "git@example.test:team/store.git" +server_id = "server" +application_id = "application" +"#, + )?; + assert_eq!( + fixture + .loader() + .load(Some(&fixture.explicit_path())) + .expect_err("HTTPS credentials cannot configure SSH"), + ConfigError::InvalidField { + field: "git.remotes.https_credentials" + } + ); + + fixture.write_explicit( + r#" +vault = "vault" +default_key = "alice" +key_material = "keys" + +[[git.remotes]] +name = "origin" +url = "https://example.test/team/store.git" +"#, + )?; + assert_eq!( + fixture + .loader() + .load(Some(&fixture.explicit_path())) + .expect_err("HTTPS credentials are required"), + ConfigError::MissingField { + field: "git.remotes.server_id" + } + ); + Ok(()) +} + +#[test] +fn ssh_remote_configuration_round_trips_without_https_credentials() -> TestResult { + let fixture = ConfigurationFixture::new()?; + fixture.write_explicit( + r#" +vault = "vault" +default_key = "alice" +key_material = "keys" + +[[git.remotes]] +name = "origin" +url = "git@example.test:team/store.git" +"#, + )?; + let config = fixture.loader().load(Some(&fixture.explicit_path()))?; + let remote = &config.git_remotes()[0]; + assert_eq!(remote.url(), "git@example.test:team/store.git"); + assert!(remote.https_credentials().is_none()); + + config.update_git_identity(&GitIdentity::new("Alice", "alice@example.test")?)?; + let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?; + assert_eq!(reloaded.git_remotes(), config.git_remotes()); + let persisted = fs::read_to_string(fixture.explicit_path())?; + assert!(!persisted.contains("server_id")); + assert!(!persisted.contains("application_id")); + Ok(()) +} + +#[test] +fn ssh_remote_parser_rejects_ambiguous_local_and_executable_inputs() { + for remote in [ + "", + "ssh://example.test", + "ssh://example.test/", + "ssh://user:secret@example.test/store.git", + "ssh://example.test/store.git?option=value", + "ssh://example.test/store.git#fragment", + "ssh://example.test/%0Acommand", + "git://example.test/store.git", + "file:///tmp/store.git", + "ext::helper command", + "../store.git", + "/tmp/store.git", + "C:/store.git", + "git@2001:db8::1:store.git", + "git@example.test:-upload-pack=evil", + "git@example.test:repo\ncommand", + ] { + assert!( + RemoteEndpoint::parse(remote).is_err(), + "forbidden remote should fail closed: {remote:?}" + ); + } +} + #[test] fn duplicate_remote_names_and_credential_references_are_rejected() -> TestResult { let fixture = ConfigurationFixture::new()?; diff --git a/crates/storage/tests/git_embedded.rs b/crates/storage/tests/git_embedded.rs index a5b2340..92a6f95 100644 --- a/crates/storage/tests/git_embedded.rs +++ b/crates/storage/tests/git_embedded.rs @@ -137,13 +137,11 @@ fn nested_repository_selection_is_innermost() -> TestResult { } #[test] -fn remotes_and_config_are_local_https_only() -> TestResult { +fn remotes_and_config_reject_local_helper_and_credential_urls() -> TestResult { let temporary = tempfile::tempdir()?; let store = Repository::open(temporary.path())?; let mut git = GitRepository::init(&store, identity())?; for forbidden in [ - "ssh://example.test/store.git", - "git@example.test:store.git", "git://example.test/store.git", "file:///tmp/store.git", "../store.git", @@ -172,6 +170,52 @@ fn remotes_and_config_are_local_https_only() -> TestResult { Ok(()) } +#[cfg(not(feature = "ssh"))] +#[test] +fn ssh_remotes_are_typed_but_unavailable_before_transport_or_mutation() -> TestResult { + let temporary = tempfile::tempdir()?; + let store = Repository::open(temporary.path())?; + let mut git = GitRepository::init(&store, identity())?; + let remote = GitRemote::ssh("origin", "git@example.test:team/store.git")?; + let unsupported = GitError::UnsupportedRemoteTransport { + transport: ironstorage::config::RemoteTransport::Ssh, + }; + + assert_eq!( + git.add_remote(remote.name().as_str(), remote.url()), + Err(unsupported.clone()) + ); + assert!(git.remotes().is_empty()); + assert_eq!( + git.config_set("remote.origin.url", remote.url()), + Err(unsupported.clone()) + ); + assert!(git.config_get("remote.origin.url")?.is_none()); + assert_eq!( + GitRepository::discover_remote_branches_with_transport( + temporary.path(), + identity(), + &remote, + &Credentials, + &CloningFetch, + &GitOperationControl::default(), + ), + Err(unsupported) + ); + Ok(()) +} + +#[cfg(feature = "ssh")] +#[test] +fn ssh_feature_allows_repository_remote_configuration() -> TestResult { + let temporary = tempfile::tempdir()?; + let store = Repository::open(temporary.path())?; + let mut git = GitRepository::init(&store, identity())?; + git.add_remote("origin", "git@example.test:team/store.git")?; + assert_eq!(git.remote_url("origin")?, "git@example.test:team/store.git"); + Ok(()) +} + struct Credentials; impl GitCredentialProvider for Credentials { @@ -347,7 +391,7 @@ fn injected_smart_http_push_sends_a_complete_pack_and_credentials() -> TestResul let remote: &GitRemote = &config.git_remotes()[0]; let store = Repository::open(config.vault())?; let mut git = GitRepository::init(&store, identity())?; - git.add_remote("origin", remote.url().as_str())?; + git.add_remote("origin", remote.url())?; fs::write(config.vault().join("secret.gpg"), b"ciphertext")?; git.stage(&["secret.gpg".into()])?; let head = git.commit("Add secret to store.")?; @@ -381,7 +425,7 @@ fn controlled_sync_pulls_then_pushes_with_one_progress_and_cancellation_contract let remote = &config.git_remotes()[0]; let store = Repository::open(config.vault())?; let mut git = GitRepository::init(&store, identity())?; - git.add_remote("origin", remote.url().as_str())?; + git.add_remote("origin", remote.url())?; let head = git.log(Some(1))?[0].id().to_owned(); set_remote_tracking(config.vault(), &head)?; let phases = Arc::new(Mutex::new(Vec::new())); @@ -409,7 +453,7 @@ fn push_propagates_authentication_and_rejects_non_fast_forward_before_upload() - let remote = &config.git_remotes()[0]; let store = Repository::open(config.vault())?; let mut git = GitRepository::init(&store, identity())?; - git.add_remote("origin", remote.url().as_str())?; + git.add_remote("origin", remote.url())?; assert_eq!( git.push_with_transport(remote, Some("main"), &Credentials, &AuthenticationFailure), Err(GitError::AuthenticationFailed) @@ -433,7 +477,7 @@ fn fetched_branches_fast_forward_and_report_typed_conflicts() -> TestResult { let remote = &config.git_remotes()[0]; let store = Repository::open(config.vault())?; let mut git = GitRepository::init(&store, identity())?; - git.add_remote("origin", remote.url().as_str())?; + git.add_remote("origin", remote.url())?; fs::write(config.vault().join("secret.gpg"), b"base")?; git.stage(&["secret.gpg".into()])?; @@ -511,7 +555,7 @@ fn cancelled_operations_stop_before_credentials_or_transport() -> TestResult { let remote = &config.git_remotes()[0]; let store = Repository::open(config.vault())?; let mut git = GitRepository::init(&store, identity())?; - git.add_remote("origin", remote.url().as_str())?; + git.add_remote("origin", remote.url())?; let control = GitOperationControl::default(); control.cancel(); assert_eq!( diff --git a/crates/storage/tests/secret_store.rs b/crates/storage/tests/secret_store.rs index e9b791f..66b2861 100644 --- a/crates/storage/tests/secret_store.rs +++ b/crates/storage/tests/secret_store.rs @@ -339,20 +339,21 @@ fn git_account_status_and_removal_never_expose_the_token() -> TestResult { "personal-git", "ironstorage-mobile", )?; + let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials"); store.unlock()?; store.store_https_git_credential( - remote.server_id(), - remote.application_id(), + server_id, + application_id, "alice", SecretBytes::new(b"private-token".to_vec()), )?; assert_eq!( - store.https_git_credential_account(remote.server_id(), remote.application_id())?, + store.https_git_credential_account(server_id, application_id)?, "alice" ); - store.delete_https_git_credential(remote.server_id(), remote.application_id())?; + store.delete_https_git_credential(server_id, application_id)?; assert!(matches!( - store.https_git_credential_account(remote.server_id(), remote.application_id()), + store.https_git_credential_account(server_id, application_id), Err(SecretStoreError::Missing) )); assert!(!format!("{store:?}").contains("private-token")); @@ -396,28 +397,29 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native")) .load(Some(&temporary.path().join("config.toml")))?; let remote = &config.git_remotes()[0]; + let (server_id, application_id) = remote.https_credentials().expect("HTTPS credentials"); backend.fail_next(SecretStoreError::Cancelled); assert!(matches!( - store.credential(remote.server_id(), remote.application_id()), + store.credential(server_id, application_id), Err(GitError::CredentialCancelled) )); backend.fail_next(SecretStoreError::Denied); assert!(matches!( - store.credential(remote.server_id(), remote.application_id()), + store.credential(server_id, application_id), Err(GitError::CredentialAccessDenied) )); let git = SecretReference::https_git_credential( - remote.server_id().as_str(), - remote.application_id().as_str(), + server_id.as_str(), + application_id.as_str(), "alice", )?; store.create(&git, SecretBytes::new(b"https-token".to_vec()))?; - let credential = store.credential(remote.server_id(), remote.application_id())?; + let credential = store.credential(server_id, application_id)?; assert_eq!(credential.username(), "alice"); assert_eq!(credential.password(), b"https-token"); store.store_https_git_credential( - remote.server_id(), - remote.application_id(), + server_id, + application_id, "bob", SecretBytes::new(b"replacement-token".to_vec()), )?; @@ -425,7 +427,7 @@ fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult store.retrieve(&git), Err(SecretStoreError::Missing) )); - let credential = store.credential(remote.server_id(), remote.application_id())?; + let credential = store.credential(server_id, application_id)?; assert_eq!(credential.username(), "bob"); assert_eq!(credential.password(), b"replacement-token"); assert!( diff --git a/docs/configuration.md b/docs/configuration.md index b131faf..fda353e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,13 +62,38 @@ configured editor. IronStorage. Both must be present together and cannot contain line breaks or angle brackets. The built-in IronStorage identity is used when both are absent. -Git remotes are HTTPS-only. URLs containing user information, passwords, -queries, or fragments are rejected. `server_id` and `application_id` are opaque -references used to retrieve credentials from the operating-system secret -store; duplicate names and duplicate reference pairs are errors. +HTTPS remote URLs containing user information, passwords, queries, or fragments +are rejected. `server_id` and `application_id` are opaque +HTTPS-only references used to retrieve credentials from the operating-system +secret store; duplicate names and duplicate reference pairs are errors. The HTTPS account name is stored inside the protected credential record, not in TOML. OpenPGP passphrases are addressed by the resolved primary fingerprint. +SSH remotes use either `ssh://[user@]host[:port]/path` or scp-like +`[user@]host:path` syntax and omit the HTTPS credential fields: + +```toml +[[git.remotes]] +name = "origin" +url = "git@git.example.test:alice/password-store.git" +``` + +The typed endpoint model is always available so an SSH remote remains readable +in configuration even when the binary was built without SSH. Such a build +returns a typed unsupported-transport error before connection or repository +mutation. The optional storage `ssh` feature contains `russh` 0.63.1 and Tokio; +`russh` default features are disabled and the Ring backend plus RSA key support +are selected explicitly. + +For SSH, URI paths are absolute, scp-like paths without a leading slash are +relative to the remote account, and `~`/`~user` paths retain tilde-expansion +semantics. Bracketed IPv6 and explicit URI ports are accepted. Host names are +IDNA-normalized, repository paths may contain Unicode, and usernames are +restricted to ASCII letters, digits, `.`, `_`, and `-`. Empty paths, control +bytes, credentials, queries, fragments, ambiguous unbracketed IPv6 or colon +paths, leading-option paths, local paths, URL rewrites, separate push URLs, +helper transports, and unknown schemes fail closed. + `clipboard_timeout_seconds` controls the native clipboard presentation lease. It defaults to 45 seconds for upstream `pass` compatibility and must be between 1 and 300 seconds. The CLI remains alive for the lease so Linux can serve its diff --git a/docs/git-synchronization.md b/docs/git-synchronization.md index a4fe2e3..27017a2 100644 --- a/docs/git-synchronization.md +++ b/docs/git-synchronization.md @@ -20,13 +20,22 @@ committer. Only their affected paths are staged, unrelated index state is preserved, no-op mutations create no commit, and commit failures restore the index so the storage transaction can roll back its files. +## Remote endpoint contract + +Storage parses credential-free HTTPS URLs plus feature-gated `ssh://` and +scp-like SSH URLs into one typed endpoint contract. Local paths, `git://`, +`file://`, helper transports, URL rewrites, separate push URLs, embedded +credentials, and unknown schemes are rejected before transport. A build +without the `ssh` feature reports SSH as unsupported before connection or +repository mutation instead of treating its configuration as malformed. + ## HTTPS transport -Remote URLs must be absolute, credential-free HTTPS URLs. SSH, scp syntax, -`git://`, `file://`, local paths, helper transports, URL rewrites, separate push -URLs, and unknown schemes are rejected before transport. Credentials are -requested with the configured server ID and application ID and remain outside -Git configuration. +HTTPS credentials are requested with the configured server ID and application +ID and remain outside Git configuration. The SSH session, host-verification, +authentication, and pack-protocol implementations are separate milestone work; +until those layers are present, network operations on SSH endpoints return the +typed unsupported-transport result. Fetch uses the embedded Rust smart-HTTP client with an explicit credential callback, so Git's credential cascade is never entered. Push implements the