Recursive AI

Sign with a key in gpg-agent in C#

All onecrypto recipes

GnuPG keeps secret keys in gpg-agent, which does every signature and decryption for gpg: it asks for the passphrase, caches it, and talks to smartcards. GpgAgentClient talks to the same agent over its socket, so a C# program can sign with a GnuPG key without reading the key file or the passphrase. Combined with RecursiveAI.OneCrypto.OpenPgp, the result is an ordinary OpenPGP signature.

Terminal
dotnet new console -o GpgAgentSign && cd GpgAgentSign
dotnet add package RecursiveAI.OneCrypto.Keys --version 0.4.0-preview.1
dotnet add package RecursiveAI.OneCrypto.OpenPgp --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 keys, point GNUPGHOME at a new, empty home for this terminal session; gpg starts a separate agent for it. The key is made without a passphrase here; with one, the agent prompts through its pinentry as it does for gpg. Skip this to use your own keyring: the program finds the agent the way gpgconf does.

Terminal
export GNUPGHOME="$(mktemp -d)"
gpg --batch --pinentry-mode loopback --passphrase '' \
  --quick-gen-key 'Alice <[email protected]>' ed25519 sign never 2>&1 | grep -v revocs
gpg --armor --export [email protected] > alice.pub.asc
Text
gpg: keybox '/tmp/tmp.ytmkatcBCm/pubring.kbx' created
gpg: /tmp/tmp.ytmkatcBCm/trustdb.gpg: trustdb created

Put this in Program.cs:

C#
using System.Text;
using RecursiveAI.OneCrypto.Keys.Agents;
using RecursiveAI.OneCrypto.OpenPgp;
 
DateTimeOffset now = DateTimeOffset.UtcNow;
 
// The certificate (public key); the secret key stays in gpg-agent
PgpCertificate alice = PgpCertificate.Parse(File.ReadAllBytes("alice.pub.asc"));
PgpKey signingKey = alice.FindSigningKey(now)
    ?? throw new InvalidOperationException("no signing key");
 
// The agent for GNUPGHOME (or ~/.gnupg). Its keys are named by keygrip; pick the one
// whose public key is the certificate's signing key.
var agent = new GpgAgentClient();
using GpgAgentKey agentKey = FindAgentKey(agent, signingKey);
using PgpSecretKey signer = PgpSecretKey.FromExternalKey(signingKey, agentKey);
Console.WriteLine($"Signing key {Convert.ToHexString(signingKey.Fingerprint)}");
Console.WriteLine($"  in the agent as keygrip {agentKey.Keygrip}");
 
File.WriteAllText("notes.txt", "Minutes, 2 November: ship 2026.11 on Thursday.\n");
var options = new PgpWriteOptions { Now = now, Signers = [signer], Armor = true };
using (FileStream input = File.OpenRead("notes.txt"))
using (FileStream output = File.Create("notes.txt.asc"))
    PgpMessageWriter.SignDetached(input, output, options);
Console.WriteLine("Wrote notes.txt.asc");
 
static GpgAgentKey FindAgentKey(GpgAgentClient agent, PgpKey key)
{
    foreach (GpgAgentKeyInfo info in agent.ListKeys())
    {
        GpgAgentKey candidate = agent.GetKey(info.Keygrip);
        try
        {
            PgpSecretKey.FromExternalKey(key, candidate).Dispose(); // throws unless it matches
            return candidate;
        }
        catch (PgpException e) when (e.Failure == PgpFailure.KeyMismatch)
        {
            candidate.Dispose();
        }
    }
    throw new InvalidOperationException("the agent has no secret key for this certificate");
}
Text
Signing key 4B4CAD4536CDDFEE6D3371427FB4B2127D2EC218
  in the agent as keygrip 5EA8DE98372CAF67D43DDDE02CCFEEC0615E3CCA
Wrote notes.txt.asc

FromExternalKey refuses an agent key whose public key is not the certificate’s (PgpFailure.KeyMismatch), which is what the search relies on. If you already know the keygrip (gpg --with-keygrip --list-secret-keys prints it), agent.GetKey(keygrip) skips the search. Every signature the agent returns is checked against the public key before it is used, unless GpgAgentOptions.VerifySignatures is turned off. To decrypt messages sent to an RSA or ECDH key in the agent, wrap the encryption subkey the same way and pass it in PgpReadOptions.ExternalKeys.

Check it with GnuPG

Terminal
gpg --verify notes.txt.asc notes.txt
Text
gpg: Signature made Mon 02 Nov 2026 09:47:10 AM UTC
gpg:                using EDDSA key 4B4CAD4536CDDFEE6D3371427FB4B2127D2EC218
gpg: checking the trustdb
gpg: marginals needed: 3  completes needed: 1  trust model: pgp
gpg: depth: 0  valid:   1  signed:   0  trust: 0-, 0q, 0n, 0m, 0f, 1u
gpg: Good signature from "Alice <[email protected]>" [ultimate]

GnuPG’s “ultimate” is its own key, made in this home. When you’re done, stop the throwaway agent and delete the home:

Terminal
gpgconf --kill gpg-agent
rm -r "$GNUPGHOME"

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).
  • No RSA-PSS. gpg-agent has no PSS operation in the versions tested, so PSS signatures are refused. DSA and ElGamal keys are not supported. Ed25519 data sent to the agent is at most 2,048 octets with gpg-agent 2.4 (OpenPGP signs a hash, so this does not limit file size).
  • Local socket only. The Windows socket form (a TCP port and a nonce) is not supported. Nothing is started: with no agent running, the client throws AgentUnavailableException; a cancelled passphrase prompt is an AgentRefusedException with IsCancelled.
  • The project tests against gpg-agent 2.4 and 2.5 with RSA, P-256, Ed25519 and Cv25519 keys. This page ran on GnuPG 2.4.8.

Socket discovery, key types and errors: Keys, two-phase signing and key backends. Signing with a key in memory instead: Sign and verify with PGP.