certificate_set.rb 14.9 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
require 'symbiosis/domain'
require 'symbiosis/ssl'
require 'symbiosis/utils'
require 'openssl'
require 'tmpdir'
require 'erb'

module Symbiosis

  class SSL

12
    class CertificateSet
13
14
15
16

      include Comparable
      include Symbiosis::Utils

17
18
      def initialize(domain, directory=nil)
        raise ArgumentError, "domain must be a Symbiosis::Domain" unless domain.is_a?(Symbiosis::Domain)
19

20
        @domain           = domain
21
22
        @certificate      = @key      = @certificate_chain      = @request = nil
        @certificate_file = @key_file = @certificate_chain_file = @request_file = nil
23
        @name = @directory = nil
24

25
26
        self.directory = directory if directory
      end
27

28
29
30
      def domain
        @domain
      end
31

32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
      def name
        @name
      end

      #
      # Set the name for this set of certificates
      #
      def name=(n)
        raise ArgumentError, "Bad SSL set name #{n.inspect}" unless n.to_s =~ /^[a-z0-9_:-]+$/i
        
        @name = n
 
        if self.directory.nil?
          if "legacy" == @name
            self.directory = self.domain.config_dir
          elsif self.name.is_a?(String)
48
            self.directory = File.join(self.domain.config_dir, "ssl", "sets", @name)
49
          end
50
        end
51
52
53
54
55
56
57
58
59
60
61
62
63

        @name
      end

      def directory
        @directory
      end

      #
      # Sets the directory name.  It is expanded relative to the domain's
      # config directory.
      #
      def directory=(d)
64
        raise ArgumentError unless d.is_a?(String)
65
        
66
        @directory = File.expand_path(d, File.join(self.domain.config_dir, "ssl", "sets"))
67
68
69
70
71
72
73
74
75
76

        if self.name.nil?
          if self.domain.config_dir == @directory
            self.name = "legacy"
          else
            self.name = File.basename(@directory)
          end
        end

        @directory
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
      end

      #
      # Sort by name.
      #
      def <=>(other)
        self.name <=> other.name
      end

      #
      # Searches for a SSL certificate using find_matching_certificate_and_key,
      # and returns the certificate's filename, or nil if nothing could be
      # found.
      #
      def certificate_file
        @certificate_file ||= nil

        if @certificate_file.nil?
          @certificate_file, @key_file = self.find_matching_certificate_and_key
        end

        @certificate_file
      end

      #
      # Sets the domains SSL certificate filename.
      #
      def certificate_file=(f)
        @certificate_file = f
      end

      #
      # Returns the X509 certificate object, or nil if the certificate could
      # not be found, or its contents unparseable.
      #
      def certificate
        return nil if self.certificate_file.nil?

        data = get_param(*(File.split(self.certificate_file).reverse))

117
        return @certificate = OpenSSL::X509::Certificate.new(data)
118
119

      rescue OpenSSL::OpenSSLError => err
120
        warn "\tSSL set #{name}: Could not parse data in #{self.certificate_file}: #{err}"
121
122
123
        return nil
      end

124
125
126
127
      def certificate=(c)
        @certificate = c
      end

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
      #
      # Searches for the domain's SSL key using
      # find_matching_certificate_and_key, and returns the key's filename, or
      # nil if nothing could be found.
      #
      def key_file
        @key_file ||= nil

        if @key_file.nil?
          @certificate_file, @key_file = self.find_matching_certificate_and_key
        end

        @key_file
      end

      #
      # Sets the domains's SSL key filename.
      #
      def key_file=(f)
        @key_file = f
      end

      #
      # Returns the directory's SSL key as an OpenSSL::PKey::RSA object, or nil
      # if no key file could be found, or could not be read.
      #
      def key
        return nil if self.key_file.nil?

        data = get_param(*(File.split(self.key_file).reverse))

159
        return @key = OpenSSL::PKey::RSA.new(data)
160
161

      rescue OpenSSL::OpenSSLError => err
162
        warn "\tSSL set #{name}: Could not parse data in #{self.key_file}: #{err}"
163
        return nil
164
165
      end

166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
      def request_file
        return @request_file unless @request_file.nil?
        
        fn = File.join(self.directory, "ssl.csr")

        @request_file = fn if File.exist?(fn)
        
        @request_file
      end

      def request_file=(r)
        @request_file = r
      end

      def request
        return nil if self.request_file.nil?

        @request = get_param(*(File.split(self.request_file).reverse))
      end

      #
      # 
      #
      def certificate_chain_file=(f)
        @certificate_chain_file = f
      end

193
194
195
196
197
      #
      # Returns the certificate chain filename, if one exists, or one has been
      # set, or nil if nothing could be found.
      #
      def certificate_chain_file
