PGP Key Generator — Zig source
Generate PGP key pairs (ECC or RSA) in your browser. Download your public and private keys. Powered by OpenPGP.js.
This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! pgp-keygen — ASCII-armored OpenPGP key pairs with an optional passphrase
//! and a revocation certificate.
//!
//! Language: Zig 0.14 (standard library only — std.process.Child drives the
//! `gpg` binary, the same engine the C port reaches through GPGME and the
//! native counterpart to the TS reference's openpgp.js: std.crypto has no
//! OpenPGP implementation).
//! Ported from: src/lib/pgp-keygen.ts (the canonical TypeScript implementation).
//! 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.
//! generatePGPKeyPair — armored public + private key, the v4 fingerprint
//! (40 lowercase hex chars), and the revocation
//! certificate.
//!
//! Three hardening choices, mirrored from the Ruby/Java/C#/Swift 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 Zig equivalent of the TS
//! island keeping the whole operation client-side.
//!
//! 2. No shell, ever. std.process.Child takes an argv SLICE, 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, keeping `Passphrase:` out of the filesystem. That
//! file is line-oriented, so a newline in the name or email would inject
//! arbitrary directives — assertSafeField rejects control characters
//! before anything is written.
const std = @import("std");
const Allocator = std.mem.Allocator;
/// Algorithm choices: ECC Curve25519 (default), RSA-2048, or RSA-4096.
pub const Algorithm = enum {
/// Ed25519 signing key + Curve25519 encryption subkey. Fast.
ecc,
rsa2048,
rsa4096,
};
/// The identity and algorithm that define the key to generate.
pub const Options = struct {
/// User's real name (goes into the key's user ID packet).
name: []const u8,
/// User's email (goes into the key's user ID packet).
email: []const u8,
/// Optional passphrase. If given, the private key is encrypted with it.
passphrase: ?[]const u8 = null,
algorithm: Algorithm = .ecc,
};
/// The four armored outputs of a generation run. All fields are owned by the
/// caller — free them with `deinit`.
pub const KeyPair = struct {
/// ASCII-armored public key (BEGIN PGP PUBLIC KEY BLOCK).
public_key: []const u8,
/// ASCII-armored private key (BEGIN PGP PRIVATE KEY BLOCK).
private_key: []const u8,
/// v4 fingerprint, 40 lowercase hex chars, no spaces.
fingerprint: []const u8,
/// ASCII-armored revocation certificate ("" when gpg wrote none).
revocation_certificate: []const u8,
pub fn deinit(self: KeyPair, allocator: Allocator) void {
allocator.free(self.public_key);
allocator.free(self.private_key);
allocator.free(self.fingerprint);
allocator.free(self.revocation_certificate);
}
};
pub const Error = error{
NameRequired,
EmailRequired,
InvalidEmail,
/// A control character or angle bracket that would corrupt the parameter file.
UnsafeField,
GpgUnavailable,
GpgFailed,
NoFingerprint,
OutOfMemory,
};
/// gpg output is small (an armored RSA-4096 private key is a few KiB), so this
/// ceiling only exists to bound a runaway child.
const MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
// --- Small ASCII helpers -------------------------------------------------------
fn trim(s: []const u8) []const u8 {
return std.mem.trim(u8, s, " \t\r\n");
}
/// Mirrors EMAIL_RE — `^[^\s@]+@[^\s@]+\.[^\s@]+$`. Because both sides exclude
/// '@', a match implies exactly one '@'; the domain must carry a dot with at
/// least one character on each side.
fn isValidEmail(email: []const u8) bool {
const at = std.mem.indexOfScalar(u8, email, '@') orelse return false;
const local = email[0..at];
const domain = email[at + 1 ..];
if (local.len == 0 or domain.len == 0) return false;
// A second '@' would put it in the domain half.
if (std.mem.indexOfScalar(u8, domain, '@') != null) return false;
for (email) |c| {
if (c == ' ' or c == '\t' or c == '\r' or c == '\n') return false;
}
const dot = std.mem.indexOfScalar(u8, domain, '.') orelse return false;
return dot > 0 and dot < domain.len - 1;
}
/// Validate the identity that goes into the key's user ID.
pub fn validateKeyGenIdentity(name: []const u8, email: []const u8) Error!void {
if (trim(name).len == 0) return Error.NameRequired;
const trimmed_email = trim(email);
if (trimmed_email.len == 0) return Error.EmailRequired;
if (!isValidEmail(trimmed_email)) return Error.InvalidEmail;
}
/// 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.
fn assertSafeField(value: []const u8) Error!void {
for (value) |c| {
// Control characters (incl. LF/CR) would inject a new directive;
// angle brackets would corrupt gpg's "Name <Email>" user-ID grammar.
if (c < 0x20 or c == 0x7f or c == '<' or c == '>') return Error.UnsafeField;
}
}
// --- Generation ----------------------------------------------------------------
/// Generate an ASCII-armored PGP key pair. ECC (Curve25519) is fast;
/// RSA-4096 can take a few seconds. Caller owns the result (`KeyPair.deinit`).
pub fn generatePGPKeyPair(allocator: Allocator, options: Options) !KeyPair {
try validateKeyGenIdentity(options.name, options.email);
const name = trim(options.name);
const email = trim(options.email);
try assertSafeField(name);
try assertSafeField(email);
// An empty-string passphrase would still encrypt the key; only a real
// passphrase should. Mirrors `passphrase ? passphrase : undefined`.
const passphrase: ?[]const u8 = blk: {
const p = options.passphrase orelse break :blk null;
if (p.len == 0) break :blk null;
try assertSafeField(p);
break :blk p;
};
var home = try EphemeralHome.create(allocator);
defer home.destroy();
const parameters = try buildKeyParameters(allocator, options.algorithm, name, email, passphrase);
defer allocator.free(parameters);
const gen = try runGpg(allocator, home.path, &.{"--gen-key"}, parameters, null);
allocator.free(gen);
const fingerprint = try readFingerprint(allocator, home.path);
errdefer allocator.free(fingerprint);
const public_raw = try runGpg(allocator, home.path, &.{ "--armor", "--export", fingerprint }, null, passphrase);
defer allocator.free(public_raw);
const private_raw = try runGpg(allocator, home.path, &.{ "--armor", "--export-secret-keys", fingerprint }, null, passphrase);
defer allocator.free(private_raw);
const public_key = try std.fmt.allocPrint(allocator, "{s}\n", .{trim(public_raw)});
errdefer allocator.free(public_key);
const private_key = try std.fmt.allocPrint(allocator, "{s}\n", .{trim(private_raw)});
errdefer allocator.free(private_key);
const revocation_certificate = try readRevocationCertificate(allocator, home.path, fingerprint);
errdefer allocator.free(revocation_certificate);
// The TS reference returns getFingerprint(), which is lowercase.
const lowered = try allocator.alloc(u8, fingerprint.len);
for (fingerprint, 0..) |c, i| lowered[i] = std.ascii.toLower(c);
allocator.free(fingerprint);
return KeyPair{
.public_key = public_key,
.private_key = private_key,
.fingerprint = lowered,
.revocation_certificate = revocation_certificate,
};
}
/// 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.
fn buildKeyParameters(
allocator: Allocator,
algorithm: Algorithm,
name: []const u8,
email: []const u8,
passphrase: ?[]const u8,
) Error![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
const w = out.writer();
try w.writeAll("%echo Generating OpenPGP key\n");
switch (algorithm) {
// v6 openpgp.js `type: 'curve25519'` == Ed25519 primary + Curve25519 subkey.
.ecc => try w.writeAll(
\\Key-Type: eddsa
\\Key-Curve: ed25519
\\Key-Usage: sign,cert
\\Subkey-Type: ecdh
\\Subkey-Curve: cv25519
\\Subkey-Usage: encrypt
\\
),
.rsa2048, .rsa4096 => {
const bits: u16 = if (algorithm == .rsa4096) 4096 else 2048;
try w.print(
\\Key-Type: rsa
\\Key-Length: {d}
\\Key-Usage: sign,cert
\\Subkey-Type: rsa
\\Subkey-Length: {d}
\\Subkey-Usage: encrypt
\\
, .{ bits, bits });
},
}
try w.print("Name-Real: {s}\n", .{name});
try w.print("Name-Email: {s}\n", .{email});
try w.writeAll("Expire-Date: 0\n"); // openpgp.js default: no expiry.
if (passphrase) |p| {
try w.print("Passphrase: {s}\n", .{p});
} else {
try w.writeAll("%no-protection\n");
}
try w.writeAll("%commit\n");
return out.toOwnedSlice();
}
/// Read the freshly generated key's v4 fingerprint from a colon listing.
/// A `fpr` record carries 40 hex chars in field 10 (index 9).
fn readFingerprint(allocator: Allocator, home: []const u8) ![]u8 {
const listing = try runGpg(allocator, home, &.{ "--list-keys", "--with-colons" }, null, null);
defer allocator.free(listing);
var lines = std.mem.splitScalar(u8, listing, '\n');
while (lines.next()) |line| {
if (!std.mem.startsWith(u8, line, "fpr:")) continue;
var fields = std.mem.splitScalar(u8, line, ':');
var index: usize = 0;
while (fields.next()) |field| : (index += 1) {
if (index != 9) continue;
if (field.len != 40) break;
for (field) |c| {
if (!std.ascii.isHex(c)) break;
} else return allocator.dupe(u8, field);
break;
}
}
return Error.NoFingerprint;
}
/// gpg writes a revocation certificate automatically at generation time, into
/// `openpgp-revocs.d/<FINGERPRINT>.rev`. It is commented out with a leading
/// ':' so it cannot be imported by accident; uncomment it the way gpg's own
/// instructions say to. Returns "" when the file is absent.
fn readRevocationCertificate(allocator: Allocator, home: []const u8, fingerprint: []const u8) ![]u8 {
var upper: [40]u8 = undefined;
if (fingerprint.len != upper.len) return allocator.dupe(u8, "");
for (fingerprint, 0..) |c, i| upper[i] = std.ascii.toUpper(c);
const path = try std.fmt.allocPrint(
allocator, "{s}/openpgp-revocs.d/{s}.rev", .{ home, upper });
defer allocator.free(path);
const raw = std.fs.cwd().readFileAlloc(allocator, path, MAX_OUTPUT_BYTES) catch |err| switch (err) {
error.FileNotFound => return allocator.dupe(u8, ""),
else => return err,
};
defer allocator.free(raw);
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
var in_block = false;
var lines = std.mem.splitScalar(u8, raw, '\n');
while (lines.next()) |raw_line| {
const stripped = std.mem.trimRight(u8, raw_line, "\r");
const line = if (std.mem.startsWith(u8, stripped, ":"))
std.mem.trimLeft(u8, stripped[1..], " \t")
else
stripped;
if (std.mem.startsWith(u8, line, "-----BEGIN PGP PUBLIC KEY BLOCK-----")) in_block = true;
if (!in_block) continue;
try out.appendSlice(line);
try out.append('\n');
if (std.mem.startsWith(u8, line, "-----END PGP PUBLIC KEY BLOCK-----")) break;
}
return out.toOwnedSlice();
}
// --- Process plumbing ----------------------------------------------------------
/// Spawn gpg with an argv SLICE against the ephemeral home and return stdout.
/// Never touches a shell. Caller owns the returned slice.
fn runGpg(
allocator: Allocator,
home: []const u8,
args: []const []const u8,
stdin_data: ?[]const u8,
passphrase: ?[]const u8,
) ![]u8 {
var argv = std.ArrayList([]const u8).init(allocator);
defer argv.deinit();
try argv.appendSlice(&.{ "gpg", "--batch", "--yes", "--no-tty" });
if (passphrase) |p| {
try argv.appendSlice(&.{ "--pinentry-mode", "loopback", "--passphrase", p });
}
try argv.appendSlice(args);
var env = try std.process.getEnvMap(allocator);
defer env.deinit();
try env.put("GNUPGHOME", home);
var child = std.process.Child.init(argv.items, allocator);
child.env_map = &env;
child.stdin_behavior = if (stdin_data != null) .Pipe else .Ignore;
child.stdout_behavior = .Pipe;
child.stderr_behavior = .Pipe;
child.spawn() catch return Error.GpgUnavailable;
if (stdin_data) |data| {
try child.stdin.?.writeAll(data);
child.stdin.?.close();
child.stdin = null;
}
// Drain both pipes together, or a large armored export fills the OS pipe
// buffer and both sides block forever. This is the same poll-then-wait
// shape std.process.Child.run uses internally; on POSIX the poller does
// not close the handles, so child.wait() still owns stream cleanup.
var poller = std.io.poll(allocator, enum { stdout, stderr }, .{
.stdout = child.stdout.?,
.stderr = child.stderr.?,
});
defer poller.deinit();
while (try poller.poll()) {
if (poller.fifo(.stdout).count > MAX_OUTPUT_BYTES) return Error.GpgFailed;
if (poller.fifo(.stderr).count > MAX_OUTPUT_BYTES) return Error.GpgFailed;
}
const stdout = try allocator.dupe(u8, poller.fifo(.stdout).readableSlice(0));
errdefer allocator.free(stdout);
const term = try child.wait();
switch (term) {
.Exited => |code| if (code != 0) {
allocator.free(stdout);
return Error.GpgFailed;
},
else => {
allocator.free(stdout);
return Error.GpgFailed;
},
}
return stdout;
}
/// A fresh 0700 GNUPGHOME under a temp directory, deleted (best-effort) by
/// `destroy`. gpg refuses a world-readable homedir, and this way no key ever
/// touches the user's real keyring.
const EphemeralHome = struct {
allocator: Allocator,
path: []u8,
fn create(allocator: Allocator) !EphemeralHome {
const base = std.posix.getenv("TMPDIR") orelse "/tmp";
var suffix: [16]u8 = undefined;
std.crypto.random.bytes(&suffix);
const path = try std.fmt.allocPrint(
allocator, "{s}/cosmodev-gnupg-{s}", .{
std.mem.trimRight(u8, base, "/"),
std.fmt.fmtSliceHexLower(&suffix),
});
errdefer allocator.free(path);
// std.fs.makeDir has no mode parameter and would create 0755; gpg
// rejects a group/world-readable homedir, so go through posix.mkdir.
try std.posix.mkdir(path, 0o700);
return .{ .allocator = allocator, .path = path };
}
fn destroy(self: *EphemeralHome) void {
// Best-effort: gpg-agent socket leftovers are not worth failing over.
std.fs.deleteTreeAbsolute(self.path) catch {};
self.allocator.free(self.path);
}
};
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 →