-
-
Notifications
You must be signed in to change notification settings - Fork 203
Description
GitHub Issue: Request to Expose KEX Shared Secret
Summary
We're implementing a Rust client that uses SSH for authentication and session establishment, but needs to derive encryption keys for a separate data channel (UDP-based). This pattern (using SSH KEX to bootstrap keys for a secondary protocol) requires access to the raw shared secret, which russh currently does not expose.
Use Case: Protocol Bridging
Several protocols use SSH as an authentication/session layer but transfer data over separate channels:
- High-speed file transfer protocols that use UDP for throughput
- VPN-over-SSH implementations with separate data planes
- Multimedia streaming protocols using SSH for signaling
- Custom enterprise protocols using SSH for auth but proprietary transports
The common pattern is:
1. Establish SSH connection (TCP)
2. Perform SSH KEX → obtain shared secret K
3. Derive secondary keys: HKDF(K, label, context) → encryption keys for other channel
4. Use derived keys for UDP/other transport encryption
Without access to the raw shared secret (K), step 3 is impossible, even though the SSH connection succeeds.
Current Behavior
The shared secret is computed in KexAlgorithm::compute_shared_secret() and stored privately:
// kex/curve25519.rs
pub struct Curve25519Kex {
local_secret: Option<Scalar>,
shared_secret: Option<MontgomeryPoint>, // ← Private, no accessor
}It's consumed internally by compute_keys() to derive SSH encryption keys, but never exposed to the library user.
Proposed Solution
Add an optional method to retrieve the shared secret bytes. Three possible approaches:
Option A: Method on KexAlgorithm trait
pub trait KexAlgorithm {
// ... existing methods ...
/// Get the raw shared secret bytes (if available)
///
/// Returns None if KEX hasn't completed or secret was already consumed.
///
/// # Security Note
/// This exposes sensitive cryptographic material. Only use for protocols
/// that require deriving additional keys from the SSH shared secret.
fn shared_secret_bytes(&self) -> Option<&[u8]>;
}Option B: Handler callback after KEX
#[async_trait]
pub trait Handler: Sized + Send {
// ... existing methods ...
/// Called after successful key exchange with the shared secret.
///
/// Default implementation discards the secret. Override to capture it
/// for protocols that derive additional keys from SSH KEX.
#[allow(unused_variables)]
async fn kex_completed(
&mut self,
kex_algorithm: &str,
shared_secret: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
Ok(())
}
}Option C: Session method (most conservative)
impl Session {
/// Get the raw shared secret (K) from the most recent KEX.
///
/// # Security Warning
/// Exposes sensitive cryptographic material. Only use for protocols
/// requiring key derivation from SSH KEX (e.g., protocol bridging).
pub fn kex_shared_secret(&self) -> Option<&[u8]>;
}Why Not Fork?
We considered forking russh, but:
- Security maintenance burden - SSH libraries require ongoing security attention
- Tracking upstream - We'd need to continuously merge security fixes
- Community benefit - Other protocol bridging use cases would benefit from upstream support
Alternatives Considered
| Alternative | Why Not Viable |
|---|---|
| Fork russh | Security maintenance burden |
| Use ssh2 (libssh2) | Also doesn't expose shared secret |
| Implement SSH KEX ourselves | Duplicates security-critical code |
| Intercept at network level | Fragile, defeats purpose of using SSH library |
Security Considerations
We understand the hesitation to expose cryptographic secrets. Some mitigations:
- Opt-in only - Default implementation returns
None - Clear documentation - Warn about security implications
- Feature flag - Gate behind
expose-kex-secretfeature (optional) - Handler callback - Makes access explicit and auditable
The shared secret is already available to anyone who implements SSH themselves. Exposing it through a controlled API is arguably safer than forcing users to fork or reimplement.
Minimal Implementation
If helpful, here's a minimal diff for Option A (curve25519 only):
// kex/mod.rs
pub trait KexAlgorithm {
fn skip_exchange(&self) -> bool;
fn server_dh(&mut self, exchange: &mut Exchange, payload: &[u8]) -> Result<(), Error>;
fn client_dh(&mut self, client_ephemeral: &mut CryptoVec, buf: &mut CryptoVec) -> Result<(), Error>;
fn compute_shared_secret(&mut self, remote_pubkey: &[u8]) -> Result<(), Error>;
fn compute_exchange_hash(&self, key: &CryptoVec, exchange: &Exchange, buffer: &mut CryptoVec) -> Result<CryptoVec, Error>;
fn compute_keys(&self, ...) -> Result<CipherPair, Error>;
+
+ /// Get raw shared secret bytes for protocol bridging use cases.
+ fn shared_secret_bytes(&self) -> Option<&[u8]> {
+ None // Default: not available
+ }
}
// kex/curve25519.rs
impl KexAlgorithm for Curve25519Kex {
// ... existing methods ...
+ fn shared_secret_bytes(&self) -> Option<&[u8]> {
+ self.shared_secret.as_ref().map(|s| s.0.as_slice())
+ }
}Similar changes would be needed for other KEX algorithms (ECDH, DH groups).
Questions for Maintainers
- Is there a security concern with exposing the shared secret that we're missing?
- Would you prefer the Handler callback approach to make access more explicit?
- Would a feature flag (
expose-kex-secret) be acceptable to gate this functionality? - Is there an existing pattern in russh for this kind of "escape hatch" functionality?
We're happy to contribute a PR if you're open to any of these approaches.
Suggested Labels
enhancementfeature-requestapi