Table of Contents

Keys and the SSH agent

Public-key authentication is the usual way to log in to SSH servers. SshSharp loads key files, uses the keys an SSH agent holds, generates new keys, and signs with keys that never leave a TPM, smart card, HSM or cloud key service.

When to use which

Where the key lives Use
A file (~/.ssh/id_ed25519, a .pem, a PuTTY .ppk) SshPrivateKey.LoadAsync
A secret store or CI variable SshPrivateKey.Parse
ssh-agent, the Windows OpenSSH agent, or Pageant (including FIDO security keys) SshAgent.FromEnvironment() and GetKeysAsync
A Windows key store, the TPM, a smart card SshPrivateKey.FromRsa / FromECDsa with RSACng / ECDsaCng
A cloud KMS, an HSM, a signing service SshPrivateKey.FromSigner with your SshKeySigner

Connecting by ssh_config alias needs none of this: IdentityFile and the agent are used automatically.

Load a key file

var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
using var key = await SshPrivateKey.LoadAsync(
    Path.Combine(home, ".ssh", "id_ed25519"),
    passphrase: Environment.GetEnvironmentVariable("SSH_KEY_PASSPHRASE"),   // null for a key without one
    cancellationToken);
Console.WriteLine($"{key.KeyType} {key.Fingerprint} {key.Comment}");

await using var client = await SshClient.ConnectAsync("example.com", 22, new SshClientOptions
{
    UserName = "deploy",
    PrivateKeys = [key],
    HostKeyVerifier = verifier,
}, cancellationToken);

Formats: OpenSSH (BEGIN OPENSSH PRIVATE KEY, encrypted too), PKCS#8 (encrypted too), PKCS#1 and SEC1 PEM, and PuTTY .ppk versions 2 and 3. Key types: Ed25519, ECDSA (P-256, P-384, P-521) and RSA of 2048 bits or more.

Load a key from a secret

// A key kept in a secret store (here: an environment variable a CI system fills), never in a file on the build agent.
using var key = SshPrivateKey.Parse(
    Environment.GetEnvironmentVariable("DEPLOY_SSH_KEY") ?? throw new InvalidOperationException("DEPLOY_SSH_KEY is not set."));

Use the SSH agent

// SSH_AUTH_SOCK, else on Windows the OpenSSH agent service, else Pageant.
var agent = SshAgent.FromEnvironment() ?? throw new InvalidOperationException("No SSH agent is running.");
var keys = await agent.GetKeysAsync(cancellationToken);   // the agent signs; the private keys never enter this process

await using var client = await SshClient.ConnectAsync("example.com", 22, new SshClientOptions
{
    UserName = "deploy",
    PrivateKeys = keys,
    HostKeyVerifier = verifier,
}, cancellationToken);

Agent keys work wherever key files do, including with certificates and for signing files. FIDO security keys (sk-ssh-ed25519@openssh.com, sk-ecdsa-sha2-nistp256@openssh.com) work through the agent: the agent and a touch of the key make each signature.

Forward the agent

await using var client = await SshClient.ConnectAsync("build.example.com", 22, new SshClientOptions
{
    UserName = "deploy",
    PrivateKeys = await agent.GetKeysAsync(cancellationToken),
    HostKeyVerifier = verifier,
    ForwardAgent = agent,   // ssh -A: git on the server can authenticate with your keys while you are connected
}, cancellationToken);
await client.RunCommandAsync("git -C /srv/app pull", cancellationToken);
Warning

While you are connected, anyone with enough rights on the server can ask your forwarded agent to sign (they cannot read the keys). Forward only to servers you trust, and prefer keys added with destination constraints (SshAgentKeyConstraints.Destinations).

Add a key to the agent

