Skip to content

PGP Key Generator — C# source

Generate PGP key pairs (ECC or RSA) in your browser. Download your public and private keys. Powered by OpenPGP.js.

This is the C# implementation — the same logic the interactive tool runs, in a shareable, citable form.

// PGP Key Generator — ASCII-armored OpenPGP key pairs with an optional
// passphrase and a revocation certificate.
//
// Language: C# 12 / .NET 8 (BCL only — System.Diagnostics.Process drives the
//           `gpg` binary, the same engine the C port reaches through GPGME and
//           the native counterpart to the TS reference's openpgp.js: the BCL
//           has no OpenPGP implementation)
// Ported from src/lib/pgp-keygen.ts
// display source — part of CosmoDev's polyglot tool pages.
//
// Public API, matching the TS reference one-for-one:
//   ValidateKeyGenIdentity — rejects an empty name, an empty email, or an
//                            email without one @ and a dotted domain.
//   GeneratePgpKeyPairAsync — armored public + private key, the v4 fingerprint
//                            (40 lowercase hex chars), and the revocation
//                            certificate.
//
// Three hardening choices worth calling out, all of them mirrored from the
// Ruby/Java ports:
//
//   1. Ephemeral keyring. Every call gets a fresh 0700 GNUPGHOME under a temp
//      directory that is deleted on the way out, so a generated private key
//      never lands in the user's real keyring — the C# equivalent of the TS
//      island keeping the whole operation client-side.
//
//   2. No shell, ever. gpg is spawned with an argument LIST
//      (ProcessStartInfo.ArgumentList), never a command string, so no name,
//      email or passphrase is parsed by /bin/sh.
//
//   3. The batch parameter file travels over stdin, not disk. gpg's
//      `--batch --gen-key` reads its parameter file from stdin when no
//      filename is given, which keeps `Passphrase:` out of the filesystem
//      entirely. Because that file is line-oriented, a newline inside the
//      name or email would inject arbitrary directives (`Name-Real: x\n
//      Passphrase: attacker`), so AssertSafeUserIdField rejects control
//      characters before anything is written.

using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;

namespace CosmoDev.Tools;

/// <summary>Algorithm choices: ECC Curve25519 (default), RSA-2048, or RSA-4096.</summary>
public enum PgpKeyGenAlgorithm
{
    /// <summary>Ed25519 signing key + Curve25519 encryption subkey. Fast.</summary>
    Ecc,
    Rsa2048,
    Rsa4096,
}

/// <summary>The identity and algorithm that define the key to generate.</summary>
public sealed record PgpKeyGenOptions
{
    /// <summary>User's real name (goes into the key's user ID packet).</summary>
    public required string Name { get; init; }

    /// <summary>User's email (goes into the key's user ID packet).</summary>
    public required string Email { get; init; }

    /// <summary>Optional passphrase. If given, the private key is encrypted with it.</summary>
    public string? Passphrase { get; init; }

    public PgpKeyGenAlgorithm Algorithm { get; init; } = PgpKeyGenAlgorithm.Ecc;
}

/// <summary>The four armored outputs of a generation run.</summary>
public sealed record PgpKeyPair(
    /// <summary>ASCII-armored public key (BEGIN PGP PUBLIC KEY BLOCK).</summary>
    string PublicKey,
    /// <summary>ASCII-armored private key (BEGIN PGP PRIVATE KEY BLOCK).</summary>
    string PrivateKey,
    /// <summary>v4 fingerprint, 40 lowercase hex chars, no spaces.</summary>
    string Fingerprint,
    /// <summary>ASCII-armored revocation certificate.</summary>
    string RevocationCertificate);

/// <summary>Raised when identity validation or the gpg run fails.</summary>
public sealed class PgpKeyGenException : Exception
{
    public PgpKeyGenException(string message) : base(message) { }
}

public static class PgpKeyGen
{
    /// <summary>
    /// Accepts `foo@bar.tld`-style addresses: one @, non-empty local + domain,
    /// a dot in the domain. Mirrors EMAIL_RE in the TS reference.
    /// </summary>
    private static readonly Regex EmailRegex =
        new(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.Compiled);

    /// <summary>A gpg colon-listing `fpr` record: 40 hex chars in field 10.</summary>
    private static readonly Regex FingerprintRegex =
        new(@"^fpr(?::[^:]*){8}:([0-9A-Fa-f]{40}):", RegexOptions.Compiled | RegexOptions.Multiline);

    // ---------------------------------------------------------------- validation

    /// <summary>
    /// Validate the identity that goes into the key's user ID.
    /// Throws <see cref="PgpKeyGenException"/> on invalid input.
    /// </summary>
    public static void ValidateKeyGenIdentity(string name, string email)
    {
        if (string.IsNullOrWhiteSpace(name)) throw new PgpKeyGenException("Name is required.");
        if (string.IsNullOrWhiteSpace(email)) throw new PgpKeyGenException("Email is required.");
        if (!EmailRegex.IsMatch(email.Trim())) throw new PgpKeyGenException("Invalid email address.");
    }

