lib/io_streams/pgp.rb in iostreams-0.12.1 vs lib/io_streams/pgp.rb in iostreams-0.13.0

- old
+ new

@@ -120,11 +120,11 @@ # Highly Recommended. # To generate a good passphrase: # `SecureRandom.urlsafe_base64(128)` # # See `man gpg` for the remaining options - def self.generate_key(name:, email:, comment: nil, passphrase: nil, key_type: 'RSA', key_length: 4096, subkey_type: 'RSA', subkey_length: key_length, expire_date: nil) + 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 @@ -133,18 +133,20 @@ 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' - out, err, status = Open3.capture3("#{executable} --batch --gen-key", binmode: true, stdin_data: params) - logger.debug { "IOStreams::Pgp.generate_key output:\n#{out}#{err}" } if logger + 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: #{out}#{err}") + raise(Pgp::Failure, "GPG Failed to generate key: #{err}#{out}") end end # Delete all private and public keys for a particular email. # @@ -160,25 +162,15 @@ # private: [true|false] # Whether to delete the private key # Default: false def self.delete_keys(email:, public: true, private: false) version_check - cmd = "for i in `gpg --with-colons --fingerprint #{email} | grep \"^fpr\" | cut -d: -f10`; do\n" - cmd << "#{executable} --batch --delete-secret-keys \"$i\" ;\n" if private - cmd << "#{executable} --batch --delete-keys \"$i\" ;\n" if public - cmd << 'done' - - out, err, status = Open3.capture3(cmd, binmode: true) - logger.debug { "IOStreams::Pgp.delete_keys output:\n#{err}#{out}" } if logger - - if status.success? - return false if err =~ /(not found|No public key)/i - raise(Pgp::Failure, "GPG Failed to delete keys for #{email}:#{err}#{out}") if out.include?('error') - true - else - raise(Pgp::Failure, "GPG Failed calling gpg to delete private keys for #{email}: #{err}#{out}") - end + 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 # Returns [true|false] whether their is a key for the supplied email or key_id 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? @@ -195,26 +187,20 @@ # name: [String] # email: [String] # Returns [] if no keys were found. def self.list_keys(email: nil, key_id: nil, private: false) version_check - cmd = private ? '--list-secret-keys' : '--list-keys' - out, err, status = Open3.capture3("#{executable} #{cmd} #{email || key_id}", binmode: true) - logger.debug { "IOStreams::Pgp.list_keys output:\n#{err}#{out}" } if logger + 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 - # 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 parse_list_output(out) else - return [] if err =~ /(key not found|No (public|secret) key|key not available)/i - raise(Pgp::Failure, "GPG Failed calling gpg to list keys for #{email || key_id}: #{err}#{out}") + 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 # Extract information from the supplied key. # @@ -228,12 +214,14 @@ # date: [String] # name: [String] # email: [String] def self.key_info(key:) version_check - out, err, status = Open3.capture3(executable, binmode: true, stdin_data: key) - logger.debug { "IOStreams::Pgp.key_info output:\n#{err}#{out}" } if logger + 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 <j@bloggs.net> @@ -253,16 +241,25 @@ # Default: true # # private: [true|false] # Whether to export the private key # Default: false - def self.export(email:, ascii: true, private: false) + # + # passphrase: [String] + # In order to export a private key the passphrase for the key must be supplied. + # Otherwise a `Inappropriate ioctl for device` error will be returned. + def self.export(email:, passphrase: nil, ascii: true, private: false) version_check - armor = ascii ? '--armor' : nil - cmd = private ? '--export-secret-keys' : '--export' - out, err, status = Open3.capture3("#{executable} #{armor} #{cmd} #{email}", binmode: true) - logger.debug { "IOStreams::Pgp.export output:\n#{err}" } if logger + raise(ArgumentError, "Missing keyword: passphrase when private: true") if private && passphrase.nil? + + armor = ascii ? '--armor' : nil + cmd = private ? '--export-secret-keys' : '--export' + loopback = pgp_version.to_f >= 2.1 ? '--pinentry-mode loopback' : '' + command = "#{executable} #{loopback} --no-tty --passphrase-fd 0 --batch #{armor} #{cmd} #{email}" + + out, err, status = Open3.capture3(command, binmode: true, stdin_data: "#{passphrase}\n") + 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 @@ -283,12 +280,14 @@ # Notes: # * Importing a new key for the same email address does not remove the prior key if any. # * Invalidated keys must be removed manually. def self.import(key:) version_check - out, err, status = Open3.capture3("#{executable} --import", binmode: true, stdin_data: key) - logger.debug { "IOStreams::Pgp.import output:\n#{err}#{out}" } if logger + 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 <pgp_test@iostreams.net>" imported @@ -331,13 +330,14 @@ 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("#{executable} --import-ownertrust", stdin_data: trust) - logger.debug { "IOStreams::Pgp.set_trust output:\n#{err}#{out}" } if logger + 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 @@ -355,11 +355,11 @@ end end nil else return if output =~ /(public key not found|No public key)/i - raise(Pgp::Failure, "GPG Failed calling gpg to list keys for #{email}: #{output}") + raise(Pgp::Failure, "GPG Failed calling #{executable} to list keys for #{email}: #{output}") end end end def self.logger=(logger) @@ -367,15 +367,16 @@ end # Returns [String] the version of pgp currently installed def self.pgp_version @pgp_version ||= begin - out, err, status = Open3.capture3("#{executable} --version") - logger.debug { "IOStreams::Pgp.version output:\n#{err}#{out}" } if logger + 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 - # gpg (GnuPG) 2.0.30 + # #{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. @@ -390,11 +391,11 @@ 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 gpg to list keys for #{email || key_id}: #{err}#{out}") + raise(Pgp::Failure, "GPG Failed calling #{executable} to list keys for #{email || key_id}: #{err}#{out}") end end end private @@ -404,18 +405,39 @@ def self.logger @logger end def self.version_check - raise(Pgp::UnsupportedVersion, "Version #{pgp_version} of gpg is not yet supported. You are welcome to submit a Pull Request.") if pgp_version.to_f >= 2.1 + raise(Pgp::UnsupportedVersion, "Version #{pgp_version} of #{executable} is not yet supported. You are welcome to submit a Pull Request.") if pgp_version.to_f >= 2.3 end + # 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 def self.parse_list_output(out) results = [] hash = {} out.each_line do |line| - if match = line.match(/(pub|sec)\s+(\d+)(.*)\/(\w+)\s+(\d+-\d+-\d+)(\s+(.+)<(.+)>)?/) + 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: (Date.parse(match[4].to_s) rescue match[4]) + } + elsif match = line.match(/(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, @@ -431,18 +453,57 @@ 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(/([A-Z0-9]+)/) + # v2.2 18A0FC1C09C0D8AE34CE659257DC4AE323C7368C + hash[:key_id] ||= match[1] end end results + end + + def self.delete_public_or_private_keys(email:, private: false) + keys = private ? 'secret-keys' : 'keys' + + list = list_keys(email: email, private: private) + return false if list.empty? + + list.each do |key_info| + if key_id = key_info[: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}" } if logger + + raise(Pgp::Failure, "GPG Failed calling #{executable} to delete #{keys} for #{email}: #{err}: #{out}") unless status.success? + raise(Pgp::Failure, "GPG Failed to delete #{keys} for #{email} #{err.strip}:#{out}") if out.include?('error') + end + end + true + end + + def self.delete_public_or_private_keys_v1(email:, private: false) + keys = private ? 'secret-keys' : 'keys' + + command = "for i in `#{executable} --list-#{keys} --with-colons --fingerprint #{email} | 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}" } if logger + + return false if err =~ /(not found|no public key)/i + raise(Pgp::Failure, "GPG Failed calling #{executable} to delete #{keys} for #{email}: #{err}: #{out}") unless status.success? + raise(Pgp::Failure, "GPG Failed to delete #{keys} for #{email} #{err.strip}: #{out}") if out.include?('error') + true end end end