ssltool 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # ssltool-complete-chain
2
+
3
+ Re-orders and completes a chain for a given certificate.
4
+
5
+ More precisely, it takes any string as input, scans for PEMs, detects one that is a certificate for
6
+ a domain name, then goes on to complete its chain, in correct order.
7
+
8
+ The pool of possible chain completions are whatever other certificates that are passed as input, plus
9
+ all certificates in the intermediate and trusted pools (see `var/pools/`).
10
+
11
+ The output is the correct and complete chain. Everything else from the input is discarded.
12
+
13
+ If the chain is incomplete, untrusted, or the certificate is self-signed, warnings will be printed to stderr.
14
+
15
+
16
+ ### Usage
17
+
18
+ `ssltool-complete-chain` can work with either stdin or file arguments, so all of the below are valid:
19
+
20
+ ### pipe a file in:
21
+ $ ssltool-complete-chain < example.com.pem
22
+
23
+ ### pass multiple file names as arguments:
24
+ $ ssltool-complete-chain example.com.pem issuer-intermediates.pem
25
+
26
+ ### pass a file descriptor from a command's stdout:
27
+ $ ssltool-complete-chain <(pbpaste)
28
+
29
+ ### or just pipe that command in:
30
+ $ pbpaste | ssltool-complete-chain
31
+
32
+
33
+
34
+ # Bootstrapping
35
+
36
+ This is how we get the list of trusted roots and the intermediates file.
37
+
38
+ This process has already been done for you, you don't need to repeat it unless you want updated data.
39
+
40
+ 1. Downloaded an updated list of trusted roots:
41
+
42
+ $ SRC="http://mxr.mozilla.org/mozilla/source/security/nss/lib/ckfw/builtins/certdata.txt?raw=1"
43
+ $ curl -s "$SRC" > var/mozilla-certdata.txt
44
+ $ bin/bootstrap-trusted-pems-from-mozilla-certdata < var/mozilla-certdata.txt > var/pools/trusted.pem
45
+
46
+
47
+ 2. Generate the intermediates pool:
48
+
49
+ $ bin/bootstrap-detect-intermediates var/all-the-certs.pem
50
+
51
+ The `var/all-the-certs.pem` is a pool of assorted certificates to extract intermediates from. You'll
52
+ have to compile this file yourself.
53
+
54
+ If circular chains are detected, all members of them will be rejected and printed to stderr. You can
55
+ resolve the cycle manually, and decide which certificate(s) to exclude to break the cycle. Add those
56
+ to `var/pools/excluded.pem` and generate the intermediates pool again.
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+
4
+ require_relative "../lib/ssltool/certificate"
5
+ require_relative "../lib/ssltool/certificate_store"
6
+
7
+ (puts DATA.read.gsub("$0", File.basename($0)); exit 1) if ARGV.empty? && STDIN.tty?
8
+
9
+ def notify_circular_chains_detected(circular_chains)
10
+ $stderr.puts "The following circular chains were detected:"
11
+ $stderr.puts
12
+ $stderr.puts "Resolve this manually and exclude the necessary certs to break the cycle by putting them your exclude pool; rerun this script."
13
+ $stderr.puts
14
+ $stderr.puts "All certificates currently participating in a cyclic chain were removed from the final intermediate pool."
15
+ $stderr.puts
16
+ cert_numbers = circular_chains.flatten.uniq.each_with_index.inject({}) { |h, (c, i)| h[c] = i; h }
17
+ circular_chains.each { |chain|
18
+ $stderr.puts "---"
19
+ chain.each { |cert|
20
+ $stderr.puts "- cert %d (signed by %s) (signs %s)" % [
21
+ cert_numbers[cert],
22
+ chain.select { |other_cert| cert.signed_by?(other_cert) }.map { |cert| cert_numbers[cert] }.join(", "),
23
+ chain.select { |other_cert| cert.signs?(other_cert) }.map { |cert| cert_numbers[cert] }.join(", "),]}}
24
+
25
+ circular_chains.flatten.uniq.each do |cert|
26
+ $stderr.puts "--- #{cert_numbers[cert]} ---"
27
+ $stderr.puts "subject.....: #{cert.subject}"
28
+ $stderr.puts "issuer......: #{cert.issuer}"
29
+ $stderr.puts "self_signed.: #{cert.self_signed?}"
30
+ $stderr.puts cert.to_s
31
+ end
32
+ end
33
+ public :notify_circular_chains_detected
34
+
35
+ store = SSLTool::CertificateStore.new("file://var/pools")
36
+ store.register_for_circular_chain_detection_notification(self)
37
+ store.detect_and_merge_intermediates!(SSLTool::Certificate.scan(ARGF.read), false)
38
+
39
+ __END__
40
+ Usage:
41
+ $0 <src-pems>
42
+
43
+ <src-pems> is the pool of certificates to extract chain-forming
44
+ intermediates from.
45
+
46
+ If circular chains are detected they'll be printed to stderr,
47
+ and all its members will be excluded from the final intermediate
48
+ pool.
49
+
50
+ You should review the circular chain manually and put the
51
+ offending certs in the exclude pool, then rerun this script.
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ require 'digest/sha1'
4
+
5
+ (puts DATA.read.gsub("$0", File.basename($0)); exit 1) if $stdin.tty? && ARGV.empty?
6
+
7
+ def get_line
8
+ return unless line = ARGF.gets
9
+ line.chomp
10
+ end
11
+
12
+ ### parse the data into the certificates and trust arrays
13
+ license = ""
14
+ certificates = []
15
+ trust = []
16
+ while line = get_line
17
+ case line
18
+ when /^#.*BEGIN LICENSE BLOCK/
19
+ begin license << line << "\n"; end until line =~ /^#.*END LICENSE BLOCK/ || !(line = get_line)
20
+ when /^$/; next
21
+ when /^#/; next
22
+ when /^CVS_ID\b/; next
23
+ when "BEGINDATA"; next
24
+ when /^CKA_CLASS CK_OBJECT_CLASS CKO_NSS_BUILTIN_ROOT_LIST$/; object = {}
25
+ when /^CKA_CLASS CK_OBJECT_CLASS CKO_CERTIFICATE$/ ; (certificates << object = {})
26
+ when /^CKA_CLASS CK_OBJECT_CLASS CKO_NSS_TRUST$/ ; (trust << object = {})
27
+ when /^(CKA_\S+) (\S+) (.+)$/ ; object[$1] = [$2, $3]
28
+ when /^(CKA_\S+) (MULTILINE_OCTAL)$/ ; object[$1] = [$2, "".tap { |data| data << line while line = get_line and line != "END" }]
29
+ else raise "Could not parse line: #{line}"
30
+ end
31
+ end
32
+
33
+ ### transform values according to data type; make keys more ruby-friendly
34
+ (certificates + trust).each do |object|
35
+ object.entries.each do |k, (t, v)|
36
+ object.delete k
37
+ object[k.sub(/^CKA_/, '').downcase.to_sym] = case t
38
+ when "CK_CERTIFICATE_TYPE"; v == "CKC_X_509" or raise "CK_CERTIFICATE_TYPE should always be CKC_X_509; Found: #{v.inspect}"; v
39
+ when "CK_TRUST" ; case v
40
+ when "CKT_NSS_MUST_VERIFY_TRUST"; :must_verify_trust
41
+ when "CKT_NSS_NOT_TRUSTED" ; :not_trusted
42
+ when "CKT_NSS_TRUSTED_DELEGATOR"; :trusted_delegator
43
+ when "CKT_NSS_TRUST_UNKNOWN" ; :trust_unknown
44
+ else raise "Unexpected CK_TRUST value: #{v.inspect}"
45
+ end
46
+ when "CK_BBOOL" ; case v
47
+ when "CK_FALSE"; false
48
+ when "CK_TRUE" ; true
49
+ else raise "Unexpected CK_BBOOL value: #{v.inspect}"
50
+ end
51
+ when "UTF8" ; v =~ /\A"(.*)"\z/ or raise "Unexpected UTF8 value: #{v}.inspect"
52
+ $1.force_encoding("UTF-8")
53
+ when "MULTILINE_OCTAL" ; v =~ /\A(\\[0-3][0-7][0-7])*\z/ or raise "Unexpected MULTILINE_OCTAL value: #{v}.inspect"
54
+ v.gsub(/\\([0-3][0-7][0-7])/) { $1.oct.chr }
55
+ else raise "Unexpected type: #{t.inspect}"
56
+ end
57
+ end
58
+ end
59
+
60
+ ### match trust data to certs, merge data
61
+ trust = trust.inject({}) { |h, t| t.key?(:cert_sha1_hash) and h[t[:cert_sha1_hash]] = t; h }
62
+ certificates.each do |cert|
63
+ trust[Digest::SHA1.digest(cert[:value])].each do |k, v|
64
+ raise "Cert / Trust value mismatch for key #{k.inspect}" if cert.key?(k) && cert[k] != v
65
+ cert[k] = v
66
+ end
67
+ end
68
+
69
+ ### select trusted certificates
70
+ trusted_certificates = certificates.select { |c| c[:trust_server_auth] == :trusted_delegator }
71
+
72
+ ### print it all
73
+ puts license
74
+ trusted_certificates.each do |cert|
75
+ puts
76
+ puts cert[:label]
77
+ puts cert[:label].gsub(/./, '=')
78
+ puts "-----BEGIN CERTIFICATE-----"
79
+ puts [cert[:value]].pack("m50")
80
+ puts "-----END CERTIFICATE-----"
81
+ end
82
+
83
+ # Hey, at least it's not MORK!
84
+
85
+
86
+ __END__
87
+ Print trusted certificates from Mozilla's certdata.txt, converting from its crazy-ass data format to saner PEM strings.
88
+
89
+ Usage:
90
+ $0 < certdata.txt
91
+
92
+ Example:
93
+ curl -s 'http://mxr.mozilla.org/mozilla/source/security/nss/lib/ckfw/builtins/certdata.txt?raw=1' | $0
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+
4
+ require "irb"
5
+ require "yaml"
6
+
7
+ require_relative "../lib/ssltool/certificate_store"
8
+
9
+ $all = SSLTool::Certificate.scan(IO.read("var/all-the-certs.pem")) rescue []
10
+ $store = SSLTool::CertificateStore.new("file://var/pools")
11
+ $trusted = $store.trusted_pool
12
+ $pool = $store.intermediate_pool
13
+ $excluded = $store.excluded_pool
14
+ $domains = $all.select(&:for_domain_name?)
15
+ IRB.start
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+
4
+ # input..: certificate chain PEM string + garbage
5
+ # output.: oredered / complete certificate chain PEM string
6
+
7
+ require_relative "../lib/ssltool/chain_resolution"
8
+
9
+ def die(msg, code=1)
10
+ $stderr.puts msg unless msg.empty?
11
+ exit code
12
+ end
13
+
14
+ begin
15
+ store = SSLTool::CertificateStore.new("file://var/pools")
16
+ chain = store.resolve_chain_from_pem_string(ARGF.read)
17
+ rescue SSLTool::ChainResolution::ZeroCertsChainResolutionError ; die("No certificate given.", 1)
18
+ rescue SSLTool::ChainResolution::ZeroHeadsChainResolutionError ; die("No certificate given covers a domain name.", 2)
19
+ rescue SSLTool::ChainResolution::TooManyHeadsChainResolutionError; die("More than one domain name certificate given.", 3)
20
+ end
21
+
22
+ puts chain
23
+ $stderr.puts("!! Failed to complete chain.") unless chain.complete?
24
+ $stderr.puts("!! Certificate is self-signed.") if chain.self_signed_untrusted?
25
+ $stderr.puts("!! Certificate is not trusted.") unless chain.trusted? || chain.self_signed?
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+
4
+ require_relative "../lib/ssltool/certificate_store"
5
+ require_relative "../lib/ssltool/adapters/filesystem"
6
+ require_relative "../lib/ssltool/adapters/sequel"
7
+
8
+ fs_store = SSLTool::CertificateStore::FilesystemAdapter.new "var/pools"
9
+ db_store = SSLTool::CertificateStore::SequelAdapter.new "sqlite://var/pools/store.sqlite"
10
+
11
+ %w[ excluded intermediate trusted ].each do |pool|
12
+ db_store.store_pool(pool, fs_store.load_pool(pool))
13
+ end
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+
4
+ # input..: any strings containing valid PEM strings
5
+ # output.: valid certificate PEM strings
6
+
7
+ require_relative "../lib/ssltool/certificate"
8
+
9
+ certs = SSLTool::PEMScanner.new(ARGF.read).certs.map(&:strip).uniq.map do |s|
10
+ begin
11
+ SSLTool::Certificate.new(s)
12
+ rescue => e
13
+ $stderr.puts "--- Failed to instantiate certificate from:", s, e
14
+ exit 1
15
+ end
16
+ end.uniq
17
+
18
+ puts certs
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+
4
+ # input..: multiple certificate PEM string + garbage
5
+ # output.: cert information
6
+
7
+ require_relative "../lib/ssltool/certificate_store"
8
+ require_relative "../lib/ssltool/adapters/filesystem"
9
+
10
+ store = SSLTool::CertificateStore.new("file://var/pools")
11
+ certs = SSLTool::Certificate.scan(ARGF.read)
12
+
13
+ certs.each do |cert|
14
+ trusted = store.trust?(cert)
15
+ puts "---"
16
+ puts cert.to_s.split(/\n/)[1, 2]
17
+ puts "Domains.....: #{cert.domain_names.join(', ')}" if cert.for_domain_name?
18
+ puts "Subject.....: #{cert.subject}"
19
+ puts "Issuer......: #{cert.issuer}"
20
+ puts "Self-signed.: #{cert.self_signed?}"
21
+ puts "Trusted.....: #{trusted}"
22
+ puts "Issued......: #{cert.not_before}"
23
+ puts "Expires.....: #{cert.not_after}"
24
+ puts "Other.......: version #{cert.version} / certificate_sign:#{cert.certificate_sign?} / certificate_authority:#{cert.certificate_authority?}"
25
+ puts
26
+ end
data/lib/ssltool.rb ADDED
@@ -0,0 +1,9 @@
1
+ module SSLTool; end
2
+
3
+ require_relative "ssltool/pem_scanner"
4
+ require_relative "ssltool/certificate"
5
+ require_relative "ssltool/certificate_store"
6
+ require_relative "ssltool/chain_resolution"
7
+ require_relative "ssltool/key_helper"
8
+ require_relative "ssltool/adapters/filesystem"
9
+ require_relative "ssltool/adapters/sequel"
@@ -0,0 +1,18 @@
1
+ # encoding: UTF-8
2
+ require 'set'
3
+
4
+ module SSLTool
5
+ class CertificateStore
6
+ class Adapter
7
+
8
+ def load_pool(pool_name)
9
+ raise NotImplementedError
10
+ end
11
+
12
+ def store_pool(pool_name, certs)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,33 @@
1
+ # encoding: UTF-8
2
+ require_relative 'base'
3
+
4
+ module SSLTool
5
+ class CertificateStore
6
+ class FilesystemAdapter < Adapter
7
+ def initialize(base_path)
8
+ @base_path = base_path
9
+ end
10
+
11
+ def load_pool(pool_name)
12
+ Certificate.scan(read_pool(pool_name)).to_set
13
+ end
14
+
15
+ def store_pool(pool_name, certs)
16
+ return if read_pool(pool_name) == certs.to_set
17
+ open(pool_path(pool_name), 'w') { |io| io.puts certs.map(&:to_pem).sort }
18
+ end
19
+
20
+ private
21
+
22
+ def pool_path(pool_name)
23
+ File.join(@base_path, "#{pool_name}.pem")
24
+ end
25
+
26
+ def read_pool(pool_name)
27
+ path = pool_path(pool_name)
28
+ return "" unless File.exists?(path)
29
+ File.read(path).strip
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,36 @@
1
+ # encoding: UTF-8
2
+ require 'sequel'
3
+ require_relative 'base'
4
+
5
+ module SSLTool
6
+ class CertificateStore
7
+ class SequelAdapter < Adapter
8
+ def initialize(database_url)
9
+ @database = Sequel.connect(database_url)
10
+ @database.create_table? :certificates do
11
+ column :pool, :varchar, null:false
12
+ column :pem, :text, null:false
13
+ column :fingerprint, :char, null:false, size:40
14
+ index :fingerprint
15
+ index [:pool, :fingerprint], unique:true
16
+ end
17
+ @certificates = @database[:certificates]
18
+ end
19
+
20
+ def load_pool(pool_name)
21
+ @certificates.filter(pool:pool_name.to_s).map(:pem).map { |pem| Certificate.new pem }.to_set
22
+ end
23
+
24
+ def store_pool(pool_name, certs)
25
+ @database.transaction do
26
+ current_set = load_pool(pool_name)
27
+ replacement_set = certs.to_set
28
+ delete_set = (current_set - replacement_set).map { |cert| { fingerprint:cert.fingerprint, pool:pool_name.to_s } }
29
+ insert_set = (replacement_set - current_set).map { |cert| { fingerprint:cert.fingerprint, pool:pool_name.to_s, pem:cert.to_pem } }
30
+ delete_set.each { |params| @certificates.where(params).delete }
31
+ @certificates.multi_insert(insert_set)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,108 @@
1
+ # encoding: UTF-8
2
+ require 'openssl'
3
+ require 'weakref'
4
+ require 'digest/sha1'
5
+
6
+ require_relative 'pem_scanner'
7
+
8
+ module SSLTool
9
+ class Certificate < OpenSSL::X509::Certificate
10
+ RX_DOMAIN_NAME = /^(\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z0-9]+$/
11
+
12
+ # The certificate_cache stuff ensures we always get the same object every time we instantiate the same certificate.
13
+ # This is regardless of differences in the string used to instantiate the object.
14
+ # The weakrefs ensure we don't hold on to certs that are not being used outside of the cache.
15
+ @@certificate_cache = {}
16
+ def self.new(s)
17
+ cert = super(s)
18
+ k = cert.fingerprint
19
+ @@certificate_cache.delete(k) if v = @@certificate_cache[k] and v.respond_to?(:weakref_alive?) && !v.weakref_alive?
20
+ (@@certificate_cache[k] ||= WeakRef.new(cert)).__getobj__
21
+ end
22
+
23
+ # returns an array of Certificate objects created from cert strings found in s
24
+ def self.scan(s)
25
+ PEMScanner.new(s).certs.map { |s| new(s) }.uniq
26
+ end
27
+
28
+ ### signing
29
+
30
+ def signed_by?(other_cert)
31
+ verify(other_cert.public_key)
32
+ rescue OpenSSL::X509::CertificateError => e
33
+ # catching common error cases and returning nil
34
+ return nil if e.message == "wrong public key type" && other_cert.signature_algorithm =~ /^ecdsa/ # this error was seen with ecdsa-with-SHA384 signers, not sure why
35
+ return nil if e.message == "unknown message digest algorithm" && signature_algorithm =~ /^md2/ # md2 is not present in later versions of openssl
36
+ raise e
37
+ end
38
+
39
+ def signs?(other_cert)
40
+ other_cert.signed_by?(self)
41
+ end
42
+
43
+ def self_signed?
44
+ signs?(self)
45
+ end
46
+
47
+ def self_issued?
48
+ subject.eql?(issuer)
49
+ end
50
+
51
+ ### properties
52
+
53
+ def fingerprint
54
+ @fingerprint ||= Digest::SHA1.hexdigest(to_der)
55
+ end
56
+
57
+ def common_name
58
+ k, v, t = subject.to_a.find { |k, v, t| k == "CN" }; v
59
+ end
60
+
61
+ def for_domain_name?
62
+ common_name =~ RX_DOMAIN_NAME
63
+ end
64
+
65
+ def domain_names
66
+ [ (common_name if for_domain_name?),
67
+ map_extension_value('subjectAltName') { |s| s.scan(/\bDNS:([^\s,]+)/) },
68
+ ].flatten.compact.sort.uniq
69
+ end
70
+
71
+ def certificate_authority?
72
+ map_extension_value('basicConstraints') { |s| s.split(", ").include?('CA:TRUE') }
73
+ end
74
+
75
+ def certificate_sign?
76
+ map_extension_value('keyUsage') { |s| s.split(", ").include?('Certificate Sign') }
77
+ end
78
+
79
+ ### extensions
80
+
81
+ module Extensions
82
+ def [](k)
83
+ return super if k.is_a?(Integer) || !k.respond_to?(:to_str)
84
+ find { |e| e.oid == k.to_str }
85
+ end
86
+ end
87
+
88
+ def extensions
89
+ super.tap { |a| class << a; include Extensions; end }
90
+ end
91
+
92
+ def map_extension_value(extension_name, default = nil)
93
+ e = extensions[extension_name]
94
+ return default if e.nil?
95
+ yield(e.value)
96
+ end
97
+
98
+ ### chain
99
+
100
+ def chain_from(certs)
101
+ chain = [self]
102
+ parent = (certs - chain).find { |cert| cert.signs?(self) }
103
+ chain.concat(parent.chain_from(certs - chain)) if parent
104
+ chain
105
+ end
106
+
107
+ end
108
+ end