# russh: Agent server never calls `Agent::confirm`, so keys added with the confirm constraint (`ssh-add -c`) sign without confirmation

russh's SSH agent server hard-codes confirmation success, so keys added with ssh-add -c sign without the user's approval.

- Payout: Research
- Severity: high
- Program: russh
- Found by: cataggar
- Disclosed: 2026-10-30
- CVE: CVE-2026-116794
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-vp5q-ff62-jwqv
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-vp5q-ff62-jwqv
- Tags: area:oss domain:oss impact:integrity impact:privesc kind:research platform:ghsa sev:high tech:rust technique:regression technique:source-review vuln:access-control vuln:auth-bypass

**Target:** SSH agent server (russh::keys::agent::server) over Unix socket
**Root cause:** In Connection::try_sign, the call to Agent::confirm for keys carrying SSH_AGENT_CONSTRAIN_CONFIRM was replaced by a hard-coded `true` future during a refactor (3e04597), so the confirmation callback is never invoked.
**Steps:**
1. Add the advisory's test to `mod test` in `russh/src/keys/mod.rs` using the existing `Incoming` helper and `ED25519_KEY`.
2. Implement an `Agent` whose `confirm` returns `false` and counts calls.
3. Serve the agent on a temp Unix socket via `agent::server::serve`.
4. Connect with `AgentClient` and call `add_identity(&key, &[agent::Constraint::Confirm])` (what `ssh-add -c` sends).
5. Call `sign_request(...)`; observe it succeeds with `Agent::confirm` called 0 times instead of failing.
**How found:** Reporter read the agent server's try_sign path, noticed the confirm branch awaited a `futures::future::ready((agent, true))` instead of `agent.confirm(...)`, then bisected crates.io releases to find the regression introduced in 0.50.0-beta.12 and confirmed via a unit test.
**Exploitation:** Any party able to reach the agent socket (other local processes of the user, or a remote host when the agent is forwarded with ssh -A) can request signatures for a confirm-constrained key and receive them without the user ever being prompted, enabling logins as the user or signing data like git commits.
**Escalation:** Agent forwarding (ssh -A) extends reachability to remote hosts; downstream Proton Pass desktop uses confirm as its 'vault unlocked' gate, so signing proceeds while locked in states where keys remain loaded.
**Insight:** When auditing agent/daemon code, grep for `future::ready` or hard-coded `true`/`Ok(())` in authorization branches left over from async-trait refactors; also check that constraint flags accepted at add_identity time are actually enforced at sign time, since accepting-but-ignoring a constraint is itself a spec violation.

## Proof of concept (copied exactly from the original report)

**Rust test proving confirm-constrained key signs without confirmation**

```
#[tokio::test]
#[cfg(unix)]
async fn test_agent_confirms_keys_added_with_the_confirm_constraint() {
    use std::sync::atomic::{AtomicUsize, Ordering};

    // An agent whose user refuses every confirmation prompt.
    #[derive(Clone)]
    struct Refuse(std::sync::Arc<AtomicUsize>);
    impl agent::server::Agent for Refuse {
        fn confirm(
            self,
            _: std::sync::Arc<PrivateKey>,
        ) -> Box<dyn Future<Output = (Self, bool)> + Send + Unpin> {
            self.0.fetch_add(1, Ordering::SeqCst);
            Box::new(futures::future::ready((self, false)))
        }
    }

    let prompts = std::sync::Arc::new(AtomicUsize::new(0));
    let dir = tempfile::tempdir().unwrap();
    let agent_path = dir.path().join("agent");
    let mut listener = tokio::net::UnixListener::bind(&agent_path).unwrap();
    let agent = Refuse(prompts.clone());
    tokio::spawn(async move {
        agent::server::serve(
            Incoming {
                listener: &mut listener,
            },
            agent,
        )
        .await
    });

    let key = decode_secret_key(ED25519_KEY, Some("blabla")).unwrap();
    let public = key.public_key().clone();
    let stream = tokio::net::UnixStream::connect(&agent_path).await.unwrap();
    let mut client = agent::client::AgentClient::connect(stream);
    // What `ssh-add -c` sends.
    client
        .add_identity(&key, &[agent::Constraint::Confirm])
        .await
        .unwrap();

    let signed = client
        .sign_request(&AgentIdentity::from(public), None, b"data".to_vec())
        .await;
    let confirm_calls = prompts.load(Ordering::SeqCst);
    assert!(
        signed.is_err(),
        "the agent signed with a confirm-constrained key without asking: \
         Agent::confirm was called {confirm_calls} time(s)"
    );
    assert_eq!(
        confirm_calls, 1,
        "Agent::confirm should be asked exactly once"
    );
}
```

