Skip to content

Commit 6562551

Browse files
committed
feat(auth): add certificate-based authentication via SSH agent
Add support for authenticating with SSH certificates held by an SSH agent, complementing the existing key-based authentication flow. - Add FutureCertificate method variant for certificate auth - Add AgentIdentity enum to represent both keys and certificates - Implement sign_request_cert for certificate-based signing - Add authenticate_certificate_with for FutureCertificate auth flow - Add hash_alg support for RSA certificate signing - Comprehensive test coverage for new functionality Closes #438
1 parent 591ec26 commit 6562551

7 files changed

Lines changed: 1129 additions & 4 deletions

File tree

russh/src/auth.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,22 @@ pub trait Signer: Sized {
163163
hash_alg: Option<HashAlg>,
164164
to_sign: CryptoVec,
165165
) -> impl Future<Output = Result<CryptoVec, Self::Error>> + Send;
166+
167+
/// Sign authentication data using a certificate identity.
168+
///
169+
/// The default implementation returns an error, indicating certificate
170+
/// signing is not supported. Implementations that support certificate
171+
/// signing (e.g., SSH agent clients) should override this method.
172+
///
173+
/// For RSA certificates, you can specify the hash algorithm to use.
174+
fn auth_certificate_sign(
175+
&mut self,
176+
_cert: &Certificate,
177+
_hash_alg: Option<HashAlg>,
178+
_to_sign: CryptoVec,
179+
) -> impl Future<Output = Result<CryptoVec, Self::Error>> + Send {
180+
async { Err((crate::SendError {}).into()) }
181+
}
166182
}
167183

168184
#[derive(Debug, Error)]
@@ -192,6 +208,20 @@ impl<R: AsyncRead + AsyncWrite + Unpin + Send + 'static> Signer
192208
.map_err(Into::into)
193209
}
194210
}
211+
212+
#[allow(clippy::manual_async_fn)]
213+
fn auth_certificate_sign(
214+
&mut self,
215+
cert: &Certificate,
216+
hash_alg: Option<HashAlg>,
217+
to_sign: CryptoVec,
218+
) -> impl Future<Output = Result<CryptoVec, Self::Error>> {
219+
async move {
220+
self.sign_request_cert(cert, hash_alg, to_sign)
221+
.await
222+
.map_err(Into::into)
223+
}
224+
}
195225
}
196226

197227
#[derive(Debug)]
@@ -212,6 +242,12 @@ pub enum Method {
212242
key: ssh_key::PublicKey,
213243
hash_alg: Option<HashAlg>,
214244
},
245+
/// Certificate-based authentication using an external signer (e.g., SSH agent).
246+
/// The certificate is sent to the server, but signing is delegated to the signer.
247+
FutureCertificate {
248+
cert: Certificate,
249+
hash_alg: Option<HashAlg>,
250+
},
215251
KeyboardInteractive {
216252
submethods: String,
217253
},