var agent = SshAgent.FromEnvironment() ?? throw new InvalidOperationException("No SSH agent is running.");
using var key = await SshPrivateKey.LoadAsync("deploy_ed25519", Environment.GetEnvironmentVariable("SSH_KEY_PASSPHRASE"), cancellationToken);
await agent.AddKeyAsync(key, new SshAgentKeyConstraints { Lifetime = TimeSpan.FromHours(1) }, cancellationToken); // ssh-add -t 1h

Generate a key

using var key = SshPrivateKey.Generate(SshKeyType.Ed25519, comment: "ci-deploy");
await key.SaveAsync("ci_ed25519", Environment.GetEnvironmentVariable("NEW_KEY_PASSPHRASE"), cancellationToken); // and ci_ed25519.pub
Console.WriteLine(key.ToAuthorizedKeysLine()); // the line for the server's ~/.ssh/authorized_keys

SaveAsync writes the private key in the OpenSSH format (encrypted with aes256-ctr and bcrypt_pbkdf when given a passphrase), readable only by the current user, and the public key next to it as .pub. It never overwrites an existing file.

Use a key in the Windows key store or the TPM

// A non-exportable RSA key in a Windows key store: the TPM (Microsoft Platform Crypto Provider), a smart card, or the
// software provider. SshSharp sees the public key and asks the key for each signature.
using var rsa = new RSACng(CngKey.Open("deploy-key"));
using var key = SshPrivateKey.FromRsa(rsa, comment: "cng:deploy-key");   // rsa stays ours: disposing key does not dispose it

await using var client = await SshClient.ConnectAsync("example.com", 22, new SshClientOptions
{
    UserName = "deploy",
    PrivateKeys = [key],
    HostKeyVerifier = verifier,
}, cancellationToken);

The same works with ECDsaCng and SshPrivateKey.FromECDsa, and with any software RSA or ECDsa. The sample tool's run-cng command logs in this way.

Use a key held by a remote service

Derive from SshKeySigner: SshSharp gives it each request to sign, with the algorithm and what the signature is for, and checks the answer against the public key before using it.

/// <summary>A key held by a remote signing service (a cloud KMS or an HSM): only the public key is local.</summary>
internal sealed class KmsSigner(IKeyService service, string keyId, ReadOnlyMemory<byte> publicKey) : SshKeySigner(publicKey)
{
    // ECDSA P-256 in the service: it signs a SHA-256 digest and answers in DER.
    public override DSASignatureFormat EcdsaSignatureFormat => DSASignatureFormat.Rfc3279DerSequence;

    public override async ValueTask<byte[]> SignAsync(SshSignRequest request, CancellationToken cancellationToken)
    {
        if (request.Purpose != SshSignaturePurpose.Authentication)
        {
            throw new UnauthorizedAccessException("This key is for logging in only.");
        }

        return await service.SignDigestAsync(keyId, SHA256.HashData(request.Data.Span), cancellationToken);
    }
}

/// <summary>Your key service's client.</summary>
internal interface IKeyService
{
    Task<byte[]> SignDigestAsync(string keyId, byte[] digest, CancellationToken cancellationToken);
}
using var key = SshPrivateKey.FromSigner(new KmsSigner(service, "ssh-deploy", publicKeyBlob), comment: "kms:ssh-deploy");
// Use it like any other key: PrivateKeys = [key]. Each signature is checked against the public key before it is sent.

Notes and pitfalls

  • Order. Keys are tried in the order given, and the server is asked whether it accepts each one before anything is signed. A server that allows few attempts (sshd's MaxAuthTries, 6 by default) may disconnect before it reaches a key late in a long list: put the right key first.
  • Dispose keys. Disposing a loaded key clears its private key material. The client does not dispose the keys you pass it.
  • RSA. RSA keys sign with rsa-sha2-256 or rsa-sha2-512, never SHA-1 ssh-rsa. Keys under 2048 bits are refused.
  • ssh.com format keys are not read; convert them with ssh-keygen -i.
  • Never log a key. ToOpenSshPrivateKey returns the private key's text, for a secret store.

See also