Skip to content

PGP Encrypt & Decrypt — Ruby source

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

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

# PGP Encrypt & Decrypt — read OpenPGP key metadata, encrypt (optionally
# signing), decrypt.
#
# 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 Encrypt & Decrypt tool,
#           ported from src/lib/pgp-encrypt.ts (the canonical TypeScript
#           implementation).
# License:  display source — part of CosmoDev's polyglot tool pages.
#
# Three entry points, matching the TS public API one-for-one:
#   read_key_info — user ID, uppercase fingerprint, algorithm, creation date,
#                   expiry (nil when the key never expires), and whether the
#                   armor holds a private key.
#   pgp_encrypt   — ASCII-armored PGP message for a recipient's public key,
#                   optionally signed with the sender's private key.
#   pgp_decrypt   — plaintext from an armored message + private key.
#
# Armored key material is imported into a fresh 0700 GNUPGHOME under a temp dir
# that is deleted on the way out, so nothing is written to the user's real
# keyring — the Ruby equivalent of the TS island keeping every operation
# client-side.
#
# gpg is always spawned with an ARGUMENT ARRAY, never a command string, so no
# key, message or passphrase is ever parsed by a shell. Passphrases go in on
# stdin via --passphrase-fd 0 rather than argv, where any local user could read
# them out of the process table.

require 'fileutils'
require 'open3'
require 'tmpdir'

