Skip to content

Add host certificate support to server and client#641

Open
gvz wants to merge 17 commits into
Eugeny:mainfrom
gvz:main
Open

Add host certificate support to server and client#641
gvz wants to merge 17 commits into
Eugeny:mainfrom
gvz:main

Conversation

@gvz

@gvz gvz commented Feb 15, 2026

Copy link
Copy Markdown

This adds support for host certificats to russh and fixes #416.
It was tested with the openssh client and the added test.
There are two test: the first test the happy path for server and client.
The second one test whether the Certificate::validate function can detect if the host presents a certificate that is singned by an untrusted authority and reject it.

I also added an example, echoserver-certificate.

Let me know what you would like me to improove to get this merged.

@gvz gvz changed the title Add host certificate support WIP: Add host certificate support Feb 15, 2026
@gvz gvz force-pushed the main branch 4 times, most recently from 2781c38 to 1fe6853 Compare February 18, 2026 20:59
@gvz gvz changed the title WIP: Add host certificate support Add host certificate support Feb 18, 2026
@gvz gvz changed the title Add host certificate support Add host certificate support to server and client Feb 18, 2026
@maximilian-maisel-bl

maximilian-maisel-bl commented Mar 9, 2026

Copy link
Copy Markdown

This seems to be the server part only, right?

If I run my minimal client example against a host certificate only OpenSSH server, I get this error:

[2026-03-09T07:17:10Z DEBUG russh::client] ssh id = Standard("SSH-2.0-russh_0.57.0")
[2026-03-09T07:17:10Z DEBUG russh::client] beginning re-key
[2026-03-09T07:17:10Z DEBUG russh::sshbuffer] > msg type 20, len 930
[2026-03-09T07:17:10Z DEBUG russh::client] < msg type 20, seqn 1, len 715
[2026-03-09T07:17:10Z DEBUG russh::negotiation] strict kex enabled
[2026-03-09T07:17:10Z DEBUG russh::client] drop session
[2026-03-09T07:17:10Z DEBUG russh::client] kex_done_signal sender was dropped RecvError(())

thread 'main' panicked at src/main.rs:36:6:
connect failed: NoCommonAlgo { kind: Key, ours: ["ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"], theirs: ["rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-256-cert-v01@openssh.com"] }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The following sshd_config line is used to set the server to host certificate only: HostKeyAlgorithms rsa-sha2-256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com.

@gvz

gvz commented Mar 9, 2026

Copy link
Copy Markdown
Author

Thanks for testing, my test uses ed25519 and sets it as the prefered algo.
I will include your test method into the tests run by cargo and hopefully fix the issue

@gvz gvz force-pushed the main branch 2 times, most recently from c031d99 to 39de561 Compare March 11, 2026 07:32
@gvz

gvz commented Mar 11, 2026

Copy link
Copy Markdown
Author

I added support for rsa certificats and fixed some other issues.
Sadly, I had to change async fn check_server_key it now takes russh::cert::PublicKeyOrCertificate instead of ssh_key::PublicKey as an argument.
To my understanding, the alternative was the modifiy the fork of ssh_keys used in russh.

@maximilian-maisel-bl

Copy link
Copy Markdown

I pulled you new version 39de561a1436e035397ebbfcb095fa314286327e and updated my example program's check_server_key function to accept a PublicKeyOrCertificate. However, I still get a NoCommonAlgo { kind: Key, ours: ["ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"], theirs: ["rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-256-cert-v01@openssh.com"] } error returned from client::connect. The check_server_key handler is not called.

@gvz

gvz commented Mar 17, 2026

Copy link
Copy Markdown
Author

I read your code and I think I firgured out what goes wrong. You are not providing a certificate during the intialization to the client, therefore it does not add any certificate handling algo to the list of available host key algos.
As of now, you need to set the rsa-sha2-256-cert-v01@openssh.com as a prefered algo.

I see that this is not the most elegant way and I am working on a more obvios one.
The current idea is to add an optional client config parameter ca_public_key.
Based on that key the corresponding algo would be added to the host key algos.
I do not what to always add all the certificate algos to the list of available algo, as without out the CA's public key they are not really available.
What do you think about that idea?

@maximilian-maisel-bl

Copy link
Copy Markdown

