module Backup module Encryptor ## # The GPG Encryptor allows you to encrypt your final archive using GnuPG, # using one of three {#mode modes} of operation. # # == First, setup defaults in your +config.rb+ file # # Configure the {#keys} Hash using {.defaults} in your +config.rb+ # to specify all valid {#recipients} and their Public Key. # # Backup::Encryptor::GPG.defaults do |encryptor| # # setup all GnuPG public keys # encryptor.keys = {} # encryptor.keys['joe@example.com'] = <<-EOS # # ...public key here... # EOS # encryptor.keys['mary@example.com'] = <<-EOS # # ...public key here... # EOS # end # # The optional {#gpg_config} and {#gpg_homedir} options would also # typically be set using {.defaults} in +config.rb+ as well. # # == Then, setup each of your Models # # Set the desired {#recipients} and/or {#passphrase} (or {#passphrase_file}) # for each {Model}, depending on the {#mode} used. # # === my_backup_01 # # This archive can only be decrypted using the private key for joe@example.com # # Model.new(:my_backup_01, 'Backup Job #1') do # # ... archives, databases, compressor and storage options, etc... # encrypt_with GPG do |encryptor| # encryptor.mode = :asymmetric # encryptor.recipients = 'joe@example.com' # end # end # # === my_backup_02 # # This archive can only be decrypted using the passphrase "a secret". # # Model.new(:my_backup_02, 'Backup Job #2') do # # ... archives, databases, compressor and storage options, etc... # encrypt_with GPG do |encryptor| # encryptor.mode = :symmetric # encryptor.passphrase = 'a secret' # end # end # # === my_backup_03 # # This archive may be decrypted using either the private key for joe@example.com # *or* mary@example.com, *and* may also be decrypted using the passphrase. # # Model.new(:my_backup_03, 'Backup Job #3') do # # ... archives, databases, compressor and storage options, etc... # encrypt_with GPG do |encryptor| # encryptor.mode = :both # encryptor.passphrase = 'a secret' # encryptor.recipients = ['joe@example.com', 'mary@example.com'] # end # end # class GPG < Base class Error < Backup::Error; end MODES = [:asymmetric, :symmetric, :both] ## # Sets the mode of operation. # # [:asymmetric] # In this mode, the final backup archive will be encrypted using the # public key(s) specified by the key identifiers in {#recipients}. # The archive may then be decrypted by anyone with a private key that # corresponds to one of the public keys used. See {#recipients} and # {#keys} for more information. # # [:symmetric] # In this mode, the final backup archive will be encrypted using the # passphrase specified by {#passphrase} or {#passphrase_file}. # The archive will be encrypted using the encryption algorithm # specified in your GnuPG configuration. See {#gpg_config} for more # information. Anyone with the passphrase may decrypt the archive. # # [:both] # In this mode, both +:asymmetric+ and +:symmetric+ options are used. # Meaning that the archive may be decrypted by anyone with a valid # private key or by using the proper passphrase. # # @param mode [String, Symbol] Sets the mode of operation. # (Defaults to +:asymmetric+) # @return [Symbol] mode that was set. # @raise [Backup::Errors::Encryptor::GPG::InvalidModeError] # if mode given is invalid. # attr_reader :mode def mode=(mode) @mode = mode.to_sym raise Error, "'#{@mode}' is not a valid mode." unless MODES.include?(@mode) end ## # Specifies the GnuPG configuration to be used. # # This should be given as the text of a +gpg.conf+ file. It will be # written to a temporary file, which will be passed to the +gpg+ command # to use instead of the +gpg.conf+ found in the GnuPG home directory. # This allows you to be certain your preferences are used. # # This is especially useful if you've also set {#gpg_homedir} and plan # on allowing Backup to automatically create that directory and import # all your public keys specified in {#keys}. In this situation, that # folder would not contain any +gpg.conf+ file, so GnuPG would simply # use it's defaults. # # While this may be specified on a per-Model basis, you would generally # just specify this in the defaults. Leading tabs/spaces are stripped # before writing the given string to the temporary configuration file. # # Backup::Encryptor::GPG.defaults do |enc| # enc.gpg_config = <<-EOF # # safely override preferences set in the receiver's public key(s) # personal-cipher-preferences TWOFISH AES256 BLOWFISH AES192 CAST5 AES # personal-digest-preferences SHA512 SHA256 SHA1 MD5 # personal-compress-preferences BZIP2 ZLIB ZIP Uncompressed # # cipher algorithm for symmetric encryption # # (if personal-cipher-preferences are not specified) # s2k-cipher-algo TWOFISH # # digest algorithm for mangling the symmetric encryption passphrase # s2k-digest-algo SHA512 # EOF # end # # @see #gpg_homedir # @return [String] attr_accessor :gpg_config ## # Set the GnuPG home directory to be used. # # This allows you to specify the GnuPG home directory on the system # where Backup will be run, keeping the keyrings used by Backup separate # from the default keyrings of the user running Backup. # By default, this would be +`~/.gnupg`+. # # If a directory is specified here, Backup will create it if needed # and ensure the correct permissions are set. All public keys Backup # imports would be added to the +pubring.gpg+ file within this directory, # and +gpg+ would be given this directory using it's +--homedir+ option. # # Any +gpg.conf+ file located in this directory would also be used by # +gpg+, unless {#gpg_config} is specified. # # The given path will be expanded before use. # # @return [String] attr_accessor :gpg_homedir ## # Specifies a Hash of public key identifiers and their public keys. # # While not _required_, it is recommended that all public keys you intend # to use be setup in {#keys}. The best place to do this is in your defaults # in +config.rb+. # # Backup::Encryptor::GPG.defaults do |enc| # enc.keys = {} # # enc.keys['joe@example.com'] = <<-EOS # -----BEGIN PGP PUBLIC KEY BLOCK----- # Version: GnuPG v1.4.12 (GNU/Linux) # # mQMqBEd5F8MRCACfArHCJFR6nkmxNiW+UE4PAW3bQla9JWFqCwu4VqLkPI/lHb5p # xHff8Fzy2O89BxD/6hXSDx2SlVmAGHOCJhShx1vfNGVYNsJn2oNK50in9kGvD0+m # [...] # SkQEHOxhMiFjAN9q4LuirSOu65uR1bnTmF+Z92++qMIuEkH4/LnN # =8gNa # -----END PGP PUBLIC KEY BLOCK----- # EOS # # enc.keys['mary@example.com'] = <<-EOS # -----BEGIN PGP PUBLIC KEY BLOCK----- # Version: GnuPG v1.4.12 (GNU/Linux) # # 2SlVmAGHOCJhShx1vfNGVYNxHff8Fzy2O89BxD/6in9kGvD0+mhXSDxsJn2oNK50 # kmxNiW+UmQMqBEd5F8MRCACfArHCJFR6qCwu4VqLkPI/lHb5pnE4PAW3bQla9JWF # [...] # AN9q4LSkQEHOxhMiFjuirSOu65u++qMIuEkH4/LnNR1bnTmF+Z92 # =8gNa # -----END PGP PUBLIC KEY BLOCK----- # # EOS # end # # All leading spaces/tabs will be stripped from the key, so the above # form may be used to set each identifier's key. # # When a public key can not be found for an identifier specified in # {#recipients}, the corresponding public key from this Hash will be # imported into +pubring.gpg+ in the GnuPG home directory ({#gpg_homedir}). # Therefore, each key *must* be the same identifier used in {#recipients}. # # To obtain the public key in ASCII format, use: # # $ gpg -a --export joe@example.com # # See {#recipients} for information on what may be used as valid identifiers. # # @return [Hash] attr_accessor :keys ## # Specifies the recipients to use when encrypting the backup archive. # # When {#mode} is set to +:asymmetric+ or +:both+, the public key for # each recipient given here will be used to encrypt the archive. Each # recipient will be able to decrypt the archive using their private key. # # If there is only one recipient, this may be specified as a String. # Otherwise, this should be an Array of Strings. Each String must be a # valid public key identifier, and *must* be the same identifier used to # specify the recipient's public key in {#keys}. This is so that if a # public key is not found for the given identifier, it may be imported # from {#keys}. # # Valid identifiers which may be used are as follows: # # [Key Fingerprint] # The key fingerprint is a 40-character hex string, which uniquely # identifies a public key. This may be obtained using the following: # # $ gpg --fingerprint john.smith@example.com # pub 1024R/4E5E8D8A 2012-07-20 # Key fingerprint = FFEA D1DB 201F B214 873E 7399 4A83 569F 4E5E 8D8A # uid John Smith # sub 1024R/92C8DFD8 2012-07-20 # # [Long Key ID] # The long Key ID is the last 16-characters of the key's fingerprint. # # The Long Key ID in this example is: 4A83569F4E5E8D8A # # $ gpg --keyid-format long -k john.smith@example.com # pub 1024R/4A83569F4E5E8D8A 2012-07-20 # uid John Smith # sub 1024R/662F18DB92C8DFD8 2012-07-20 # # [Short Key ID] # The short Key ID is the last 8-characters of the key's fingerprint. # This is the default key format seen when listing keys. # # The Short Key ID in this example is: 4E5E8D8A # # $ gpg -k john.smith@example.com # pub 1024R/4E5E8D8A 2012-07-20 # uid John Smith # sub 1024R/92C8DFD8 2012-07-20 # # [Email Address] # This must exactly match an email address for one of the UID records # associated with the recipient's public key. # # Recipient identifier forms may be mixed, as long as the identifier used # here is the same as that used in {#keys}. Also, all spaces will be stripped # from the identifier when used, so the following would be valid. # # Backup::Model.new(:my_backup, 'My Backup') do # encrypt_with GPG do |enc| # enc.recipients = [ # # John Smith # '4A83 569F 4E5E 8D8A', # # Mary Smith # 'mary.smith@example.com' # ] # end # end # # @return [String, Array] attr_accessor :recipients ## # Specifies the passphrase to use symmetric encryption. # # When {#mode} is +:symmetric+ or +:both+, this passphrase will be used # to symmetrically encrypt the archive. # # Use of this option will override the use of {#passphrase_file}. # # @return [String] attr_accessor :passphrase ## # Specifies the passphrase file to use symmetric encryption. # # When {#mode} is +:symmetric+ or +:both+, this file will be passed # to the +gpg+ command line, where +gpg+ will read the first line from # this file and use it for the passphrase. # # The file path given here will be expanded to a full path. # # If {#passphrase} is specified, {#passphrase_file} will be ignored. # Therefore, if you have set {#passphrase} in your global defaults, # but wish to use {#passphrase_file} with a specific {Model}, be sure # to clear {#passphrase} within that model's configuration. # # Backup::Encryptor::GPG.defaults do |enc| # enc.passphrase = 'secret phrase' # end # # Backup::Model.new(:my_backup, 'My Backup') do # # other directives... # encrypt_with GPG do |enc| # enc.mode = :symmetric # enc.passphrase = nil # enc.passphrase_file = '/path/to/passphrase.file' # end # end # # @return [String] attr_accessor :passphrase_file ## # Configures default accessor values for new class instances. # # If all required options are set, then no further configuration # would be needed within a Model's definition when an Encryptor is added. # Therefore, the following example is sufficient to encrypt +:my_backup+: # # # Defaults set in config.rb # Backup::Encryptor::GPG.defaults do |encryptor| # encryptor.keys = {} # encryptor.keys['joe@example.com'] = <<-EOS # -----BEGIN PGP PUBLIC KEY BLOCK----- # Version: GnuPG v1.4.12 (GNU/Linux) # # mI0EUBR6CwEEAMVSlFtAXO4jXYnVFAWy6chyaMw+gXOFKlWojNXOOKmE3SujdLKh # kWqnafx7VNrb8cjqxz6VZbumN9UgerFpusM3uLCYHnwyv/rGMf4cdiuX7gGltwGb # (...etc...) # mLekS3xntUhhgHKc4lhf4IVBqG4cFmwSZ0tZEJJUSESb3TqkkdnNLjE= # =KEW+ # -----END PGP PUBLIC KEY BLOCK----- # EOS # # encryptor.recipients = 'joe@example.com' # end # # # Encryptor set in the model # Backup::Model.new(:my_backup, 'My Backup') do # # archives, storage options, etc... # encrypt_with GPG # end # # @!scope class # @see Config::Helpers::ClassMethods#defaults # @yield [config] OpenStruct object # @!method defaults ## # Creates a new instance of Backup::Encryptor::GPG. # # This constructor is not used directly when configuring Backup. # Use {Model#encrypt_with}. # # Model.new(:backup_trigger, 'Backup Label') do # archive :my_archive do |archive| # archive.add '/some/directory' # end # # compress_with Gzip # # encrypt_with GPG do |encryptor| # encryptor.mode = :both # encryptor.passphrase = 'a secret' # encryptor.recipients = ['joe@example.com', 'mary@example.com'] # end # # store_with SFTP # # notify_by Mail # end # # @api private def initialize(&block) super instance_eval(&block) if block_given? @mode ||= :asymmetric end ## # This is called as part of the procedure run by the Packager. # It sets up the needed options to pass to the gpg command, # then yields the command to use as part of the packaging procedure. # Once the packaging procedure is complete, it will return # so that any clean-up may be performed after the yield. # Cleanup is also ensured, as temporary files may hold sensitive data. # If no options can be built, the packaging process will be aborted. # # @api private def encrypt_with log! prepare if mode_options.empty? raise Error, "Encryption could not be performed for mode '#{mode}'" end yield "#{utility(:gpg)} #{base_options} #{mode_options}", ".gpg" ensure cleanup end private ## # Remove any temporary directories and reset all instance variables. # def prepare FileUtils.rm_rf(@tempdirs, secure: true) if @tempdirs @tempdirs = [] @base_options = nil @mode_options = nil @user_recipients = nil @user_keys = nil @system_identifiers = nil end alias :cleanup :prepare ## # Returns the options needed for the gpg command line which are # not dependant on the #mode. --no-tty supresses output of certain # messages, like the "Reading passphrase from file descriptor..." # messages during symmetric encryption # def base_options @base_options ||= begin opts = ["--no-tty"] path = setup_gpg_homedir opts << "--homedir '#{path}'" if path path = setup_gpg_config opts << "--options '#{path}'" if path opts.join(" ") end end ## # Setup the given :gpg_homedir if needed, ensure the proper permissions # are set, and return the directory's path. Otherwise, return false. # # If the GnuPG files do not exist, trigger their creation by requesting # --list-secret-keys. Some commands, like for symmetric encryption, will # issue messages about their creation on STDERR, which generates unwanted # warnings in the log. This way, if any of these files are created here, # we will get those messages on STDOUT for the log, without the actual # secret key listing which we don't care about. # def setup_gpg_homedir return false unless gpg_homedir path = File.expand_path(gpg_homedir) FileUtils.mkdir_p(path) FileUtils.chown(Config.user, nil, path) FileUtils.chmod(0o700, path) unless %w[pubring.gpg secring.gpg trustdb.gpg] .all? { |name| File.exist? File.join(path, name) } run("#{utility(:gpg)} --homedir '#{path}' -K 2>&1 >/dev/null") end path rescue => err raise Error.wrap \ err, "Failed to create or set permissions for #gpg_homedir" end ## # Write the given #gpg_config to a tempfile, within a tempdir, and # return the file's path to be given to the gpg --options argument. # If no #gpg_config is set, return false. # # This is required in order to set the proper permissions on the # directory containing the tempfile. The tempdir will be removed # after the packaging procedure is completed. # # Once written, we'll call check_gpg_config to make sure there are # no problems that would prevent gpg from running with this config. # If any errors occur during this process, we can not proceed. # We'll cleanup to remove the tempdir (if created) and raise an error. # def setup_gpg_config return false unless gpg_config dir = Dir.mktmpdir("backup-gpg_config", Config.tmp_path) @tempdirs << dir file = Tempfile.open("backup-gpg_config", dir) file.write gpg_config.gsub(/^[[:blank:]]+/, "") file.close check_gpg_config(file.path) file.path rescue => err cleanup raise Error.wrap(err, "Error creating temporary file for #gpg_config.") end ## # Make sure the temporary GnuPG config file created from #gpg_config # does not have any syntax errors that would prevent gpg from running. # If so, raise the returned error message. # Note that Cli::Helpers#run may also raise an error here. # def check_gpg_config(path) ret = run( "#{utility(:gpg)} --options '#{path}' --gpgconf-test 2>&1" ).chomp raise ret unless ret.empty? end ## # Returns the options needed for the gpg command line to perform # the encryption based on the #mode. # def mode_options @mode_options ||= begin s_opts = symmetric_options if mode != :asymmetric a_opts = asymmetric_options if mode != :symmetric [s_opts, a_opts].compact.join(" ") end end ## # Process :passphrase or :passphrase_file and return the command line # options to perform symmetric encryption. If no :passphrase is # specified, or an error occurs creating a temporary file for it, then # try to use :passphrase_file if it's set. # If the option can not be set, log a warning and return nil. # def symmetric_options path = setup_passphrase_file unless path || passphrase_file.to_s.empty? path = File.expand_path(passphrase_file.to_s) end if path && File.exist?(path) "-c --passphrase-file '#{path}'" else Logger.warn("Symmetric encryption options could not be set.") nil end end ## # Create a temporary file, within a tempdir, to hold the :passphrase and # return the file's path. If an error occurs, log a warning. # Return false if no :passphrase is set or an error occurs. # def setup_passphrase_file return false if passphrase.to_s.empty? dir = Dir.mktmpdir("backup-gpg_passphrase", Config.tmp_path) @tempdirs << dir file = Tempfile.open("backup-gpg_passphrase", dir) file.write passphrase.to_s file.close file.path rescue => err Logger.warn Error.wrap(err, "Error creating temporary passphrase file.") false end ## # Process :recipients, importing their public key from :keys if needed, # and return the command line options to perform asymmetric encryption. # Log a warning and return nil if no valid recipients are found. # def asymmetric_options if user_recipients.empty? Logger.warn "No recipients available for asymmetric encryption." nil else # skip trust database checks "-e --trust-model always " + user_recipients.map { |r| "-r '#{r}'" }.join(" ") end end ## # Returns an Array of the public key identifiers the user specified # in :recipients. Each identifier is 'cleaned' so that exact matches # can be performed. Then each is checked to ensure it will find a # public key that exists in the system's public keyring. # If the identifier does not match an existing key, the public key # associated with the identifier in :keys will be imported for use. # If no key can be found in the system or in :keys for the identifier, # a warning will be issued; as we will attempt to encrypt the backup # and proceed if at all possible. # def user_recipients @user_recipients ||= begin [recipients].flatten.compact.map do |identifier| identifier = clean_identifier(identifier) if system_identifiers.include?(identifier) identifier else key = user_keys[identifier] if key # will log a warning and return nil if the import fails import_key(identifier, key) else Logger.warn \ "No public key was found in #keys for '#{identifier}'" nil end end end.compact end end ## # Returns the #keys hash set by the user with all identifiers # (Hash keys) 'cleaned' for exact matching. If the cleaning process # creates duplicate keys, the user will be warned. # def user_keys @user_keys ||= begin _keys = keys || {} ret = Hash[_keys.map { |k, v| [clean_identifier(k), v] }] if ret.keys.count != _keys.keys.count Logger.warn \ "Duplicate public key identifiers were detected in #keys." end ret end end ## # Cleans a public key identifier. # Strip out all spaces, upcase non-email identifiers, # and wrap email addresses in <> to perform exact matching. # def clean_identifier(str) str = str.to_s.gsub(/[[:blank:]]+/, "") str =~ /@/ ? "<#{str.gsub(/(<|>)/, "")}>" : str.upcase end ## # Import the given public key and return the 16 character Key ID. # If the import fails, return nil. # Note that errors raised by Cli::Helpers#run may also be rescued here. # def import_key(identifier, key) file = Tempfile.open("backup-gpg_import", Config.tmp_path) file.write(key.gsub(/^[[:blank:]]+/, "")) file.close ret = run "#{utility(:gpg)} #{base_options} " \ "--keyid-format 0xlong --import '#{file.path}' 2>&1" file.delete keyid = ret.match(/ 0x(\w{16})/).to_a[1] raise "GPG Returned:\n#{ret.gsub(/^\s*/, " ")}" unless keyid keyid rescue => err Logger.warn Error.wrap( err, "Public key import failed for '#{identifier}'" ) nil end ## # Parse the information for all the public keys found in the public # keyring (based on #gpg_homedir setting) and return an Array of all # identifiers which could be used to specify a valid key. # def system_identifiers @system_identifiers ||= begin skip_key = false data = run "#{utility(:gpg)} #{base_options} " \ "--with-colons --fixed-list-mode --fingerprint" data.lines.map do |line| line.strip! # process public key record if line =~ /^pub:/ validity, keyid, capabilities = line.split(":").values_at(1, 4, 11) # skip keys marked as revoked ('r'), expired ('e'), # invalid ('i') or disabled ('D') if validity[0, 1] =~ /(r|e|i)/ || capabilities =~ /D/ skip_key = true next nil else skip_key = false # return both the long and short id next [keyid[-8..-1], keyid] end else # wait for the next valid public key record next if skip_key # process UID records for the current public key if line =~ /^uid:/ validity, userid = line.split(":").values_at(1, 9) # skip records marked as revoked ('r'), expired ('e') # or invalid ('i') if validity !~ /(r|e|i)/ # return the last email found in user id string, # since this includes user supplied comments. # return nil if no email found. email = nil str = userid while match = str.match(/<.+?@.+?>/) email = match[0] str = match.post_match end next email end # return public key's fingerprint elsif line =~ /^fpr:/ next line.split(":")[9] end nil # ignore any other lines end end.flatten.compact end end end end end