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/<FINGERPRINT>.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 →