Thanks for investigation my issue. I added this to my config based on your code example in the PR:

     config.preferred.key = Cow::Owned(vec![
         Algorithm::from_str(&Algorithm::Rsa { hash: Some(Sha256) }.to_certificate_type()).unwrap(),
         Algorithm::from_str(&Algorithm::Rsa { hash: Some(Sha512) }.to_certificate_type()).unwrap(),
     ]);

For me, it was not very intuitive that preferred acts as enabled algorithms.
I had to dig into the russh source and the new code examples to understand how to assemble the correct Algorithm variants for certificate keys. This area probably needs more explicit documentation.
On the API side, I'd suggest adding some constructor like client::Config::default_with_certificates() that
gives users a working config without reverse-engineering the correct list. Your suggested alternative of providing a ca_public_key to the client also sound promising to me.

After adding certificate algorithms to config.preferred.key, the client negotiates a key algorithm and gets past the initial kex phase. However, the connection still fails during the verification stage before my custom Handler gets called and can check the certificate against a CA.

This is the output of my test app:

[2026-03-17T11:38:19Z DEBUG russh::client] ssh id = Standard("SSH-2.0-russh_0.57.0")
[2026-03-17T11:38:20Z DEBUG russh::client] beginning re-key
[2026-03-17T11:38:20Z DEBUG russh::sshbuffer] > msg type 20, len 892
[2026-03-17T11:38:20Z DEBUG russh::client] < msg type 20, seqn 1, len 715
[2026-03-17T11:38:20Z DEBUG russh::negotiation] strict kex enabled
[2026-03-17T11:38:20Z DEBUG russh::client::kex] negotiated algorithms: Names { kex: Name("curve25519-sha256"), key: Rsa { hash: Some(Sha256) }, cipher: Name("aes256-gcm@openssh.com"), client_mac: Name("hmac-sha2-512-etm@openssh.com"), server_mac: Name("hmac-sha2-512-etm@openssh.com"), server_compression: None, client_compression: None, ignore_guessed: false, strict_kex: true }
[2026-03-17T11:38:20Z DEBUG russh::sshbuffer] > msg type 30, len 37
[2026-03-17T11:38:20Z DEBUG russh::client] kex impl continues: ClientKex { cause: Initial, state: "waiting for DH response" }
[2026-03-17T11:38:20Z DEBUG russh::client] < msg type 31, seqn 2, len 2308
[2026-03-17T11:38:20Z DEBUG russh::client::kex] received server host key: Ok("ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgUqHZYD+uSIzTn4P8e5eWAB64hUlv6RO35SjvlHKgEsQ=")
[2026-03-17T11:38:20Z DEBUG russh::client::kex] wrong server sig: signature::Error { source: Some(unsupported algorithm: ssh-rsa-cert-v01@openssh.com) }
[2026-03-17T11:38:20Z DEBUG russh::client] drop session
[2026-03-17T11:38:20Z DEBUG russh::client] kex_done_signal sender was dropped RecvError(())

thread 'main' panicked at src/main.rs:43:6:

@gvz gvz force-pushed the main branch 4 times, most recently from bfb7223 to 6355835 Compare March 19, 2026 07:34
@gvz

gvz commented Mar 19, 2026

Copy link
Copy Markdown
Author

ok, i did some more work on this, added the ca_public_key. I also did some testing thie a openssh server configured as you described, now I optimistic that it works.

this is a client code i used:

use std::fs;
use std::net::{Ipv4Addr, SocketAddrV4};
use std::sync::Arc;

use russh::cert::PublicKeyOrCertificate;
use russh::client;
use russh::keys::ssh_key::PublicKey;
use russh::Disconnect;

struct MyClient {}

impl client::Handler for MyClient {
    type Error = russh::Error;
    async fn check_server_key(
        &mut self,
        server_pub_key: &PublicKeyOrCertificate,
    ) -> Result<bool, Self::Error> {
        match server_pub_key {
            PublicKeyOrCertificate::Certificate(_cert) => {
                print!("got certificate");
                Ok(true)
            }
            PublicKeyOrCertificate::PublicKey { .. } => {
                eprintln!("Server presented a plain public key, not a certificate.");
                Ok(false)
            }
        }
    }
}
#[tokio::main]
async fn main() -> Result<(), String> {
    env_logger::builder()
        .filter_level(log::LevelFilter::Debug)
        .init();
    let ca_key_data = fs::read_to_string("ca_host_key.pub").unwrap();
    let ca_public_key = PublicKey::from_openssh(&ca_key_data).unwrap();
    let client_config = client::Config {
        ca_public_keys: Some(vec![ca_public_key.clone()]),
        ..Default::default()
    };
    let client = client::connect(
        Arc::new(client_config),
        SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 2222),
        MyClient {},
    )
    .await
    .expect("connect failed");
    client
        .disconnect(Disconnect::ByApplication, "", "")
        .await
        .unwrap();

    Ok(())
}

