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-256orrsa-sha2-512, never SHA-1ssh-rsa. Keys under 2048 bits are refused. - ssh.com format keys are not read; convert them with
ssh-keygen -i. - Never log a key.
ToOpenSshPrivateKeyreturns the private key's text, for a secret store.
See also
- Authentication (in-depth: method order, agent constraints, keyboard-interactive, banners)
- Certificates
- Signing files and commits
- SshPrivateKey, SshAgent and SshKeySigner in the API reference