Module: IOStreams::Pgp

Defined in:
lib/io_streams/pgp.rb,
lib/io_streams/pgp/reader.rb,
lib/io_streams/pgp/writer.rb

Overview

Read/Write PGP/GPG file or stream.

Example Setup:

1. Install OpenPGP
   Mac OSX (homebrew) : `brew install gpg2`
   Redhat Linux: `rpm install gpg2`

2. # Generate senders private and public key
   IOStreams::Pgp.generate_key(name: 'Sender', email: '[email protected]', passphrase: 'sender_passphrase')

3. # Generate receivers private and public key
   IOStreams::Pgp.generate_key(name: 'Receiver', email: '[email protected]', passphrase: 'receiver_passphrase')

Example 1:

# Generate encrypted file for a specific recipient and sign it with senders credentials
data = %w(this is some data that should be encrypted using pgp)
IOStreams::Pgp::Writer.open('secure.gpg', recipient: '[email protected]', signer: '[email protected]', signer_passphrase: 'sender_passphrase') do |output|
  data.each { |word| output.puts(word) }
end

# Decrypt the file sent to `[email protected]` using its private key
# Recipient must also have the senders public key to verify the signature
IOStreams::Pgp::Reader.open('secure.gpg', passphrase: 'receiver_passphrase') do |stream|
  while !stream.eof?
    p stream.read(10)
    puts
  end
end

Example 2:

# Default user and passphrase to sign the output file:
IOStreams::Pgp::Writer.default_signer            = '[email protected]'
IOStreams::Pgp::Writer.default_signer_passphrase = 'sender_passphrase'

# Default passphrase for decrypting recipients files.
# Note: Usually this would be the senders passphrase, but in this example
#       it is decrypting the file intended for the recipient.
IOStreams::Pgp::Reader.default_passphrase = 'receiver_passphrase'

# Generate encrypted file for a specific recipient and sign it with senders credentials
data = %w(this is some data that should be encrypted using pgp)
IOStreams.writer('secure.gpg', streams: {pgp: {recipient: '[email protected]'}}) do |output|
  data.each { |word| output.puts(word) }
end

# Decrypt the file sent to `[email protected]` using its private key
# Recipient must also have the senders public key to verify the signature
IOStreams.reader('secure.gpg') do |stream|
  while data = stream.read(10)
    p data
  end
end

FAQ:

Delete test keys:

IOStreams::Pgp.delete_keys(email: '[email protected]', private: true)
IOStreams::Pgp.delete_keys(email: '[email protected]', private: true)

Limitations

  • Designed for processing larger files since a process is spawned for each file processed.

  • For small in memory files or individual emails, use the ‘opengpgme’ library.

Compression Performance:

Running tests on an Early 2015 Macbook Pro Dual Core with Ruby v2.3.1

Input file: test.log 3.6GB
  :none:  size: 3.6GB  write:  52s  read:  45s
  :zip:   size: 411MB  write:  75s  read:  31s
  :zlib:  size: 241MB  write:  66s  read:  23s  ( 756KB Memory )
  :bzip2: size: 129MB  write: 430s  read: 130s  ( 5MB Memory )

Notes:

  • Tested against gnupg v1.4.21 and v2.0.30

  • Does not work yet with gnupg v2.1. Pull Requests welcome.

Defined Under Namespace

Classes: Failure, Reader, UnsupportedVersion, Writer

Class Method Summary collapse

Class Method Details

.delete_keys(email:, public: true, private: false) ⇒ Object

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] Email address 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


165
166
167
168
169
170
171
172
# File 'lib/io_streams/pgp.rb', line 165

def self.delete_keys(email:, 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, private: true) if private
  status      = send(method_name, email: email, private: false) if public
  status
end

.executableObject



94
95
96
# File 'lib/io_streams/pgp.rb', line 94

def self.executable
  @executable
end

.executable=(executable) ⇒ Object



98
99
100
# File 'lib/io_streams/pgp.rb', line 98

def self.executable=(executable)
  @executable = executable
end

.export(email:, ascii: true) ⇒ Object

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


242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/io_streams/pgp.rb', line 242

