Skip to content

PGP Encrypt & Decrypt — Zig source

Encrypt or decrypt messages with PGP public/private keys. Powered by OpenPGP.js, runs entirely in your browser.

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

//! pgp-encrypt — read OpenPGP key metadata, encrypt (optionally signing), decrypt.
//!
//! 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-encrypt.ts (the canonical TypeScript implementation).
//! display source — part of CosmoDev's polyglot tool pages.
//!
//! Three entry points, matching the TS public API one-for-one:
//!   readKeyInfo — user ID, uppercase fingerprint, algorithm, creation date,
//!                 expiry (null when the key never expires), and whether the
//!                 armor holds a private key.
//!   pgpEncrypt  — ASCII-armored PGP message for a recipient's public key,
//!                 optionally signed with the sender's private key.
//!   pgpDecrypt  — plaintext from an armored message + private key.
//!
//! Hardening, mirrored across every port of this tool:
//!
//!   1. Ephemeral keyring. Armored key material is imported into a fresh 0700
//!      GNUPGHOME under a temp directory that is deleted on the way out, so
//!      nothing is written to the user's real keyring — the Zig equivalent of
//!      the TS island keeping every operation client-side.
//!
//!   2. No shell, ever. std.process.Child takes an argv SLICE, so no key,
//!      message or passphrase is parsed by /bin/sh.
//!
//!   3. Messages and armored keys travel over stdin/stdout; passphrases go
//!      through --passphrase with --pinentry-mode loopback --batch, so no
//!      secret is ever written to a file.
//!
//! readKeyInfo uses `--import-options show-only --import`, which parses the
//! armor and prints its packets WITHOUT adding anything to the keyring — the
//! closest gpg equivalent of openpgp.js `readKey`.

const std = @import("std");
const Allocator = std.mem.Allocator;

/// Metadata extracted from one armored PGP key. All slices are owned by the
/// caller — free them with `deinit`.
pub const KeyInfo = struct {
    user_id: []const u8,
    /// v4 fingerprint, 40 uppercase hex chars (the TS reference uppercases it).
    fingerprint: []const u8,
    algorithm: []const u8,
    /// ISO yyyy-mm-dd.
    creation_date: []const u8,
    /// ISO yyyy-mm-dd, null when the key never expires.
    expiry: ?[]const u8,
    is_private: bool,

    pub fn deinit(self: KeyInfo, allocator: Allocator) void {
        allocator.free(self.user_id);
        allocator.free(self.fingerprint);
        allocator.free(self.creation_date);
        if (self.expiry) |e| allocator.free(e);
    }
};

pub const Error = error{
    EmptyKeyInput,
    EmptyMessage,
    EmptyPublicKey,
    EmptyArmoredMessage,
    EmptyPrivateKey,
    InvalidKey,
    GpgUnavailable,
    GpgFailed,
    OutOfMemory,
};

/// Armored PGP data is small; this ceiling only bounds a runaway child.
const MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024;

/// gpg's numeric public-key algorithm ids -> openpgp.js-style names, so the
/// `algorithm` field reads the same as `getAlgorithmInfo().algorithm` does in
/// the TS reference.
fn algorithmName(id: u32) []const u8 {
    return switch (id) {
        1 => "rsaEncryptSign",
        2 => "rsaEncrypt",
        3 => "rsaSign",
        16 => "elgamal",
        17 => "dsa",
        18 => "ecdh",
        19 => "ecdsa",
        22 => "eddsaLegacy",
        25 => "x25519",
        27 => "ed25519",
        else => "unknown",
    };
}

fn trim(s: []const u8) []const u8 {
    return std.mem.trim(u8, s, " \t\r\n");
}

// --- Public API ----------------------------------------------------------------

