Recursive AI

Sign a file with a key in ssh-agent in C#

All onecrypto recipes

ssh-agent holds your unlocked SSH keys so ssh never reads the key files again. SshAgentClient talks to the same agent over SSH_AUTH_SOCK: it lists the keys and asks the agent to sign, and the private key never reaches your program. The program below signs a file the way ssh-keygen -Y sign does, so ssh-keygen -Y verify and anything that reads Git’s SSH signatures can check it.

Terminal
dotnet new console -o SshAgentSign && cd SshAgentSign
dotnet add package RecursiveAI.OneCrypto.Keys --version 0.4.0-preview.1

OneCrypto is a preview, so dotnet add package needs --version or --prerelease. To try it without touching your own agent and keys, start a throwaway agent on a temporary socket, give it a new Ed25519 key, and delete the key file, so the key only exists in the agent. Skip this to use your own agent: the program reads SSH_AUTH_SOCK like ssh does.

Terminal
export SSH_AUTH_SOCK="$(mktemp -d)/agent.sock"
eval "$(ssh-agent -a "$SSH_AUTH_SOCK")"
ssh-keygen -q -t ed25519 -N '' -C '[email protected]' -f demo_key
ssh-add demo_key
rm demo_key
Text
Agent pid 3085783
Identity added: demo_key ([email protected])

Put this in Program.cs:

C#
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
using RecursiveAI.OneCrypto.Keys;
using RecursiveAI.OneCrypto.Keys.Agents;
using RecursiveAI.OneCrypto.Wire.Ssh;
 
// The agent in SSH_AUTH_SOCK, the one ssh and ssh-add use
var agent = new SshAgentClient();
IReadOnlyList<SshAgentIdentity> identities = agent.ListIdentities();
foreach (SshAgentIdentity id in identities)
    Console.WriteLine($"Agent key: {id.PublicKey?.Type} {id.Comment}");
 
SshAgentIdentity alice = identities.First(i => i.Comment == "[email protected]");
using SshAgentKey key = agent.GetKey(alice.PublicKey!);
 
File.WriteAllText("release.txt", "release 2026.11, build 4127\n");
byte[] data = File.ReadAllBytes("release.txt");
File.WriteAllText("release.txt.sig", SignSshsig(key, data, "file"));
Console.Write(File.ReadAllText("release.txt.sig"));
 
// An SSH signature file (OpenSSH PROTOCOL.sshsig), as `ssh-keygen -Y sign -n file` writes.
// The agent signs; the program only frames what it signs and what it returns.
static string SignSshsig(SshAgentKey key, byte[] data, string ns)
{
    byte[] digest = SHA512.HashData(data);
    byte[] toSign = [.. "SSHSIG"u8, .. Str(ns), .. Str(""), .. Str("sha512"), .. Str(digest)];
    SignatureSpec spec = key.SshKey.Type == SshKeyType.Rsa
        ? SignatureSpec.RsaPkcs1(HashAlgorithmName.SHA512) // rsa-sha2-512
        : key.DefaultSpec;
    byte[] signature = key.SignToSshSignature(toSign, spec);
    byte[] file = [.. "SSHSIG"u8, 0, 0, 0, 1, .. Str(key.SshKey.Blob.ToArray()),
        .. Str(ns), .. Str(""), .. Str("sha512"), .. Str(signature)];
    string b64 = Convert.ToBase64String(file);
    var pem = new StringBuilder("-----BEGIN SSH SIGNATURE-----\n");
    for (int i = 0; i < b64.Length; i += 70)
        pem.Append(b64, i, Math.Min(70, b64.Length - i)).Append('\n');
    return pem.Append("-----END SSH SIGNATURE-----\n").ToString();
}
 
// An SSH "string": a 4-byte big-endian length, then the bytes
static byte[] Str(object value)
{
    byte[] bytes = value is string s ? Encoding.UTF8.GetBytes(s) : (byte[])value;
    byte[] length = new byte[4];
    BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length);
    return [.. length, .. bytes];
}
Text
Agent key: Ed25519 [email protected]
-----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgwDd/whwuFxWgXZ/T8XC9PUyLdK
9HySgVm0vodrnoqPEAAAAEZmlsZQAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gtZWQyNTUx
OQAAAEBa4i1o1bMPB9XQfG3P2cYgE83B8dqxQopBuIVt6azuHY1bdHVkN4DMfEn98Ql3zc
OcgRl5lEAw1l/OK3O4lnIO
-----END SSH SIGNATURE-----

Every signature the agent returns is checked against the key’s public key before SignToSshSignature hands it back (unless SshAgentOptions.VerifySignatures is turned off), and the algorithm on it must be the one asked for. RSA keys sign with rsa-sha2-256 or rsa-sha2-512; the SHA-1 ssh-rsa form is never requested. ECDSA keys work the same way as Ed25519. GetKey returns an ISigningKey, so the same agent key can also sign a CMS message or a certificate through OneCrypto’s other APIs.

Check it with ssh-keygen

ssh-keygen -Y verify takes an allowed-signers file that maps an identity to a public key:

Terminal
echo "[email protected] $(cat demo_key.pub)" > allowed_signers
ssh-keygen -Y verify -f allowed_signers -I [email protected] -n file \
  -s release.txt.sig < release.txt
echo 'release 2026.11, build 4128' | ssh-keygen -Y verify -f allowed_signers \
  -I [email protected] -n file -s release.txt.sig
Text
Good "file" signature for [email protected] with ED25519 key SHA256:To7Hg/mJ8VjR8Tu/oSzHajqBpd0h99zb5TurOlfVmZ4
Signature verification failed: incorrect signature
Could not verify signature.

The second check changes the file, and ssh-keygen rejects the signature. When you’re done, stop the throwaway agent:

Terminal
eval "$(ssh-agent -k)"

Limits

  • Not audited. OneCrypto (cryptography, not cryptocurrency) has not been independently audited. It is a preview: 0.4.0-preview.1, and the API can change.
  • The key-backend glue (PKCS #11, gpg-agent and ssh-agent) “had an internal review … it has not been independently audited” (Key-backend review).
  • The agent hashes the data itself, so SignDigest is not available, and neither are RSA-PSS or security-key (sk-) keys. A refusal from the agent (a locked agent, a key added with ssh-add -c whose confirmation was denied) is an AgentRefusedException, and the agent never says which.
  • No SSHSIG API yet. The SSH signature file is built by the code above; signing and verifying SSH signature files is a planned package.
  • The project tests against OpenSSH’s ssh-agent with RSA, ECDSA on three curves and Ed25519 keys. This page ran on OpenSSH 10.2.

Protocol, limits and errors: Keys, two-phase signing and key backends.