198
199
200
201
202
203
204
        return @certificate_chain_file unless @certificate_chain_file.nil?

        fn = File.join(self.directory, "ssl.bundle")

        @certificate_chain_file = fn if File.exist?(fn)

        @certificate_chain_file
205
206
207
208
      end

      alias bundle_file certificate_chain_file

209
210
211
212
213
214
      def certificate_chain
        return nil if self.request_file.nil?

        @request = get_param(*(File.split(self.certificate_chain_file).reverse))
      end

215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
      #
      # Add a path with extra SSL certs (for testing).
      #
      def add_ca_path(path)
        @ca_paths ||= []

        @ca_paths << path if File.directory?(path)
      end

      #
      # Sets up and returns a new OpenSSL::X509::Store.
      #
      # If any CA paths have been set using add_ca_path, then these are added to the store.
      #
      # If certificate_chain_file has been set, then this is added to the store.
      #
      # This is regenerated on every call.
      #
      def certificate_store
        @ca_paths ||= []

        certificate_store = OpenSSL::X509::Store.new
        certificate_store.set_default_paths

        @ca_paths.each{|path| certificate_store.add_path(path)}
        chain_file = self.certificate_chain_file
        begin
          certificate_store.add_file(chain_file) unless chain_file.nil?
        rescue OpenSSL::X509::StoreError
244
           warn "\tSSL set #{name}: Unable to add chain file to the store."
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
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
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
        end
        certificate_store
      end

      #
      # Return the available certificate/key files for a domain.  It will check
      # files with the following extensions for both keys and certificates.
      #  * key
      #  * crt
      #  * combined
      #  * pem
      #
      # It will return an array of certificate and key filenames that could be
      # read and parsed successfully by OpenSSL.  The array has to sub-arrays,
      # the first being certificate filenames, the second key filenames, i.e.
      # <code>[[certificates] , [keys]]</code>.  If a file contains both a
      # certificate and key, it will appear in both arrays.
      #
      def available_files
        certificates = []
        key_files = []

        #
        # Try a number of permutations
        #
        %w(combined key crt cert pem).each do |ext|
          #
          # See if the file exists.
          #
          contents = get_param("ssl.#{ext}", self.directory)

          #
          # If it doesn't exist/is unreadble, return nil.
          #
          next unless contents.is_a?(String)

          this_fn = File.join(self.directory, "ssl.#{ext}")

          this_cert = nil
          this_key = nil

          #
          # Check the certificate
          #
          begin
            this_cert = OpenSSL::X509::Certificate.new(contents)
          rescue OpenSSL::OpenSSLError
            #
            # This means the file did not contain a cert, or the cert it contains
            # is unreadable.
            #
            this_cert = nil
          end

          #
          # Check to see if the file contains a key.
          #
          begin
            this_key = OpenSSL::PKey::RSA.new(contents)
          rescue OpenSSL::OpenSSLError
            #
            # This means the file did not contain a key, or the key it contains
            # is unreadable.
            #
            this_key = nil
          end

          #
          # Finally, if we have a key and certificate in one file, check they
          # match, otherwise reject.
          #
          if this_key and this_cert and this_cert.check_private_key(this_key)
            certificates << [this_fn, this_cert]
            key_files << this_fn
          elsif this_key and !this_cert
            key_files << this_fn
          elsif this_cert and !this_key
            certificates << [this_fn, this_cert]
          end
        end

        #
        # Order certificates by time to expiry, penalising any that are
        # before their start time or after their expiry time.
        #
        now = Time.now
        certificate_files = certificates.sort_by { |fn, cert|
          score = cert.not_after.to_i
          score -= cert.not_before.to_i if cert.not_before > now
          score -= now.to_i if now > cert.not_after
          -score
        }.map { |fn, cert| fn }

        [certificate_files, key_files]
      end

      #
      # This returns an array of files for the domain that contain valid certificates.
      #
      def available_certificate_files
        self.available_files.first
      end

      #
      # This returns an array of files for the domain that contain valid keys.
      #
      def available_key_files
        self.available_files.last
      end

      #
      # Tests each of the available key and certificate files, until a matching
      # pair is found.  Returns an array of [certificate filename, key_filename],
      # or nil if no match is found.
      #
      # The order in which keys and certficates are matched is determined by
      # available_files.
      #
      def find_matching_certificate_and_key
        #
        # Find the certificates and keys
        #
        certificate_files, key_files = self.available_files

        return nil if certificate_files.empty? or key_files.empty?

        #
        # Test each certificate...
        certificate_files.each do |cert_fn|
          cert = OpenSSL::X509::Certificate.new(File.read(cert_fn))
          #
          # ...with each key
          key_files.each do |key_fn|
            key = OpenSSL::PKey::RSA.new(File.read(key_fn))
            #
            # This tests the private key, and returns the current certificate and
            # key if they verify.
            return [cert_fn, key_fn] if cert.check_private_key(key)
          end
        end

        #
        # Return nil if no matching keys and certs are found
        return nil
      end

      #
      # This method performs a variety of checks on an SSL certificate and key:
      #
      # * Is the certificate valid for this domain name or any of its aliases
      # * Has the certificate started yet?
      # * Has the certificate expired?
      #
      # If any of these checks fail, a warning is raised.
      #
      # * Does the key match the certificate?
      # * If the certificate is not self-signed, does it need a bundle?
      #
      # If either of these last two checks fail, a
      # OpenSSL::X509::CertificateError is raised.
      #
      def verify(certificate = self.certificate, key = self.key, store = self.certificate_store, strict_checking=false)
        unless certificate.is_a?(OpenSSL::X509::Certificate) and key.is_a?(OpenSSL::PKey::PKey)
          return false
        end

        #
        # Firstly check that the certificate is valid for the domain or one of its aliases.
        #
        unless ([@domain.name] + @domain.aliases).any? { |domain_alias| OpenSSL::SSL.verify_certificate_identity(certificate, domain_alias) }
          msg = "The certificate subject is not valid for this domain #{@domain.name}."
          if strict_checking
            raise OpenSSL::X509::CertificateError, msg
          else
