diff options
Diffstat (limited to 'lib/rubygems/security')
| -rw-r--r-- | lib/rubygems/security/policies.rb | 114 | ||||
| -rw-r--r-- | lib/rubygems/security/policy.rb | 288 | ||||
| -rw-r--r-- | lib/rubygems/security/signer.rb | 212 | ||||
| -rw-r--r-- | lib/rubygems/security/trust_dir.rb | 117 |
4 files changed, 731 insertions, 0 deletions
diff --git a/lib/rubygems/security/policies.rb b/lib/rubygems/security/policies.rb new file mode 100644 index 0000000000..41f66043ad --- /dev/null +++ b/lib/rubygems/security/policies.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module Gem::Security + ## + # No security policy: all package signature checks are disabled. + + NoSecurity = Policy.new( + "No Security", + verify_data: false, + verify_signer: false, + verify_chain: false, + verify_root: false, + only_trusted: false, + only_signed: false + ) + + ## + # AlmostNo security policy: only verify that the signing certificate is the + # one that actually signed the data. Make no attempt to verify the signing + # certificate chain. + # + # This policy is basically useless. better than nothing, but can still be + # easily spoofed, and is not recommended. + + AlmostNoSecurity = Policy.new( + "Almost No Security", + verify_data: true, + verify_signer: false, + verify_chain: false, + verify_root: false, + only_trusted: false, + only_signed: false + ) + + ## + # Low security policy: only verify that the signing certificate is actually + # the gem signer, and that the signing certificate is valid. + # + # This policy is better than nothing, but can still be easily spoofed, and + # is not recommended. + + LowSecurity = Policy.new( + "Low Security", + verify_data: true, + verify_signer: true, + verify_chain: false, + verify_root: false, + only_trusted: false, + only_signed: false + ) + + ## + # Medium security policy: verify the signing certificate, verify the signing + # certificate chain all the way to the root certificate, and only trust root + # certificates that we have explicitly allowed trust for. + # + # This security policy is reasonable, but it allows unsigned packages, so a + # malicious person could simply delete the package signature and pass the + # gem off as unsigned. + + MediumSecurity = Policy.new( + "Medium Security", + verify_data: true, + verify_signer: true, + verify_chain: true, + verify_root: true, + only_trusted: true, + only_signed: false + ) + + ## + # High security policy: only allow signed gems to be installed, verify the + # signing certificate, verify the signing certificate chain all the way to + # the root certificate, and only trust root certificates that we have + # explicitly allowed trust for. + # + # This security policy is significantly more difficult to bypass, and offers + # a reasonable guarantee that the contents of the gem have not been altered. + + HighSecurity = Policy.new( + "High Security", + verify_data: true, + verify_signer: true, + verify_chain: true, + verify_root: true, + only_trusted: true, + only_signed: true + ) + + ## + # Policy used to verify a certificate and key when signing a gem + + SigningPolicy = Policy.new( + "Signing Policy", + verify_data: false, + verify_signer: true, + verify_chain: true, + verify_root: true, + only_trusted: false, + only_signed: false + ) + + ## + # Hash of configured security policies + + Policies = { + "NoSecurity" => NoSecurity, + "AlmostNoSecurity" => AlmostNoSecurity, + "LowSecurity" => LowSecurity, + "MediumSecurity" => MediumSecurity, + "HighSecurity" => HighSecurity, + # SigningPolicy is not intended for use by `gem -P` so do not list it + }.freeze +end diff --git a/lib/rubygems/security/policy.rb b/lib/rubygems/security/policy.rb new file mode 100644 index 0000000000..128958ab80 --- /dev/null +++ b/lib/rubygems/security/policy.rb @@ -0,0 +1,288 @@ +# frozen_string_literal: true + +require_relative "../user_interaction" + +## +# A Gem::Security::Policy object encapsulates the settings for verifying +# signed gem files. This is the base class. You can either declare an +# instance of this or use one of the preset security policies in +# Gem::Security::Policies. + +class Gem::Security::Policy + include Gem::UserInteraction + + attr_reader :name + + attr_accessor :only_signed + attr_accessor :only_trusted + attr_accessor :verify_chain + attr_accessor :verify_data + attr_accessor :verify_root + attr_accessor :verify_signer + + ## + # Create a new Gem::Security::Policy object with the given mode and + # options. + + def initialize(name, policy = {}, opt = {}) + @name = name + + @opt = opt + + # Default to security + @only_signed = true + @only_trusted = true + @verify_chain = true + @verify_data = true + @verify_root = true + @verify_signer = true + + policy.each_pair do |key, val| + case key + when :verify_data then @verify_data = val + when :verify_signer then @verify_signer = val + when :verify_chain then @verify_chain = val + when :verify_root then @verify_root = val + when :only_trusted then @only_trusted = val + when :only_signed then @only_signed = val + end + end + end + + ## + # Verifies each certificate in +chain+ has signed the following certificate + # and is valid for the given +time+. + + def check_chain(chain, time) + raise Gem::Security::Exception, "missing signing chain" unless chain + raise Gem::Security::Exception, "empty signing chain" if chain.empty? + + begin + chain.each_cons 2 do |issuer, cert| + check_cert cert, issuer, time + end + + true + rescue Gem::Security::Exception => e + raise Gem::Security::Exception, "invalid signing chain: #{e.message}" + end + end + + ## + # Verifies that +data+ matches the +signature+ created by +public_key+ and + # the +digest+ algorithm. + + def check_data(public_key, digest, signature, data) + raise Gem::Security::Exception, "invalid signature" unless + public_key.verify digest, signature, data.digest + + true + end + + ## + # Ensures that +signer+ is valid for +time+ and was signed by the +issuer+. + # If the +issuer+ is +nil+ no verification is performed. + + def check_cert(signer, issuer, time) + raise Gem::Security::Exception, "missing signing certificate" unless + signer + + message = "certificate #{signer.subject}" + + if (not_before = signer.not_before) && not_before > time + raise Gem::Security::Exception, + "#{message} not valid before #{not_before}" + end + + if (not_after = signer.not_after) && not_after < time + raise Gem::Security::Exception, "#{message} not valid after #{not_after}" + end + + if issuer && !signer.verify(issuer.public_key) + raise Gem::Security::Exception, + "#{message} was not issued by #{issuer.subject}" + end + + true + end + + ## + # Ensures the public key of +key+ matches the public key in +signer+ + + def check_key(signer, key) + unless signer && key + return true unless @only_signed + + raise Gem::Security::Exception, "missing key or signature" + end + + raise Gem::Security::Exception, + "certificate #{signer.subject} does not match the signing key" unless + signer.check_private_key(key) + + true + end + + ## + # Ensures the root certificate in +chain+ is self-signed and valid for + # +time+. + + def check_root(chain, time) + raise Gem::Security::Exception, "missing signing chain" unless chain + + root = chain.first + + raise Gem::Security::Exception, "missing root certificate" unless root + + raise Gem::Security::Exception, + "root certificate #{root.subject} is not self-signed " \ + "(issuer #{root.issuer})" if + root.issuer != root.subject + + check_cert root, root, time + end + + ## + # Ensures the root of +chain+ has a trusted certificate in Gem::Security.trust_dir and + # the digests of the two certificates match according to +digester+ + + def check_trust(chain, digester, trust_dir) + raise Gem::Security::Exception, "missing signing chain" unless chain + + root = chain.first + + raise Gem::Security::Exception, "missing root certificate" unless root + + path = Gem::Security.trust_dir.cert_path root + + unless File.exist? path + message = "root cert #{root.subject} is not trusted".dup + + message << " (root of signing cert #{chain.last.subject})" if + chain.length > 1 + + raise Gem::Security::Exception, message + end + + save_cert = OpenSSL::X509::Certificate.new File.read path + save_dgst = digester.digest save_cert.public_key.to_pem + + pkey_str = root.public_key.to_pem + cert_dgst = digester.digest pkey_str + + raise Gem::Security::Exception, + "trusted root certificate #{root.subject} checksum " \ + "does not match signing root certificate checksum" unless + save_dgst == cert_dgst + + true + end + + ## + # Extracts the email or subject from +certificate+ + + def subject(certificate) # :nodoc: + certificate.extensions.each do |extension| + next unless extension.oid == "subjectAltName" + + return extension.value + end + + certificate.subject.to_s + end + + def inspect # :nodoc: + format("[Policy: %s - data: %p signer: %p chain: %p root: %p " \ + "signed-only: %p trusted-only: %p]", @name, @verify_chain, @verify_data, @verify_root, @verify_signer, @only_signed, @only_trusted) + end + + ## + # For +full_name+, verifies the certificate +chain+ is valid, the +digests+ + # match the signatures +signatures+ created by the signer depending on the + # +policy+ settings. + # + # If +key+ is given it is used to validate the signing certificate. + + def verify(chain, key = nil, digests = {}, signatures = {}, full_name = "(unknown)") + if signatures.empty? + if @only_signed + raise Gem::Security::Exception, + "unsigned gems are not allowed by the #{name} policy" + elsif digests.empty? + # lack of signatures is irrelevant if there is nothing to check + # against + else + alert_warning "#{full_name} is not signed" + return + end + end + + opt = @opt + digester = Gem::Security.create_digest + trust_dir = opt[:trust_dir] + time = Time.now + + _, signer_digests = digests.find do |_algorithm, file_digests| + file_digests.values.first.name == Gem::Security::DIGEST_NAME + end + + if @verify_data + raise Gem::Security::Exception, "no digests provided (probable bug)" if + signer_digests.nil? || signer_digests.empty? + else + signer_digests = {} + end + + signer = chain.last + + check_key signer, key if key + + check_cert signer, nil, time if @verify_signer + + check_chain chain, time if @verify_chain + + check_root chain, time if @verify_root + + if @only_trusted + check_trust chain, digester, trust_dir + elsif signatures.empty? && digests.empty? + # trust is irrelevant if there's no signatures to verify + else + alert_warning "#{subject signer} is not trusted for #{full_name}" + end + + signatures.each do |file, _| + digest = signer_digests[file] + + raise Gem::Security::Exception, "missing digest for #{file}" unless + digest + end + + signer_digests.each do |file, digest| + signature = signatures[file] + + raise Gem::Security::Exception, "missing signature for #{file}" unless + signature + + check_data signer.public_key, digester, signature, digest if @verify_data + end + + true + end + + ## + # Extracts the certificate chain from the +spec+ and calls #verify to ensure + # the signatures and certificate chain is valid according to the policy.. + + def verify_signatures(spec, digests, signatures) + chain = spec.cert_chain.map do |cert_pem| + OpenSSL::X509::Certificate.new cert_pem + end + + verify chain, nil, digests, signatures, spec.full_name + + true + end + + alias_method :to_s, :name # :nodoc: +end diff --git a/lib/rubygems/security/signer.rb b/lib/rubygems/security/signer.rb new file mode 100644 index 0000000000..eeeeb52906 --- /dev/null +++ b/lib/rubygems/security/signer.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +## +# Basic OpenSSL-based package signing class. + +require_relative "../user_interaction" + +class Gem::Security::Signer + include Gem::UserInteraction + + ## + # The chain of certificates for signing including the signing certificate + + attr_accessor :cert_chain + + ## + # The private key for the signing certificate + + attr_accessor :key + + ## + # The digest algorithm used to create the signature + + attr_reader :digest_algorithm + + ## + # The name of the digest algorithm, used to pull digests out of the hash by + # name. + + attr_reader :digest_name # :nodoc: + + ## + # Gem::Security::Signer options + + attr_reader :options + + DEFAULT_OPTIONS = { + expiration_length_days: 365, + }.freeze + + ## + # Attempts to re-sign an expired cert with a given private key + def self.re_sign_cert(expired_cert, expired_cert_path, private_key) + return unless expired_cert.not_after < Time.now + + expiry = expired_cert.not_after.strftime("%Y%m%d%H%M%S") + expired_cert_file = "#{File.basename(expired_cert_path)}.expired.#{expiry}" + new_expired_cert_path = File.join(Gem.user_home, ".gem", expired_cert_file) + + Gem::Security.write(expired_cert, new_expired_cert_path) + + re_signed_cert = Gem::Security.re_sign( + expired_cert, + private_key, + Gem::Security::ONE_DAY * Gem.configuration.cert_expiration_length_days + ) + + Gem::Security.write(re_signed_cert, expired_cert_path) + + yield(expired_cert_path, new_expired_cert_path) if block_given? + end + + ## + # Creates a new signer with an RSA +key+ or path to a key, and a certificate + # +chain+ containing X509 certificates, encoding certificates or paths to + # certificates. + + def initialize(key, cert_chain, passphrase = nil, options = {}) + @cert_chain = cert_chain + @key = key + @passphrase = passphrase + @options = DEFAULT_OPTIONS.merge(options) + + unless @key + default_key = File.join Gem.default_key_path + @key = default_key if File.exist? default_key + end + + unless @cert_chain + default_cert = File.join Gem.default_cert_path + @cert_chain = [default_cert] if File.exist? default_cert + end + + @digest_name = Gem::Security::DIGEST_NAME + @digest_algorithm = Gem::Security.create_digest(@digest_name) + + if @key && !@key.is_a?(OpenSSL::PKey::PKey) + @key = OpenSSL::PKey.read(File.read(@key), @passphrase) + end + + if @cert_chain + @cert_chain = @cert_chain.compact.map do |cert| + next cert if OpenSSL::X509::Certificate === cert + + cert = File.read cert if File.exist? cert + + OpenSSL::X509::Certificate.new cert + end + + load_cert_chain + end + end + + ## + # Extracts the full name of +cert+. If the certificate has a subjectAltName + # this value is preferred, otherwise the subject is used. + + def extract_name(cert) # :nodoc: + subject_alt_name = cert.extensions.find {|e| e.oid == "subjectAltName" } + + if subject_alt_name + /\Aemail:/ =~ subject_alt_name.value # rubocop:disable Performance/StartWith + + $' || subject_alt_name.value + else + cert.subject + end + end + + ## + # Loads any missing issuers in the cert chain from the trusted certificates. + # + # If the issuer does not exist it is ignored as it will be checked later. + + def load_cert_chain # :nodoc: + return if @cert_chain.empty? + + while @cert_chain.first.issuer.to_s != @cert_chain.first.subject.to_s do + issuer = Gem::Security.trust_dir.issuer_of @cert_chain.first + + break unless issuer # cert chain is verified later + + @cert_chain.unshift issuer + end + end + + ## + # Sign data with given digest algorithm + + def sign(data) + return unless @key + + raise Gem::Security::Exception, "no certs provided" if @cert_chain.empty? + + if @cert_chain.length == 1 && @cert_chain.last.not_after < Time.now + alert("Your certificate has expired, trying to re-sign it...") + + re_sign_key( + expiration_length: (Gem::Security::ONE_DAY * options[:expiration_length_days]) + ) + end + + full_name = extract_name @cert_chain.last + + Gem::Security::SigningPolicy.verify @cert_chain, @key, {}, {}, full_name + + @key.sign @digest_algorithm.new, data + end + + ## + # Attempts to re-sign the private key if the signing certificate is expired. + # + # The key will be re-signed if: + # * The expired certificate is self-signed + # * The expired certificate is saved at ~/.gem/gem-public_cert.pem + # and the private key is saved at ~/.gem/gem-private_key.pem + # * There is no file matching the expiry date at + # ~/.gem/gem-public_cert.pem.expired.%Y%m%d%H%M%S + # + # If the signing certificate can be re-signed the expired certificate will + # be saved as ~/.gem/gem-public_cert.pem.expired.%Y%m%d%H%M%S where the + # expiry time (not after) is used for the timestamp. + + def re_sign_key(expiration_length: Gem::Security::ONE_YEAR) # :nodoc: + old_cert = @cert_chain.last + + disk_cert_path = File.join(Gem.default_cert_path) + disk_cert = begin + File.read(disk_cert_path) + rescue StandardError + nil + end + + disk_key_path = File.join(Gem.default_key_path) + disk_key = begin + OpenSSL::PKey.read(File.read(disk_key_path), @passphrase) + rescue StandardError + nil + end + + return unless disk_key + + if disk_key.to_pem == @key.to_pem && disk_cert == old_cert.to_pem + expiry = old_cert.not_after.strftime("%Y%m%d%H%M%S") + old_cert_file = "gem-public_cert.pem.expired.#{expiry}" + old_cert_path = File.join(Gem.user_home, ".gem", old_cert_file) + + unless File.exist?(old_cert_path) + Gem::Security.write(old_cert, old_cert_path) + + cert = Gem::Security.re_sign(old_cert, @key, expiration_length) + + Gem::Security.write(cert, disk_cert_path) + + alert("Your cert: #{disk_cert_path} has been auto re-signed with the key: #{disk_key_path}") + alert("Your expired cert will be located at: #{old_cert_path}") + + @cert_chain = [cert] + end + end + end +end diff --git a/lib/rubygems/security/trust_dir.rb b/lib/rubygems/security/trust_dir.rb new file mode 100644 index 0000000000..d23d161cfe --- /dev/null +++ b/lib/rubygems/security/trust_dir.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +## +# The TrustDir manages the trusted certificates for gem signature +# verification. + +class Gem::Security::TrustDir + ## + # Default permissions for the trust directory and its contents + + DEFAULT_PERMISSIONS = { + trust_dir: 0o700, + trusted_cert: 0o600, + }.freeze + + ## + # The directory where trusted certificates will be stored. + + attr_reader :dir + + ## + # Creates a new TrustDir using +dir+ where the directory and file + # permissions will be checked according to +permissions+ + + def initialize(dir, permissions = DEFAULT_PERMISSIONS) + @dir = dir + @permissions = permissions + + @digester = Gem::Security.create_digest + end + + ## + # Returns the path to the trusted +certificate+ + + def cert_path(certificate) + name_path certificate.subject + end + + ## + # Enumerates trusted certificates. + + def each_certificate + return enum_for __method__ unless block_given? + + glob = File.join @dir, "*.pem" + + Dir[glob].each do |certificate_file| + certificate = load_certificate certificate_file + + yield certificate, certificate_file + rescue OpenSSL::X509::CertificateError + next # HACK: warn + end + end + + ## + # Returns the issuer certificate of the given +certificate+ if it exists in + # the trust directory. + + def issuer_of(certificate) + path = name_path certificate.issuer + + return unless File.exist? path + + load_certificate path + end + + ## + # Returns the path to the trusted certificate with the given ASN.1 +name+ + + def name_path(name) + digest = @digester.hexdigest name.to_s + + File.join @dir, "cert-#{digest}.pem" + end + + ## + # Loads the given +certificate_file+ + + def load_certificate(certificate_file) + pem = File.read certificate_file + + OpenSSL::X509::Certificate.new pem + end + + ## + # Add a certificate to trusted certificate list. + + def trust_cert(certificate) + verify + + destination = cert_path certificate + + File.open destination, "wb", 0o600 do |io| + io.write certificate.to_pem + io.chmod(@permissions[:trusted_cert]) + end + end + + ## + # Make sure the trust directory exists. If it does exist, make sure it's + # actually a directory. If not, then create it with the appropriate + # permissions. + + def verify + require "fileutils" + if File.exist? @dir + raise Gem::Security::Exception, + "trust directory #{@dir} is not a directory" unless + File.directory? @dir + + FileUtils.chmod 0o700, @dir + else + FileUtils.mkdir_p @dir, mode: @permissions[:trust_dir] + end + end +end |