@maximilian-maisel-bl

Copy link
Copy Markdown

Thank you for updating your MR and providing a client example. The API looks much nicer now than in the previous version that used the preferred field. I tried your example code and adjusted the file ca_host_key.pub with the contents from my known_hosts line matching this certificate, starting with ssh-rsa AA....

When connecting to my server with openssh and only this single line in my known_hosts, everything works fine and I get the following relevant debug output:

debug1: Server host certificate: ssh-rsa-cert-v01@openssh.com SHA256:REDACTED, serial 1 ID "test" CA ssh-rsa SHA256:REDACTED valid forever
debug3: record_hostkey: found ca key type RSA in file ~/.ssh/known_hosts:1
debug1: Host '192.168.56.11' is known and matches the RSA-CERT host certificate.

However, when I run your example with only the CA-pubkey and IP address adjusted, I still get:

[2026-03-20T07:56:33Z DEBUG russh::client] ssh id = Standard("SSH-2.0-russh_0.57.0")
[2026-03-20T07:56:33Z DEBUG russh::client] beginning re-key
[2026-03-20T07:56:33Z DEBUG russh::negotiation] Push CA cert algo ssh-rsa-cert-v01@openssh.com
[2026-03-20T07:56:33Z DEBUG russh::negotiation] Push CA cert algo rsa-sha2-256-cert-v01@openssh.com
[2026-03-20T07:56:33Z DEBUG russh::negotiation] Push CA cert algo rsa-sha2-512-cert-v01@openssh.com
[2026-03-20T07:56:33Z DEBUG russh::sshbuffer] > msg type 20, len 921
[2026-03-20T07:56:33Z DEBUG russh::client] < msg type 20, seqn 1, len 715
[2026-03-20T07:56:33Z DEBUG russh::negotiation] strict kex enabled
[2026-03-20T07:56:33Z DEBUG russh::negotiation] Push CA cert algo ssh-rsa-cert-v01@openssh.com
[2026-03-20T07:56:33Z DEBUG russh::negotiation] Push CA cert algo rsa-sha2-256-cert-v01@openssh.com
[2026-03-20T07:56:33Z DEBUG russh::negotiation] Push CA cert algo rsa-sha2-512-cert-v01@openssh.com
[2026-03-20T07:56:33Z DEBUG russh::client::kex] negotiated algorithms: Names { kex: Name("curve25519-sha256"), key: Rsa { hash: Some(Sha256) }, cipher: Name("aes256-gcm@openssh.com"), client_mac: Name("hmac-sha2-512-etm@openssh.com"), server_mac: Name("hmac-sha2-512-etm@openssh.com"), server_compression: None, client_compression: None, ignore_guessed: false, strict_kex: true }
[2026-03-20T07:56:33Z DEBUG russh::sshbuffer] > msg type 30, len 37
[2026-03-20T07:56:33Z DEBUG russh::client] kex impl continues: ClientKex { cause: Initial, state: "waiting for DH response" }
[2026-03-20T07:56:33Z DEBUG russh::client] < msg type 31, seqn 2, len 2308
[2026-03-20T07:56:33Z DEBUG russh::client::kex] received server host key: Ok("ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgUqHZYD+uSIzTn4P8e5eWAB64hUlv6RO35SjvlHKgEsQ=")
[2026-03-20T07:56:33Z DEBUG russh::client::kex] wrong server sig: signature::Error { source: Some(unsupported algorithm: ssh-rsa-cert-v01@openssh.com) }
[2026-03-20T07:56:33Z DEBUG russh::client] drop session
[2026-03-20T07:56:33Z DEBUG russh::client] kex_done_signal sender was dropped RecvError(())

thread 'main' panicked at src/main.rs:47:6:
connect failed: WrongServerSig

@gvz

gvz commented Mar 21, 2026

