module IOStreams::Pgp

Read/Write PGP/GPG file or stream.

Limitations

Public Class Methods

delete_keys(email: nil, key_id: nil, public: true, private: false) click to toggle source

Delete all private and public keys for a particular email.

Returns false if no key was found. Raises an exception if it fails to delete the key.

email: [String] Optional email address for the key. key_id: [String] Optional id for the key.

public: [true|false]

Whether to delete the public key
Default: true

private: [true|false]

Whether to delete the private key
Default: false
# File lib/io_streams/pgp.rb, line 98
def self.delete_keys(email: nil, key_id: nil, public: true, private: false)
  version_check
  method_name = pgp_version.to_f >= 2.2 ? :delete_public_or_private_keys : :delete_public_or_private_keys_v1
  status      = false
  status      = send(method_name, email: email, key_id: key_id, private: true) if private
  status      = send(method_name, email: email, key_id: key_id, private: false) if public
  status
end
delete_public_or_private_keys(email: nil, key_id: nil, private: false) click to toggle source
# File lib/io_streams/pgp.rb, line 436
def self.delete_public_or_private_keys(email: nil, key_id: nil, private: false)
  keys = private ? "secret-keys" : "keys"

  list = email ? list_keys(email: email, private: private) : list_keys(key_id: key_id)
  return false if list.empty?

  list.each do |key_info|
    key_id = key_info[:key_id]
    next unless key_id

    command          = "#{executable} --batch --no-tty --yes --delete-#{keys} #{key_id}"
    out, err, status = Open3.capture3(command, binmode: true)
    logger&.debug { "IOStreams::Pgp.delete_keys: #{command}\n#{err}#{out}" }

    unless status.success?
      raise(Pgp::Failure, "GPG Failed calling #{executable} to delete #{keys} for #{email || key_id}: #{err}: #{out}")
    end
    raise(Pgp::Failure, "GPG Failed to delete #{keys} for #{email || key_id} #{err.strip}:#{out}") if out.include?("error")
  end
  true
end
delete_public_or_private_keys_v1(email: nil, key_id: nil, private: false) click to toggle source
# File lib/io_streams/pgp.rb, line 458
def self.delete_public_or_private_keys_v1(email: nil, key_id: nil, private: false)
  keys = private ? "secret-keys" : "keys"

  command = "for i in `#{executable} --list-#{keys} --with-colons --fingerprint #{email || key_id} | grep \"^fpr\" | cut -d: -f10`; do\n"
  command << "#{executable} --batch --no-tty --yes --delete-#{keys} \"$i\" ;\n"
  command << "done"

  out, err, status = Open3.capture3(command, binmode: true)
  logger&.debug { "IOStreams::Pgp.delete_keys: #{command}\n#{err}: #{out}" }

  return false if err =~ /(not found|no public key)/i
  unless status.success?
    raise(Pgp::Failure, "GPG Failed calling #{executable} to delete #{keys} for #{email || key_id}: #{err}: #{out}")
  end
  raise(Pgp::Failure, "GPG Failed to delete #{keys} for #{email || key_id} #{err.strip}: #{out}") if out.include?("error")

  true
end
executable() click to toggle source
# File lib/io_streams/pgp.rb, line 18
def self.executable
  @executable
end
executable=(executable) click to toggle source
# File lib/io_streams/pgp.rb, line 22
def self.executable=(executable)
  @executable = executable
end
export(email:, ascii: true, private: false, passphrase: nil) click to toggle source

Returns [String] containing all the public keys for the supplied email address.

email: [String] Email address for requested key.

ascii: [true|false]

Whether to export as ASCII text instead of binary format
Default: true
# File lib/io_streams/pgp.rb, line 175
def self.export(email:, ascii: true, private: false, passphrase: nil)
  version_check

  command = "#{executable} "
  command << "--pinentry-mode loopback " if pgp_version.to_f >= 2.1
  command << "--armor " if ascii
  command << "--no-tty  --batch --passphrase"
  command << (passphrase ? " #{passphrase} " : "-fd 0 ")
  command << (private ? "--export-secret-keys #{email}" : "--export #{email}")

  out, err, status = Open3.capture3(command, binmode: true)
  logger&.debug { "IOStreams::Pgp.export: #{command}\n#{err}" }

  raise(Pgp::Failure, "GPG Failed reading key: #{email}: #{err}") unless status.success? && out.length.positive?

  out
end
fingerprint(email:) click to toggle source

DEPRECATED - Use key_ids instead of fingerprints