    /// <summary>
    /// Reject anything that could break out of one line of the batch parameter
    /// file. The TS reference hands a structured object to openpgp.js and needs
    /// no such guard; a line-oriented gpg parameter file does.
    /// </summary>
    private static void AssertSafeUserIdField(string value, string field)
    {
        foreach (var ch in value)
        {
            if (ch is '\n' or '\r' || char.IsControl(ch))
                throw new PgpKeyGenException($"{field} must not contain control characters.");
        }

        // gpg's own user-ID grammar: these would corrupt the "Name (Comment) <Email>" form.
        if (value.AsSpan().IndexOfAny('<', '>') >= 0)
            throw new PgpKeyGenException($"{field} must not contain angle brackets.");
    }

    // ---------------------------------------------------------------- generation

    /// <summary>
    /// Generate an ASCII-armored PGP key pair. ECC (Curve25519) is fast;
    /// RSA-4096 can take a few seconds.
    /// </summary>
    public static async Task<PgpKeyPair> GeneratePgpKeyPairAsync(
        PgpKeyGenOptions options,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(options);
        ValidateKeyGenIdentity(options.Name, options.Email);

        var name = options.Name.Trim();
        var email = options.Email.Trim();
        AssertSafeUserIdField(name, "Name");
        AssertSafeUserIdField(email, "Email");

        // An empty-string passphrase would still encrypt the key; only a real
        // passphrase should. Mirrors `passphrase ? passphrase : undefined`.
        var passphrase = string.IsNullOrEmpty(options.Passphrase) ? null : options.Passphrase;
        if (passphrase is not null) AssertSafeUserIdField(passphrase, "Passphrase");

        using var home = new EphemeralGnupgHome();

        var parameters = BuildKeyParameters(options.Algorithm, name, email, passphrase);
        await RunGpgAsync(home, ["--batch", "--gen-key"], stdin: parameters,
            cancellationToken: cancellationToken).ConfigureAwait(false);

        var fingerprint = await ReadFingerprintAsync(home, cancellationToken).ConfigureAwait(false);

        var publicKey = await RunGpgAsync(home, ["--armor", "--export", fingerprint],
            passphrase: passphrase, cancellationToken: cancellationToken).ConfigureAwait(false);

        var privateKey = await RunGpgAsync(home, ["--armor", "--export-secret-keys", fingerprint],
            passphrase: passphrase, cancellationToken: cancellationToken).ConfigureAwait(false);

        var revocationCertificate = await ReadRevocationCertificateAsync(
            home, fingerprint, cancellationToken).ConfigureAwait(false);

        return new PgpKeyPair(
            PublicKey: publicKey.Trim() + "\n",
            PrivateKey: privateKey.Trim() + "\n",
            // The TS reference returns getFingerprint(), which is lowercase.
            Fingerprint: fingerprint.ToLowerInvariant(),
            RevocationCertificate: revocationCertificate);
    }

    /// <summary>
    /// The gpg `--gen-key` parameter file. `%no-protection` is required when no
    /// passphrase is given, otherwise gpg refuses to create an unprotected key
    /// in batch mode.
    /// </summary>
    private static string BuildKeyParameters(
        PgpKeyGenAlgorithm algorithm, string name, string email, string? passphrase)
    {
        var sb = new StringBuilder();
        sb.Append("%echo Generating OpenPGP key\n");

        if (algorithm == PgpKeyGenAlgorithm.Ecc)
        {
            // v6 openpgp.js `type: 'curve25519'` == Ed25519 primary + Curve25519 subkey.
            sb.Append("Key-Type: eddsa\n");
            sb.Append("Key-Curve: ed25519\n");
            sb.Append("Key-Usage: sign,cert\n");
            sb.Append("Subkey-Type: ecdh\n");
            sb.Append("Subkey-Curve: cv25519\n");
            sb.Append("Subkey-Usage: encrypt\n");
        }
        else
        {
            var bits = algorithm == PgpKeyGenAlgorithm.Rsa4096 ? 4096 : 2048;
            sb.Append("Key-Type: rsa\n");
            sb.Append($"Key-Length: {bits}\n");
            sb.Append("Key-Usage: sign,cert\n");
            sb.Append("Subkey-Type: rsa\n");
            sb.Append($"Subkey-Length: {bits}\n");
            sb.Append("Subkey-Usage: encrypt\n");
        }

        sb.Append($"Name-Real: {name}\n");
        sb.Append($"Name-Email: {email}\n");
        sb.Append("Expire-Date: 0\n"); // openpgp.js default: no expiry.

        if (passphrase is null) sb.Append("%no-protection\n");
        else sb.Append($"Passphrase: {passphrase}\n");

        sb.Append("%commit\n");
        return sb.ToString();
    }

