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 +4 -4
- data/LICENSE +21 -0
- data/README.md +3 -6
- data/bin/mkchain +2 -7
- data/lib/mkchain/cli.rb +79 -0
- data/lib/mkchain/core.rb +151 -0
- data/lib/mkchain/version.rb +5 -0
- data/lib/mkchain.rb +4 -23
- data/mkchain.gemspec +18 -10
- metadata +15 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dd1dfa10f2fce6363800bd991a591ec457816dec9da5fb0953c2552d3bd43cb5
|
4
|
+
data.tar.gz: b2d96f3d347f7e2e43b033f7664504c24c29154aab522accda414f19d7880d8b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
data/lib/mkchain/cli.rb
ADDED
@@ -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
|
data/lib/mkchain/core.rb
ADDED
@@ -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
|
data/lib/mkchain.rb
CHANGED
@@ -1,24 +1,5 @@
|
|
1
|
-
|
2
|
-
require 'open-uri'
|
1
|
+
# frozen_string_literal: true
|
3
2
|
|
4
|
-
|
5
|
-
|
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 =
|
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 = '>=
|
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
|
18
|
-
|
19
|
-
|
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:
|
4
|
+
version: 2.0.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- David Adams
|
8
|
-
|
8
|
+
- David Warkentin
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
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:
|
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
|
-
|
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:
|
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.
|
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: []
|