/// Read a PGP key (public or private) and extract its metadata.
/// Fails on invalid or unrecognized key material.
pub fn readKeyInfo(allocator: Allocator, armored_key: []const u8) !KeyInfo {
    const trimmed = trim(armored_key);
    if (trimmed.len == 0) return Error.EmptyKeyInput;

    // Detect key type from the armor header, exactly as the TS reference does.
    const is_private =
        std.mem.indexOf(u8, trimmed, "-----BEGIN PGP PRIVATE KEY BLOCK-----") != null or
        std.mem.indexOf(u8, trimmed, "-----BEGIN PGP SECRET KEY BLOCK-----") != null;

    var home = try EphemeralHome.create(allocator);
    defer home.destroy();

    // show-only parses the armor and prints packets without importing.
    const listing = runGpg(
        allocator,
        home.path,
        &.{ "--with-colons", "--import-options", "show-only", "--import" },
        trimmed,
        null,
    ) catch return Error.InvalidKey;
    defer allocator.free(listing);

    return parseKeyListing(allocator, listing, is_private) orelse Error.InvalidKey;
}

/// Encrypt a plaintext message for a recipient's public key.
/// Returns an ASCII-armored PGP message; the caller owns it.
///
/// Optionally signs with the sender's private key (signing_private_key + passphrase).
pub fn pgpEncrypt(
    allocator: Allocator,
    message: []const u8,
    public_key_armored: []const u8,
    signing_private_key_armored: ?[]const u8,
    passphrase: ?[]const u8,
) ![]u8 {
    if (message.len == 0) return Error.EmptyMessage;
    if (public_key_armored.len == 0) return Error.EmptyPublicKey;

    var home = try EphemeralHome.create(allocator);
    defer home.destroy();

    const recipient = try importKey(allocator, home.path, trim(public_key_armored), null);
    defer allocator.free(recipient);

    var argv = std.ArrayList([]const u8).init(allocator);
    defer argv.deinit();
    try argv.appendSlice(&.{ "--armor", "--encrypt", "--recipient", recipient });
    // The freshly imported key carries no web-of-trust path in this throwaway
    // keyring, so gpg would refuse it without an explicit trust model. The
    // caller chose this recipient, which is exactly what openpgp.js assumes.
    try argv.appendSlice(&.{ "--trust-model", "always" });

    var signer_fpr: ?[]u8 = null;
    defer if (signer_fpr) |f| allocator.free(f);

    var signing_passphrase: ?[]const u8 = null;
    if (signing_private_key_armored) |signing_key| {
        if (signing_key.len > 0) {
            signing_passphrase = if (passphrase) |p| (if (p.len > 0) p else null) else null;
            signer_fpr = try importKey(allocator, home.path, trim(signing_key), signing_passphrase);
            try argv.appendSlice(&.{ "--sign", "--local-user", signer_fpr.? });
        }
    }

    return runGpg(allocator, home.path, argv.items, message, signing_passphrase);
}

/// Decrypt an ASCII-armored PGP message with the recipient's private key.
/// Returns the plaintext message; the caller owns it.
pub fn pgpDecrypt(
    allocator: Allocator,
    armored_message: []const u8,
    private_key_armored: []const u8,
    passphrase: ?[]const u8,
) ![]u8 {
    if (armored_message.len == 0) return Error.EmptyArmoredMessage;
    if (private_key_armored.len == 0) return Error.EmptyPrivateKey;

    var home = try EphemeralHome.create(allocator);
    defer home.destroy();

    const effective_passphrase: ?[]const u8 =
        if (passphrase) |p| (if (p.len > 0) p else null) else null;

    const fpr = try importKey(allocator, home.path, trim(private_key_armored), effective_passphrase);
    allocator.free(fpr);

    return runGpg(allocator, home.path, &.{"--decrypt"}, trim(armored_message), effective_passphrase);
}

// --- Colon-listing parsing -----------------------------------------------------

/// Return field `index` (0-based) of a colon-separated record, or null.
fn field(line: []const u8, index: usize) ?[]const u8 {
    var it = std.mem.splitScalar(u8, line, ':');
    var i: usize = 0;
    while (it.next()) |f| : (i += 1) {
        if (i == index) return f;
    }
    return null;
}