russh/src/client/encrypted.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,38 @@ impl Session {
252252
})
253253
}
254254
}
255+
Some(auth::Method::FutureCertificate { cert, hash_alg }) => {
256+
debug!("certificate");
257+
self.common.buffer.clear();
258+
let i = enc.client_make_to_sign(
259+
&self.common.auth_user,
260+
&PublicKeyOrCertificate::Certificate(cert.clone()),
261+
&mut self.common.buffer,
262+
)?;
263+
let len = self.common.buffer.len();
264+
let buf = std::mem::replace(
265+
&mut self.common.buffer,
266+
CryptoVec::new(),
267+
);
268+
269+
self.sender
270+
.send(Reply::SignRequestCert { cert, hash_alg, data: buf })
271+
.map_err(|_| crate::Error::SendError)?;
272+
self.common.buffer = loop {
273+
match self.receiver.recv().await {
274+
Some(Msg::Signed { data }) => break data,
275+
None => return Err(crate::Error::RecvError.into()),
276+
_ => {}
277+
}
278+
};
279+
if self.common.buffer.len() != len {
280+
// The buffer was modified.
281+
push_packet!(enc.write, {
282+
#[allow(clippy::indexing_slicing)] // length checked
283+
enc.write.extend(&self.common.buffer[i..]);
284+
})
285+
}
286+
}
255287
_ => {}
256288
}
257289
}
@@ -940,6 +972,18 @@ impl Encrypted {
940972
key.to_bytes()?.as_slice().encode(&mut self.write)?;
941973
true
942974
}
975+
auth::Method::FutureCertificate { ref cert, .. } => {
976+
user.as_bytes().encode(&mut self.write)?;
977+
"ssh-connection".encode(&mut self.write)?;
978+
"publickey".encode(&mut self.write)?;
979+
self.write.push(0); // This is a probe
980+
981+
cert.algorithm()
982+
.to_certificate_type()
983+
.encode(&mut self.write)?;
984+
cert.to_bytes()?.as_slice().encode(&mut self.write)?;
985+
true
986+
}
943987
auth::Method::KeyboardInteractive { ref submethods } => {
944988
debug!("Keyboard interactive");
945989
user.as_bytes().encode(&mut self.write)?;

russh/src/client/mod.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ enum Reply {
119119
key: ssh_key::PublicKey,
120120
data: CryptoVec,
121121
},
122+
SignRequestCert {
123+
cert: Certificate,
124+
hash_alg: Option<HashAlg>,
125+
data: CryptoVec,
126+
},
122127
AuthInfoRequest {
123128
name: String,
124129
instructions: String,
@@ -500,6 +505,64 @@ impl<H: Handler> Handle<H> {
500505
}
501506
}
502507

508+
/// Authenticate using a certificate with a custom signer that implements the
509+
/// [`Signer`][auth::Signer] trait. This is for certificate-based authentication
510+
/// where the signing is delegated to an external signer (e.g., SSH agent).
511+
///
512+
/// For RSA certificates, you can specify the hash algorithm to use.
513+
pub async fn authenticate_certificate_with<U: Into<String>, S: auth::Signer>(
514+
&mut self,
515+
user: U,
516+
cert: Certificate,
517+
hash_alg: Option<HashAlg>,
518+
signer: &mut S,
519+
) -> Result<AuthResult, S::Error> {
520+
let user = user.into();
521+
if self
522+
.sender
523+
.send(Msg::Authenticate {
524+
user,
525+
method: auth::Method::FutureCertificate { cert, hash_alg },
526+
})
527+
.await
528+
.is_err()
529+
{
530+
return Err((crate::SendError {}).into());
531+
}
532+
loop {
533+
let reply = self.receiver.recv().await;
534+
match reply {
535+
Some(Reply::AuthSuccess) => return Ok(AuthResult::Success),
536+
Some(Reply::AuthFailure {
537+
proceed_with_methods: remaining_methods,
538+
partial_success,
539+
}) => {
540+
return Ok(AuthResult::Failure {
541+
remaining_methods,
542+
partial_success,
543+
});
544+
}
545+
Some(Reply::SignRequestCert { cert, hash_alg, data }) => {
546+
let data = signer.auth_certificate_sign(&cert, hash_alg, data).await;
547+
let data = match data {
548+
Ok(data) => data,
549+
Err(e) => return Err(e),
550+
};
551+
if self.sender.send(Msg::Signed { data }).await.is_err() {
552+
return Err((crate::SendError {}).into());
553+
}
554+
}
555+
None => {
556+
return Ok(AuthResult::Failure {
557+
remaining_methods: MethodSet::empty(),
558+
partial_success: false,
559+
});
560+
}
561+
_ => {}
562+
}
563+
}
564+
}
565+
503566
/// Wait for confirmation that a channel is open
504567
async fn wait_channel_confirmation(
505568
&self,

russh/src/keys/agent/client.rs

Lines changed: 122 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ use byteorder::{BigEndian, ByteOrder};
44
use bytes::Bytes;
55
use log::{debug, error};
66
use ssh_encoding::{Decode, Encode, Reader};
7-
use ssh_key::{Algorithm, HashAlg, PrivateKey, PublicKey, Signature};
7+
use ssh_key::{Algorithm, Certificate, HashAlg, PrivateKey, PublicKey, Signature};
88
use tokio;
99
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
1010

11-
use super::{msg, Constraint};
11+
use super::{msg, AgentIdentity, Constraint};
1212
use crate::helpers::EncodedExt;
1313
use crate::keys::{key, Error};
1414
use crate::CryptoVec;
@@ -254,8 +254,12 @@ impl<S: AgentStream + Unpin> AgentClient<S> {
254254
Ok(())
255255
}
256256

257-
/// Ask the agent for a list of the currently registered secret
258-
/// keys.
257+
/// Ask the agent for a list of the currently registered public keys.
258+
///
259+
/// Note: Certificates held by the agent will be returned as their underlying public
260+
/// key only, without the certificate data. Use
261+
/// [`request_identities_full`](Self::request_identities_full) to retrieve full
262+
/// certificate information.
259263
pub async fn request_identities(&mut self) -> Result<Vec<PublicKey>, Error> {
260264
self.buf.clear();
261265
self.buf.resize(4);
@@ -282,6 +286,70 @@ impl<S: AgentStream + Unpin> AgentClient<S> {
282286
Ok(keys)
283287
}
284288

289+
/// Ask the agent for a list of identities, including certificates.
290+
///
291+
/// Unlike [`request_identities`](Self::request_identities) which only returns public keys,
292+
/// this method correctly parses OpenSSH certificates held by the agent.
293+
pub async fn request_identities_full(&mut self) -> Result<Vec<AgentIdentity>, Error> {
294+
self.buf.clear();
295+
self.buf.resize(4);
296+
msg::REQUEST_IDENTITIES.encode(&mut self.buf)?;
297+
let len = self.buf.len() - 4;
298+
BigEndian::write_u32(&mut self.buf[..], len as u32);
299+
300+
self.read_response().await?;
301+
debug!("identities: {:?}", &self.buf[..]);
302+
let mut identities = Vec::new();
303+
304+
#[allow(clippy::indexing_slicing)] // static length
305+
if let Some((&msg::IDENTITIES_ANSWER, mut r)) = self.buf.split_first() {
306+
let n = u32::decode(&mut r)?;
307+
for _ in 0..n {
308+
let key_blob = Bytes::decode(&mut r)?;
309+
let comment = String::decode(&mut r)?;
310+
311+
// Check if blob starts with a certificate algorithm by reading the algorithm string.
312+
// Certificate algorithms end with "-cert-v01@openssh.com".
313+
// This avoids parsing the blob twice for regular keys.
314+
let identity = if Self::is_certificate_blob(&key_blob) {
315+
match Certificate::decode(&mut key_blob.as_ref()) {
316+
Ok(cert) => AgentIdentity::Certificate { cert, comment },
317+
Err(_) => {
318+
// Fallback to public key if certificate parsing fails
319+
let key = key::parse_public_key(&key_blob)?;
320+
AgentIdentity::PublicKey { key, comment }
321+
}
322+
}
323+
} else {
324+
let key = key::parse_public_key(&key_blob)?;
325+
AgentIdentity::PublicKey { key, comment }
326+
};
327+
identities.push(identity);
328+
}
329+
}
330+
331+
Ok(identities)
332+
}
333+
334+
/// Check if a key blob appears to be a certificate by examining the algorithm prefix.
335+
/// Certificate algorithms end with "-cert-v01@openssh.com".
336+
fn is_certificate_blob(blob: &[u8]) -> bool {
337+
// The blob starts with a length-prefixed string containing the algorithm name.
338+
// Read the length (4 bytes, big-endian) and then the algorithm string.
339+
let Some(len_bytes) = blob.get(..4) else {
340+
return false;
341+
};
342+
let alg_len = BigEndian::read_u32(len_bytes) as usize;
343+
let Some(alg_bytes) = blob.get(4..4 + alg_len) else {
344+
return false;
345+
};
346+
if let Ok(alg_str) = str::from_utf8(alg_bytes) {
347+
alg_str.ends_with("-cert-v01@openssh.com")
348+
} else {
349+
false
350+
}
351+
}
352+
285353
/// Ask the agent to sign the supplied piece of data.
286354
pub async fn sign_request(
287355
&mut self,
@@ -307,6 +375,56 @@ impl<S: AgentStream + Unpin> AgentClient<S> {
307375
}
308376
}
309377

378+
/// Ask the agent to sign data using a certificate identity.
379+
///
380+
/// This sends the certificate blob to the agent (not just the public key),
381+
/// allowing the agent to match it to the correct private key.
382+
///
383+
/// For RSA certificates, you can specify the hash algorithm to use.
384+
pub async fn sign_request_cert(
385+
&mut self,
386+
cert: &Certificate,
387+
hash_alg: Option<HashAlg>,
388+
mut data: CryptoVec,
389+
) -> Result<CryptoVec, Error> {
390+
debug!("sign_request_cert: {data:?}");
391+
392+
self.buf.clear();
393+
self.buf.resize(4);
394+
msg::SIGN_REQUEST.encode(&mut self.buf)?;
395+
cert.to_bytes()?.encode(&mut self.buf)?;
396+
data.as_ref().encode(&mut self.buf)?;
397+
398+
// Calculate hash flag for RSA certificates (same logic as prepare_sign_request)
399+
let hash = match cert.algorithm() {
400+
Algorithm::Rsa { .. } => match hash_alg {
401+
Some(HashAlg::Sha256) => 2,
402+
Some(HashAlg::Sha512) => 4,
403+
_ => 0,
404+
},
405+
_ => 0,
406+
};
407+
408+
hash.encode(&mut self.buf)?;
409+
410+
let len = self.buf.len() - 4;
411+
BigEndian::write_u32(&mut self.buf[..], len as u32);
412+
413+
self.read_response().await?;
414+
415+
match self.buf.split_first() {
416+
Some((&msg::SIGN_RESPONSE, mut r)) => {
417+
self.write_signature(&mut r, hash, &mut data)?;
418+
Ok(data)
419+
}
420+
Some((&msg::FAILURE, _)) => Err(Error::AgentFailure),
421+
_ => {
422+
debug!("self.buf = {:?}", &self.buf[..]);
423+
Err(Error::AgentProtocolError)
424+
}
425+
}
426+
}
427+
310428
fn prepare_sign_request(
311429
&mut self,
312430
public: &ssh_key::PublicKey,

0 commit comments

Comments
 (0)