Copy link
Copy Markdown
Author

hm, interesting. Would you share you ssh server configuration, so I can try to figure out the differences and why the code works with my ssh server config but not yours?

@maximilian-maisel-bl

Copy link
Copy Markdown

Hi @gvz,

I did some further testing and figured out that the issue is related to the processing of host certificates with unlimited validity.

I generated my test PKI and certificates with the following commands:

ssh-keygen -t rsa -b 4096 -f host_ca_new -C host_ca_new
ssh-keygen -t rsa -b 4096 -f ssh_host_rsa_key -N ''

Then I added this to known_hosts:
@cert-authority 192.168.* ${contents_of_ca.pub}

Generate a test host certificate with infinite validity:
ssh-keygen -s host_ca_new -I foo -h ssh_host_rsa_key.pub
This does not work with your PR but works with OpenSSH client.

Adding an expiry time makes it work:
ssh-keygen -s host_ca_new -I foo -h -V +52w ssh_host_rsa_key.pub

For reference, this is my sshd_config from an Ubuntu 24.04 test machine:

# This is the sshd server system-wide configuration file.  See
# sshd_config(5) for more information.

# This sshd was compiled with PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games

# The strategy used for options in the default sshd_config shipped with
# OpenSSH is to specify options with their default value where
# possible, but leave them commented.  Uncommented options override the
# default value.