419
            puts "\tSSL set #{name}: #{msg}" if $VERBOSE
420
421
422
423
424
425
426
427
428
429
430
          end
        end

        # Next check that the key matches the certificate.
        #
        #
        unless certificate.check_private_key(key)
          raise OpenSSL::X509::CertificateError, "The certificate's public key does not match the supplied private key for #{@domain.name}."
        end

        #
431
        # We always need a store
432
        #
433
        store = OpenSSL::X509::Store.new unless store.is_a?(OpenSSL::X509::Store)
434
435

        #
436
        # See if we can verify it using the certificate store,
437
438
        # including any bundle that has been uploaded.
        #
439
        if store.verify(certificate)
440
          puts "\tSSL set #{name}: Signed by \"#{certificate.issuer.to_s}\" for #{@domain.name}" if $DEBUG
441

442
        elsif store.error == 18
443
          unless certificate.verify(key)
444
            raise OpenSSL::X509::CertificateError, "\tSSL set #{name}: Self signed, but the signature doesn't validate."
445
          end
446
          puts "\tSSL set #{name}: Self-signed certificate for #{@domain.name}." if $DEBUG
447
        else
448
          msg =  "Not valid for #{@domain.name} -- "
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
          case store.error
            when 2, 20
              msg += "the intermediate bundle is missing"
            else
              msg += store.error_string
          end

          #
          # The numeric errors are detailed in /usr/include/openssl/x509_vfy.h
          #
          msg += " (#{store.error})"

          #
          # If we can't verify -- raise an error if strict_checking is enabled
          #
464
465
466
          if strict_checking
            raise OpenSSL::X509::CertificateError, msg
          else
467
            puts "\tSSL set #{name}: #{msg}" if $VERBOSE
468
469
470
          end
        end

471
        store.error
472
473
      end

474
475
476
      def write
        raise ArgumentError, "The directory for this SSL certificate set has been given" if self.directory.nil?

477
        raise Errno::EEXIST.new self.directory if File.exist?(self.directory)
478
479
        mkdir_p(File.dirname(self.directory))

480
481
482
        #
        # Drop privs before creating the temporary directory, if required.
        #
Patrick J Cherry's avatar
Patrick J Cherry committed
483
484
        Process.egid = self.domain.gid if Process.gid == 0
        Process.euid = self.domain.uid if Process.uid == 0
485

486
487
        tmpdir = Dir.mktmpdir(self.name+"-ssl-")

488
489
490
491
492
493
494
        #
        # Restore privs -- set_param will use the owner/group of the directory
        # when writing files.
        #       
        Process.euid = 0 if Process.uid == 0
        Process.egid = 0 if Process.gid == 0

495
496
497
498
499
500
501
502
503
504
505
506
        combined = [:certificate, :bundle, :key].map{|k| self.__send__(k) }.flatten.compact

        set_param("ssl.key",self.key.to_pem, tmpdir)
        set_param("ssl.crt",self.certificate.to_pem, tmpdir)
        set_param("ssl.csr",self.request.to_pem, tmpdir) if self.request

        set_param("ssl.bundle",self.bundle.map(&:to_pem).join("\n"), tmpdir) if self.bundle and !self.bundle.empty?
        set_param("ssl.combined", combined.map(&:to_pem).join("\n"), tmpdir)

        FileUtils.mv(tmpdir, self.directory)

        self.directory
507
508
509
510
511
512
513
      ensure
        #
        # Make sure we restore privs.
        #
        Process.euid = 0 if Process.uid == 0 and Process.euid != Process.uid
        Process.egid = 0 if Process.gid == 0 and Process.egid != Process.gid

514
515
      end

516
517
518
519
520
    end

  end

end