    /// <summary>Read the freshly generated key's v4 fingerprint from a colon listing.</summary>
    private static async Task<string> ReadFingerprintAsync(
        EphemeralGnupgHome home, CancellationToken cancellationToken)
    {
        var listing = await RunGpgAsync(home, ["--list-keys", "--with-colons"],
            cancellationToken: cancellationToken).ConfigureAwait(false);

        var match = FingerprintRegex.Match(listing);
        if (!match.Success)
            throw new PgpKeyGenException("gpg produced no fingerprint — key generation failed.");

        return match.Groups[1].Value;
    }

    /// <summary>
    /// gpg writes a revocation certificate automatically at generation time,
    /// into `openpgp-revocs.d/&lt;FINGERPRINT&gt;.rev`. It is commented out with
    /// leading ':' so it cannot be imported by accident; uncomment it the way
    /// gpg's own instructions say to.
    /// </summary>
    private static async Task<string> ReadRevocationCertificateAsync(
        EphemeralGnupgHome home, string fingerprint, CancellationToken cancellationToken)
    {
        var path = Path.Combine(home.Path, "openpgp-revocs.d", $"{fingerprint.ToUpperInvariant()}.rev");
        if (!File.Exists(path)) return string.Empty;

        var raw = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);

        var armored = new StringBuilder();
        var inBlock = false;
        foreach (var rawLine in raw.ReplaceLineEndings("\n").Split('\n'))
        {
            var line = rawLine.StartsWith(':') ? rawLine[1..].TrimStart() : rawLine;
            if (line.StartsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----")) inBlock = true;
            if (!inBlock) continue;
            armored.Append(line).Append('\n');
            if (line.StartsWith("-----END PGP PUBLIC KEY BLOCK-----")) break;
        }
        return armored.ToString();
    }

    // ---------------------------------------------------------------- process

    /// <summary>
    /// Spawn gpg with an argument LIST against the ephemeral home and return
    /// stdout. Never touches a shell.
    /// </summary>
    private static async Task<string> RunGpgAsync(
        EphemeralGnupgHome home,
        IEnumerable<string> args,
        string? stdin = null,
        string? passphrase = null,
        CancellationToken cancellationToken = default)
    {
        var psi = new ProcessStartInfo("gpg")
        {
            RedirectStandardInput = true,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false, // no shell => no injection via name/email/passphrase
            CreateNoWindow = true,
        };

        psi.Environment["GNUPGHOME"] = home.Path;

        psi.ArgumentList.Add("--batch");
        psi.ArgumentList.Add("--yes");
        psi.ArgumentList.Add("--no-tty");
        if (passphrase is not null)
        {
            psi.ArgumentList.Add("--pinentry-mode");
            psi.ArgumentList.Add("loopback");
            psi.ArgumentList.Add("--passphrase");
            psi.ArgumentList.Add(passphrase);
        }
        foreach (var arg in args) psi.ArgumentList.Add(arg);

        using var process = Process.Start(psi)
            ?? throw new PgpKeyGenException("Could not start gpg — is GnuPG installed and on PATH?");

        // Drain both pipes while writing stdin, or a large armored export
        // fills the OS pipe buffer and both sides block forever.
        var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
        var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);

        if (stdin is not null)
            await process.StandardInput.WriteAsync(stdin.AsMemory(), cancellationToken).ConfigureAwait(false);
        process.StandardInput.Close();

        await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
        var stdout = await stdoutTask.ConfigureAwait(false);
        var stderr = await stderrTask.ConfigureAwait(false);

        if (process.ExitCode != 0)
            throw new PgpKeyGenException($"gpg exited {process.ExitCode}: {stderr.Trim()}");

        return stdout;
    }

    /// <summary>
    /// A fresh 0700 GNUPGHOME under a temp directory, deleted (best-effort) on
    /// dispose. gpg refuses a world-readable homedir, and this way no key ever
    /// touches the user's real keyring.
    /// </summary>
    private sealed class EphemeralGnupgHome : IDisposable
    {
        public string Path { get; }

        public EphemeralGnupgHome()
        {
            Path = System.IO.Path.Combine(
                System.IO.Path.GetTempPath(), $"cosmodev-gnupg-{Guid.NewGuid():N}");

            if (OperatingSystem.IsWindows())
            {
                // No POSIX mode bits; the per-user temp directory is the ACL boundary.
                Directory.CreateDirectory(Path);
            }
            else
            {
                Directory.CreateDirectory(
                    Path,
                    UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
            }
        }

        public void Dispose()
        {
            try
            {
                Directory.Delete(Path, recursive: true);
            }
            catch (DirectoryNotFoundException)
            {
                // Already gone — nothing to clean.
            }
            catch (IOException)
            {
                // gpg-agent socket leftovers; the OS temp cleaner owns it now.
            }
        }
    }
}

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →