mkchain 1.0.3 → 2.0.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fdbd7f7b809a418c581c5cb91e446acf5fcbe56488001a95c1294af836b4772
4
- data.tar.gz: d7f434b17fae4f78f3203378210deae947740c451b7df43cfbe79dfbe58df234
3
+ metadata.gz: dd1dfa10f2fce6363800bd991a591ec457816dec9da5fb0953c2552d3bd43cb5
4
+ data.tar.gz: b2d96f3d347f7e2e43b033f7664504c24c29154aab522accda414f19d7880d8b
5
5
  SHA512:
6
- metadata.gz: 990ff4e9ab0d6e0152efc8f064231ad95002ca4bde7f8749209a7747f9c416694b3dcfa951fda702daf31602eebfe41f0f62bc5ca432c92b00d248c548fd13e2
7
- data.tar.gz: a5904ad244ca7c99262aa25ca1f86ab0c1b4db4cadb57cdef506eecda69ff2fb8d71ee160aa5c4e014c3599e09f3666e67fa7dcc364c1b1108d7d3fd5142b2ab
6
+ metadata.gz: 6707656fc9eeab78477b4078a8bdf048a4d74bc86ff6c5ea57899a64d32599ae62d9bb491f234aa67cc61779caaf84091f3ad66852dc5c51bee593843a4efb55
7
+ data.tar.gz: 9a2a060b92db1061bf25ada2ec174457ac93dcf9a1313be8b792d8d55754c65dc583079c7a9a21b26c44d6b5c691d5937d10629694fbf8596a755d3c58c3dece
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Instructure, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -5,23 +5,22 @@ intermediate certificate chain, and print it to stdout. This replaces the
5
5
  need to copy/edit cert-vendor provided chain files and deal with certificate
6
6
  order.
7
7
 
8
-
9
8
  ## Installation
10
9
 
11
10
  $ rake install
12
11
 
13
-
14
12
  ## Command-line Usage
15
13
 
16
14
  $ mkchain site.example.com.crt > site.example.com.chain
17
-
15
+ $ mkchain -c 2025-05-30 site.example.com.crt > site.example.com.chain
16
+ $ mkchain -lr site.example.com.crt > site.example.com.fullchain
18
17
 
19
18
  ## Ruby Library
20
19
 
21
20
  You can also invoke `mkchain` from Ruby code:
22
21
 
23
22
  require 'mkchain'
24
- chain_str = MkChain.chain(File.read(cert_filename))
23
+ chain_str = MkChain.new(include_root: true).chain(File.read(cert_filename))
25
24
 
26
25
  This method returns a string containing the contents of the intermediate
27
26
  chain in PEM format. If no chain can be built from the certificate, a
@@ -29,14 +28,12 @@ chain in PEM format. If no chain can be built from the certificate, a
29
28
  (ie, if the certificate was signed directly by the root CA), then an empty
30
29
  string will be returned.
31
30
 
32
-
33
31
  ## No guarantee
34
32
 
35
33
  This method of building an intermediate chain depends on the signing
36
34
  certificate being in the `authorityInfoAccess` X.509 extension field under
37
35
  `CA Issuers`. That's a common but not universal pattern.
38
36
 
39
-
40
37
  ## Similar Tools
41
38
 
42
39
  * https://whatsmychaincert.com/
data/bin/mkchain CHANGED
@@ -1,11 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'mkchain'
4
5
 
5
- abort 'Usage: mkchain <cert-filename>' unless ARGV.count == 1
6
-
7
- filename = ARGV[0]
8
- abort "No such file '#{filename}'" unless File.exist?(filename)
9
- abort "Cannot read file '#{filename}'" unless File.readable?(filename)
10
-
11
- puts MkChain.chain(File.read(filename))
6
+ MkChain::CLI.start
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module MkChain
6
+ class CLI
7
+ def self.start(argv = ARGV)
8
+ new.run(argv)
9
+ end
10
+
11
+ def run(argv)
12
+ options = parse_options(argv)
13
+
14
+ filename = argv.shift&.strip
15
+ abort 'No certificate file specified.' if filename.nil? || filename.empty?
16
+ abort "No such file '#{filename}'" unless File.exist?(filename)
17
+ abort "Cannot read file '#{filename}'" unless File.readable?(filename)
18
+
19
+ puts MkChain::Core.new(options).chain(File.read(filename))
20
+ rescue ArgumentError, MkChain::NoChainFoundException, MkChain::UnknownFormat => e
21
+ puts "Error: #{e.message}"
22
+ exit 1
23
+ rescue StandardError => e
24
+ puts "Unexpected error: #{e.message}"
25
+ exit 1
26
+ end
27
+
28
+ private
29
+
30
+ def parse_options(argv) # rubocop:disable Metrics/MethodLength
31
+ options = {
32
+ include_leaf: false,
33
+ include_root: false,
34
+ cacert_date: nil
35
+ }
36
+ opt_parser = OptionParser.new do |opts|
37
+ opts.banner = 'Usage: mkchain [options] <cert-filename>'
38
+ opts.on('-h', '--help', 'Display this help message') do
39
+ puts opts
40
+ exit
41
+ end
42
+ opts.on('-l', '--include-leaf', 'Include the leaf certificate') do
43
+ options[:include_leaf] = true
44
+ end
45
+ opts.on('-r', '--include-root', 'Include the root certificate') do
46
+ options[:include_root] = true
47
+ end
48
+ opts.on('-c', '--cacert-date DATE',
49
+ 'Build chain against a specific CA bundle revision for better legacy client ' \
50
+ 'compatibility. See https://curl.se/docs/caextract.html') do |date|
51
+ options[:cacert_date] = Date.parse(date)
52
+ require 'net/http'
53
+ require 'uri'
54
+ uri = URI("https://curl.se/ca/cacert-#{date}.pem")
55
+ response = Net::HTTP.get_response(uri)
56
+ if response.code.to_i != 200
57
+ raise "No CA bundle found for date #{date}. Please check the date format or availability. " \
58
+ 'For a subset of available revisions, visit https://curl.se/docs/caextract.html'
59
+ end
60
+
61
+ options[:cacert_date] = date
62
+ rescue Date::Error
63
+ puts 'Invalid date format. Use YYYY-MM-DD.'
64
+ exit 1
65
+ rescue StandardError => e
66
+ puts "Error fetching CA bundle: #{e.message}"
67
+ exit 1
68
+ end
69
+ opts.on('-v', '--version', 'Display version information') do
70
+ puts "mkchain #{MkChain::VERSION}"
71
+ exit
72
+ end
73
+ end
74
+
75
+ opt_parser.parse!(argv)
76
+ options
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'open-uri'
5
+
6
+ module MkChain
7
+ class NoChainFoundException < StandardError; end
8
+ class UnknownFormat < StandardError; end
9
+
10
+ class Core
11
+ def self.parse_pem(data)
12
+ # First try to parse as PEM-encoded X.509 certificates
13
+ begin
14
+ pem_blocks = data.scan(/-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----/m).flatten
15
+ certs = pem_blocks.map do |block|
16
+ OpenSSL::X509::Certificate.new([
17
+ '-----BEGIN CERTIFICATE-----',
18
+ block.strip,
19
+ '-----END CERTIFICATE-----'
20
+ ].join("\n"))
21
+ end
22
+
23
+ return certs unless certs.empty?
24
+ rescue OpenSSL::X509::CertificateError
25
+ # fall through to try PKCS#7
26
+ end
27
+
28
+ # Otherwise attempt PEM-encoded PKCS#7
29
+ begin
30
+ pkcs7 = OpenSSL::PKCS7.new(data)
31
+ return pkcs7.certificates if pkcs7.certificates.any?
32
+ rescue OpenSSL::PKCS7::PKCS7Error, ArgumentError
33
+ # fall through
34
+ end
35
+
36
+ raise UnknownFormat, 'Invalid PEM/PKCS#7 format'
37
+ end
38
+
39
+ def self.parse_der(data)
40
+ # Try to parse as PKCS#7 format first since it can wrap X.509 certs
41
+ begin
42
+ pkcs7 = OpenSSL::PKCS7.new(data)
43
+ return pkcs7.certificates if pkcs7.certificates.any?
44
+ rescue OpenSSL::PKCS7::PKCS7Error, ArgumentError
45
+ # fall through to try X.509 parsing
46
+ end
47
+
48
+ # If it fails, try to parse as a single X.509 certificate
49
+ begin
50
+ cert = OpenSSL::X509::Certificate.new(data)
51
+ return [cert]
52
+ rescue OpenSSL::X509::CertificateError
53
+ # fall through
54
+ end
55
+
56
+ raise UnknownFormat, 'Invalid DER format - could not parse as PKCS#7 or X.509'
57
+ end
58
+
59
+ def initialize(options = {})
60
+ @options = { include_leaf: false, include_root: false, cacert_date: nil }.merge(options)
61
+ end
62
+
63
+ def fetch_certificates(url)
64
+ @certificate_cache ||= {}
65
+ return @certificate_cache[url] if @certificate_cache.key?(url)
66
+
67
+ result = begin
68
+ data = URI.parse(url).read
69
+ if data.start_with?('-----BEGIN ')
70
+ self.class.parse_pem(data)
71
+ elsif data.getbyte(0) == 0x30
72
+ self.class.parse_der(data)
73
+ else
74
+ raise UnknownFormat, "Unknown certificate format - found leading bytes: #{data.byteslice(0, 4).unpack('H*')}"
75
+ end
76
+ rescue OpenURI::HTTPError => e
77
+ raise "Failed to fetch certificates from #{url}: #{e.message}"
78
+ end
79
+
80
+ @certificate_cache[url] = result
81
+ result
82
+ end
83
+
84
+ def chain(cert_str) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
85
+ # Ensure we have a valid certificate string
86
+ raise ArgumentError, 'Certificate string cannot be nil or empty' if cert_str.nil? || cert_str.strip.empty?
87
+
88
+ # If cacert_date is provided, attempt to load a CA bundle at the specified revision
89
+ cacert_date = @options.fetch(:cacert_date, nil)
90
+ cacert_url = "https://curl.se/ca/cacert#{"-#{cacert_date}" if cacert_date}.pem"
91
+ begin
92
+ # Download the CA bundle from the specified date to a temporary file
93
+ require 'tempfile'
94
+ tempfile = Tempfile.new("cacert-#{cacert_date || 'latest'}.pem")
95
+ tempfile.binmode
96
+ tempfile.write(URI.parse(cacert_url).read)
97
+ tempfile.rewind
98
+
99
+ # Load the CA bundle into an OpenSSL::X509::Store
100
+ ca_store = OpenSSL::X509::Store.new
101
+ ca_store.add_file(tempfile.path)
102
+ tempfile.close
103
+ tempfile.unlink
104
+ rescue OpenSSL::X509::StoreError => e
105
+ raise "Failed to load CA bundle (#{cacert_date || 'latest'}): #{e.message}"
106
+ end
107
+
108
+ # Parse the certificate and initialize the chain
109
+ leaf = OpenSSL::X509::Certificate.new(cert_str)
110
+ untrusted = []
111
+
112
+ # Walk through the certificate chain to find intermediates based on AIA extensions
113
+ queue = [leaf]
114
+ while (current = queue.shift)
115
+ # rubocop:disable Style/SafeNavigationChainLength
116
+ uri = current.extensions.find { |ext| ext.oid == 'authorityInfoAccess' }
117
+ &.value
118
+ &.scan(%r{CA Issuers - URI:(https?://\S+)})
119
+ &.flatten&.first
120
+ # rubocop:enable Style/SafeNavigationChainLength
121
+ next unless uri
122
+
123
+ fetch_certificates(uri).each do |c|
124
+ next if c.subject == c.issuer # Skip self-signed/root certs
125
+
126
+ key = [c.subject.to_s, c.issuer.to_s, c.serial]
127
+ unless untrusted.any? { |u| key == [u.subject.to_s, u.issuer.to_s, u.serial] }
128
+ untrusted << c
129
+ queue << c
130
+ end
131
+ end
132
+ end
133
+ raise NoChainFoundException, 'No intermediate certificates found' if untrusted.empty?
134
+
135
+ # Attempt to build the chain from the untrusted certificates using the CA store
136
+ ctx = OpenSSL::X509::StoreContext.new(ca_store, leaf, untrusted)
137
+ raise "Failed to verify and build chain: #{ctx.error_string}" unless ctx.verify
138
+
139
+ # Collect the chain from the context
140
+ chain = ctx.chain
141
+ raise NoChainFoundException, 'No valid certificate chain found' if chain.empty?
142
+
143
+ # Remove the root and/or leaf if not requested
144
+ chain = chain[..-2] if @options[:include_root] == false
145
+ chain = chain[1..] if @options[:include_leaf] == false
146
+
147
+ # Return the chain as an array of PEM-encoded strings
148
+ chain.join
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MkChain
4
+ VERSION = '2.0.2'
5
+ end
data/lib/mkchain.rb CHANGED
@@ -1,24 +1,5 @@
1
- require 'openssl'
2
- require 'open-uri'
1
+ # frozen_string_literal: true
3
2
 
4
- class MkChain
5
- class NoChainFoundException < Exception; end
6
-
7
- def self.chain(cert_str)
8
- chain = []
9
- cert = OpenSSL::X509::Certificate.new(cert_str)
10
-
11
- loop do
12
- url = cert.extensions.select { |ext| ext.oid == 'authorityInfoAccess' }
13
- .first.value.match(%r{^CA Issuers - URI:(https?://.+)$})[1] rescue break
14
-
15
- cert = OpenSSL::X509::Certificate.new(URI.open(url).read) rescue break
16
- chain << cert.to_pem
17
- end
18
-
19
- raise NoChainFoundException, 'No intermediate chain found' if chain.empty?
20
-
21
- # the last cert will be the root cert, which doesn't belong in the chain
22
- chain[0..-1].join
23
- end
24
- end
3
+ require_relative 'mkchain/cli'
4
+ require_relative 'mkchain/core'
5
+ require_relative 'mkchain/version'
data/mkchain.gemspec CHANGED
@@ -1,23 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/mkchain'
4
+
1
5
  Gem::Specification.new do |s|
2
6
  s.name = 'mkchain'
3
- s.version = '1.0.3'
4
- s.authors = ['David Adams']
5
- s.email = 'dadams@instructure.com'
6
- s.date = Time.now.strftime('%Y-%m-%d')
7
+ s.version = MkChain::VERSION
8
+ s.authors = ['David Adams', 'David Warkentin']
9
+ s.email = ['dadams@instructure.com', 'dwarkentin@instructure.com']
7
10
  s.license = 'MIT'
8
11
  s.homepage = 'https://github.com/instructure/mkchain'
9
- s.required_ruby_version = '>=2.0.0'
12
+ s.required_ruby_version = '>=3.0.0'
10
13
 
11
14
  s.summary = 'Create a chain file from SSL cert'
12
15
  s.description =
13
16
  'Creates an intermediate chain file from the given SSL certificate'
14
17
 
18
+ s.metadata = {
19
+ 'rubygems_mfa_required' => 'true',
20
+ 'source_code_uri' => s.homepage,
21
+ }
22
+
15
23
  s.require_paths = ['lib']
16
- s.files = [
17
- 'lib/mkchain.rb',
18
- 'README.md',
19
- 'mkchain.gemspec'
20
- ]
24
+ s.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ Dir.glob('lib/**/*.rb') +
26
+ Dir.glob('bin/*') +
27
+ %w[README.md LICENSE mkchain.gemspec]
28
+ end
21
29
  s.bindir = 'bin'
22
30
  s.executables = ['mkchain']
23
31
  end
metadata CHANGED
@@ -1,31 +1,38 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mkchain
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Adams
8
- autorequire:
8
+ - David Warkentin
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-02-25 00:00:00.000000000 Z
11
+ date: 2025-07-31 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Creates an intermediate chain file from the given SSL certificate
14
- email: dadams@instructure.com
14
+ email:
15
+ - dadams@instructure.com
16
+ - dwarkentin@instructure.com
15
17
  executables:
16
18
  - mkchain
17
19
  extensions: []
18
20
  extra_rdoc_files: []
19
21
  files:
22
+ - LICENSE
20
23
  - README.md
21
24
  - bin/mkchain
22
25
  - lib/mkchain.rb
26
+ - lib/mkchain/cli.rb
27
+ - lib/mkchain/core.rb
28
+ - lib/mkchain/version.rb
23
29
  - mkchain.gemspec
24
30
  homepage: https://github.com/instructure/mkchain
25
31
  licenses:
26
32
  - MIT
27
- metadata: {}
28
- post_install_message:
33
+ metadata:
34
+ rubygems_mfa_required: 'true'
35
+ source_code_uri: https://github.com/instructure/mkchain
29
36
  rdoc_options: []
30
37
  require_paths:
31
38
  - lib
@@ -33,15 +40,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
33
40
  requirements:
34
41
  - - ">="
35
42
  - !ruby/object:Gem::Version
36
- version: 2.0.0
43
+ version: 3.0.0
37
44
  required_rubygems_version: !ruby/object:Gem::Requirement
38
45
  requirements:
39
46
  - - ">="
40
47
  - !ruby/object:Gem::Version
41
48
  version: '0'
42
49
  requirements: []
43
- rubygems_version: 3.1.4
44
- signing_key:
50
+ rubygems_version: 3.6.2
45
51
  specification_version: 4
46
52
  summary: Create a chain file from SSL cert
47
53
  test_files: []