Skip to content

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 →