csr_peek 0.1.0
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 +7 -0
- data/CHANGELOG.md +62 -0
- data/LICENSE +21 -0
- data/README.md +240 -0
- data/exe/csr_peek +96 -0
- data/lib/csr_peek/certificate.rb +163 -0
- data/lib/csr_peek/csr.rb +101 -0
- data/lib/csr_peek/extensions.rb +157 -0
- data/lib/csr_peek/inspectable.rb +129 -0
- data/lib/csr_peek/key_facts.rb +54 -0
- data/lib/csr_peek/names.rb +39 -0
- data/lib/csr_peek/policy.rb +98 -0
- data/lib/csr_peek/version.rb +5 -0
- data/lib/csr_peek.rb +150 -0
- metadata +63 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "ipaddr"
|
|
5
|
+
|
|
6
|
+
module CsrPeek
|
|
7
|
+
# Decoders for the X.509 extensions we surface, working from the extension's
|
|
8
|
+
# DER rather than OpenSSL's human-readable #value string. The display string
|
|
9
|
+
# splits on ", " (which corrupts a directoryName between RDNs) and its labels
|
|
10
|
+
# are locale- and version-dependent; decoding the ASN.1 is exact and stable.
|
|
11
|
+
# Every method tolerates a nil extension and never raises.
|
|
12
|
+
module Extensions
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# GeneralName CHOICE context tags (RFC 5280 4.2.1.6).
|
|
16
|
+
GN_RFC822 = 1 # rfc822Name (email)
|
|
17
|
+
GN_DNS = 2 # dNSName
|
|
18
|
+
GN_URI = 6 # uniformResourceIdentifier
|
|
19
|
+
GN_IP = 7 # iPAddress
|
|
20
|
+
GN_DIRNAME = 4 # directoryName
|
|
21
|
+
|
|
22
|
+
# keyUsage BIT STRING positions (RFC 5280 4.2.1.3), bit 0 first.
|
|
23
|
+
KEY_USAGE_NAMES = %w[
|
|
24
|
+
digitalSignature nonRepudiation keyEncipherment dataEncipherment
|
|
25
|
+
keyAgreement keyCertSign cRLSign encipherOnly decipherOnly
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
def empty_sans
|
|
29
|
+
{dns: [], ip: [], email: [], uri: [], other: []}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Parse a subjectAltName extension into { dns:, ip:, email:, uri:, other: }.
|
|
33
|
+
# The result is deep-frozen: the value objects hold it directly, and a
|
|
34
|
+
# caller must not be able to mutate a memoized view.
|
|
35
|
+
def subject_alt_names(extension)
|
|
36
|
+
deep_freeze(parse_san(extension))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse_san(extension)
|
|
40
|
+
return empty_sans if extension.nil?
|
|
41
|
+
|
|
42
|
+
der = extn_value_der(extension)
|
|
43
|
+
return empty_sans if der.nil?
|
|
44
|
+
|
|
45
|
+
general_names(OpenSSL::ASN1.decode(der))
|
|
46
|
+
rescue OpenSSL::ASN1::ASN1Error, OpenSSL::X509::ExtensionError
|
|
47
|
+
empty_sans
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Deep-freeze so the value object that holds this is genuinely immutable:
|
|
51
|
+
# the category arrays AND the (OpenSSL-derived, non-literal) strings inside.
|
|
52
|
+
def deep_freeze(sans)
|
|
53
|
+
sans.each_value { |values| values.each(&:freeze).freeze }
|
|
54
|
+
sans.freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# keyUsage as canonical names, e.g. ["digitalSignature", "keyEncipherment"].
|
|
58
|
+
def key_usages(extension)
|
|
59
|
+
return [] if extension.nil?
|
|
60
|
+
|
|
61
|
+
der = extn_value_der(extension)
|
|
62
|
+
return [] if der.nil?
|
|
63
|
+
|
|
64
|
+
bits = OpenSSL::ASN1.decode(der)
|
|
65
|
+
bytes = bits.value.bytes
|
|
66
|
+
KEY_USAGE_NAMES.each_index.select { |i| bit_set?(bytes, i) }.map { |i| KEY_USAGE_NAMES[i] }
|
|
67
|
+
rescue OpenSSL::ASN1::ASN1Error, NoMethodError
|
|
68
|
+
[]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# extendedKeyUsage as short OID names, e.g. ["serverAuth", "clientAuth"].
|
|
72
|
+
# Falls back to the dotted OID for usages OpenSSL cannot name.
|
|
73
|
+
def extended_key_usages(extension)
|
|
74
|
+
return [] if extension.nil?
|
|
75
|
+
|
|
76
|
+
der = extn_value_der(extension)
|
|
77
|
+
return [] if der.nil?
|
|
78
|
+
|
|
79
|
+
sequence = OpenSSL::ASN1.decode(der)
|
|
80
|
+
Array(sequence.value).map do |oid|
|
|
81
|
+
(oid.respond_to?(:sn) ? (oid.sn || oid.oid) : oid.to_s).freeze
|
|
82
|
+
end
|
|
83
|
+
rescue OpenSSL::ASN1::ASN1Error, NoMethodError
|
|
84
|
+
[]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# basicConstraints as { ca: true/false, path_length: Integer or nil }.
|
|
88
|
+
def basic_constraints(extension)
|
|
89
|
+
absent = {ca: false, path_length: nil}
|
|
90
|
+
return absent if extension.nil?
|
|
91
|
+
|
|
92
|
+
der = extn_value_der(extension)
|
|
93
|
+
return absent if der.nil?
|
|
94
|
+
|
|
95
|
+
sequence = OpenSSL::ASN1.decode(der)
|
|
96
|
+
elements = Array(sequence.value)
|
|
97
|
+
ca = elements.find { |e| e.is_a?(OpenSSL::ASN1::Boolean) }&.value || false
|
|
98
|
+
path = elements.find { |e| e.is_a?(OpenSSL::ASN1::Integer) }
|
|
99
|
+
{ca: ca, path_length: path&.value&.to_i}
|
|
100
|
+
rescue OpenSSL::ASN1::ASN1Error, NoMethodError
|
|
101
|
+
absent
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The raw DER carried inside an extension's OCTET STRING.
|
|
105
|
+
def extn_value_der(extension)
|
|
106
|
+
sequence = OpenSSL::ASN1.decode(extension.to_der)
|
|
107
|
+
octet = Array(sequence.value).find { |el| el.is_a?(OpenSSL::ASN1::OctetString) }
|
|
108
|
+
octet&.value
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def general_names(sequence)
|
|
112
|
+
result = empty_sans
|
|
113
|
+
Array(sequence.value).each do |gn|
|
|
114
|
+
next unless gn.respond_to?(:tag) && gn.tag_class == :CONTEXT_SPECIFIC
|
|
115
|
+
|
|
116
|
+
case gn.tag
|
|
117
|
+
when GN_DNS then result[:dns] << gn.value.to_s
|
|
118
|
+
when GN_RFC822 then result[:email] << gn.value.to_s
|
|
119
|
+
when GN_URI then result[:uri] << gn.value.to_s
|
|
120
|
+
when GN_IP then (ip = format_ip(gn.value)) && result[:ip] << ip
|
|
121
|
+
else result[:other] << general_name_to_s(gn)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
result
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# dotted-quad for IPv4, canonical (compressed) form for IPv6, nil otherwise.
|
|
128
|
+
def format_ip(bytes)
|
|
129
|
+
raw = bytes.to_s
|
|
130
|
+
case raw.bytesize
|
|
131
|
+
when 4 then raw.unpack("C4").join(".")
|
|
132
|
+
when 16 then IPAddr.new(IPAddr.ntop(raw)).to_s
|
|
133
|
+
end
|
|
134
|
+
rescue IPAddr::Error
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# directoryName is decoded to its slash form so no information is lost; any
|
|
139
|
+
# other kind is labelled by its tag number.
|
|
140
|
+
def general_name_to_s(gn)
|
|
141
|
+
if gn.tag == GN_DIRNAME
|
|
142
|
+
inner = Array(gn.value).first
|
|
143
|
+
return "dirName:#{OpenSSL::X509::Name.new(inner.to_der)}" if inner
|
|
144
|
+
end
|
|
145
|
+
"tag#{gn.tag}"
|
|
146
|
+
rescue
|
|
147
|
+
"tag#{gn.tag}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def bit_set?(bytes, index)
|
|
151
|
+
byte = bytes[index / 8]
|
|
152
|
+
return false if byte.nil?
|
|
153
|
+
|
|
154
|
+
byte.anybits?(0x80 >> (index % 8))
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require_relative "policy"
|
|
5
|
+
|
|
6
|
+
module CsrPeek
|
|
7
|
+
# The reading surface shared by the Csr and Certificate value objects.
|
|
8
|
+
#
|
|
9
|
+
# Both are immutable Data structs built from the same precomputed members
|
|
10
|
+
# (subject, subject_alt_names, key_type, key_bits, ec_curve, public_key,
|
|
11
|
+
# openssl). This module turns those flat members into the friendly questions
|
|
12
|
+
# callers ask. Because the members are already resolved and frozen, nothing
|
|
13
|
+
# here re-touches the raise-prone key-loading path.
|
|
14
|
+
#
|
|
15
|
+
# It is *prepended* (not included) into each Data class so that its value
|
|
16
|
+
# semantics - #==, #hash, #inspect keyed on the canonical DER - override the
|
|
17
|
+
# defaults Data generates, which would otherwise compare the live OpenSSL
|
|
18
|
+
# handles and dump their bytes.
|
|
19
|
+
module Inspectable
|
|
20
|
+
# Cryptographic strength floors (independent of any issuance policy).
|
|
21
|
+
MIN_RSA_BITS = 2048
|
|
22
|
+
MIN_EC_BITS = 256
|
|
23
|
+
MIN_DSA_BITS = 2048
|
|
24
|
+
|
|
25
|
+
# Edwards signature schemes: strong regardless of RSA-style bit size.
|
|
26
|
+
STRONG_UNSIZED_TYPES = %w[ED25519 ED448].freeze
|
|
27
|
+
|
|
28
|
+
def common_name
|
|
29
|
+
subject["CN"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def dns_names
|
|
33
|
+
subject_alt_names[:dns]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def ip_addresses
|
|
37
|
+
subject_alt_names[:ip]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# DNS SANs when present, else the Common Name. De-duplicated, stable order.
|
|
41
|
+
def all_names
|
|
42
|
+
return dns_names.uniq unless dns_names.empty?
|
|
43
|
+
|
|
44
|
+
common_name ? [common_name] : []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Cryptographic strength only - see #acceptable_key? for policy. A key that
|
|
48
|
+
# failed to load ("unknown") is treated as weak: "cannot verify" is not safe.
|
|
49
|
+
def weak_key?
|
|
50
|
+
case key_type
|
|
51
|
+
when "RSA" then key_bits.nil? || key_bits < MIN_RSA_BITS
|
|
52
|
+
when "EC" then key_bits.nil? || key_bits < MIN_EC_BITS
|
|
53
|
+
when "DSA" then key_bits.nil? || key_bits < MIN_DSA_BITS
|
|
54
|
+
when *STRONG_UNSIZED_TYPES then false
|
|
55
|
+
else true
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# True when the key satisfies the issuance policy (Baseline Requirements by
|
|
60
|
+
# default). See CsrPeek::Policy.
|
|
61
|
+
def acceptable_key?(policy = Policy::BASELINE)
|
|
62
|
+
key_policy_violations(policy).empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The policy reasons the key is unacceptable, as symbols (empty if fine).
|
|
66
|
+
def key_policy_violations(policy = Policy::BASELINE)
|
|
67
|
+
policy.key_violations(
|
|
68
|
+
type: key_type,
|
|
69
|
+
bits: key_bits,
|
|
70
|
+
curve: ec_curve,
|
|
71
|
+
spki_fingerprint: spki_fingerprint(:sha256)
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Hex fingerprint of the SubjectPublicKeyInfo DER - the stable identity of
|
|
76
|
+
# the key across every CSR and certificate that carries it. nil when the
|
|
77
|
+
# key could not be loaded.
|
|
78
|
+
def spki_fingerprint(algo = :sha256)
|
|
79
|
+
key = public_key
|
|
80
|
+
return nil if key.nil?
|
|
81
|
+
|
|
82
|
+
OpenSSL::Digest.hexdigest(digest_name(algo), key.public_to_der)
|
|
83
|
+
rescue OpenSSL::PKey::PKeyError, OpenSSL::Digest::DigestError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Hex fingerprint of the whole object (DER). nil if it cannot be encoded.
|
|
88
|
+
def fingerprint(algo = :sha256)
|
|
89
|
+
OpenSSL::Digest.hexdigest(digest_name(algo), openssl.to_der)
|
|
90
|
+
rescue OpenSSL::Digest::DigestError,
|
|
91
|
+
OpenSSL::X509::RequestError,
|
|
92
|
+
OpenSSL::X509::CertificateError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Value semantics keyed on the canonical DER, not the live OpenSSL handle,
|
|
97
|
+
# so two parses of the same bytes are equal and usable as hash keys.
|
|
98
|
+
def ==(other)
|
|
99
|
+
other.is_a?(self.class) && der_identity == other.send(:der_identity)
|
|
100
|
+
end
|
|
101
|
+
alias_method :eql?, :==
|
|
102
|
+
|
|
103
|
+
def hash
|
|
104
|
+
der_identity.hash
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def inspect
|
|
108
|
+
"#<#{self.class.name} cn=#{common_name.inspect} #{key_type} #{key_bits || "?"}-bit>"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def der_identity
|
|
114
|
+
openssl.to_der
|
|
115
|
+
rescue
|
|
116
|
+
object_id
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def digest_name(algo)
|
|
120
|
+
case algo.to_s.downcase
|
|
121
|
+
when "sha256" then "SHA256"
|
|
122
|
+
when "sha1" then "SHA1"
|
|
123
|
+
when "md5" then "MD5"
|
|
124
|
+
when "sha512" then "SHA512"
|
|
125
|
+
else algo.to_s
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module CsrPeek
|
|
6
|
+
# Pure, nil-safe extraction of the plain facts about a public key: its type,
|
|
7
|
+
# size, and curve. This runs once, at build time, against a key that may have
|
|
8
|
+
# failed to load (nil). Turning the key into flat data here is what lets the
|
|
9
|
+
# value objects answer every downstream question without ever touching the
|
|
10
|
+
# raise-prone key-loading path again.
|
|
11
|
+
module KeyFacts
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# => { type: String, bits: Integer or nil, curve: String or nil }
|
|
15
|
+
def of(pkey)
|
|
16
|
+
{type: type_of(pkey), bits: bits_of(pkey), curve: curve_of(pkey)}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# "RSA", "EC", "DSA", an upper-cased OID for Edwards keys ("ED25519"), or
|
|
20
|
+
# "unknown" when the key could not be loaded.
|
|
21
|
+
def type_of(pkey)
|
|
22
|
+
case pkey
|
|
23
|
+
when nil then "unknown"
|
|
24
|
+
when OpenSSL::PKey::RSA then "RSA"
|
|
25
|
+
when OpenSSL::PKey::EC then "EC"
|
|
26
|
+
when OpenSSL::PKey::DSA then "DSA"
|
|
27
|
+
else
|
|
28
|
+
(pkey.respond_to?(:oid) ? pkey.oid : pkey.class.name.split("::").last).to_s
|
|
29
|
+
end
|
|
30
|
+
rescue OpenSSL::PKey::PKeyError, NoMethodError
|
|
31
|
+
"unknown"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Size in bits: RSA modulus, DSA prime, or EC curve degree. nil otherwise.
|
|
35
|
+
def bits_of(pkey)
|
|
36
|
+
case pkey
|
|
37
|
+
when OpenSSL::PKey::RSA then pkey.n&.num_bits
|
|
38
|
+
when OpenSSL::PKey::DSA then pkey.p&.num_bits
|
|
39
|
+
when OpenSSL::PKey::EC then pkey.group&.degree
|
|
40
|
+
end
|
|
41
|
+
rescue OpenSSL::PKey::PKeyError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The EC curve name (e.g. "prime256v1"), or nil for non-EC keys.
|
|
46
|
+
def curve_of(pkey)
|
|
47
|
+
return nil unless pkey.is_a?(OpenSSL::PKey::EC)
|
|
48
|
+
|
|
49
|
+
pkey.group&.curve_name
|
|
50
|
+
rescue OpenSSL::PKey::PKeyError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module CsrPeek
|
|
6
|
+
# Helpers for turning an OpenSSL::X509::Name (subject, issuer) into plain Ruby.
|
|
7
|
+
# Subject Alternative Names live in CsrPeek::Extensions, not here.
|
|
8
|
+
module Names
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Convert an OpenSSL::X509::Name to a hash of { "CN" => ..., "O" => ... }.
|
|
12
|
+
#
|
|
13
|
+
# Keys are OpenSSL's short names for known OIDs ("CN", "O", "OU", ...) and
|
|
14
|
+
# the dotted OID string for anything it does not recognize. When an
|
|
15
|
+
# attribute repeats (multiple OU, say), the FIRST value wins; the full
|
|
16
|
+
# ordered list, repeats included, is available via #components. Never raises.
|
|
17
|
+
# Values are frozen (they come from OpenSSL, not from frozen literals) so the
|
|
18
|
+
# immutable value object that holds this hash is immutable all the way down.
|
|
19
|
+
def subject_to_h(name)
|
|
20
|
+
return {} if name.nil?
|
|
21
|
+
|
|
22
|
+
name.to_a.each_with_object({}) do |(oid, value, _type), acc|
|
|
23
|
+
acc[oid.freeze] ||= value.freeze
|
|
24
|
+
end.freeze
|
|
25
|
+
rescue
|
|
26
|
+
{}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The ordered list of [oid, value] pairs from a name, preserving repeats.
|
|
30
|
+
# Deep-frozen for the same reason as #subject_to_h.
|
|
31
|
+
def components(name)
|
|
32
|
+
return [] if name.nil?
|
|
33
|
+
|
|
34
|
+
name.to_a.map { |oid, value, _type| [oid.freeze, value.freeze].freeze }.freeze
|
|
35
|
+
rescue
|
|
36
|
+
[]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CsrPeek
|
|
4
|
+
# An issuance policy: the rules a key or certificate must satisfy to be
|
|
5
|
+
# *acceptable*, as opposed to whether a key is *cryptographically weak*.
|
|
6
|
+
#
|
|
7
|
+
# These are deliberately separate ideas. An Ed25519 key is not weak, but a
|
|
8
|
+
# policy scoped to the CA/Browser Forum Baseline Requirements does not permit
|
|
9
|
+
# it for public TLS. #weak_key? answers the maths; a Policy answers "may I
|
|
10
|
+
# issue against this?". Pass your own Policy to raise the bar (3072-bit RSA,
|
|
11
|
+
# a curve allowlist, a compromised-key blocklist) without monkey-patching.
|
|
12
|
+
#
|
|
13
|
+
# strict = CsrPeek::Policy.new(min_rsa_bits: 3072, allowed_curves: %w[secp384r1])
|
|
14
|
+
# csr.acceptable_key?(strict) # => false
|
|
15
|
+
# csr.key_policy_violations(strict) # => [:rsa_too_small]
|
|
16
|
+
class Policy
|
|
17
|
+
attr_reader :min_rsa_bits, :min_ec_bits, :min_dsa_bits, :allowed_key_types,
|
|
18
|
+
:allowed_curves, :weak_signature_hashes, :blocked_spki_fingerprints
|
|
19
|
+
|
|
20
|
+
# allowed_key_types / allowed_curves default to nil meaning "no restriction
|
|
21
|
+
# on this axis". blocked_spki_fingerprints is matched case-insensitively and
|
|
22
|
+
# ignoring colons, so "AA:BB" and "aabb" are the same entry.
|
|
23
|
+
def initialize(min_rsa_bits: 2048, min_ec_bits: 256, min_dsa_bits: 2048,
|
|
24
|
+
allowed_key_types: %w[RSA EC], allowed_curves: nil,
|
|
25
|
+
weak_signature_hashes: %w[md2 md4 md5 sha1],
|
|
26
|
+
blocked_spki_fingerprints: [])
|
|
27
|
+
@min_rsa_bits = min_rsa_bits
|
|
28
|
+
@min_ec_bits = min_ec_bits
|
|
29
|
+
@min_dsa_bits = min_dsa_bits
|
|
30
|
+
@allowed_key_types = allowed_key_types&.map(&:to_s)&.to_set&.freeze
|
|
31
|
+
@allowed_curves = allowed_curves&.map(&:to_s)&.to_set&.freeze
|
|
32
|
+
@weak_signature_hashes = weak_signature_hashes.map { |h| h.to_s.downcase }.freeze
|
|
33
|
+
@blocked_spki_fingerprints = blocked_spki_fingerprints
|
|
34
|
+
.map { |f| normalize_fingerprint(f) }.to_set.freeze
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The reasons this key fails the policy, as an array of symbols. Empty means
|
|
39
|
+
# acceptable. Inputs are the plain values an inspector already computes.
|
|
40
|
+
def key_violations(type:, bits:, curve:, spki_fingerprint: nil)
|
|
41
|
+
return [:key_unreadable] if type.nil? || type == "unknown"
|
|
42
|
+
|
|
43
|
+
violations = []
|
|
44
|
+
violations << :unsupported_key_type unless key_type_allowed?(type)
|
|
45
|
+
violations.concat(size_violations(type, bits, curve))
|
|
46
|
+
violations << :key_blocklisted if blocked?(spki_fingerprint)
|
|
47
|
+
violations
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def key_type_allowed?(type)
|
|
51
|
+
allowed_key_types.nil? || allowed_key_types.include?(type.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def curve_allowed?(curve)
|
|
55
|
+
allowed_curves.nil? || (curve && allowed_curves.include?(curve.to_s))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def blocked?(spki_fingerprint)
|
|
59
|
+
return false if spki_fingerprint.nil? || blocked_spki_fingerprints.empty?
|
|
60
|
+
|
|
61
|
+
blocked_spki_fingerprints.include?(normalize_fingerprint(spki_fingerprint))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# True when a certificate's signature algorithm uses a hash this policy
|
|
65
|
+
# rejects (MD5, SHA-1, ...), matched as a substring of the lower-cased name.
|
|
66
|
+
# An unknown (blank) algorithm fails closed - "cannot verify" is not "safe",
|
|
67
|
+
# the same stance #weak_key? takes for an unloadable key.
|
|
68
|
+
def weak_signature?(signature_algorithm)
|
|
69
|
+
name = signature_algorithm.to_s.downcase
|
|
70
|
+
return true if name.empty?
|
|
71
|
+
|
|
72
|
+
weak_signature_hashes.any? { |h| name.include?(h) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def size_violations(type, bits, curve)
|
|
78
|
+
case type
|
|
79
|
+
when "RSA" then (bits.nil? || bits < min_rsa_bits) ? [:rsa_too_small] : []
|
|
80
|
+
when "DSA" then (bits.nil? || bits < min_dsa_bits) ? [:dsa_too_small] : []
|
|
81
|
+
when "EC"
|
|
82
|
+
v = []
|
|
83
|
+
v << :ec_too_small if bits.nil? || bits < min_ec_bits
|
|
84
|
+
v << :curve_not_allowed unless curve_allowed?(curve)
|
|
85
|
+
v
|
|
86
|
+
else [] # Edwards and other strong-but-maybe-unpermitted types: type gate handles them.
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize_fingerprint(value)
|
|
91
|
+
value.to_s.downcase.delete(":")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The CA/Browser Forum Baseline Requirements as a starting point. Defined
|
|
95
|
+
# last so that #initialize can rely on every (private) helper it calls.
|
|
96
|
+
BASELINE = new.freeze
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/csr_peek.rb
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
require_relative "csr_peek/version"
|
|
6
|
+
require_relative "csr_peek/policy"
|
|
7
|
+
require_relative "csr_peek/names"
|
|
8
|
+
require_relative "csr_peek/extensions"
|
|
9
|
+
require_relative "csr_peek/key_facts"
|
|
10
|
+
require_relative "csr_peek/inspectable"
|
|
11
|
+
require_relative "csr_peek/csr"
|
|
12
|
+
require_relative "csr_peek/certificate"
|
|
13
|
+
|
|
14
|
+
# CsrPeek reads certificate signing requests and X.509 certificates and hands
|
|
15
|
+
# back the useful parts safely: subject, common name, Subject Alternative
|
|
16
|
+
# Names, key type and size, and public-key fingerprints. Malformed input
|
|
17
|
+
# returns nil rather than raising, so a bad upload never becomes a 500.
|
|
18
|
+
#
|
|
19
|
+
# csr = CsrPeek.parse(pem_string)
|
|
20
|
+
# csr&.common_name # => "example.com"
|
|
21
|
+
# csr&.dns_names # => ["example.com", "www.example.com"]
|
|
22
|
+
# csr&.weak_key? # => false
|
|
23
|
+
#
|
|
24
|
+
# When you want to know *why* something failed to parse, use the bang variants
|
|
25
|
+
# (parse!, parse_certificate!), which raise CsrPeek::ParseError with a reason.
|
|
26
|
+
#
|
|
27
|
+
# Every name and SAN value comes straight from the (attacker-controlled) input
|
|
28
|
+
# and may contain control characters, newlines, or markup. Escape it before
|
|
29
|
+
# rendering it into HTML, logs, or shell - CsrPeek reports faithfully, it does
|
|
30
|
+
# not sanitize.
|
|
31
|
+
module CsrPeek
|
|
32
|
+
# Base class for every error CsrPeek raises on purpose.
|
|
33
|
+
class Error < StandardError; end
|
|
34
|
+
|
|
35
|
+
# Raised by parse!/parse_certificate! when the input is not a readable CSR or
|
|
36
|
+
# certificate. The non-bang parse/parse_certificate rescue this and return nil.
|
|
37
|
+
class ParseError < Error; end
|
|
38
|
+
|
|
39
|
+
# Upper bound on the input we will look at, so untrusted data cannot exhaust
|
|
40
|
+
# memory or CPU. A real CSR or certificate is a few kilobytes; a full chain is
|
|
41
|
+
# tens. 1 MiB is very generous while still bounding the work.
|
|
42
|
+
MAX_INPUT_BYTES = 1 << 20
|
|
43
|
+
|
|
44
|
+
# Cap on the number of certificates parse_certificates will return from one
|
|
45
|
+
# bundle, so a pathological input cannot make us parse unboundedly many.
|
|
46
|
+
MAX_CHAIN_CERTIFICATES = 100
|
|
47
|
+
|
|
48
|
+
BEGIN_CERTIFICATE = "-----BEGIN CERTIFICATE-----"
|
|
49
|
+
END_CERTIFICATE = "-----END CERTIFICATE-----"
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# Parse a PEM or DER CSR. Returns a CsrPeek::Csr, or nil on invalid input.
|
|
54
|
+
def parse(input)
|
|
55
|
+
parse!(input)
|
|
56
|
+
rescue ParseError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Like #parse, but raises CsrPeek::ParseError instead of returning nil.
|
|
61
|
+
def parse!(input)
|
|
62
|
+
validate_size!(input)
|
|
63
|
+
raise ParseError, "input is empty" if blank?(input)
|
|
64
|
+
|
|
65
|
+
request = decode(input, "certificate signing request") do
|
|
66
|
+
OpenSSL::X509::Request.new(input)
|
|
67
|
+
end
|
|
68
|
+
Csr.from_openssl(request)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Parse a PEM or DER X.509 certificate. Returns a CsrPeek::Certificate, or
|
|
72
|
+
# nil on invalid input.
|
|
73
|
+
def parse_certificate(input)
|
|
74
|
+
parse_certificate!(input)
|
|
75
|
+
rescue ParseError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Like #parse_certificate, but raises CsrPeek::ParseError instead of nil.
|
|
80
|
+
def parse_certificate!(input)
|
|
81
|
+
validate_size!(input)
|
|
82
|
+
raise ParseError, "input is empty" if blank?(input)
|
|
83
|
+
|
|
84
|
+
certificate = decode(input, "X.509 certificate") do
|
|
85
|
+
OpenSSL::X509::Certificate.new(input)
|
|
86
|
+
end
|
|
87
|
+
Certificate.from_openssl(certificate)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Parse every certificate in a PEM bundle (a chain, a fullchain.pem), in file
|
|
91
|
+
# order, as an array of CsrPeek::Certificate. Unparseable blocks are skipped.
|
|
92
|
+
# A single PEM or DER certificate yields a one-element array; junk yields [].
|
|
93
|
+
# Use this instead of #parse_certificate, which only sees the first block.
|
|
94
|
+
def parse_certificates(input)
|
|
95
|
+
return [] unless within_size?(input)
|
|
96
|
+
return [] if blank?(input)
|
|
97
|
+
|
|
98
|
+
certificates = extract_pem_certificates(input.to_s)
|
|
99
|
+
return certificates unless certificates.empty?
|
|
100
|
+
|
|
101
|
+
# No PEM blocks at all: try the whole input as one (DER) certificate.
|
|
102
|
+
cert = parse_certificate(input)
|
|
103
|
+
cert ? [cert] : []
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Run the OpenSSL constructor, translating its failures into ParseError.
|
|
107
|
+
# Only the constructor is wrapped, so a bug in the wrapper build step is not
|
|
108
|
+
# silently reported as invalid input.
|
|
109
|
+
def decode(input, label)
|
|
110
|
+
yield
|
|
111
|
+
rescue OpenSSL::X509::RequestError, OpenSSL::X509::CertificateError,
|
|
112
|
+
ArgumentError, TypeError => e
|
|
113
|
+
raise ParseError, "not a valid #{label} (#{e.message})"
|
|
114
|
+
end
|
|
115
|
+
private_class_method :decode
|
|
116
|
+
|
|
117
|
+
# Split on the END delimiter - linear in the input length - then reconstruct
|
|
118
|
+
# each block. A regex that spans BEGIN...END is quadratic when END markers are
|
|
119
|
+
# missing, which is a cheap denial-of-service against untrusted bundles.
|
|
120
|
+
def extract_pem_certificates(str)
|
|
121
|
+
certificates = []
|
|
122
|
+
str.split(END_CERTIFICATE).each do |chunk|
|
|
123
|
+
start = chunk.index(BEGIN_CERTIFICATE)
|
|
124
|
+
next if start.nil?
|
|
125
|
+
|
|
126
|
+
cert = parse_certificate(chunk[start..] + END_CERTIFICATE)
|
|
127
|
+
certificates << cert if cert
|
|
128
|
+
break if certificates.size >= MAX_CHAIN_CERTIFICATES
|
|
129
|
+
end
|
|
130
|
+
certificates
|
|
131
|
+
end
|
|
132
|
+
private_class_method :extract_pem_certificates
|
|
133
|
+
|
|
134
|
+
def validate_size!(input)
|
|
135
|
+
return if within_size?(input)
|
|
136
|
+
|
|
137
|
+
raise ParseError, "input exceeds #{MAX_INPUT_BYTES} bytes"
|
|
138
|
+
end
|
|
139
|
+
private_class_method :validate_size!
|
|
140
|
+
|
|
141
|
+
def within_size?(input)
|
|
142
|
+
!input.respond_to?(:bytesize) || input.bytesize <= MAX_INPUT_BYTES
|
|
143
|
+
end
|
|
144
|
+
private_class_method :within_size?
|
|
145
|
+
|
|
146
|
+
def blank?(input)
|
|
147
|
+
input.nil? || input.to_s.strip.empty?
|
|
148
|
+
end
|
|
149
|
+
private_class_method :blank?
|
|
150
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: csr_peek
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Suleyman Musayev
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'Parse a certificate signing request or an X.509 certificate and read
|
|
13
|
+
the useful parts safely: subject, common name, Subject Alternative Names, key type
|
|
14
|
+
and size, and public-key fingerprints. Malformed input returns nil instead of leaking
|
|
15
|
+
raw OpenSSL exceptions. Includes CA/Browser Forum aware weak-key checks. Zero runtime
|
|
16
|
+
dependencies beyond the standard library.'
|
|
17
|
+
email:
|
|
18
|
+
- slmusayev@gmail.com
|
|
19
|
+
executables:
|
|
20
|
+
- csr_peek
|
|
21
|
+
extensions: []
|
|
22
|
+
extra_rdoc_files: []
|
|
23
|
+
files:
|
|
24
|
+
- CHANGELOG.md
|
|
25
|
+
- LICENSE
|
|
26
|
+
- README.md
|
|
27
|
+
- exe/csr_peek
|
|
28
|
+
- lib/csr_peek.rb
|
|
29
|
+
- lib/csr_peek/certificate.rb
|
|
30
|
+
- lib/csr_peek/csr.rb
|
|
31
|
+
- lib/csr_peek/extensions.rb
|
|
32
|
+
- lib/csr_peek/inspectable.rb
|
|
33
|
+
- lib/csr_peek/key_facts.rb
|
|
34
|
+
- lib/csr_peek/names.rb
|
|
35
|
+
- lib/csr_peek/policy.rb
|
|
36
|
+
- lib/csr_peek/version.rb
|
|
37
|
+
homepage: https://github.com/msuliq/csr_peek
|
|
38
|
+
licenses:
|
|
39
|
+
- MIT
|
|
40
|
+
metadata:
|
|
41
|
+
rubygems_mfa_required: 'true'
|
|
42
|
+
homepage_uri: https://github.com/msuliq/csr_peek
|
|
43
|
+
source_code_uri: https://github.com/msuliq/csr_peek
|
|
44
|
+
changelog_uri: https://github.com/msuliq/csr_peek/blob/main/CHANGELOG.md
|
|
45
|
+
bug_tracker_uri: https://github.com/msuliq/csr_peek/issues
|
|
46
|
+
rdoc_options: []
|
|
47
|
+
require_paths:
|
|
48
|
+
- lib
|
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 3.2.0
|
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '0'
|
|
59
|
+
requirements: []
|
|
60
|
+
rubygems_version: 3.6.9
|
|
61
|
+
specification_version: 4
|
|
62
|
+
summary: A friendlier, safe way to read CSRs and certificates in Ruby
|
|
63
|
+
test_files: []
|