Implement SSH identity authentication
This commit is contained in:
81
Cargo.lock
generated
81
Cargo.lock
generated
@@ -290,7 +290,7 @@ dependencies = [
|
|||||||
"p12",
|
"p12",
|
||||||
"p256 0.13.2",
|
"p256 0.13.2",
|
||||||
"pem",
|
"pem",
|
||||||
"pkcs1",
|
"pkcs1 0.7.5",
|
||||||
"pkcs8 0.10.2",
|
"pkcs8 0.10.2",
|
||||||
"plist",
|
"plist",
|
||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
@@ -299,7 +299,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"ring",
|
"ring",
|
||||||
"rsa",
|
"rsa 0.9.10",
|
||||||
"scroll",
|
"scroll",
|
||||||
"security-framework 2.11.1",
|
"security-framework 2.11.1",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
@@ -2020,6 +2020,16 @@ dependencies = [
|
|||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-primes"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-bigint 0.7.5",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptographic-message-syntax"
|
name = "cryptographic-message-syntax"
|
||||||
version = "0.27.0"
|
version = "0.27.0"
|
||||||
@@ -2807,7 +2817,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3834,7 +3844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1"
|
checksum = "1a791e6620676a875f362f3156ed213e73ca099a09bf992c18812abe65cc37b1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bstr",
|
"bstr",
|
||||||
"hashbrown 0.15.5",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5378,6 +5388,7 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
"url",
|
"url",
|
||||||
"windows-native-keyring-store",
|
"windows-native-keyring-store",
|
||||||
@@ -5463,7 +5474,7 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd"
|
checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6947,7 +6958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7354,7 +7365,7 @@ dependencies = [
|
|||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
"replace_with",
|
"replace_with",
|
||||||
"ripemd",
|
"ripemd",
|
||||||
"rsa",
|
"rsa 0.9.10",
|
||||||
"sha1 0.10.7",
|
"sha1 0.10.7",
|
||||||
"sha1-checked",
|
"sha1-checked",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
@@ -7484,6 +7495,16 @@ dependencies = [
|
|||||||
"spki 0.7.3",
|
"spki 0.7.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs1"
|
||||||
|
version = "0.8.0-rc.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e"
|
||||||
|
dependencies = [
|
||||||
|
"der 0.8.1",
|
||||||
|
"spki 0.8.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkcs5"
|
name = "pkcs5"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -7857,7 +7878,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8446,7 +8467,7 @@ dependencies = [
|
|||||||
"num-bigint-dig",
|
"num-bigint-dig",
|
||||||
"num-integer",
|
"num-integer",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs1",
|
"pkcs1 0.7.5",
|
||||||
"pkcs8 0.10.2",
|
"pkcs8 0.10.2",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"signature 2.2.0",
|
"signature 2.2.0",
|
||||||
@@ -8455,6 +8476,25 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rsa"
|
||||||
|
version = "0.10.0-rc.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid 0.10.2",
|
||||||
|
"crypto-bigint 0.7.5",
|
||||||
|
"crypto-primes",
|
||||||
|
"digest 0.11.3",
|
||||||
|
"pkcs1 0.8.0-rc.4",
|
||||||
|
"pkcs8 0.11.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"signature 3.0.0",
|
||||||
|
"spki 0.8.0",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rtoolbox"
|
name = "rtoolbox"
|
||||||
version = "0.0.5"
|
version = "0.0.5"
|
||||||
@@ -8508,12 +8548,14 @@ dependencies = [
|
|||||||
"p521 0.14.0",
|
"p521 0.14.0",
|
||||||
"pageant",
|
"pageant",
|
||||||
"pbkdf2",
|
"pbkdf2",
|
||||||
|
"pkcs1 0.8.0-rc.4",
|
||||||
"pkcs5",
|
"pkcs5",
|
||||||
"pkcs8 0.11.0",
|
"pkcs8 0.11.0",
|
||||||
"polyval 0.7.3",
|
"polyval 0.7.3",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"ring",
|
"ring",
|
||||||
|
"rsa 0.10.0-rc.18",
|
||||||
"russh-cryptovec",
|
"russh-cryptovec",
|
||||||
"russh-util",
|
"russh-util",
|
||||||
"salsa20",
|
"salsa20",
|
||||||
@@ -8623,7 +8665,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8713,7 +8755,7 @@ dependencies = [
|
|||||||
"security-framework 3.7.0",
|
"security-framework 3.7.0",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9574,6 +9616,7 @@ checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"crypto-bigint 0.7.5",
|
||||||
"ctutils",
|
"ctutils",
|
||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
"pem-rfc7468 1.0.0",
|
"pem-rfc7468 1.0.0",
|
||||||
@@ -9596,6 +9639,7 @@ dependencies = [
|
|||||||
"p384 0.14.0",
|
"p384 0.14.0",
|
||||||
"p521 0.14.0",
|
"p521 0.14.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
|
"rsa 0.10.0-rc.18",
|
||||||
"sec1 0.8.1",
|
"sec1 0.8.1",
|
||||||
"sha1 0.11.0",
|
"sha1 0.11.0",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
@@ -9770,7 +9814,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10040,6 +10084,17 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-stream"
|
||||||
|
version = "0.1.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.19"
|
version = "0.7.19"
|
||||||
@@ -11198,7 +11253,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ reqwest = { version = "0.13", default-features = false, features = ["blocking",
|
|||||||
rfd = { version = "0.17", default-features = false }
|
rfd = { version = "0.17", default-features = false }
|
||||||
rqrr = { version = "0.10", default-features = false }
|
rqrr = { version = "0.10", default-features = false }
|
||||||
rpassword = "7.5"
|
rpassword = "7.5"
|
||||||
russh = { version = "0.63.1", default-features = false, features = ["ring"] }
|
russh = { version = "0.63.1", default-features = false, features = ["ring", "rsa"] }
|
||||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] }
|
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color", "unstable-rendered-line-info"] }
|
||||||
security-framework = "3.7"
|
security-framework = "3.7"
|
||||||
secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||||
@@ -63,7 +63,8 @@ sha2 = "0.10"
|
|||||||
shlex = "1.3"
|
shlex = "1.3"
|
||||||
simple-file-manifest = "0.11"
|
simple-file-manifest = "0.11"
|
||||||
toml = "0.9"
|
toml = "0.9"
|
||||||
tokio = { version = "1.53.1", default-features = false, features = ["io-util", "net", "rt-multi-thread", "sync", "time"] }
|
tokio = { version = "1.53.1", default-features = false, features = ["io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
||||||
|
tokio-stream = { version = "0.1", features = ["net"] }
|
||||||
uniffi = "0.32"
|
uniffi = "0.32"
|
||||||
url = { version = "2.5", default-features = false }
|
url = { version = "2.5", default-features = false }
|
||||||
windows-native-keyring-store = { version = "1.1", default-features = false }
|
windows-native-keyring-store = { version = "1.1", default-features = false }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Dependency and license review
|
# Dependency and license review
|
||||||
|
|
||||||
Reviewed 2026-08-16. The OpenPGP backend decision and release packaging review
|
Reviewed 2026-08-25. The OpenPGP backend decision and release packaging review
|
||||||
are complete. IronStorage is licensed under the MIT License. Every package in
|
are complete. IronStorage is licensed under the MIT License. Every package in
|
||||||
the Rust workspace declares that license, and the iPhone release bundles the
|
the Rust workspace declares that license, and the iPhone release bundles the
|
||||||
exact third-party license files from its Cargo dependency graph.
|
exact third-party license files from its Cargo dependency graph.
|
||||||
@@ -23,6 +23,7 @@ The current direct dependencies are:
|
|||||||
| [rfd 0.17](https://crates.io/crates/rfd/0.17.2), [ashpd 0.13](https://crates.io/crates/ashpd/0.13.13) | Native macOS/Windows folder picker and Linux XDG portal folder picker | MIT |
|
| [rfd 0.17](https://crates.io/crates/rfd/0.17.2), [ashpd 0.13](https://crates.io/crates/ashpd/0.13.13) | Native macOS/Windows folder picker and Linux XDG portal folder picker | MIT |
|
||||||
| [gix 0.86](https://crates.io/crates/gix/0.86.0), [gix-config 0.59](https://crates.io/crates/gix-config/0.59.0) | Embedded Git objects, index, references, fetch, and merge | MIT OR Apache-2.0 |
|
| [gix 0.86](https://crates.io/crates/gix/0.86.0), [gix-config 0.59](https://crates.io/crates/gix-config/0.59.0) | Embedded Git objects, index, references, fetch, and merge | MIT OR Apache-2.0 |
|
||||||
| [reqwest 0.13](https://crates.io/crates/reqwest/0.13.4) | HTTPS smart-Git transport with Rustls | MIT OR Apache-2.0 |
|
| [reqwest 0.13](https://crates.io/crates/reqwest/0.13.4) | HTTPS smart-Git transport with Rustls | MIT OR Apache-2.0 |
|
||||||
|
| [russh 0.63](https://crates.io/crates/russh/0.63.1), [Tokio 1.53](https://crates.io/crates/tokio/1.53.1) | Optional pure-Rust SSH transport, key/agent authentication, and protocol runtime | Apache-2.0 OR MIT; MIT |
|
||||||
| [flate2 1.1](https://crates.io/crates/flate2/1.1.9), [sha1 0.10](https://crates.io/crates/sha1/0.10.7) | Git pack compression and checksums | MIT OR Apache-2.0 |
|
| [flate2 1.1](https://crates.io/crates/flate2/1.1.9), [sha1 0.10](https://crates.io/crates/sha1/0.10.7) | Git pack compression and checksums | MIT OR Apache-2.0 |
|
||||||
| [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 |
|
| [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 |
|
||||||
| [keepass 0.13](https://crates.io/crates/keepass/0.13.20) | Pure-Rust KeePass KDBX 3/4 decryption and parsing for direct imports | MIT |
|
| [keepass 0.13](https://crates.io/crates/keepass/0.13.20) | Pure-Rust KeePass KDBX 3/4 decryption and parsing for direct imports | MIT |
|
||||||
@@ -51,6 +52,7 @@ decision.
|
|||||||
| Alternative GPG implementation | [`sequoia-openpgp` 2.4](https://crates.io/crates/sequoia-openpgp/2.4.1) | LGPL-2.0-or-later | Hold in reserve. Its default Nettle backend is native; its Rust backend exists, but the LGPL adds distribution work we can avoid. |
|
| Alternative GPG implementation | [`sequoia-openpgp` 2.4](https://crates.io/crates/sequoia-openpgp/2.4.1) | LGPL-2.0-or-later | Hold in reserve. Its default Nettle backend is native; its Rust backend exists, but the LGPL adds distribution work we can avoid. |
|
||||||
| GnuPG integration | [`gpgme` 0.11](https://crates.io/crates/gpgme/0.11.0) | LGPL-2.1 | Reject: native GPGME/GnuPG integration and GPG engine processes violate the portability and no-process requirements. |
|
| GnuPG integration | [`gpgme` 0.11](https://crates.io/crates/gpgme/0.11.0) | LGPL-2.1 | Reject: native GPGME/GnuPG integration and GPG engine processes violate the portability and no-process requirements. |
|
||||||
| Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Selected with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only, supply credentials directly, and use the storage-owned receive-pack implementation for push. |
|
| Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Selected with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only, supply credentials directly, and use the storage-owned receive-pack implementation for push. |
|
||||||
|
| SSH client and agent protocol | [`russh` 0.63](https://crates.io/crates/russh/0.63.1) | Apache-2.0 OR MIT | Selected behind the optional `ssh` feature with default features off and Ring/RSA enabled. Storage restricts host and authentication signatures to modern algorithms, selects exactly one configured key or agent fingerprint, and never invokes OpenSSH or another process. |
|
||||||
| Git FFI fallback | [`git2` 0.21](https://crates.io/crates/git2/0.21.0) | MIT OR Apache-2.0 | Reject for now; it links libgit2 and is unnecessary for the HTTPS-only scope. |
|
| Git FFI fallback | [`git2` 0.21](https://crates.io/crates/git2/0.21.0) | MIT OR Apache-2.0 | Reject for now; it links libgit2 and is unnecessary for the HTTPS-only scope. |
|
||||||
| Server/application credentials | [`keyring-core` 1.0](https://crates.io/crates/keyring-core/1.0.0), [`apple-native-keyring-store` 1.0](https://crates.io/crates/apple-native-keyring-store/1.0.2), [`windows-native-keyring-store` 1.1](https://crates.io/crates/windows-native-keyring-store/1.1.0), [`zbus-secret-service-keyring-store` 1.0](https://crates.io/crates/zbus-secret-service-keyring-store/1.0.0) | MIT OR Apache-2.0 | Selected behind target-specific dependencies. Apple supports legacy Keychain plus protected-data user presence, Windows uses Credential Manager, and Linux uses Secret Service with the Tokio/Rust-crypto feature. |
|
| Server/application credentials | [`keyring-core` 1.0](https://crates.io/crates/keyring-core/1.0.0), [`apple-native-keyring-store` 1.0](https://crates.io/crates/apple-native-keyring-store/1.0.2), [`windows-native-keyring-store` 1.1](https://crates.io/crates/windows-native-keyring-store/1.1.0), [`zbus-secret-service-keyring-store` 1.0](https://crates.io/crates/zbus-secret-service-keyring-store/1.0.0) | MIT OR Apache-2.0 | Selected behind target-specific dependencies. Apple supports legacy Keychain plus protected-data user presence, Windows uses Credential Manager, and Linux uses Secret Service with the Tokio/Rust-crypto feature. |
|
||||||
| Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | `zeroize` selected for the storage-owned redacted byte type; consider `secrecy` only when typed exposure controls add value. |
|
| Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | `zeroize` selected for the storage-owned redacted byte type; consider `secrecy` only when typed exposure controls add value. |
|
||||||
@@ -68,6 +70,17 @@ With this path, the central crate needs no third-party native GPG, Git, OTP, or
|
|||||||
QR library. Apple Security/LocalAuthentication, Windows Credential Manager,
|
QR library. Apple Security/LocalAuthentication, Windows Credential Manager,
|
||||||
Linux Secret Service, and Apple camera APIs remain operating-system boundaries.
|
Linux Secret Service, and Apple camera APIs remain operating-system boundaries.
|
||||||
|
|
||||||
|
## Security audit
|
||||||
|
|
||||||
|
`cargo audit` 0.22.2 was run against the 2026-08-25 lockfile. It reports
|
||||||
|
RUSTSEC-2023-0071 for `rsa` 0.9.10 (through `pgp`) and 0.10.0-rc.18 (through
|
||||||
|
Russh); no fixed release is available. The Marvin advisory concerns RSA
|
||||||
|
decryption timing. The SSH dependency is used for signatures, while the
|
||||||
|
existing OpenPGP dependency can decrypt RSA material, so the finding remains a
|
||||||
|
tracked risk rather than an ignored or suppressed advisory. The audit also
|
||||||
|
reports allowed unmaintained/unsound warnings in transitive UI and utility
|
||||||
|
dependencies; none are hidden by an audit configuration.
|
||||||
|
|
||||||
## Release packaging
|
## Release packaging
|
||||||
|
|
||||||
`ironstorage-apple-release licenses` traverses the iPhone Rust library's locked,
|
`ironstorage-apple-release licenses` traverses the iPhone Rust library's locked,
|
||||||
|
|||||||
@@ -94,3 +94,4 @@ nix = { version = "0.31", features = ["fs"] }
|
|||||||
rand_chacha = "0.3"
|
rand_chacha = "0.3"
|
||||||
smallvec = "1.15"
|
smallvec = "1.15"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
tokio-stream.workspace = true
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ impl Config {
|
|||||||
Some(remote) => {
|
Some(remote) => {
|
||||||
git.insert(
|
git.insert(
|
||||||
"remotes".to_owned(),
|
"remotes".to_owned(),
|
||||||
toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote))]),
|
toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote)?)]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -497,7 +497,7 @@ impl Config {
|
|||||||
let mut git = toml::Table::new();
|
let mut git = toml::Table::new();
|
||||||
git.insert(
|
git.insert(
|
||||||
"remotes".to_owned(),
|
"remotes".to_owned(),
|
||||||
toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote))]),
|
toml::Value::Array(vec![toml::Value::Table(git_remote_document(remote)?)]),
|
||||||
);
|
);
|
||||||
root.insert("git".to_owned(), toml::Value::Table(git));
|
root.insert("git".to_owned(), toml::Value::Table(git));
|
||||||
}
|
}
|
||||||
@@ -734,7 +734,7 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn git_remote_document(remote: &GitRemote) -> toml::Table {
|
fn git_remote_document(remote: &GitRemote) -> Result<toml::Table, ConfigError> {
|
||||||
let mut configured = toml::Table::new();
|
let mut configured = toml::Table::new();
|
||||||
configured.insert(
|
configured.insert(
|
||||||
"name".to_owned(),
|
"name".to_owned(),
|
||||||
@@ -754,7 +754,39 @@ fn git_remote_document(remote: &GitRemote) -> toml::Table {
|
|||||||
toml::Value::String(application_id.as_str().to_owned()),
|
toml::Value::String(application_id.as_str().to_owned()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
configured
|
if let Some(authentication) = remote.ssh_authentication() {
|
||||||
|
match authentication.identity() {
|
||||||
|
SshIdentitySource::KeyFile(path) => {
|
||||||
|
configured.insert(
|
||||||
|
"ssh_identity_file".to_owned(),
|
||||||
|
toml::Value::String(path_text(path, "git.remotes.ssh_identity_file")?),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
SshIdentitySource::Agent {
|
||||||
|
fingerprint,
|
||||||
|
socket,
|
||||||
|
} => {
|
||||||
|
configured.insert(
|
||||||
|
"ssh_agent_fingerprint".to_owned(),
|
||||||
|
toml::Value::String(fingerprint.to_string()),
|
||||||
|
);
|
||||||
|
if let Some(socket) = socket {
|
||||||
|
configured.insert(
|
||||||
|
"ssh_agent_socket".to_owned(),
|
||||||
|
toml::Value::String(path_text(socket, "git.remotes.ssh_agent_socket")?),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configured.insert(
|
||||||
|
"ssh_known_hosts_file".to_owned(),
|
||||||
|
toml::Value::String(path_text(
|
||||||
|
authentication.known_hosts_file(),
|
||||||
|
"git.remotes.ssh_known_hosts_file",
|
||||||
|
)?),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(configured)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deterministic path context for configuration loading.
|
/// Deterministic path context for configuration loading.
|
||||||
@@ -940,6 +972,114 @@ pub struct SshEndpoint {
|
|||||||
path: SshRepositoryPath,
|
path: SshRepositoryPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct SshFingerprint(String);
|
||||||
|
|
||||||
|
impl SshFingerprint {
|
||||||
|
pub fn parse(value: impl Into<String>) -> Result<Self, InvalidRemoteEndpoint> {
|
||||||
|
let value = value.into();
|
||||||
|
let encoded = value.strip_prefix("SHA256:").ok_or(InvalidRemoteEndpoint)?;
|
||||||
|
if encoded.len() != 43 {
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
let digest = data_encoding::BASE64_NOPAD
|
||||||
|
.decode(encoded.as_bytes())
|
||||||
|
.map_err(|_| InvalidRemoteEndpoint)?;
|
||||||
|
if digest.len() != 32 {
|
||||||
|
return Err(InvalidRemoteEndpoint);
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SshFingerprint {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum SshIdentitySource {
|
||||||
|
KeyFile(PathBuf),
|
||||||
|
Agent {
|
||||||
|
fingerprint: SshFingerprint,
|
||||||
|
socket: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshIdentitySource {
|
||||||
|
pub fn key_file(&self) -> Option<&Path> {
|
||||||
|
match self {
|
||||||
|
Self::KeyFile(path) => Some(path),
|
||||||
|
Self::Agent { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn agent_fingerprint(&self) -> Option<&SshFingerprint> {
|
||||||
|
match self {
|
||||||
|
Self::KeyFile(_) => None,
|
||||||
|
Self::Agent { fingerprint, .. } => Some(fingerprint),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn agent_socket(&self) -> Option<&Path> {
|
||||||
|
match self {
|
||||||
|
Self::Agent { socket, .. } => socket.as_deref(),
|
||||||
|
Self::KeyFile(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SshRemoteAuthentication {
|
||||||
|
identity: SshIdentitySource,
|
||||||
|
known_hosts_file: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshRemoteAuthentication {
|
||||||
|
pub fn key_file(
|
||||||
|
identity_file: PathBuf,
|
||||||
|
known_hosts_file: PathBuf,
|
||||||
|
) -> Result<Self, ConfigError> {
|
||||||
|
validate_standalone_ssh_path(&identity_file, "git.remotes.ssh_identity_file")?;
|
||||||
|
validate_standalone_ssh_path(&known_hosts_file, "git.remotes.ssh_known_hosts_file")?;
|
||||||
|
Ok(Self {
|
||||||
|
identity: SshIdentitySource::KeyFile(identity_file),
|
||||||
|
known_hosts_file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn agent(
|
||||||
|
fingerprint: SshFingerprint,
|
||||||
|
socket: Option<PathBuf>,
|
||||||
|
known_hosts_file: PathBuf,
|
||||||
|
) -> Result<Self, ConfigError> {
|
||||||
|
if let Some(socket) = &socket {
|
||||||
|
validate_standalone_ssh_path(socket, "git.remotes.ssh_agent_socket")?;
|
||||||
|
}
|
||||||
|
validate_standalone_ssh_path(&known_hosts_file, "git.remotes.ssh_known_hosts_file")?;
|
||||||
|
Ok(Self {
|
||||||
|
identity: SshIdentitySource::Agent {
|
||||||
|
fingerprint,
|
||||||
|
socket,
|
||||||
|
},
|
||||||
|
known_hosts_file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn identity(&self) -> &SshIdentitySource {
|
||||||
|
&self.identity
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn known_hosts_file(&self) -> &Path {
|
||||||
|
&self.known_hosts_file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SshEndpoint {
|
impl SshEndpoint {
|
||||||
pub fn user(&self) -> Option<&str> {
|
pub fn user(&self) -> Option<&str> {
|
||||||
self.user.as_deref()
|
self.user.as_deref()
|
||||||
@@ -1122,7 +1262,7 @@ enum GitRemoteCredentials {
|
|||||||
server_id: ServerId,
|
server_id: ServerId,
|
||||||
application_id: ApplicationId,
|
application_id: ApplicationId,
|
||||||
},
|
},
|
||||||
Ssh,
|
Ssh(Option<SshRemoteAuthentication>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GitRemote {
|
impl GitRemote {
|
||||||
@@ -1132,25 +1272,49 @@ impl GitRemote {
|
|||||||
server_id: impl Into<String>,
|
server_id: impl Into<String>,
|
||||||
application_id: impl Into<String>,
|
application_id: impl Into<String>,
|
||||||
) -> Result<Self, ConfigError> {
|
) -> Result<Self, ConfigError> {
|
||||||
let mut remotes = validate_remotes(vec![RawGitRemote {
|
let mut remotes = validate_remotes(
|
||||||
|
vec![RawGitRemote {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
url: url.into(),
|
url: url.into(),
|
||||||
server_id: Some(server_id.into()),
|
server_id: Some(server_id.into()),
|
||||||
application_id: Some(application_id.into()),
|
application_id: Some(application_id.into()),
|
||||||
}])?;
|
ssh_identity_file: None,
|
||||||
|
ssh_agent_fingerprint: None,
|
||||||
|
ssh_agent_socket: None,
|
||||||
|
ssh_known_hosts_file: None,
|
||||||
|
}],
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
Ok(remotes.remove(0))
|
Ok(remotes.remove(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ssh(name: impl Into<String>, url: impl Into<String>) -> Result<Self, ConfigError> {
|
pub fn ssh(name: impl Into<String>, url: impl Into<String>) -> Result<Self, ConfigError> {
|
||||||
let mut remotes = validate_remotes(vec![RawGitRemote {
|
let mut remotes = validate_remotes(
|
||||||
|
vec![RawGitRemote {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
url: url.into(),
|
url: url.into(),
|
||||||
server_id: None,
|
server_id: None,
|
||||||
application_id: None,
|
application_id: None,
|
||||||
}])?;
|
ssh_identity_file: None,
|
||||||
|
ssh_agent_fingerprint: None,
|
||||||
|
ssh_agent_socket: None,
|
||||||
|
ssh_known_hosts_file: None,
|
||||||
|
}],
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
Ok(remotes.remove(0))
|
Ok(remotes.remove(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn ssh_with_authentication(
|
||||||
|
name: impl Into<String>,
|
||||||
|
url: impl Into<String>,
|
||||||
|
authentication: SshRemoteAuthentication,
|
||||||
|
) -> Result<Self, ConfigError> {
|
||||||
|
let mut remote = Self::ssh(name, url)?;
|
||||||
|
remote.credentials = GitRemoteCredentials::Ssh(Some(authentication));
|
||||||
|
Ok(remote)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn name(&self) -> &RemoteName {
|
pub fn name(&self) -> &RemoteName {
|
||||||
&self.name
|
&self.name
|
||||||
}
|
}
|
||||||
@@ -1169,7 +1333,14 @@ impl GitRemote {
|
|||||||
server_id,
|
server_id,
|
||||||
application_id,
|
application_id,
|
||||||
} => Some((server_id, application_id)),
|
} => Some((server_id, application_id)),
|
||||||
GitRemoteCredentials::Ssh => None,
|
GitRemoteCredentials::Ssh(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn ssh_authentication(&self) -> Option<&SshRemoteAuthentication> {
|
||||||
|
match &self.credentials {
|
||||||
|
GitRemoteCredentials::Ssh(authentication) => authentication.as_ref(),
|
||||||
|
GitRemoteCredentials::Https { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1412,6 +1583,10 @@ struct RawGitRemote {
|
|||||||
url: String,
|
url: String,
|
||||||
server_id: Option<String>,
|
server_id: Option<String>,
|
||||||
application_id: Option<String>,
|
application_id: Option<String>,
|
||||||
|
ssh_identity_file: Option<PathBuf>,
|
||||||
|
ssh_agent_fingerprint: Option<String>,
|
||||||
|
ssh_agent_socket: Option<PathBuf>,
|
||||||
|
ssh_known_hosts_file: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_config(
|
fn validate_config(
|
||||||
@@ -1512,7 +1687,7 @@ fn validate_config(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let git_remotes = validate_remotes(raw.git.remotes)?;
|
let git_remotes = validate_remotes(raw.git.remotes, Some(base))?;
|
||||||
|
|
||||||
Ok(Config {
|
Ok(Config {
|
||||||
source,
|
source,
|
||||||
@@ -1664,7 +1839,10 @@ fn parse_environment_editor(
|
|||||||
.map_err(|()| EditorError::InvalidCommand { source: variable })
|
.map_err(|()| EditorError::InvalidCommand { source: variable })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_remotes(raw: Vec<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigError> {
|
fn validate_remotes(
|
||||||
|
raw: Vec<RawGitRemote>,
|
||||||
|
config_base: Option<&Path>,
|
||||||
|
) -> Result<Vec<GitRemote>, ConfigError> {
|
||||||
let mut names = BTreeSet::new();
|
let mut names = BTreeSet::new();
|
||||||
let mut references = BTreeSet::new();
|
let mut references = BTreeSet::new();
|
||||||
let mut remotes = Vec::with_capacity(raw.len());
|
let mut remotes = Vec::with_capacity(raw.len());
|
||||||
@@ -1679,6 +1857,15 @@ fn validate_remotes(raw: Vec<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigErro
|
|||||||
RemoteEndpoint::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl {
|
RemoteEndpoint::parse(&remote.url).map_err(|_| ConfigError::InvalidRemoteUrl {
|
||||||
name: name.0.clone(),
|
name: name.0.clone(),
|
||||||
})?;
|
})?;
|
||||||
|
let has_ssh_fields = remote.ssh_identity_file.is_some()
|
||||||
|
|| remote.ssh_agent_fingerprint.is_some()
|
||||||
|
|| remote.ssh_agent_socket.is_some()
|
||||||
|
|| remote.ssh_known_hosts_file.is_some();
|
||||||
|
if matches!(&endpoint, RemoteEndpoint::Https(_)) && has_ssh_fields {
|
||||||
|
return Err(ConfigError::InvalidField {
|
||||||
|
field: "git.remotes.ssh_authentication",
|
||||||
|
});
|
||||||
|
}
|
||||||
let credentials = match (&endpoint, remote.server_id, remote.application_id) {
|
let credentials = match (&endpoint, remote.server_id, remote.application_id) {
|
||||||
(RemoteEndpoint::Https(_), Some(server_id), Some(application_id)) => {
|
(RemoteEndpoint::Https(_), Some(server_id), Some(application_id)) => {
|
||||||
let server_id = ServerId(validate_identifier("git.remotes.server_id", server_id)?);
|
let server_id = ServerId(validate_identifier("git.remotes.server_id", server_id)?);
|
||||||
@@ -1704,7 +1891,60 @@ fn validate_remotes(raw: Vec<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigErro
|
|||||||
field: "git.remotes.application_id",
|
field: "git.remotes.application_id",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
(RemoteEndpoint::Ssh(_), None, None) => GitRemoteCredentials::Ssh,
|
(RemoteEndpoint::Ssh(_), None, None) => {
|
||||||
|
let Some(base) = config_base else {
|
||||||
|
if has_ssh_fields {
|
||||||
|
return Err(ConfigError::InvalidField {
|
||||||
|
field: "git.remotes.ssh_authentication",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
remotes.push(GitRemote {
|
||||||
|
name,
|
||||||
|
url: remote.url,
|
||||||
|
endpoint,
|
||||||
|
credentials: GitRemoteCredentials::Ssh(None),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let known_hosts_file = match remote.ssh_known_hosts_file {
|
||||||
|
Some(path) => resolve_ssh_path(base, path, "git.remotes.ssh_known_hosts_file")?,
|
||||||
|
None => default_known_hosts_path().ok_or(ConfigError::InvalidField {
|
||||||
|
field: "git.remotes.ssh_known_hosts_file",
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
let identity = match (
|
||||||
|
remote.ssh_identity_file,
|
||||||
|
remote.ssh_agent_fingerprint,
|
||||||
|
remote.ssh_agent_socket,
|
||||||
|
) {
|
||||||
|
(Some(path), None, None) => SshIdentitySource::KeyFile(resolve_ssh_path(
|
||||||
|
base,
|
||||||
|
path,
|
||||||
|
"git.remotes.ssh_identity_file",
|
||||||
|
)?),
|
||||||
|
(None, Some(fingerprint), socket) => SshIdentitySource::Agent {
|
||||||
|
fingerprint: SshFingerprint::parse(fingerprint).map_err(|_| {
|
||||||
|
ConfigError::InvalidField {
|
||||||
|
field: "git.remotes.ssh_agent_fingerprint",
|
||||||
|
}
|
||||||
|
})?,
|
||||||
|
socket: socket
|
||||||
|
.map(|path| {
|
||||||
|
resolve_ssh_path(base, path, "git.remotes.ssh_agent_socket")
|
||||||
|
})
|
||||||
|
.transpose()?,
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
return Err(ConfigError::InvalidField {
|
||||||
|
field: "git.remotes.ssh_authentication",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
GitRemoteCredentials::Ssh(Some(SshRemoteAuthentication {
|
||||||
|
identity,
|
||||||
|
known_hosts_file,
|
||||||
|
}))
|
||||||
|
}
|
||||||
(RemoteEndpoint::Ssh(_), _, _) => {
|
(RemoteEndpoint::Ssh(_), _, _) => {
|
||||||
return Err(ConfigError::InvalidField {
|
return Err(ConfigError::InvalidField {
|
||||||
field: "git.remotes.https_credentials",
|
field: "git.remotes.https_credentials",
|
||||||
@@ -1721,6 +1961,43 @@ fn validate_remotes(raw: Vec<RawGitRemote>) -> Result<Vec<GitRemote>, ConfigErro
|
|||||||
Ok(remotes)
|
Ok(remotes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_ssh_path(
|
||||||
|
base: &Path,
|
||||||
|
value: PathBuf,
|
||||||
|
field: &'static str,
|
||||||
|
) -> Result<PathBuf, ConfigError> {
|
||||||
|
if value.as_os_str().is_empty() || path_text(&value, field)?.chars().any(char::is_control) {
|
||||||
|
return Err(ConfigError::InvalidField { field });
|
||||||
|
}
|
||||||
|
Ok(resolve_path(base, &value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_standalone_ssh_path(path: &Path, field: &'static str) -> Result<(), ConfigError> {
|
||||||
|
if !path.is_absolute()
|
||||||
|
|| path.as_os_str().is_empty()
|
||||||
|
|| path_text(path, field)?.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err(ConfigError::InvalidField { field });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn default_known_hosts_path() -> Option<PathBuf> {
|
||||||
|
env::var_os("USERPROFILE")
|
||||||
|
.filter(|home| !home.is_empty() && Path::new(home).is_absolute())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.map(|home| home.join(".ssh").join("known_hosts"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn default_known_hosts_path() -> Option<PathBuf> {
|
||||||
|
env::var_os("HOME")
|
||||||
|
.filter(|home| !home.is_empty() && Path::new(home).is_absolute())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.map(|home| home.join(".ssh").join("known_hosts"))
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_identifier(field: &'static str, value: String) -> Result<String, ConfigError> {
|
fn validate_identifier(field: &'static str, value: String) -> Result<String, ConfigError> {
|
||||||
if value.is_empty()
|
if value.is_empty()
|
||||||
|| value.len() > 128
|
|| value.len() > 128
|
||||||
@@ -1794,7 +2071,16 @@ fn validate_known_fields(value: &toml::Value, source: &Path) -> Result<(), Confi
|
|||||||
validate_table(
|
validate_table(
|
||||||
remote,
|
remote,
|
||||||
&format!("git.remotes[{index}]"),
|
&format!("git.remotes[{index}]"),
|
||||||
&["name", "url", "server_id", "application_id"],
|
&[
|
||||||
|
"name",
|
||||||
|
"url",
|
||||||
|
"server_id",
|
||||||
|
"application_id",
|
||||||
|
"ssh_identity_file",
|
||||||
|
"ssh_agent_fingerprint",
|
||||||
|
"ssh_agent_socket",
|
||||||
|
"ssh_known_hosts_file",
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use sha1::{Digest as _, Sha1};
|
|||||||
use zeroize::Zeroize as _;
|
use zeroize::Zeroize as _;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{ApplicationId, GitRemote, RemoteEndpoint, RemoteTransport, ServerId},
|
config::{ApplicationId, GitRemote, RemoteEndpoint, RemoteTransport, ServerId, SshFingerprint},
|
||||||
crypto::{KeyHandle, KeyStore, SecretProvider},
|
crypto::{KeyHandle, KeyStore, SecretProvider},
|
||||||
mutation::{TreeCommit, TreeCommitError, TreeCommitter},
|
mutation::{TreeCommit, TreeCommitError, TreeCommitter},
|
||||||
recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
|
recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
|
||||||
@@ -364,6 +364,67 @@ impl GitDiffEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct SshHostKey {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
algorithm: String,
|
||||||
|
fingerprint: SshFingerprint,
|
||||||
|
encoded: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SshHostKey {
|
||||||
|
#[cfg(feature = "ssh")]
|
||||||
|
pub(crate) fn new(
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
algorithm: String,
|
||||||
|
fingerprint: SshFingerprint,
|
||||||
|
encoded: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
algorithm,
|
||||||
|
fingerprint,
|
||||||
|
encoded,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn host(&self) -> &str {
|
||||||
|
&self.host
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn port(&self) -> u16 {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn algorithm(&self) -> &str {
|
||||||
|
&self.algorithm
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn fingerprint(&self) -> &SshFingerprint {
|
||||||
|
&self.fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssh")]
|
||||||
|
pub(crate) fn encoded(&self) -> &str {
|
||||||
|
&self.encoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for SshHostKey {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("SshHostKey")
|
||||||
|
.field("host", &self.host)
|
||||||
|
.field("port", &self.port)
|
||||||
|
.field("algorithm", &self.algorithm)
|
||||||
|
.field("fingerprint", &self.fingerprint)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||||
pub enum GitError {
|
pub enum GitError {
|
||||||
NotRepository,
|
NotRepository,
|
||||||
@@ -390,6 +451,44 @@ pub enum GitError {
|
|||||||
RemoteNotFound {
|
RemoteNotFound {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
|
SshAuthenticationNotConfigured,
|
||||||
|
SshIdentityMissing {
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
SshIdentityInvalid {
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
SshUnsupportedAlgorithm {
|
||||||
|
algorithm: String,
|
||||||
|
},
|
||||||
|
SshKeyPassphraseUnavailable {
|
||||||
|
fingerprint: SshFingerprint,
|
||||||
|
},
|
||||||
|
SshKeyPassphraseDenied {
|
||||||
|
fingerprint: SshFingerprint,
|
||||||
|
},
|
||||||
|
SshKeyPassphraseCancelled {
|
||||||
|
fingerprint: SshFingerprint,
|
||||||
|
},
|
||||||
|
SshKeyPassphraseRejected {
|
||||||
|
fingerprint: SshFingerprint,
|
||||||
|
},
|
||||||
|
SshAgentUnavailable,
|
||||||
|
SshAgentIdentityMissing {
|
||||||
|
fingerprint: SshFingerprint,
|
||||||
|
},
|
||||||
|
SshAuthenticationRejected,
|
||||||
|
UnknownSshHostKey {
|
||||||
|
host_key: Box<SshHostKey>,
|
||||||
|
},
|
||||||
|
ChangedSshHostKey {
|
||||||
|
host_key: Box<SshHostKey>,
|
||||||
|
line: usize,
|
||||||
|
},
|
||||||
|
SshKnownHostsUnavailable {
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
SshProtocolFailed,
|
||||||
CredentialsUnavailable,
|
CredentialsUnavailable,
|
||||||
CredentialAccessDenied,
|
CredentialAccessDenied,
|
||||||
CredentialCancelled,
|
CredentialCancelled,
|
||||||
@@ -441,6 +540,73 @@ impl fmt::Display for GitError {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Self::RemoteNotFound { name } => write!(formatter, "Git remote not found: {name}"),
|
Self::RemoteNotFound { name } => write!(formatter, "Git remote not found: {name}"),
|
||||||
|
Self::SshAuthenticationNotConfigured => {
|
||||||
|
formatter.write_str("SSH authentication is not configured")
|
||||||
|
}
|
||||||
|
Self::SshIdentityMissing { path } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"SSH identity file was not found: {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::SshIdentityInvalid { path } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"SSH identity file is invalid: {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::SshUnsupportedAlgorithm { algorithm } => {
|
||||||
|
write!(formatter, "unsupported SSH key algorithm: {algorithm}")
|
||||||
|
}
|
||||||
|
Self::SshKeyPassphraseUnavailable { fingerprint } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"SSH key passphrase is unavailable for {fingerprint}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::SshKeyPassphraseDenied { fingerprint } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"access to the SSH key passphrase was denied for {fingerprint}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::SshKeyPassphraseCancelled { fingerprint } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"SSH key passphrase access was cancelled for {fingerprint}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::SshKeyPassphraseRejected { fingerprint } => {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"SSH key passphrase was rejected for {fingerprint}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::SshAgentUnavailable => formatter.write_str("the SSH agent is unavailable"),
|
||||||
|
Self::SshAgentIdentityMissing { fingerprint } => {
|
||||||
|
write!(formatter, "the SSH agent does not contain {fingerprint}")
|
||||||
|
}
|
||||||
|
Self::SshAuthenticationRejected => {
|
||||||
|
formatter.write_str("SSH public-key authentication was rejected")
|
||||||
|
}
|
||||||
|
Self::UnknownSshHostKey { host_key } => write!(
|
||||||
|
formatter,
|
||||||
|
"unknown SSH host key for {}:{} ({})",
|
||||||
|
host_key.host, host_key.port, host_key.fingerprint
|
||||||
|
),
|
||||||
|
Self::ChangedSshHostKey { host_key, line } => write!(
|
||||||
|
formatter,
|
||||||
|
"changed SSH host key for {}:{} ({}, known_hosts line {line})",
|
||||||
|
host_key.host, host_key.port, host_key.fingerprint
|
||||||
|
),
|
||||||
|
Self::SshKnownHostsUnavailable { path } => write!(
|
||||||
|
formatter,
|
||||||
|
"SSH known-hosts file is unavailable: {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Self::SshProtocolFailed => formatter.write_str("the SSH protocol failed"),
|
||||||
Self::CredentialsUnavailable => {
|
Self::CredentialsUnavailable => {
|
||||||
formatter.write_str("HTTPS Git credentials are unavailable")
|
formatter.write_str("HTTPS Git credentials are unavailable")
|
||||||
}
|
}
|
||||||
@@ -617,6 +783,10 @@ pub trait GitCredentialProvider {
|
|||||||
) -> Result<GitCredential, GitError>;
|
) -> Result<GitCredential, GitError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait SshPassphraseProvider {
|
||||||
|
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError>;
|
||||||
|
}
|
||||||
|
|
||||||
pub trait GitSmartHttpTransport {
|
pub trait GitSmartHttpTransport {
|
||||||
fn advertise_receive_pack(
|
fn advertise_receive_pack(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ pub mod repository;
|
|||||||
mod secret;
|
mod secret;
|
||||||
#[cfg(feature = "full")]
|
#[cfg(feature = "full")]
|
||||||
pub mod secret_store;
|
pub mod secret_store;
|
||||||
|
#[cfg(all(feature = "full", feature = "ssh"))]
|
||||||
|
pub mod ssh;
|
||||||
#[cfg(feature = "full")]
|
#[cfg(feature = "full")]
|
||||||
pub mod write;
|
pub mod write;
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::{ApplicationId, ServerId},
|
config::{ApplicationId, ServerId, SshFingerprint},
|
||||||
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
||||||
git::{GitCredential, GitCredentialProvider, GitError},
|
git::{GitCredential, GitCredentialProvider, GitError, SshPassphraseProvider},
|
||||||
repository::SecretBytes,
|
repository::SecretBytes,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ const MAX_CACHE_LIFETIME: Duration = Duration::from_secs(15 * 60);
|
|||||||
const MAX_CACHE_CAPACITY: usize = 128;
|
const MAX_CACHE_CAPACITY: usize = 128;
|
||||||
const OPENPGP_PASSPHRASE_SERVICE: &str = "de.rfc1437.ironstorage.openpgp-passphrase";
|
const OPENPGP_PASSPHRASE_SERVICE: &str = "de.rfc1437.ironstorage.openpgp-passphrase";
|
||||||
const HTTPS_GIT_SERVICE: &str = "de.rfc1437.ironstorage.https-git";
|
const HTTPS_GIT_SERVICE: &str = "de.rfc1437.ironstorage.https-git";
|
||||||
|
const SSH_KEY_PASSPHRASE_SERVICE: &str = "de.rfc1437.ironstorage.ssh-key-passphrase";
|
||||||
|
|
||||||
/// The purpose and stable, non-secret identity of an OS credential.
|
/// The purpose and stable, non-secret identity of an OS credential.
|
||||||
#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
|
#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
@@ -44,6 +45,9 @@ enum SecretReferenceKind {
|
|||||||
application_id: String,
|
application_id: String,
|
||||||
account: String,
|
account: String,
|
||||||
},
|
},
|
||||||
|
SshKeyPassphrase {
|
||||||
|
fingerprint: SshFingerprint,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SecretReference {
|
impl SecretReference {
|
||||||
@@ -87,10 +91,17 @@ impl SecretReference {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn ssh_key_passphrase(fingerprint: SshFingerprint) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: SecretReferenceKind::SshKeyPassphrase { fingerprint },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn account(&self) -> Option<&str> {
|
pub fn account(&self) -> Option<&str> {
|
||||||
match &self.kind {
|
match &self.kind {
|
||||||
SecretReferenceKind::OpenPgpPassphrase { .. } => None,
|
SecretReferenceKind::OpenPgpPassphrase { .. } => None,
|
||||||
SecretReferenceKind::HttpsGitCredential { account, .. } => Some(account),
|
SecretReferenceKind::HttpsGitCredential { account, .. } => Some(account),
|
||||||
|
SecretReferenceKind::SshKeyPassphrase { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +120,11 @@ impl SecretReference {
|
|||||||
server_id: server_id.clone(),
|
server_id: server_id.clone(),
|
||||||
application_id: application_id.clone(),
|
application_id: application_id.clone(),
|
||||||
},
|
},
|
||||||
|
SecretReferenceKind::SshKeyPassphrase { fingerprint } => {
|
||||||
|
SecretLocator::SshKeyPassphrase {
|
||||||
|
fingerprint: fingerprint.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,6 +138,9 @@ impl fmt::Debug for SecretReference {
|
|||||||
Self {
|
Self {
|
||||||
kind: SecretReferenceKind::HttpsGitCredential { .. },
|
kind: SecretReferenceKind::HttpsGitCredential { .. },
|
||||||
} => formatter.write_str("SecretReference::HttpsGitCredential([REDACTED])"),
|
} => formatter.write_str("SecretReference::HttpsGitCredential([REDACTED])"),
|
||||||
|
Self {
|
||||||
|
kind: SecretReferenceKind::SshKeyPassphrase { .. },
|
||||||
|
} => formatter.write_str("SecretReference::SshKeyPassphrase([REDACTED])"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +156,9 @@ pub enum SecretLocator {
|
|||||||
server_id: String,
|
server_id: String,
|
||||||
application_id: String,
|
application_id: String,
|
||||||
},
|
},
|
||||||
|
SshKeyPassphrase {
|
||||||
|
fingerprint: SshFingerprint,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for SecretLocator {
|
impl fmt::Debug for SecretLocator {
|
||||||
@@ -148,6 +170,9 @@ impl fmt::Debug for SecretLocator {
|
|||||||
Self::HttpsGitCredential { .. } => {
|
Self::HttpsGitCredential { .. } => {
|
||||||
formatter.write_str("SecretLocator::HttpsGitCredential([REDACTED])")
|
formatter.write_str("SecretLocator::HttpsGitCredential([REDACTED])")
|
||||||
}
|
}
|
||||||
|
Self::SshKeyPassphrase { .. } => {
|
||||||
|
formatter.write_str("SecretLocator::SshKeyPassphrase([REDACTED])")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,6 +187,9 @@ impl SecretLocator {
|
|||||||
server_id,
|
server_id,
|
||||||
application_id,
|
application_id,
|
||||||
} => (HTTPS_GIT_SERVICE, format!("{server_id}/{application_id}")),
|
} => (HTTPS_GIT_SERVICE, format!("{server_id}/{application_id}")),
|
||||||
|
Self::SshKeyPassphrase { fingerprint } => {
|
||||||
|
(SSH_KEY_PASSPHRASE_SERVICE, fingerprint.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,6 +237,7 @@ impl SecretProtectionPolicy {
|
|||||||
match &reference.kind {
|
match &reference.kind {
|
||||||
SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp,
|
SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp,
|
||||||
SecretReferenceKind::HttpsGitCredential { .. } => self.git,
|
SecretReferenceKind::HttpsGitCredential { .. } => self.git,
|
||||||
|
SecretReferenceKind::SshKeyPassphrase { .. } => self.git,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +245,7 @@ impl SecretProtectionPolicy {
|
|||||||
match locator {
|
match locator {
|
||||||
SecretLocator::OpenPgpPassphrase { .. } => self.openpgp,
|
SecretLocator::OpenPgpPassphrase { .. } => self.openpgp,
|
||||||
SecretLocator::HttpsGitCredential { .. } => self.git,
|
SecretLocator::HttpsGitCredential { .. } => self.git,
|
||||||
|
SecretLocator::SshKeyPassphrase { .. } => self.git,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -815,6 +845,23 @@ impl<B: SecretStoreBackend> GitCredentialProvider for SecretStore<B> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<B: SecretStoreBackend> SshPassphraseProvider for SecretStore<B> {
|
||||||
|
fn ssh_key_passphrase(&self, fingerprint: &SshFingerprint) -> Result<SecretBytes, GitError> {
|
||||||
|
self.retrieve(&SecretReference::ssh_key_passphrase(fingerprint.clone()))
|
||||||
|
.map_err(|error| match error {
|
||||||
|
SecretStoreError::Cancelled => GitError::SshKeyPassphraseCancelled {
|
||||||
|
fingerprint: fingerprint.clone(),
|
||||||
|
},
|
||||||
|
SecretStoreError::Denied => GitError::SshKeyPassphraseDenied {
|
||||||
|
fingerprint: fingerprint.clone(),
|
||||||
|
},
|
||||||
|
_ => GitError::SshKeyPassphraseUnavailable {
|
||||||
|
fingerprint: fingerprint.clone(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn provider_error(error: SecretStoreError) -> SecretProviderError {
|
fn provider_error(error: SecretStoreError) -> SecretProviderError {
|
||||||
match error {
|
match error {
|
||||||
SecretStoreError::Missing => SecretProviderError::Missing,
|
SecretStoreError::Missing => SecretProviderError::Missing,
|
||||||
@@ -878,6 +925,10 @@ fn encode_record(
|
|||||||
write_field(&mut encoded, application_id.as_bytes())?;
|
write_field(&mut encoded, application_id.as_bytes())?;
|
||||||
write_field(&mut encoded, account.as_bytes())?;
|
write_field(&mut encoded, account.as_bytes())?;
|
||||||
}
|
}
|
||||||
|
SecretReferenceKind::SshKeyPassphrase { fingerprint } => {
|
||||||
|
encoded.push(3);
|
||||||
|
write_field(&mut encoded, fingerprint.as_str().as_bytes())?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let length = u32::try_from(value.expose().len()).map_err(|_| SecretStoreError::Corrupted)?;
|
let length = u32::try_from(value.expose().len()).map_err(|_| SecretStoreError::Corrupted)?;
|
||||||
encoded.extend_from_slice(&length.to_be_bytes());
|
encoded.extend_from_slice(&length.to_be_bytes());
|
||||||
@@ -919,6 +970,14 @@ fn decode_record(encoded: SecretBytes) -> Result<SecretRecord, SecretStoreError>
|
|||||||
)
|
)
|
||||||
.map_err(|_| SecretStoreError::Corrupted)?
|
.map_err(|_| SecretStoreError::Corrupted)?
|
||||||
}
|
}
|
||||||
|
3 => {
|
||||||
|
let (fingerprint, rest) = read_field(remainder)?;
|
||||||
|
remainder = rest;
|
||||||
|
SecretReference::ssh_key_passphrase(
|
||||||
|
SshFingerprint::parse(read_text(fingerprint)?)
|
||||||
|
.map_err(|_| SecretStoreError::Corrupted)?,
|
||||||
|
)
|
||||||
|
}
|
||||||
_ => return Err(SecretStoreError::Corrupted),
|
_ => return Err(SecretStoreError::Corrupted),
|
||||||
};
|
};
|
||||||
if remainder.len() < 4 {
|
if remainder.len() < 4 {
|
||||||
@@ -993,8 +1052,8 @@ fn copy_secret(value: &SecretBytes) -> SecretBytes {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
HTTPS_GIT_SERVICE, OPENPGP_PASSPHRASE_SERVICE, RECORD_MAGIC, RECORD_VERSION, SecretBytes,
|
HTTPS_GIT_SERVICE, OPENPGP_PASSPHRASE_SERVICE, RECORD_MAGIC, RECORD_VERSION,
|
||||||
SecretLocator, SecretStoreError, decode_record,
|
SSH_KEY_PASSPHRASE_SERVICE, SecretBytes, SecretLocator, SecretStoreError, decode_record,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn openpgp_record(fingerprint: &[u8], secret: &[u8]) -> SecretBytes {
|
fn openpgp_record(fingerprint: &[u8], secret: &[u8]) -> SecretBytes {
|
||||||
@@ -1033,6 +1092,12 @@ mod tests {
|
|||||||
server_id: "server".to_owned(),
|
server_id: "server".to_owned(),
|
||||||
application_id: "application".to_owned(),
|
application_id: "application".to_owned(),
|
||||||
};
|
};
|
||||||
|
let ssh = SecretLocator::SshKeyPassphrase {
|
||||||
|
fingerprint: crate::config::SshFingerprint::parse(
|
||||||
|
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
openpgp.service_and_user(),
|
openpgp.service_and_user(),
|
||||||
(
|
(
|
||||||
@@ -1044,7 +1109,15 @@ mod tests {
|
|||||||
git.service_and_user(),
|
git.service_and_user(),
|
||||||
(HTTPS_GIT_SERVICE, "server/application".to_owned())
|
(HTTPS_GIT_SERVICE, "server/application".to_owned())
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ssh.service_and_user(),
|
||||||
|
(
|
||||||
|
SSH_KEY_PASSPHRASE_SERVICE,
|
||||||
|
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned()
|
||||||
|
)
|
||||||
|
);
|
||||||
assert!(OPENPGP_PASSPHRASE_SERVICE.starts_with("de.rfc1437.ironstorage."));
|
assert!(OPENPGP_PASSPHRASE_SERVICE.starts_with("de.rfc1437.ironstorage."));
|
||||||
assert!(HTTPS_GIT_SERVICE.starts_with("de.rfc1437.ironstorage."));
|
assert!(HTTPS_GIT_SERVICE.starts_with("de.rfc1437.ironstorage."));
|
||||||
|
assert!(SSH_KEY_PASSPHRASE_SERVICE.starts_with("de.rfc1437.ironstorage."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1148
crates/storage/src/ssh.rs
Normal file
1148
crates/storage/src/ssh.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -701,7 +701,7 @@ url = "https://example.test/team/store.git"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ssh_remote_configuration_round_trips_without_https_credentials() -> TestResult {
|
fn ssh_remote_configuration_round_trips_with_secret_free_authentication() -> TestResult {
|
||||||
let fixture = ConfigurationFixture::new()?;
|
let fixture = ConfigurationFixture::new()?;
|
||||||
fixture.write_explicit(
|
fixture.write_explicit(
|
||||||
r#"
|
r#"
|
||||||
@@ -712,12 +712,27 @@ key_material = "keys"
|
|||||||
[[git.remotes]]
|
[[git.remotes]]
|
||||||
name = "origin"
|
name = "origin"
|
||||||
url = "git@example.test:team/store.git"
|
url = "git@example.test:team/store.git"
|
||||||
|
ssh_identity_file = "keys/id_ed25519"
|
||||||
|
ssh_known_hosts_file = "known_hosts"
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||||
let remote = &config.git_remotes()[0];
|
let remote = &config.git_remotes()[0];
|
||||||
assert_eq!(remote.url(), "git@example.test:team/store.git");
|
assert_eq!(remote.url(), "git@example.test:team/store.git");
|
||||||
assert!(remote.https_credentials().is_none());
|
assert!(remote.https_credentials().is_none());
|
||||||
|
let authentication = remote.ssh_authentication().expect("SSH authentication");
|
||||||
|
assert_eq!(
|
||||||
|
authentication.identity().key_file(),
|
||||||
|
Some(
|
||||||
|
fs::canonicalize(fixture.temporary.path())?
|
||||||
|
.join("cwd/config/keys/id_ed25519")
|
||||||
|
.as_path()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
authentication.known_hosts_file(),
|
||||||
|
fs::canonicalize(fixture.temporary.path())?.join("cwd/config/known_hosts")
|
||||||
|
);
|
||||||
|
|
||||||
config.update_git_identity(&GitIdentity::new("Alice", "alice@example.test")?)?;
|
config.update_git_identity(&GitIdentity::new("Alice", "alice@example.test")?)?;
|
||||||
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||||
@@ -725,6 +740,70 @@ url = "git@example.test:team/store.git"
|
|||||||
let persisted = fs::read_to_string(fixture.explicit_path())?;
|
let persisted = fs::read_to_string(fixture.explicit_path())?;
|
||||||
assert!(!persisted.contains("server_id"));
|
assert!(!persisted.contains("server_id"));
|
||||||
assert!(!persisted.contains("application_id"));
|
assert!(!persisted.contains("application_id"));
|
||||||
|
assert!(!persisted.contains("passphrase"));
|
||||||
|
assert!(persisted.contains("ssh_identity_file"));
|
||||||
|
assert!(persisted.contains("ssh_known_hosts_file"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssh_authentication_requires_exactly_one_identity_source() -> TestResult {
|
||||||
|
let fixture = ConfigurationFixture::new()?;
|
||||||
|
fixture.write_explicit(
|
||||||
|
r#"
|
||||||
|
vault = "vault"
|
||||||
|
default_key = "alice"
|
||||||
|
key_material = "keys"
|
||||||
|
|
||||||
|
[[git.remotes]]
|
||||||
|
name = "origin"
|
||||||
|
url = "ssh://git@example.test/team/store.git"
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.loader()
|
||||||
|
.load(Some(&fixture.explicit_path()))
|
||||||
|
.expect_err("SSH identity is required"),
|
||||||
|
ConfigError::InvalidField {
|
||||||
|
field: "git.remotes.ssh_authentication"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.write_explicit(
|
||||||
|
r#"
|
||||||
|
vault = "vault"
|
||||||
|
default_key = "alice"
|
||||||
|
key_material = "keys"
|
||||||
|
|
||||||
|
[[git.remotes]]
|
||||||
|
name = "origin"
|
||||||
|
url = "ssh://git@example.test/team/store.git"
|
||||||
|
ssh_agent_fingerprint = "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
|
ssh_agent_socket = "agent.sock"
|
||||||
|
ssh_known_hosts_file = "known_hosts"
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
let config = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||||
|
let authentication = config.git_remotes()[0]
|
||||||
|
.ssh_authentication()
|
||||||
|
.expect("SSH authentication");
|
||||||
|
assert_eq!(
|
||||||
|
authentication
|
||||||
|
.identity()
|
||||||
|
.agent_fingerprint()
|
||||||
|
.expect("agent fingerprint")
|
||||||
|
.as_str(),
|
||||||
|
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
authentication.identity().agent_socket(),
|
||||||
|
Some(
|
||||||
|
fs::canonicalize(fixture.temporary.path())?
|
||||||
|
.join("cwd/config/agent.sock")
|
||||||
|
.as_path()
|
||||||
|
)
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
config::{ConfigLoader, GitRemote},
|
config::{ConfigLoader, GitRemote, SshFingerprint},
|
||||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError},
|
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider as _, SecretProviderError},
|
||||||
git::{GitCredentialProvider as _, GitError},
|
git::{GitCredentialProvider as _, GitError, SshPassphraseProvider as _},
|
||||||
repository::{EncryptedEntry, SecretBytes},
|
repository::{EncryptedEntry, SecretBytes},
|
||||||
secret_store::{
|
secret_store::{
|
||||||
OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretLocator,
|
OpenPgpPassphrasePrompt, OpenPgpPassphrasePromptError, SecretCachePolicy, SecretLocator,
|
||||||
@@ -274,6 +274,41 @@ fn denied_cancelled_unavailable_and_corrupted_are_typed_and_redacted() -> TestRe
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssh_passphrases_are_retrieved_by_fingerprint_with_typed_access_failures() -> TestResult {
|
||||||
|
let backend = MemoryBackend::default();
|
||||||
|
let store = store(backend.clone());
|
||||||
|
store.unlock()?;
|
||||||
|
let fingerprint = SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")?;
|
||||||
|
let reference = SecretReference::ssh_key_passphrase(fingerprint.clone());
|
||||||
|
store.create(
|
||||||
|
&reference,
|
||||||
|
SecretBytes::new(b"protected-passphrase".to_vec()),
|
||||||
|
)?;
|
||||||
|
assert_eq!(
|
||||||
|
store.ssh_key_passphrase(&fingerprint)?.expose(),
|
||||||
|
b"protected-passphrase"
|
||||||
|
);
|
||||||
|
backend.fail_next(SecretStoreError::Denied);
|
||||||
|
assert!(matches!(
|
||||||
|
store.ssh_key_passphrase(&fingerprint),
|
||||||
|
Err(GitError::SshKeyPassphraseDenied { fingerprint: denied }) if denied == fingerprint
|
||||||
|
));
|
||||||
|
backend.fail_next(SecretStoreError::Cancelled);
|
||||||
|
assert!(matches!(
|
||||||
|
store.ssh_key_passphrase(&fingerprint),
|
||||||
|
Err(GitError::SshKeyPassphraseCancelled { fingerprint: cancelled })
|
||||||
|
if cancelled == fingerprint
|
||||||
|
));
|
||||||
|
let missing = SshFingerprint::parse("SHA256:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE")?;
|
||||||
|
assert!(matches!(
|
||||||
|
store.ssh_key_passphrase(&missing),
|
||||||
|
Err(GitError::SshKeyPassphraseUnavailable { fingerprint }) if fingerprint == missing
|
||||||
|
));
|
||||||
|
assert!(!format!("{reference:?}").contains("protected-passphrase"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResult {
|
fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResult {
|
||||||
let backend = MemoryBackend::default();
|
let backend = MemoryBackend::default();
|
||||||
|
|||||||
@@ -70,16 +70,35 @@ The HTTPS account name is stored inside the protected credential record, not in
|
|||||||
TOML. OpenPGP passphrases are addressed by the resolved primary fingerprint.
|
TOML. OpenPGP passphrases are addressed by the resolved primary fingerprint.
|
||||||
|
|
||||||
SSH remotes use either `ssh://[user@]host[:port]/path` or scp-like
|
SSH remotes use either `ssh://[user@]host[:port]/path` or scp-like
|
||||||
`[user@]host:path` syntax and omit the HTTPS credential fields:
|
`[user@]host:path` syntax and omit the HTTPS credential fields. They select
|
||||||
|
exactly one private-key file or one already-running SSH-agent identity:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[[git.remotes]]
|
[[git.remotes]]
|
||||||
name = "origin"
|
name = "origin"
|
||||||
url = "git@git.example.test:alice/password-store.git"
|
url = "git@git.example.test:alice/password-store.git"
|
||||||
|
ssh_identity_file = "keys/id_ed25519"
|
||||||
|
ssh_known_hosts_file = "known_hosts"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Relative paths are resolved against `config.toml`. If
|
||||||
|
`ssh_known_hosts_file` is omitted, `~/.ssh/known_hosts` is used. Agent
|
||||||
|
authentication replaces `ssh_identity_file` with an exact SHA-256 fingerprint
|
||||||
|
and may name a socket explicitly:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
ssh_agent_fingerprint = "SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU"
|
||||||
|
ssh_agent_socket = "/run/user/1000/ssh-agent.socket"
|
||||||
|
```
|
||||||
|
|
||||||
|
Private-key passphrases are stored by fingerprint in the operating-system
|
||||||
|
secret store and never appear in TOML. IronStorage does not read OpenSSH
|
||||||
|
configuration, try additional keys, prompt for passwords or
|
||||||
|
keyboard-interactive authentication, launch an agent, or invoke proxy/helper
|
||||||
|
commands.
|
||||||
|
|
||||||
The typed endpoint model is always available so an SSH remote remains readable
|
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
|
through the Rust API even when the binary was built without SSH. Such a build
|
||||||
returns a typed unsupported-transport error before connection or repository
|
returns a typed unsupported-transport error before connection or repository
|
||||||
mutation. The optional storage `ssh` feature contains `russh` 0.63.1 and Tokio;
|
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
|
`russh` default features are disabled and the Ring backend plus RSA key support
|
||||||
@@ -94,6 +113,12 @@ bytes, credentials, queries, fragments, ambiguous unbracketed IPv6 or colon
|
|||||||
paths, leading-option paths, local paths, URL rewrites, separate push URLs,
|
paths, leading-option paths, local paths, URL rewrites, separate push URLs,
|
||||||
helper transports, and unknown schemes fail closed.
|
helper transports, and unknown schemes fail closed.
|
||||||
|
|
||||||
|
Server identity is checked before authentication against the configured
|
||||||
|
known-hosts file. Exact, hashed, and non-default-port host entries are
|
||||||
|
supported. Unknown keys return their host, algorithm, and SHA-256 fingerprint
|
||||||
|
for explicit confirmation; confirming appends the key atomically. A changed
|
||||||
|
key is a hard failure and is never replaced by that confirmation API.
|
||||||
|
|
||||||
`clipboard_timeout_seconds` controls the native clipboard presentation lease.
|
`clipboard_timeout_seconds` controls the native clipboard presentation lease.
|
||||||
It defaults to 45 seconds for upstream `pass` compatibility and must be between
|
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
|
1 and 300 seconds. The CLI remains alive for the lease so Linux can serve its
|
||||||
|
|||||||
@@ -32,10 +32,7 @@ repository mutation instead of treating its configuration as malformed.
|
|||||||
## HTTPS transport
|
## HTTPS transport
|
||||||
|
|
||||||
HTTPS credentials are requested with the configured server ID and application
|
HTTPS credentials are requested with the configured server ID and application
|
||||||
ID and remain outside Git configuration. The SSH session, host-verification,
|
ID and remain outside Git configuration.
|
||||||
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
|
Fetch uses the embedded Rust smart-HTTP client with an explicit credential
|
||||||
callback, so Git's credential cascade is never entered. Push implements the
|
callback, so Git's credential cascade is never entered. Push implements the
|
||||||
@@ -45,6 +42,24 @@ requests `report-status`, and accepts the update only after both unpack and ref
|
|||||||
status succeed. HTTP redirects are disabled so authorization cannot cross an
|
status succeed. HTTP redirects are disabled so authorization cannot cross an
|
||||||
origin boundary.
|
origin boundary.
|
||||||
|
|
||||||
|
## SSH authentication
|
||||||
|
|
||||||
|
With the optional `ssh` feature, storage opens an in-process Russh client and
|
||||||
|
verifies the server key before any authentication or command. Known-host files
|
||||||
|
are read with size and line bounds; exact, hashed, and bracketed non-default
|
||||||
|
port entries are supported. Unknown keys require an explicit call to persist
|
||||||
|
the confirmed key atomically. Changed keys always fail and are never replaced.
|
||||||
|
|
||||||
|
Authentication uses one configured OpenSSH private-key file (Ed25519, ECDSA,
|
||||||
|
or RSA) or one exact SHA-256 fingerprint from an already-running SSH agent.
|
||||||
|
Encrypted-key passphrases come from `SecretBytes` in the operating-system
|
||||||
|
secret store. Identity attempts are bounded and deterministic; IronStorage
|
||||||
|
does not spray keys, use passwords or keyboard-interactive authentication,
|
||||||
|
read OpenSSH configuration, start/probe an agent process, or run proxy/helper
|
||||||
|
commands. SHA-1 host signatures and `ssh-rsa` authentication are excluded.
|
||||||
|
Cancellation interrupts connection and authentication without changing Git,
|
||||||
|
known hosts, or secure storage.
|
||||||
|
|
||||||
Pull refuses a dirty worktree. It fast-forwards when possible and otherwise
|
Pull refuses a dirty worktree. It fast-forwards when possible and otherwise
|
||||||
uses the embedded three-way tree merge. Unresolved paths are returned as typed
|
uses the embedded three-way tree merge. Unresolved paths are returned as typed
|
||||||
`MergeConflicts`; no conflict markers or partial checkout are written. Checkout
|
`MergeConflicts`; no conflict markers or partial checkout are written. Checkout
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ password-store repositories, Git configuration, command arguments, and logs
|
|||||||
contain only opaque identifiers; passphrases, tokens, and HTTPS passwords are
|
contain only opaque identifiers; passphrases, tokens, and HTTPS passwords are
|
||||||
stored by the operating system.
|
stored by the operating system.
|
||||||
|
|
||||||
`SecretReference` has two validated forms. OpenPGP passphrases are keyed by the
|
`SecretReference` has three validated forms. OpenPGP passphrases are keyed by the
|
||||||
primary fingerprint. HTTPS Git credentials are keyed by purpose, server ID,
|
primary fingerprint. HTTPS Git credentials are keyed by purpose, server ID,
|
||||||
application ID, and account. The account is kept inside the protected record,
|
application ID, and account. The account is kept inside the protected record,
|
||||||
so a configured server/application pair can retrieve it without adding an
|
so a configured server/application pair can retrieve it without adding an
|
||||||
account or secret value to TOML. References, locators, store state, and errors
|
account or secret value to TOML. SSH private-key passphrases are keyed by the
|
||||||
all use redacted `Debug` output.
|
key's SHA-256 fingerprint. References, locators, store state, and errors all use
|
||||||
|
redacted `Debug` output.
|
||||||
|
|
||||||
Stored values use a small versioned binary envelope containing their reference
|
Stored values use a small versioned binary envelope containing their reference
|
||||||
and secret bytes. Retrieval validates the envelope and exact reference before
|
and secret bytes. Retrieval validates the envelope and exact reference before
|
||||||
@@ -47,8 +48,8 @@ if the platform lock operation reports an error. Caching is disabled unless a
|
|||||||
caller explicitly selects `SecretCachePolicy::Timed`. Timed policies are capped
|
caller explicitly selects `SecretCachePolicy::Timed`. Timed policies are capped
|
||||||
at 128 entries and 15 minutes, expire lazily, and are always cleared on lock.
|
at 128 entries and 15 minutes, expire lazily, and are always cleared on lock.
|
||||||
|
|
||||||
The same unlocked store implements the OpenPGP `SecretProvider` and HTTPS Git
|
The same unlocked store implements the OpenPGP `SecretProvider`, HTTPS Git
|
||||||
`GitCredentialProvider`. The CLI uses it for terminal `show` and embedded `git
|
`GitCredentialProvider`, and SSH `SshPassphraseProvider`. The CLI uses it for
|
||||||
fetch`, proving that protected keys and remote authentication are resolved only
|
terminal `show` and embedded Git, proving that protected keys and remote
|
||||||
through opaque references. Tests inject a memory backend and never access a
|
authentication are resolved only through opaque references. Tests inject a
|
||||||
developer or CI user keyring.
|
memory backend and never access a developer or CI user keyring.
|
||||||
|
|||||||
Reference in New Issue
Block a user