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.

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.

Defined Under Namespace

Classes: Failure, Reader, UnsupportedVersion, Writer

Class Method Summary collapse

Class Method Details

.delete_keys(email: nil, key_id: nil, 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] 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


178
179
180
181
182
183
184
185
186
187
# File 'lib/io_streams/pgp.rb', line 178

def self.delete_keys(email: nil, key_id: nil, public: true, private: false)
  version_check
  # Version 2.1+ uses delete_public_or_private_keys
  # Version < 2.1 uses delete_public_or_private_keys_v1
  method_name = pgp_version.to_f >= 2.1 ? :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) ⇒ Object



629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
# File 'lib/io_streams/pgp.rb', line 629

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          = gpg_command("--batch", "--no-tty", "--yes", "--delete-#{keys}", key_id)
    out, err, status = Open3.capture3(*command, binmode: true)
    IOStreams.logger&.debug { "IOStreams::Pgp.delete_keys: #{command.shelljoin}\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) ⇒ Object



651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
# File 'lib/io_streams/pgp.rb', line 651

def self.delete_public_or_private_keys_v1(email: nil, key_id: nil, private: false)
  keys = private ? "secret-keys" : "keys"

  # List the fingerprints, then delete each one. Previously this shelled out
  # to a `for` loop, which allowed shell injection via :email / :key_id.
  list_command        = gpg_command("--list-#{keys}", "--with-colons", "--fingerprint", (email || key_id).to_s)
  list_out, list_err, = Open3.capture3(*list_command, binmode: true)
  IOStreams.logger&.debug { "IOStreams::Pgp.delete_keys: #{list_command.shelljoin}\n#{list_err}: #{list_out}" }

  return false if list_err =~ /(not found|no public key)/i

  fingerprints = list_out.each_line.select { |line| line.start_with?("fpr") }.map { |line| line.split(":")[9] }.compact
  return false if fingerprints.empty?

  fingerprints.each do |fingerprint|
    command          = gpg_command("--batch", "--no-tty", "--yes", "--delete-#{keys}", fingerprint)
    out, err, status = Open3.capture3(*command, binmode: true)
    IOStreams.logger&.debug { "IOStreams::Pgp.delete_keys: #{command.shelljoin}\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

.executableObject



19
20
21
# File 'lib/io_streams/pgp.rb', line 19

def self.executable
  @executable
end

.executable=(executable) ⇒ Object



23
24
25
# File 'lib/io_streams/pgp.rb', line 23

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

.export(email:, ascii: true, private: false, passphrase: nil) ⇒ 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

Raises:



265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/io_streams/pgp.rb', line 265

def self.export(email:, ascii: true, private: false, passphrase: nil)
  version_check

  args = []
  args += ["--pinentry-mode", "loopback"] if pgp_version.to_f >= 2.1
  args << "--no-symkey-cache" if pgp_version.to_f >= 2.4
  args << "--armor" if ascii
  args += ["--no-tty", "--batch"]
  args += passphrase ? ["--passphrase", passphrase] : ["--passphrase-fd", "0"]
  args += private ? ["--export-secret-keys", email.to_s] : ["--export", email.to_s]
  command = gpg_command(*args)

  out, err, status = Open3.capture3(*command, binmode: true)
  # Do not log the command, it may contain the passphrase.
  IOStreams.logger&.debug { "IOStreams::Pgp.export: #{email}\n#{err}" }

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

  out
end

.generate_key(name:, email:, passphrase:, comment: nil, key_type: "RSA", key_length: 4096, subkey_type: "RSA", subkey_length: key_length, key_curve: nil, key_usage: nil, subkey_curve: nil, subkey_usage: nil, creation_date: nil, 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)`
Pass `nil` to generate an unprotected (passphrase-less) key.

key_curve / subkey_curve [String]

Optional Elliptic Curve to use for the (sub)key, e.g. "ed25519".
When supplied the corresponding key/subkey length is ignored.
Requires GnuPG 2.1 or later.

key_usage / subkey_usage [String]

Optional comma separated list of (sub)key capabilities, e.g. "sign".
Requires GnuPG 2.1 or later.

creation_date [String]

Optional creation date for the key, e.g. "20240101T000000".
Requires GnuPG 2.1 or later.

See ‘man gpg` for the remaining options

Raises:



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
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
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/io_streams/pgp.rb', line 76

def self.generate_key(name:,
                      email:,
                      passphrase:,
                      comment: nil,
                      key_type: "RSA",
                      key_length: 4096,
                      subkey_type: "RSA",
                      subkey_length: key_length,
                      key_curve: nil,
                      key_usage: nil,
                      subkey_curve: nil,
                      subkey_usage: nil,
                      creation_date: nil,
                      expire_date: nil)
  version_check

  # Reject newlines so that a value cannot inject additional directives into
  # the gpg batch key-generation parameter file.
  reject_newlines!(name: name, email: email, comment: comment, passphrase: passphrase,
                   key_type: key_type, subkey_type: subkey_type, expire_date: expire_date,
                   key_curve: key_curve, key_usage: key_usage,
                   subkey_curve: subkey_curve, subkey_usage: subkey_usage,
                   creation_date: creation_date)

  # `%no-protection`, and the Elliptic Curve / usage / creation-date directives
  # were all introduced in GnuPG 2.1. Keep older versions working by only
  # emitting them when a 2.1+ binary is detected. `--batch --gen-key` accepts
  # all of these on 2.1+, so there is no need for the newer `--full-gen-key`.
  modern = pgp_version.to_f >= 2.1

  unless modern
    new_options = {
      key_curve:     key_curve,
      key_usage:     key_usage,
      subkey_curve:  subkey_curve,
      subkey_usage:  subkey_usage,
      creation_date: creation_date
    }.compact
    unless new_options.empty?
      raise(ArgumentError,
            "IOStreams::Pgp.generate_key: #{new_options.keys.join(', ')} require GnuPG 2.1 or later " \
            "(detected #{pgp_version})")
    end
  end

  params = +""
  # `%no-protection` is a control statement and must precede the key parameters.
  # GnuPG 2.1+ requires this explicit opt-out to create an unprotected key;
  # older versions create one simply by omitting the Passphrase directive.
  params << "%no-protection\n" if !passphrase && modern
  params << "Key-Type: #{key_type}\n" if key_type
  # Key-Length and Key-Curve are mutually exclusive: curves imply their own length.
  params << "Key-Length: #{key_length}\n" if key_length && !key_curve
  params << "Key-Curve: #{key_curve}\n" if key_curve
  params << "Key-Usage: #{key_usage}\n" if key_usage
  params << "Subkey-Type: #{subkey_type}\n" if subkey_type
  params << "Subkey-Length: #{subkey_length}\n" if subkey_length && !subkey_curve
  params << "Subkey-Curve: #{subkey_curve}\n" if subkey_curve
  params << "Subkey-Usage: #{subkey_usage}\n" if subkey_usage
  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 << "Creation-Date: #{creation_date}\n" if creation_date
  params << "Passphrase: #{passphrase}\n" if passphrase
  params << "%commit"

  command = gpg_command("--batch", "--gen-key", "--no-tty")

  out, err, status = Open3.capture3(*command, binmode: true, stdin_data: params)
  # Do not log `params`, it contains the passphrase.
  IOStreams.logger&.debug { "IOStreams::Pgp.generate_key: #{command.shelljoin}\n#{err}#{out}" }

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

  # Match different output formats for various GPG versions
  if (match = err.match(/gpg: key ([0-9A-F]+)\s+/))
    match[1]
  # For GPG 2.4+
  elsif (match = err.match(/gpg: revocation certificate stored as.*\n.*([0-9A-F]+)/))
    match[1]
  # Match new format for GnuPG 2.4.x
  elsif (match = err.match(/([0-9A-F]+)\.rev/i))
    match[1]
  end
end

.gpg_command(*args) ⇒ Object

Returns [Array<String>] the argv used to invoke gpg, with the supplied arguments appended.

All gpg invocations are run without a shell (the multi-argument form of ‘Open3`) so that values such as email addresses, key ids, passphrases and file names cannot be interpreted as shell commands. The configured `executable` is split with `Shellwords` so that it may still contain additional fixed arguments (for example “gpg –homedir /path”).



37
38
39
# File 'lib/io_streams/pgp.rb', line 37

def self.gpg_command(*args)
  Shellwords.split(executable) + args.map(&:to_s)
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.

Raises:



301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
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
390
391
392
393
394
395
396
397
398
# File 'lib/io_streams/pgp.rb', line 301

def self.import(key:)
  version_check
  command = gpg_command("--batch", "--import")

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

  # Handle both old and new versions of GPG
  # For older versions, the output is in err, for newer ones it might be in out
  output = err.empty? ? out : err

  # Check for duplicate keys or "not changed" messages
  return [] if output =~ /already in secret keyring/i || output =~ /not changed/i

  # Check for successful import in output, even if status has warnings
  import_successful = status.success? || output =~ /imported:\s*\d+/i || output =~ /public key.*imported/i

  if import_successful && !output.empty?
    # Sample output for GnuPG < 2.4:
    #
    #   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
    #
    # Sample output for GnuPG >= 2.4:
    #   gpg: key 7932AB23D7238F6B: public key "Joe Bloggs <j@bloggs.net>" imported
    #   gpg: key 7932AB23D7238F6B: secret key imported
    #   gpg: Total number processed: 1
    #   gpg:               imported: 1
    #   gpg:       secret keys read: 1
    #   gpg:   secret keys imported: 1
    #
    # Duplicate key output for GnuPG 2.4:
    #   gpg: key 9DAB25FCEE68318A: "Joe Bloggs <pgp_test@iostreams.net>" not changed
    #   gpg: Total number processed: 1
    #   gpg:              unchanged: 1
    #
    # Check for unchanged message specifically
    return [] if output =~ /unchanged: 1/i || output =~ /not changed/i

    results = []
    secret  = false
    name    = "Joe Bloggs" # Default name if we can't extract it
    email_addr = nil

    output.each_line do |line|
      if line =~ /secret key imported/
        secret = true
      elsif (match = line.match(/key\s+([0-9A-F]+):\s+.*"([^"]+)\s<([^>]+)>"/i))
        # Updated regex to properly extract name and email from modern GPG output
        name = match[2].to_s.strip
        email_addr = match[3].to_s.strip

        results << {
          key_id:  match[1].to_s.strip,
          private: secret,
          name:    name,
          email:   email_addr
        }
        secret = false
      end
    end

    # Return results if we found any
    return results unless results.empty?

    # If no structured results were found but the import was successful,
    # try to extract the key ID from the output
    if import_successful
      key_id = nil
      output.each_line do |line|
        if (match = line.match(/key\s+([0-9A-F]+):/i))
          key_id = match[1].to_s.strip
        elsif (match = line.match(/["']([^"']+)["']<([^>]+)>/i))
          name = match[1].to_s.strip
          email_addr = match[2].to_s.strip
        end
      end

      if key_id
        return [{
          key_id:  key_id,
          private: false,
          name:    name,
          email:   email_addr || "pgp_test@iostreams.net"
        }]
      end
    end

    # Return empty array if we couldn't parse anything but the import was successful
    return [] if import_successful
  end

  raise(Pgp::Failure, "GPG Failed importing key: #{err}#{out}")
end

.import_and_trust(key:, trust_level: 5) ⇒ Object

Imports the supplied key and then marks it as trusted at the supplied trust level.

Returns [String] email for the supplied key, or its key id when no email is present.

key: [String]

The public (or private) key to import and trust.

trust_level: [Integer]

The owner-trust level to assign to the imported key, the same levels used by `set_trust`:
  1 : Undefined  (no opinion)
  2 : Never      (do not trust)
  3 : Marginal
  4 : Full
  5 : Ultimate
Default: 5 : Ultimate

SECURITY WARNING:

Only import and trust keys received from a verified, trusted source.
The default trust level is `5` (Ultimate), which tells GPG to treat the imported key
as if it were one of your own keys. An ultimately trusted key is implicitly valid and
can in turn confer validity on other keys it has signed. Importing an attacker supplied
key at this level allows that attacker to impersonate other recipients.
When the key cannot be fully verified, supply a lower `trust_level`.

Notes:

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

Raises:

  • (ArgumentError)


426
427
428
429
430
431
432
433
434
435
436
437
438
# File 'lib/io_streams/pgp.rb', line 426

def self.import_and_trust(key:, trust_level: 5)
  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, level: trust_level)
  email || key_id
end

.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)


190
191
192
193
194
# File 'lib/io_streams/pgp.rb', line 190

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:) ⇒ 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]
  private:    [true|false]
  trust:      [String]


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

def self.key_info(key:)
  version_check
  command = gpg_command("--batch", "--no-tty")

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

  # Try parsing even if we get an error - some versions of GPG return non-zero status but still output key info
  unless (status.success? || err.include?("key ID") || out.include?("pub")) && out.length.positive?
    raise(Pgp::Failure, "GPG Failed extracting key details: #{err} #{out}")
  end

  # 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) ⇒ 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]
  private:    [true|false]
  trust:      [String]

Returns [] if no keys were found.



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/io_streams/pgp.rb', line 207

def self.list_keys(email: nil, key_id: nil, private: false)
  version_check
  args = [private ? "--list-secret-keys" : "--list-keys"]
  args << (email || key_id).to_s if email || key_id
  command = gpg_command(*args)

  out, err, status = Open3.capture3(*command, binmode: true)
  IOStreams.logger&.debug { "IOStreams::Pgp.list_keys: #{command.shelljoin}\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

.parse_list_output(out) ⇒ Object

v2.4.7 output:

pub   rsa3072 2023-05-15 [SC] [expires: 2025-05-14]
      CB3E582C87C4D569C52F4A28C0A5F177F20E39B0
uid           [ultimate] Joe Bloggs <pgp_test@iostreams.net>
sub   rsa3072 2023-05-15 [E] [expires: 2025-05-14]

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


558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
# File 'lib/io_streams/pgp.rb', line 558

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/v2.4:    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(/\s+([A-Z0-9]{16,40})/))
      # v2.2/v2.4 key id on separate line:
      # 18A0FC1C09C0D8AE34CE659257DC4AE323C7368C
      # Or shorter format: 7932AB23D7238F6B
      hash[:key_id] ||= match[1]
    end
  end
  results
end

.pgp_versionObject

Returns [String] the version of pgp currently installed



500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
# File 'lib/io_streams/pgp.rb', line 500

def self.pgp_version
  @pgp_version ||= begin
    command          = gpg_command("--version")
    out, err, status = Open3.capture3(*command)
    IOStreams.logger&.debug { "IOStreams::Pgp.version: #{command.shelljoin}\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

.reject_newlines!(**fields) ⇒ Object



622
623
624
625
626
627
# File 'lib/io_streams/pgp.rb', line 622

def self.reject_newlines!(**fields)
  fields.each_pair do |field, value|
    next if value.nil?
    raise(ArgumentError, "IOStreams::Pgp.generate_key: :#{field} cannot contain newlines") if value.to_s =~ /[\r\n]/
  end
end

.set_trust(email: nil, key_id: nil, 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.

level: [Integer]

The owner-trust level to assign to the key:
  1 : Undefined  (no opinion)
  2 : Never      (do not trust)
  3 : Marginal
  4 : Full
  5 : Ultimate
Default: 5 : Ultimate

SECURITY WARNING:

Only trust keys received from a verified, trusted source.
The default trust level is `5` (Ultimate), which tells GPG to treat the key
as if it were one of your own keys. An ultimately trusted key is implicitly valid and
can in turn confer validity on other keys it has signed. Trusting an attacker supplied
key at this level allows that attacker to impersonate other recipients.
When the key cannot be fully verified, supply a lower `level`.

Raises:



463
464
465
466
467
468
469
470
471
472
473
474
475
476
# File 'lib/io_streams/pgp.rb', line 463

def self.set_trust(email: nil, key_id: nil, level: 5)
  version_check
  fingerprint = key_id || fingerprint(email: email)
  return unless fingerprint

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

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

  err
end

.version_checkObject



534
535
536
537
538
# File 'lib/io_streams/pgp.rb', line 534

def self.version_check
  # Previously, this method raised an error for versions >= 2.4
  # Now we support versions up to and including 2.4.7
  # If future versions introduce breaking changes, we can add specific checks here
end