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 →