/// Parse a `--with-colons` listing. The first pub/sec record carries the
/// algorithm (field 4), creation (field 6) and expiry (field 7) as epoch
/// seconds; the following fpr record carries the fingerprint (field 10) and
/// the first uid record the user ID (field 10).
fn parseKeyListing(allocator: Allocator, listing: []const u8, is_private: bool) ?KeyInfo {
    var algorithm: ?[]const u8 = null;
    var creation_secs: i64 = 0;
    var expiry_secs: i64 = 0;
    var fingerprint: ?[]const u8 = null;
    var user_id: ?[]const u8 = null;

    var lines = std.mem.splitScalar(u8, listing, '\n');
    while (lines.next()) |raw_line| {
        const line = std.mem.trimRight(u8, raw_line, "\r");
        const tag = field(line, 0) orelse continue;

        if (std.mem.eql(u8, tag, "pub") or std.mem.eql(u8, tag, "sec")) {
            if (algorithm != null) continue;
            const algo_id = std.fmt.parseInt(u32, field(line, 3) orelse "0", 10) catch 0;
            algorithm = algorithmName(algo_id);
            creation_secs = std.fmt.parseInt(i64, field(line, 5) orelse "0", 10) catch 0;
            expiry_secs = std.fmt.parseInt(i64, field(line, 6) orelse "0", 10) catch 0;
        } else if (std.mem.eql(u8, tag, "fpr")) {
            if (fingerprint != null) continue;
            const f = field(line, 9) orelse continue;
            if (f.len == 40) fingerprint = f;
        } else if (std.mem.eql(u8, tag, "uid")) {
            if (user_id != null) continue;
            const f = field(line, 9) orelse continue;
            if (f.len > 0) user_id = f;
        }
    }

    const fpr = fingerprint orelse return null;
    const algo = algorithm orelse return null;
    if (creation_secs <= 0) return null;

    const owned_fpr = allocator.alloc(u8, fpr.len) catch return null;
    for (fpr, 0..) |c, i| owned_fpr[i] = std.ascii.toUpper(c);

    // openpgp.js `getUserIDs()[0] || 'unknown'`.
    const owned_uid = if (user_id) |u|
        decodeColonEscapes(allocator, u) catch return null
    else
        allocator.dupe(u8, "unknown") catch return null;

    const created = isoDay(allocator, creation_secs) catch return null orelse return null;
    const expires = if (expiry_secs > 0) (isoDay(allocator, expiry_secs) catch null) else null;

    return KeyInfo{
        .user_id = owned_uid,
        .fingerprint = owned_fpr,
        .algorithm = algo,
        .creation_date = created,
        .expiry = expires,
        .is_private = is_private,
    };
}

/// Epoch seconds -> "yyyy-mm-dd" in UTC, matching the TS reference's
/// `toISOString().split('T')[0]`.
fn isoDay(allocator: Allocator, seconds: i64) !?[]u8 {
    if (seconds <= 0) return null;
    const epoch = std.time.epoch.EpochSeconds{ .secs = @intCast(seconds) };
    const year_day = epoch.getEpochDay().calculateYearDay();
    const month_day = year_day.calculateMonthDay();
    return try std.fmt.allocPrint(allocator, "{d:0>4}-{d:0>2}-{d:0>2}", .{
        year_day.year,
        month_day.month.numeric(),
        month_day.day_index + 1,
    });
}

/// gpg escapes ':' and other reserved bytes in colon listings as `\xHH`.
fn decodeColonEscapes(allocator: Allocator, value: []const u8) ![]u8 {
    var out = std.ArrayList(u8).init(allocator);
    errdefer out.deinit();

    var i: usize = 0;
    while (i < value.len) {
        if (i + 3 < value.len and value[i] == '\\' and value[i + 1] == 'x') {
            if (std.fmt.parseInt(u8, value[i + 2 .. i + 4], 16)) |byte| {
                try out.append(byte);
                i += 4;
                continue;
            } else |_| {}
        }
        try out.append(value[i]);
        i += 1;
    }
    return out.toOwnedSlice();
}

// --- Key import ----------------------------------------------------------------

/// Import one armored key into the ephemeral home and return its fingerprint.
fn importKey(
    allocator: Allocator,
    home: []const u8,
    armored: []const u8,
    passphrase: ?[]const u8,
) ![]u8 {
    const imported = runGpg(allocator, home, &.{"--import"}, armored, passphrase) catch
        return Error.InvalidKey;
    allocator.free(imported);

    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;
        const f = field(line, 9) orelse continue;
        if (f.len == 40) return allocator.dupe(u8, f);
    }
    return Error.InvalidKey;
}

// --- 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 message 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 →