Skip to content

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 →