# File lib/io_streams/pgp.rb, line 290
def self.fingerprint(email:)
  version_check
  Open3.popen2e("#{executable} --list-keys --fingerprint --with-colons #{email}") do |_stdin, out, waith_thr|
    output = out.read.chomp
    if !waith_thr.value.success? && !(output !~ /(public key not found|No public key)/i)
      raise(Pgp::Failure, "GPG Failed calling #{executable} to list keys for #{email}: #{output}")
    end

    output.each_line do |line|
      if (match = line.match(/\Afpr.*::([^\:]*):\Z/))
        return match[1]
      end
    end
    nil
  end
end
generate_key(name:, email:, passphrase:, comment: nil, key_type: "RSA", key_length: 4096, subkey_type: "RSA", subkey_length: key_length, expire_date: nil) click to toggle source

Generate a new ultimate trusted local public and private key.

Returns [String] the key id for the generated key. Raises an exception if it fails to generate the key.

name: [String]

Name of who owns the key, such as organization

email: [String]

Email address for the key

comment: [String]

Optional comment to add to the generated key

passphrase [String]

Optional passphrase to secure the key with.
Highly Recommended.
To generate a good passphrase:
  `SecureRandom.urlsafe_base64(128)`

See `man gpg` for the remaining options

# File lib/io_streams/pgp.rb, line 49
def self.generate_key(name:,
                      email:,
                      passphrase:,
                      comment: nil,
                      key_type: "RSA",
                      key_length: 4096,
                      subkey_type: "RSA",
                      subkey_length: key_length,
                      expire_date: nil)
  version_check
  params = ""
  params << "Key-Type: #{key_type}\n" if key_type
  params << "Key-Length: #{key_length}\n" if key_length
  params << "Subkey-Type: #{subkey_type}\n" if subkey_type
  params << "Subkey-Length: #{subkey_length}\n" if subkey_length
  params << "Name-Real: #{name}\n" if name
  params << "Name-Comment: #{comment}\n" if comment
  params << "Name-Email: #{email}\n" if email
  params << "Expire-Date: #{expire_date}\n" if expire_date
  params << "Passphrase: #{passphrase}\n" if passphrase
  params << "%commit"
  command = "#{executable} --batch --gen-key --no-tty"

  out, err, status = Open3.capture3(command, binmode: true, stdin_data: params)
  logger&.debug { "IOStreams::Pgp.generate_key: #{command}\n#{params}\n#{err}#{out}" }

  raise(Pgp::Failure, "GPG Failed to generate key: #{err}#{out}") unless status.success?

  match = err.match(/gpg: key ([0-9A-F]+)\s+/)
  return unless match

  match[1]
end
import(key:) click to toggle source

Imports the supplied public/private key

Returns [Array<Hash>] keys that were successfully imported.

Each Hash consists of:
  key_id: [String]
  type:   [String]
  name:   [String]
  email:  [String]

Returns [] if the same key was previously imported.

Raises Pgp::Failure if there was an issue importing any of the keys.

Notes:

  • Importing a new key for the same email address does not remove the prior key if any.

  • Invalidated keys must be removed manually.