module PGPEncrypt
  # Key metadata, mirroring PGPKeyInfo in the TS reference.
  KeyInfo = Struct.new(:user_id, :fingerprint, :algorithm, :creation_date,
                       :expiry, :private, keyword_init: true) do
    # `private` collides with Ruby's visibility keyword when read bare.
    alias_method :private?, :private
  end

  PRIVATE_ARMOR_HEADERS = [
    '-----BEGIN PGP PRIVATE KEY BLOCK-----',
    '-----BEGIN PGP SECRET KEY BLOCK-----'
  ].freeze

  # gpg's --with-colons public-key algorithm numbers (RFC 4880 §9.1 + the
  # modern additions) mapped to the names openpgp.js reports.
  ALGORITHM_NAMES = {
    '1' => 'rsaEncryptSign', '2' => 'rsaEncrypt', '3' => 'rsaSign',
    '16' => 'elgamal', '17' => 'dsa', '18' => 'ecdh', '19' => 'ecdsa',
    '22' => 'eddsaLegacy', '25' => 'x25519', '27' => 'ed25519'
  }.freeze

  module_function

  # Read a PGP key (public or private) and extract its metadata.
  # Raises on invalid or unrecognized key material.
  def read_key_info(armored_key)
    trimmed = armored_key.to_s.strip
    raise ArgumentError, 'Key input is empty or invalid.' if trimmed.empty?

    # Detect key type from the armor header, exactly as the TS reference does —
    # before handing anything to gpg.
    is_private = PRIVATE_ARMOR_HEADERS.any? { |header| trimmed.include?(header) }

    with_ephemeral_home do |home|
      import_key(home, trimmed)
      pub = colon_record(home, 'pub') or raise 'Invalid PGP key: no primary key packet.'

      KeyInfo.new(
        user_id: first_user_id(home),
        fingerprint: primary_fingerprint(home).upcase,
        algorithm: ALGORITHM_NAMES.fetch(pub[3], "unknown(#{pub[3]})"),
        creation_date: epoch_to_date(pub[5]),
        expiry: epoch_to_date(pub[6]), # blank field = never expires -> nil
        private: is_private
      )
    end
  rescue ArgumentError
    raise
  rescue StandardError => e
    raise "Invalid PGP key: #{e.message}"
  end

  # Encrypt a plaintext message for a recipient's public key. Returns an
  # ASCII-armored PGP message. When `signing_private_key` is given the message
  # is signed as well; `passphrase` unlocks that signing key.
  def pgp_encrypt(message, public_key, signing_private_key: nil, passphrase: nil)
    raise ArgumentError, 'Message must not be empty.' if message.to_s.empty?
    raise ArgumentError, 'Recipient public key must not be empty.' if public_key.to_s.empty?

    pass = passphrase.to_s.empty? ? nil : passphrase.to_s

    with_ephemeral_home do |home|
      import_key(home, public_key.to_s.strip)
      recipient = primary_fingerprint(home)

      args = ['--armor', '--encrypt', '--recipient', recipient,
              # The ephemeral keyring carries no ownertrust, so every recipient
              # would otherwise trip gpg's "not certified" prompt.
              '--trust-model', 'always']

      if signing_private_key.to_s.strip.empty?
        run_gpg(home, args, stdin: message.to_s)
      else
        import_key(home, signing_private_key.to_s.strip, passphrase: pass)
        signer = secret_fingerprint(home)
        # --passphrase-fd 0 is taken by the passphrase, so the plaintext is fed
        # through a temp file inside the ephemeral (0700) home instead.
        encrypt_signed(home, args, message.to_s, signer, pass)
      end
    end
  end

  # Decrypt an ASCII-armored PGP message with the recipient's private key.
  # Returns the plaintext.
  def pgp_decrypt(armored_message, private_key, passphrase: nil)
    raise ArgumentError, 'Armored message must not be empty.' if armored_message.to_s.empty?
    raise ArgumentError, 'Private key must not be empty.' if private_key.to_s.empty?

    pass = passphrase.to_s.empty? ? nil : passphrase.to_s

    with_ephemeral_home do |home|
      import_key(home, private_key.to_s.strip, passphrase: pass)
      message_path = write_temp(home, 'message.asc', armored_message.to_s.strip)
      run_gpg(home, ['--decrypt', message_path], passphrase: pass)
    end
  end

  # --- internals -------------------------------------------------------------

  # 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`.
  # Exactly one of `stdin` / `passphrase` may occupy stdin; a passphrase wins,
  # so callers that need both route the payload through a temp file.
  def run_gpg(home, args, stdin: nil, passphrase: nil)
    base = ['gpg', '--homedir', home, '--batch', '--no-tty', '--yes']
    base += ['--pinentry-mode', 'loopback', '--passphrase-fd', '0'] if passphrase
    input = passphrase ? "#{passphrase}\n" : stdin.to_s

    out, err, status = Open3.capture3(*base, *args, stdin_data: input)
    raise(err.strip.empty? ? "gpg exited #{status.exitstatus}" : err.strip) unless status.success?

    out
  end

  # Encrypt + sign. The plaintext goes to a file so stdin stays free for the
  # signing key's passphrase.
  def encrypt_signed(home, args, message, signer, pass)
    path = write_temp(home, 'plaintext.txt', message)
    run_gpg(home, args + ['--sign', '--local-user', signer, path], passphrase: pass)
  end

  # Import armored key material. Importing an encrypted secret key needs the
  # passphrase, so it is forwarded when present.
  def import_key(home, armored, passphrase: nil)
    path = write_temp(home, 'key.asc', armored)
    run_gpg(home, ['--import', path], passphrase: passphrase)
  end

  # Write inside the 0700 ephemeral home — never /tmp at large — so key
  # material and plaintext are never world-readable, even briefly.
  def write_temp(home, name, content)
    path = File.join(home, name)
    File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |io| io.write(content) }
    path
  end

  # The first colon record of the given type, split into its fields.
  def colon_record(home, type, secret: false)
    listing = run_gpg(home, [secret ? '--list-secret-keys' : '--list-keys', '--with-colons'])
    row = listing.each_line.find { |line| line.start_with?("#{type}:") }
    row&.split(':')
  end

  def primary_fingerprint(home)
    fpr = colon_record(home, 'fpr') or raise 'no fingerprint record'
    fpr[9].to_s
  end

  def secret_fingerprint(home)
    listing = run_gpg(home, ['--list-secret-keys', '--with-colons'])
    row = listing.each_line.find { |line| line.start_with?('fpr:') }
    raise 'Signing key holds no secret material.' unless row

    row.split(':')[9].to_s
  end

  # uid records carry the user ID in field 10; 'unknown' matches the TS
  # fallback when a key has no user ID at all.
  def first_user_id(home)
    uid = colon_record(home, 'uid')
    value = uid && uid[9].to_s.strip
    value.nil? || value.empty? ? 'unknown' : unescape_colon_field(value)
  end

  # gpg escapes non-ASCII and delimiter bytes in colon output as \xNN.
  def unescape_colon_field(value)
    value.gsub(/\\x([0-9a-fA-F]{2})/) { Regexp.last_match(1).hex.chr }
         .force_encoding(Encoding::UTF_8)
  end

  # Colon-format timestamps are UNIX epoch seconds; an empty field means "no
  # expiry", which the TS reference surfaces as null.
  def epoch_to_date(field)
    return nil if field.nil? || field.to_s.strip.empty?

    Time.at(Integer(field, 10)).utc.strftime('%Y-%m-%d')
  rescue ArgumentError, TypeError
    nil
  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 →