def self.export(email:, ascii: true)
  version_check

  armor    = ascii ? '--armor' : nil
  loopback = pgp_version.to_f >= 2.1 ? '--pinentry-mode loopback' : ''
  command  = "#{executable} #{loopback} --no-tty --passphrase-fd 0 --batch #{armor} --export #{email}"

  out, err, status = Open3.capture3(command, binmode: true)
  logger.debug { "IOStreams::Pgp.export: #{command}\n#{err}" } if logger
  if status.success? && out.length > 0
    out
  else
    raise(Pgp::Failure, "GPG Failed reading key: #{email}: #{err}")
  end
end

.fingerprint(email:) ⇒ Object

DEPRECATED - Use key_ids instead of fingerprints



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
# File 'lib/io_streams/pgp.rb', line 337

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.each_line do |line|
        if match = line.match(/\Afpr.*::([^\:]*):\Z/)
          return match[1]
        end
      end
      nil
    else
      return if output =~ /(public key not found|No public key)/i
      raise(Pgp::Failure, "GPG Failed calling #{executable} to list keys for #{email}: #{output}")
    end
  end
end

.generate_key(name:, email:, comment: nil, passphrase:, key_type: 'RSA', key_length: 4096, subkey_type: 'RSA', subkey_length: key_length, expire_date: nil) ⇒ Object

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



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/io_streams/pgp.rb', line 125

def self.generate_key(name:, email:, comment: nil, passphrase:, 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 --quiet"

  out, err, status = Open3.capture3(command, binmode: true, stdin_data: params)
  logger.debug { "IOStreams::Pgp.generate_key: #{command}\n#{err}#{out}" } if logger
  if status.success?
    if match = err.match(/gpg: key ([0-9A-F]+)\s+/)
      return match[1]
    end
  else
    raise(Pgp::Failure, "GPG Failed to generate key: #{err}#{out}")
  end
end

.has_key?(email: nil, key_id: nil, private: false) ⇒ Boolean

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

Returns:

  • (Boolean)

Raises:

  • (ArgumentError)


175
176
177
178
179
# File 'lib/io_streams/pgp.rb', line 175

def self.has_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

.import(key:) ⇒ Object

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.



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/io_streams/pgp.rb', line 273

def self.import(key:)
  version_check
  command = "#{executable} --import"

  out, err, status = Open3.capture3(command, binmode: true, stdin_data: key)
  logger.debug { "IOStreams::Pgp.import: #{command}\n#{err}#{out}" } if logger
  if status.success? && err.length > 0
    # Sample output
    #
    #   gpg: key C16500E3: secret key imported\n"
    #   gpg: key C16500E3: public key "Joe Bloggs <[email protected]>" 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 <[email protected]>\" 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

.key_info(key:) ⇒ Object

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]


217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/io_streams/pgp.rb', line 217

def self.key_info(key:)
  version_check
  command = "#{executable}"

  out, err, status = Open3.capture3(command, binmode: true, stdin_data: key)
  logger.debug { "IOStreams::Pgp.key_info: #{command}\n#{err}#{out}" } if logger
  if status.success? && out.length > 0
    # Sample Output:
    #
    #   pub  4096R/3A5456F5 2017-06-07
    #   uid                            Joe Bloggs <[email protected]>
    #   sub  4096R/2C9B240B 2017-06-07
    parse_list_output(out)
  else
    raise(Pgp::Failure, "GPG Failed extracting key details: #{err} #{out}")
  end
end

.list_keys(email: nil, key_id: nil, private: false) ⇒ Object

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.



190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/io_streams/pgp.rb', line 190

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 logger
  if status.success? && out.length > 0
    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=(logger) ⇒ Object



355
356
357
# File 'lib/io_streams/pgp.rb', line 355

def self.logger=(logger)
  @logger = logger
end

.pgp_versionObject

Returns [String] the version of pgp currently installed



360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/io_streams/pgp.rb', line 360

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 logger
    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
      return [] 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

.set_trust(email:, level: 5) ⇒ Object

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


320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/io_streams/pgp.rb', line 320

def self.set_trust(email:, level: 5)
  version_check
  fingerprint = 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}" } if logger
  if status.success?
    err
  else
    raise(Pgp::Failure, "GPG Failed trusting key: #{err} #{out}")
  end
end