# File lib/io_streams/pgp.rb, line 208
def self.import(key:)
  version_check
  command = "#{executable} --batch --import"

  out, err, status = Open3.capture3(command, binmode: true, stdin_data: key)
  logger&.debug { "IOStreams::Pgp.import: #{command}\n#{err}#{out}" }
  if status.success? && !err.empty?
    # Sample output
    #
    #   gpg: key C16500E3: secret key imported\n"
    #   gpg: key C16500E3: public key "Joe Bloggs <pgp_test@iostreams.net>" imported
    #   gpg: Total number processed: 1
    #   gpg:               imported: 1  (RSA: 1)
    #   gpg:       secret keys read: 1
    #   gpg:   secret keys imported: 1
    #
    # Ignores unchanged:
    #   gpg: key 9615D46D: \"Joe Bloggs <j@bloggs.net>\" not changed\n
    results = []
    secret  = false
    err.each_line do |line|
      if line =~ /secret key imported/
        secret = true
      elsif (match = line.match(/key\s+(\w+):\s+(\w+).+\"(.*)<(.*)>\"/))
        results << {
          key_id:  match[1].to_s.strip,
          private: secret,
          name:    match[3].to_s.strip,
          email:   match[4].to_s.strip
        }
        secret = false
      end
    end
    results
  else
    return [] if err =~ /already in secret keyring/

    raise(Pgp::Failure, "GPG Failed importing key: #{err}#{out}")
  end
end
import_and_trust(key:) click to toggle source

Returns [String] email for the supplied after importing and trusting the key

Notes:

  • If the same email address has multiple keys then only the first is currently trusted.

# File lib/io_streams/pgp.rb, line 253
def self.import_and_trust(key:)
  raise(ArgumentError, "Key cannot be empty") if key.nil? || (key == "")

  key_info = key_info(key: key).last

  email = key_info.fetch(:email, nil)
  key_id = key_info.fetch(:key_id, nil)
  raise(ArgumentError, "Recipient email or key id cannot be extracted from supplied key") unless email || key_id

  import(key: key)
  set_trust(email: email, key_id: key_id)
  email || key_id
end
key?(email: nil, key_id: nil, private: false) click to toggle source

Returns [true|false] whether their is a key for the supplied email or key_id

# File lib/io_streams/pgp.rb, line 108
def self.key?(email: nil, key_id: nil, private: false)
  raise(ArgumentError, "Either :email, or :key_id must be supplied") if email.nil? && key_id.nil?

  !list_keys(email: email, key_id: key_id, private: private).empty?
end
key_info(key:) click to toggle source

Extract information from the supplied key.

Useful for confirming encryption keys before importing them.

Returns [Array<Hash>] the list of primary keys.

Each Hash consists of:
  key_length: [Integer]
  key_type:   [String]
  key_id:     [String]
  date:       [String]
  name:       [String]
  email:      [String]
# File lib/io_streams/pgp.rb, line 151
def self.key_info(key:)
  version_check
  command = executable.to_s

  out, err, status = Open3.capture3(command, binmode: true, stdin_data: key)
  logger&.debug { "IOStreams::Pgp.key_info: #{command}\n#{err}#{out}" }

  raise(Pgp::Failure, "GPG Failed extracting key details: #{err} #{out}") unless status.success? && out.length.positive?

  # Sample Output:
  #
  #   pub  4096R/3A5456F5 2017-06-07
  #   uid                            Joe Bloggs <j@bloggs.net>
  #   sub  4096R/2C9B240B 2017-06-07
  parse_list_output(out)
end
list_keys(email: nil, key_id: nil, private: false) click to toggle source

Returns [Array<Hash>] the list of keys.

Each Hash consists of:
  key_length: [Integer]
  key_type:   [String]
  key_id:     [String]
  date:       [String]
  name:       [String]
  email:      [String]

Returns [] if no keys were found.

# File lib/io_streams/pgp.rb, line 123
def self.list_keys(email: nil, key_id: nil, private: false)
  version_check
  cmd     = private ? "--list-secret-keys" : "--list-keys"
  command = "#{executable} #{cmd} #{email || key_id}"

  out, err, status = Open3.capture3(command, binmode: true)
  logger&.debug { "IOStreams::Pgp.list_keys: #{command}\n#{err}#{out}" }
  if status.success? && out.length.positive?
    parse_list_output(out)
  else
    return [] if err =~ /(not found|No (public|secret) key|key not available)/i

    raise(Pgp::Failure, "GPG Failed calling '#{executable}' to list keys for #{email || key_id}: #{err}#{out}")
  end
end
logger() click to toggle source
# File lib/io_streams/pgp.rb, line 348
def self.logger
  @logger
end
logger=(logger) click to toggle source
# File lib/io_streams/pgp.rb, line 307
def self.logger=(logger)
  @logger = logger
end
parse_list_output(out) click to toggle source

v2.2.1 output:

pub   rsa1024 2017-10-24 [SCEA]
18A0FC1C09C0D8AE34CE659257DC4AE323C7368C
uid           [ultimate] Joe Bloggs <pgp_test@iostreams.net>
sub   rsa1024 2017-10-24 [SEA]

v2.0.30 output:

pub   4096R/3A5456F5 2017-06-07
uid       [ unknown] Joe Bloggs <j@bloggs.net>
sub   4096R/2C9B240B 2017-06-07

v1.4 output:

sec   2048R/27D2E7FA 2016-10-05
uid                  Receiver <receiver@example.org>
ssb   2048R/893749EA 2016-10-05
# File lib/io_streams/pgp.rb, line 374
def self.parse_list_output(out)
  results = []
  hash    = {}
  out.each_line do |line|
    if (match = line.match(/(pub|sec)\s+(\D+)(\d+)\s+(\d+-\d+-\d+)\s+(.*)/))
      # v2.2:    pub   rsa1024 2017-10-24 [SCEA]
      hash = {
        private:    match[1] == "sec",
        key_length: match[3].to_s.to_i,
        key_type:   match[2],
        date:       (begin
          Date.parse(match[4].to_s)
        rescue StandardError
          match[4]
        end)
      }
    elsif (match = line.match(%r{(pub|sec)\s+(\d+)(.*)/(\w+)\s+(\d+-\d+-\d+)(\s+(.+)<(.+)>)?}))
      # Matches: pub  2048R/C7F9D9CB 2016-10-26
      # Or:      pub  2048R/C7F9D9CB 2016-10-26 Receiver <receiver@example.org>
      hash = {
        private:    match[1] == "sec",
        key_length: match[2].to_s.to_i,
        key_type:   match[3],
        key_id:     match[4],
        date:       (begin
          Date.parse(match[5].to_s)
        rescue StandardError
          match[5]
        end)
      }
      # Prior to gpg v2.0.30
      if match[7]
        hash[:name]  = match[7].strip
        hash[:email] = match[8].strip
        results << hash
        hash = {}
      end
    elsif (match = line.match(/uid\s+(\[(.+)\]\s+)?(.+)<(.+)>/))
      # Matches:  uid       [ unknown] Joe Bloggs <j@bloggs.net>
      # Or:       uid                  Joe Bloggs <j@bloggs.net>
      # v2.2:     uid           [ultimate] Joe Bloggs <pgp_test@iostreams.net>
      hash[:email] = match[4].strip
      hash[:name]  = match[3].to_s.strip
      hash[:trust] = match[2].to_s.strip if match[1]
      results << hash
      hash = {}
    elsif (match = line.match(/uid\s+(\[(.+)\]\s+)?(.+)/))
      # Matches:  uid       [ unknown] Joe Bloggs
      # Or:       uid                  Joe Bloggs
      # v2.2:     uid           [ultimate] Joe Bloggs
      hash[:name]  = match[3].to_s.strip
      hash[:trust] = match[2].to_s.strip if match[1]
      results << hash
      hash = {}
    elsif (match = line.match(/([A-Z0-9]+)/))
      # v2.2  18A0FC1C09C0D8AE34CE659257DC4AE323C7368C
      hash[:key_id] ||= match[1]
    end
  end
  results
end
pgp_version() click to toggle source

Returns [String] the version of pgp currently installed

# File lib/io_streams/pgp.rb, line 312
def self.pgp_version
  @pgp_version ||= begin
    command          = "#{executable} --version"
    out, err, status = Open3.capture3(command)
    logger&.debug { "IOStreams::Pgp.version: #{command}\n#{err}#{out}" }
    if status.success?
      # Sample output
      #   #{executable} (GnuPG) 2.0.30
      #   libgcrypt 1.7.6
      #   Copyright (C) 2015 Free Software Foundation, Inc.
      #   License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
      #   This is free software: you are free to change and redistribute it.
      #   There is NO WARRANTY, to the extent permitted by law.
      #
      #   Home: ~/.gnupg
      #   Supported algorithms:
      #   Pubkey: RSA, RSA, RSA, ELG, DSA
      #   Cipher: IDEA, 3DES, CAST5, BLOWFISH, AES, AES192, AES256, TWOFISH,
      #           CAMELLIA128, CAMELLIA192, CAMELLIA256
      #   Hash: MD5, SHA1, RIPEMD160, SHA256, SHA384, SHA512, SHA224
      #   Compression: Uncompressed, ZIP, ZLIB, BZIP2
      if (match = out.lines.first.match(/(\d+\.\d+.\d+)/))
        match[1]
      end
    else
      if err !~ /(key not found|No (public|secret) key)/i
        raise(Pgp::Failure, "GPG Failed calling #{executable} to list keys for #{email || key_id}: #{err}#{out}")
      end

      []
    end
  end
end
set_trust(email: nil, key_id: nil, level: 5) click to toggle source

Set the trust level for an existing key.

Returns [String] output if the trust was successfully updated Returns nil if the email was not found

After importing keys, they are not trusted and the relevant trust level must be set.

Default: 5 : Ultimate
# File lib/io_streams/pgp.rb, line 274
def self.set_trust(email: nil, key_id: nil, level: 5)
  version_check
  fingerprint = key_id || fingerprint(email: email)
  return unless fingerprint

  command          = "#{executable} --import-ownertrust"
  trust            = "#{fingerprint}:#{level + 1}:\n"
  out, err, status = Open3.capture3(command, stdin_data: trust)
  logger&.debug { "IOStreams::Pgp.set_trust: #{command}\n#{err}#{out}" }

  raise(Pgp::Failure, "GPG Failed trusting key: #{err} #{out}") unless status.success?

  err
end
version_check() click to toggle source
# File lib/io_streams/pgp.rb, line 352
def self.version_check
  return unless pgp_version.to_f >= 2.4

  raise(
    Pgp::UnsupportedVersion,
    "Version #{pgp_version} of #{executable} is not yet supported. Please submit a Pull Request to support it."
  )
end