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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CsrPeek
4
+ VERSION = "0.1.0"
5
+ 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: []