PGP Key Generator — Ruby source
Generate PGP key pairs (ECC or RSA) in your browser. Download your public and private keys. Powered by OpenPGP.js.
This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# PGP Key Generator — OpenPGP key pair generation (ECC Curve25519, RSA-2048,
# RSA-4096).
#
# Language: Ruby (3.x, standard library only — open3/tmpdir/fileutils drive the
# `gpg` binary, the same engine the C port reaches through GPGME and
# the native counterpart to the TS reference's openpgp.js)
# Source: CosmoDev polyglot showcase port of the PGP Key Generator tool,
# ported from src/lib/pgp-keygen.ts (the canonical TypeScript
# implementation).
# License: display source — part of CosmoDev's polyglot tool pages.
#
# The TS reference is a thin wrapper around openpgp.js `generateKey`; this port
# keeps that shape. Identity validation stays pure Ruby, key generation
# delegates to GnuPG. Both produce the same four outputs: an ASCII-armored
# public key, an ASCII-armored private key, the 40-hex-char v4 fingerprint, and
# an armored revocation certificate.
#
# The private key never touches the user's real keyring: every gpg invocation
# runs against a fresh 0700 GNUPGHOME under a temp dir that is deleted on the
# way out — the Ruby equivalent of the TS island's "generation happens entirely
# client-side" guarantee.
#
# Two deliberate hardening choices the TS version gets for free and a shell-out
# port does not:
# 1. gpg is always spawned with an ARGUMENT ARRAY, never a command string, so
# no user input is ever parsed by a shell.
# 2. The user ID is built from name + email, which GnuPG parses structurally
# ("Name (comment) <email>"). openpgp.js takes those as separate fields and
# cannot be confused; gpg can, so `assert_safe_user_id` rejects the
# delimiter characters outright rather than escaping them.
require 'fileutils'
require 'open3'
require 'tmpdir'
module PGPKeygen
# Generated key material. `revocation_certificate` is the armored cert GnuPG
# writes alongside every new key.
KeyPair = Struct.new(:public_key, :private_key, :fingerprint,
:revocation_certificate, keyword_init: true)
# Algorithm choices, mirroring PGPKeyGenAlgorithm in the TS reference.
ALGORITHMS = {
'ecc' => 'ed25519', # Ed25519 primary + Curve25519 encryption subkey
'rsa2048' => 'rsa2048',
'rsa4096' => 'rsa4096'
}.freeze
# Accepts `foo@bar.tld`-style addresses: one @, non-empty local + domain, a
# dot in the domain. Same shape as EMAIL_RE in the TS reference.
EMAIL_RE = /\A[^\s@]+@[^\s@]+\.[^\s@]+\z/
# Characters that would let a crafted name restructure the user ID GnuPG
# parses. Not a TS concern (openpgp.js takes structured fields).
USER_ID_FORBIDDEN = /[<>()\x00-\x1f\x7f]/
module_function
# Validate the identity that goes into the key's user ID. Raises on invalid
# input; returns nil when the identity is usable.
def validate_key_gen_identity(name, email)
raise ArgumentError, 'Name is required.' if name.to_s.strip.empty?
raise ArgumentError, 'Email is required.' if email.to_s.strip.empty?
raise ArgumentError, 'Invalid email address.' unless EMAIL_RE.match?(email.to_s.strip)
nil
end
# Generate an ASCII-armored PGP key pair. ECC (Curve25519) is fast; RSA-4096
# can take a few seconds. A nil or empty passphrase leaves the private key
# unencrypted — matching the TS reference, where only a truthy passphrase is
# forwarded to openpgp.js.
#
# PGPKeygen.generate_key_pair(name: 'Ada Lovelace',
# email: 'ada@example.com',
# algorithm: 'ecc')
def generate_key_pair(name:, email:, algorithm: 'ecc', passphrase: nil)
validate_key_gen_identity(name, email)
gpg_algorithm = ALGORITHMS[algorithm.to_s] or
raise ArgumentError, "Unsupported algorithm: #{algorithm}"
user_id = build_user_id(name.to_s.strip, email.to_s.strip)
pass = passphrase.to_s.empty? ? nil : passphrase.to_s
with_ephemeral_home do |home|
# EXPIRE 0 = never expires, matching openpgp.js's default.
run_gpg(home, ['--quick-generate-key', user_id, gpg_algorithm, 'default', '0'],
passphrase: pass)
fingerprint = read_fingerprint(home)
KeyPair.new(
public_key: run_gpg(home, ['--armor', '--export', fingerprint]),
private_key: run_gpg(home, ['--armor', '--export-secret-keys', fingerprint],
passphrase: pass),
fingerprint: fingerprint.downcase, # TS returns lowercase hex, no spaces
revocation_certificate: read_revocation_certificate(home, fingerprint)
)
end
end
# --- internals -------------------------------------------------------------
# "Name <email>" — the RFC-ish user ID packet GnuPG expects.
def build_user_id(name, email)
assert_safe_user_id(name, 'Name')
assert_safe_user_id(email, 'Email')
"#{name} <#{email}>"
end
# Reject anything that could restructure the user ID GnuPG parses. Escaping
# is not attempted: a name with angle brackets is a bad name, not a name that
# needs quoting.
def assert_safe_user_id(value, field)
return unless USER_ID_FORBIDDEN.match?(value)
raise ArgumentError,
"#{field} must not contain <, >, parentheses or control characters."
end
# A private keyring that exists only for the duration of the block. 0700 is
# required — gpg refuses to run against a world-readable home.
def with_ephemeral_home
dir = Dir.mktmpdir('cosmodev-pgp-')
FileUtils.chmod(0o700, dir)
yield dir
ensure
FileUtils.remove_entry(dir) if dir && File.directory?(dir)
end
# Spawn gpg with an argument array (never a shell string) against `home`.
# A passphrase is fed on stdin via --passphrase-fd 0 so it never appears in
# the process table where any local user could read it.
def run_gpg(home, args, passphrase: nil)
base = ['gpg', '--homedir', home, '--batch', '--no-tty', '--yes']
base += ['--pinentry-mode', 'loopback', '--passphrase-fd', '0'] if passphrase
out, err, status = Open3.capture3(*base, *args, stdin_data: passphrase ? "#{passphrase}\n" : '')
raise "gpg failed: #{err.strip.empty? ? "exit #{status.exitstatus}" : err.strip}" unless status.success?
out
end
# The primary key's fingerprint: field 10 of the first `fpr` colon record.
def read_fingerprint(home)
listing = run_gpg(home, ['--list-keys', '--with-colons'])
row = listing.each_line.find { |line| line.start_with?('fpr:') }
raise 'gpg generated no key (no fingerprint record).' unless row
row.split(':')[9].to_s
end
# gpg writes an armored revocation certificate per key under
# openpgp-revocs.d/<FINGERPRINT>.rev at generation time.
def read_revocation_certificate(home, fingerprint)
path = File.join(home, 'openpgp-revocs.d', "#{fingerprint.upcase}.rev")
File.exist?(path) ? File.read(path) : ''
end
end
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 →