require 'yaml' require 'openssl' require 'r509/exceptions' require 'r509/io_helpers' require 'r509/subject' require 'r509/privatekey' require 'fileutils' require 'pathname' module R509 # Module to contain all configuration related classes (e.g. CaConfig, CaProfile, SubjectItemPolicy) module Config # Provides access to configuration profiles class CaProfile attr_reader :basic_constraints, :key_usage, :extended_key_usage, :certificate_policies, :subject_item_policy # @option [String] :basic_constraints # @option [Array] :key_usage # @option [Array] :extended_key_usage # @option [Array] :certificate_policies # @option [R509::Config::SubjectItemPolicy] :subject_item_policy optional def initialize(opts = {}) @basic_constraints = opts[:basic_constraints] @key_usage = opts[:key_usage] @extended_key_usage = opts[:extended_key_usage] @certificate_policies = opts[:certificate_policies] if opts.has_key?(:subject_item_policy) and not opts[:subject_item_policy].kind_of?(R509::Config::SubjectItemPolicy) end @subject_item_policy = opts[:subject_item_policy] || nil end end # returns information about the subject item policy for a profile class SubjectItemPolicy attr_reader :required, :optional # @param [Hash] hash of required/optional subject items. These must be in OpenSSL shortname format. # @example sample hash # {"CN" => "required", # "O" => "required", # "OU" => "optional", # "ST" => "required", # "C" => "required", # "L" => "required", # "emailAddress" => "optional"} def initialize(hash={}) if not hash.kind_of?(Hash) raise ArgumentError, "Must supply a hash in form 'shortname'=>'required/optional'" end @required = [] @optional = [] if not hash.empty? hash.each_pair do |key,value| if value == "required" @required.push(key) elsif value == "optional" @optional.push(key) else raise ArgumentError, "Unknown subject item policy value. Allowed values are required and optional" end end end end # @param [R509::Subject] subject # @return [R509::Subject] validated version of the subject or error def validate_subject(subject) # convert the subject components into an array of component names that match # those that are on the required list supplied = subject.to_a.each do |item| @required.include?(item[0]) end.map do |item| item[0] end # so we can make sure they gave us everything that's required diff = @required - supplied if not diff.empty? raise R509::R509Error, "This profile requires you supply "+@required.join(", ") end # the validated subject contains only those subject components that are either # required or optional R509::Subject.new(subject.to_a.select do |item| @required.include?(item[0]) or @optional.include?(item[0]) end) end end # pool of configs, so we can support multiple CAs from a single config file class CaConfigPool # @option configs [Hash] the configs to add to the pool def initialize(configs) @configs = configs end # get all the config names def names @configs.keys end # retrieve a particular config by its name def [](name) @configs[name] end # @return a list of all the configs in this pool def all @configs.values end # Loads the named configuration config from a yaml string. # @param [String] name The name of the config within the file. Note # that a single yaml file can contain more than one configuration. # @param [String] yaml_data The filename to load yaml config data from. def self.from_yaml(name, yaml_data, opts = {}) conf = YAML.load(yaml_data) configs = {} conf[name].each_pair do |ca_name, data| configs[ca_name] = R509::Config::CaConfig.load_from_hash(data, opts) end R509::Config::CaConfigPool.new(configs) end end # Stores a configuration for our CA. class CaConfig include R509::IOHelpers extend R509::IOHelpers attr_accessor :ca_cert, :crl_validity_hours, :message_digest, :cdp_location, :crl_start_skew_seconds, :ocsp_location, :ocsp_chain, :ocsp_start_skew_seconds, :ocsp_validity_hours, :crl_number_file, :crl_list_file # @option opts [R509::Cert] :ca_cert Cert+Key pair # @option opts [Integer] :crl_validity_hours (168) The number of hours that # a CRL will be valid. Defaults to 7 days. # @option opts [Hash] :profiles # @option opts [String] :message_digest (SHA1) The hashing algorithm to use. # @option opts [String] :cdp_location # @option opts [String] :ocsp_location # @option opts [String] :crl_number_file The file that we will save # the CRL numbers to. defaults to a StringIO object if not provided # @option opts [String] :crl_list_file The file that we will save # the CRL list data to. defaults to a StringIO object if not provided # @option opts [R509::Cert] :ocsp_cert An optional cert+key pair # OCSP signing delegate # @option opts [Array] :ocsp_chain An optional array # that constitutes the chain to attach to an OCSP response # def initialize(opts = {} ) if not opts.has_key?(:ca_cert) then raise ArgumentError, 'Config object requires that you pass :ca_cert' end @ca_cert = opts[:ca_cert] if not @ca_cert.kind_of?(R509::Cert) then raise ArgumentError, ':ca_cert must be of type R509::Cert' end #ocsp data if opts.has_key?(:ocsp_cert) and not opts[:ocsp_cert].kind_of?(R509::Cert) and not opts[:ocsp_cert].nil? raise ArgumentError, ':ocsp_cert, if provided, must be of type R509::Cert' end if opts.has_key?(:ocsp_cert) and not opts[:ocsp_cert].nil? and not opts[:ocsp_cert].has_private_key? raise ArgumentError, ':ocsp_cert must contain a private key, not just a certificate' end @ocsp_cert = opts[:ocsp_cert] unless opts[:ocsp_cert].nil? @ocsp_location = opts[:ocsp_location] @ocsp_chain = opts[:ocsp_chain] if opts[:ocsp_chain].kind_of?(Array) @ocsp_validity_hours = opts[:ocsp_validity_hours] || 168 @ocsp_start_skew_seconds = opts[:ocsp_start_skew_seconds] || 3600 @crl_validity_hours = opts[:crl_validity_hours] || 168 @crl_start_skew_seconds = opts[:crl_start_skew_seconds] || 3600 @crl_number_file = opts[:crl_number_file] || nil @crl_list_file = opts[:crl_list_file] || nil @cdp_location = opts[:cdp_location] @message_digest = opts[:message_digest] || "SHA1" @profiles = {} if opts[:profiles] opts[:profiles].each_pair do |name, prof| set_profile(name, prof) end end end # @return [R509::Cert] either a custom OCSP cert or the ca_cert def ocsp_cert if @ocsp_cert.nil? then @ca_cert else @ocsp_cert end end # @param [String] name The name of the profile # @param [R509::Config::CaProfile] prof The profile configuration def set_profile(name, prof) unless prof.is_a?(R509::Config::CaProfile) raise TypeError, "profile is supposed to be a R509::Config::CaProfile" end @profiles[name] = prof end # @param [String] prof # @return [R509::Config::CaProfile] The config profile. def profile(prof) if !@profiles.has_key?(prof) raise R509::R509Error, "unknown profile '#{prof}'" end @profiles[prof] end # @return [Integer] The number of profiles def num_profiles @profiles.count end ######### Class Methods ########## # Load the configuration from a data hash. The same type that might be # used when loading from a YAML file. # @param [Hash] conf A hash containing all the configuration options # @option opts [String] :ca_root_path The root path for the CA. Defaults to # the current working directory. def self.load_from_hash(conf, opts = {}) if conf.nil? raise ArgumentError, "conf not found" end unless conf.kind_of?(Hash) raise ArgumentError, "conf must be a Hash" end ca_root_path = Pathname.new(opts[:ca_root_path] || FileUtils.getwd) unless File.directory?(ca_root_path) raise R509Error, "ca_root_path is not a directory: #{ca_root_path}" end ca_cert_hash = conf['ca_cert'] if ca_cert_hash.has_key?('engine') ca_cert = self.load_with_engine(ca_cert_hash,ca_root_path) end if ca_cert.nil? and ca_cert_hash.has_key?('pkcs12') ca_cert = self.load_with_pkcs12(ca_cert_hash,ca_root_path) end if ca_cert.nil? and ca_cert_hash.has_key?('cert') ca_cert = self.load_with_key(ca_cert_hash,ca_root_path) end if conf.has_key?("ocsp_cert") if conf["ocsp_cert"].has_key?('engine') ocsp_cert = self.load_with_engine(conf["ocsp_cert"],ca_root_path) end if ocsp_cert.nil? and conf["ocsp_cert"].has_key?('pkcs12') ocsp_cert = self.load_with_pkcs12(conf["ocsp_cert"],ca_root_path) end if ocsp_cert.nil? and conf["ocsp_cert"].has_key?('cert') ocsp_cert = self.load_with_key(conf["ocsp_cert"],ca_root_path) end end ocsp_chain = [] if conf.has_key?("ocsp_chain") ocsp_chain_data = read_data(ca_root_path+conf["ocsp_chain"]) cert_regex = /-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m ocsp_chain_data.scan(cert_regex) do |cert| ocsp_chain.push(OpenSSL::X509::Certificate.new(cert)) end end opts = { :ca_cert => ca_cert, :ocsp_cert => ocsp_cert, :ocsp_chain => ocsp_chain, :crl_validity_hours => conf['crl_validity_hours'], :ocsp_validity_hours => conf['ocsp_validity_hours'], :ocsp_start_skew_seconds => conf['ocsp_start_skew_seconds'], :ocsp_location => conf['ocsp_location'], :cdp_location => conf['cdp_location'], :message_digest => conf['message_digest'], } if conf.has_key?("crl_list") opts[:crl_list_file] = (ca_root_path + conf['crl_list']).to_s end if conf.has_key?("crl_number") opts[:crl_number_file] = (ca_root_path + conf['crl_number']).to_s end profs = {} conf['profiles'].keys.each do |profile| data = conf['profiles'][profile] if not data["subject_item_policy"].nil? subject_item_policy = R509::Config::SubjectItemPolicy.new(data["subject_item_policy"]) end profs[profile] = R509::Config::CaProfile.new(:key_usage => data["key_usage"], :extended_key_usage => data["extended_key_usage"], :basic_constraints => data["basic_constraints"], :certificate_policies => data["certificate_policies"], :subject_item_policy => subject_item_policy) end unless conf['profiles'].nil? opts[:profiles] = profs # Create the instance. self.new(opts) end # Loads the named configuration config from a yaml file. # @param [String] conf_name The name of the config within the file. Note # that a single yaml file can contain more than one configuration. # @param [String] yaml_file The filename to load yaml config data from. def self.load_yaml(conf_name, yaml_file, opts = {}) conf = YAML.load_file(yaml_file) self.load_from_hash(conf[conf_name], opts) end # Loads the named configuration config from a yaml string. # @param [String] conf_name The name of the config within the file. Note # that a single yaml file can contain more than one configuration. # @param [String] yaml_data The filename to load yaml config data from. def self.from_yaml(conf_name, yaml_data, opts = {}) conf = YAML.load(yaml_data) self.load_from_hash(conf[conf_name], opts) end private def self.load_with_engine(ca_cert_hash,ca_root_path) if ca_cert_hash.has_key?('key') raise R509Error, "You can't specify both key and engine" end if ca_cert_hash.has_key?('pkcs12') raise R509Error, "You can't specify both engine and pkcs12" end if not ca_cert_hash.has_key?('key_name') raise R509Error, "You must supply a key_name with an engine" end if ca_cert_hash['engine'].respond_to?(:load_private_key) #this path is only for testing...ugh engine = ca_cert_hash['engine'] else #this path can't be tested by unit tests. bah! engine = OpenSSL::Engine.by_id(ca_cert_hash['engine']) end ca_key = R509::PrivateKey.new( :engine => engine, :key_name => ca_cert_hash['key_name'] ) ca_cert_file = ca_root_path + ca_cert_hash['cert'] ca_cert = R509::Cert.new( :cert => read_data(ca_cert_file), :key => ca_key ) ca_cert end def self.load_with_pkcs12(ca_cert_hash,ca_root_path) if ca_cert_hash.has_key?('cert') raise R509Error, "You can't specify both pkcs12 and cert" end if ca_cert_hash.has_key?('key') raise R509Error, "You can't specify both pkcs12 and key" end pkcs12_file = ca_root_path + ca_cert_hash['pkcs12'] ca_cert = R509::Cert.new( :pkcs12 => read_data(pkcs12_file), :password => ca_cert_hash['password'] ) ca_cert end def self.load_with_key(ca_cert_hash,ca_root_path) ca_cert_file = ca_root_path + ca_cert_hash['cert'] if ca_cert_hash.has_key?('key') ca_key_file = ca_root_path + ca_cert_hash['key'] ca_key = R509::PrivateKey.new( :key => read_data(ca_key_file), :password => ca_cert_hash['password'] ) ca_cert = R509::Cert.new( :cert => read_data(ca_cert_file), :key => ca_key ) else # in certain cases (OCSP responders for example) we may want # to load a ca_cert with no private key ca_cert = R509::Cert.new(:cert => read_data(ca_cert_file)) end ca_cert end end end end