Include /etc/ssh/sshd_config.d/*.conf

#Port 22
#AddressFamily any
#ListenAddress 0.0.0.0
#ListenAddress ::

HostKey /etc/ssh/ssh_host_rsa_key
HostCertificate /etc/ssh/ssh_host_rsa_key-cert4.pub
#HostKeyAlgorithms rsa-sha2-256,rsa-sha2-256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com
HostKeyAlgorithms rsa-sha2-256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com
#HostKey /etc/ssh/ssh_host_rsa_key
#HostKey /etc/ssh/ssh_host_ecdsa_key
#HostKey /etc/ssh/ssh_host_ed25519_key

# Ciphers and keying
#RekeyLimit default none

# Logging
#SyslogFacility AUTH
#LogLevel INFO

# Authentication:

#LoginGraceTime 2m
#PermitRootLogin prohibit-password
#StrictModes yes
#MaxAuthTries 6
#MaxSessions 10

#PubkeyAuthentication yes

# Expect .ssh/authorized_keys2 to be disregarded by default in future.
#AuthorizedKeysFile	.ssh/authorized_keys .ssh/authorized_keys2

#AuthorizedPrincipalsFile none

#AuthorizedKeysCommand none
#AuthorizedKeysCommandUser nobody

# For this to work you will also need host keys in /etc/ssh/ssh_known_hosts
#HostbasedAuthentication no
# Change to yes if you don't trust ~/.ssh/known_hosts for
# HostbasedAuthentication
#IgnoreUserKnownHosts no
# Don't read the user's ~/.rhosts and ~/.shosts files
#IgnoreRhosts yes

# To disable tunneled clear text passwords, change to no here!
#PasswordAuthentication yes
#PermitEmptyPasswords no

# Change to yes to enable challenge-response passwords (beware issues with
# some PAM modules and threads)
KbdInteractiveAuthentication no

# Kerberos options
#KerberosAuthentication no
#KerberosOrLocalPasswd yes
#KerberosTicketCleanup yes
#KerberosGetAFSToken no

# GSSAPI options
#GSSAPIAuthentication no
#GSSAPICleanupCredentials yes
#GSSAPIStrictAcceptorCheck yes
#GSSAPIKeyExchange no

# Set this to 'yes' to enable PAM authentication, account processing,
# and session processing. If this is enabled, PAM authentication will
# be allowed through the KbdInteractiveAuthentication and
# PasswordAuthentication.  Depending on your PAM configuration,
# PAM authentication via KbdInteractiveAuthentication may bypass
# the setting of "PermitRootLogin prohibit-password".
# If you just want the PAM account and session checks to run without
# PAM authentication, then enable this but set PasswordAuthentication
# and KbdInteractiveAuthentication to 'no'.
UsePAM yes

#AllowAgentForwarding yes
#AllowTcpForwarding yes
#GatewayPorts no
X11Forwarding yes
#X11DisplayOffset 10
#X11UseLocalhost yes
#PermitTTY yes
PrintMotd no
#PrintLastLog yes
#TCPKeepAlive yes
#PermitUserEnvironment no
#Compression delayed
#ClientAliveInterval 0
#ClientAliveCountMax 3
#UseDNS no
#PidFile /run/sshd.pid
#MaxStartups 10:30:100
#PermitTunnel no
#ChrootDirectory none
#VersionAddendum none

# no default banner path
#Banner none

# Allow client to pass locale environment variables
AcceptEnv LANG LC_*

# override default of no subsystems
Subsystem	sftp	/usr/lib/openssh/sftp-server

# Example of overriding settings on a per-user basis
#Match User anoncvs
#	X11Forwarding no
#	AllowTcpForwarding no
#	PermitTTY no
#	ForceCommand cvs server

@gvz

gvz commented Apr 1, 2026

Copy link
Copy Markdown
Author

thanks for your help, ssh has so many fun ways to make my code fail.
I will go, add a test for this and try to fix it.

@gvz

gvz commented May 7, 2026

Copy link
Copy Markdown
Author

@maximilian-maisel-bl It looks like the internal https://github.com/Eugeny/RustCrypto-SSH/ has problems with infintiv livetimes.
I am exlorion options whats the best way to fix it

gvz and others added 8 commits May 8, 2026 08:32
this adds ca_public_key to the client config, if not none this adds the
the algorhism corresponding to the ones advertised while connecting to a
host
Co-authored-by: louib <code@louib.net>
Co-authored-by: Eugene <inbox@null.page>
this adds ca_public_key to the client config, if not none this adds the
the algorhism corresponding to the ones advertised while connecting to a
host
- Update check_server_key impls in tests.rs to accept
&PublicKeyOrCertificate
- Replace removed OsRng import with rand::rng() in test_server_cert.rs
@gvz

gvz commented May 8, 2026

Copy link
Copy Markdown
Author

I opened a pull request to upstream ssh-key repo that fixes the certificate validity issue:
RustCrypto/SSH#504

@gvz gvz force-pushed the main branch 4 times, most recently from b6b90a5 to 1a8b123 Compare May 9, 2026 11:09
gvz added 2 commits May 9, 2026 13:12
…4::MAX)

OpenSSH PROTOCOL.certkeys specifies that valid_before=0xffffffffffffffff
(u64::MAX) means the certificate never expires. The forked ssh-key crate
previously rejected this value in UnixTime::new, causing
Certificate::from_bytes
to fail for infinite-validity certs. The client kex code silently fell
through
to the plain public-key path, which also failed — breaking connections
to any
server whose host certificate was generated without the -V flag.

Fixes:
- russh-ssh-key: add FOREVER_SECS sentinel and cap its SystemTime at
MAX_SECS
  so u64::MAX round-trips through encoding correctly
- Cargo.toml: patch ssh-encoding to the bundled path dep to avoid
two-instance
  type mismatch between path and registry versions of the same crate
- tests: add test_server_infinite_validity_certificate_auth regression
test
gvz added 4 commits May 9, 2026 16:28
primefield 0.14.0-rc.7 fails to compile on Rust 1.88.0 due to const
generic type inference regression. Raising the floor on p256/p384/p521
to rc.9 ensures cargo minimal-versions resolves primefield to rc.9+.
…-versions CI

ed25519 rc.4 and ed25519-dalek pre.6 use Error::KeyMalformed as a unit
variant, incompatible with pkcs8 0.11 stable which changed it to a tuple
variant. Pin ed25519 to 3.0.0 stable and raise ed25519-dalek floor to
pre.7 so cargo minimal-versions never selects the broken versions.
num-bigint 0.4.0 calls div_ceil(&x) which broke when Rust 1.73
stabilized div_ceil(x) by value. yasna 0.5.0 depends on num-bigint
with no floor, so minimal-versions resolves to 0.4.0.
ctr 0.10.0-rc.3 uses cipher::common::BlockSizes which doesn't exist
in cipher 0.5 stable. Add aliased floor pin to force 0.10.0 stable
while keeping russh's direct dep on ctr 0.9 (compatible with aes 0.8).
@gvz

gvz commented May 10, 2026

Copy link
Copy Markdown
Author

RustCrypto/SSH#504 got merged to upstream rustCrypto

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Support Host-Key-Certificates

3 participants