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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 942630258469d6c7e50b6951a51a49ea4c357ef1cf12e861de5af23aaa1fa735
4
+ data.tar.gz: ed71dc05821135752ae788f53b671b1e2529dbac44bcdeece3394cb5492b71b1
5
+ SHA512:
6
+ metadata.gz: 3dd1f46b568fafd5903ec3ec88b99e39174514018e89d06d97583bc28192fd26293420277ae16c13e3babcebf231a4f8d23f6b8c7234ee7570703aa35a527f5f
7
+ data.tar.gz: 474826a92e714615048836ee660f1585b06cfc35e0e89b242ed4f8f7205f1261ff2d18a56d07e21f0804c742de5330d969860a154df6f7564c34f6051fd30821
data/CHANGELOG.md ADDED
@@ -0,0 +1,62 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+
11
+ - Initial release.
12
+ - `CsrPeek.parse` and `CsrPeek.parse_certificate`, returning nil on bad input.
13
+ - `CsrPeek.parse!` and `CsrPeek.parse_certificate!`, raising
14
+ `CsrPeek::ParseError` with a reason when you want to know why input failed.
15
+ - Subject, common name, and categorized Subject Alternative Names.
16
+ - Key type, size, curve, SPKI fingerprint, and CA/Browser Forum weak-key check.
17
+ - Whole-object fingerprints for CSRs and certificates.
18
+ - Certificate signature algorithm and a `weak_signature?` check (flags MD5/SHA-1).
19
+ - Certificate `ca?`, `path_length`, `key_usage`, `extended_key_usage`, and
20
+ `version`.
21
+ - Certificate validity split into `valid_at?`, `expired?`, and `not_yet_valid?`.
22
+ - `CsrPeek::Policy`, a pluggable issuance policy (minimum key sizes, key-type
23
+ and curve allowlists, a compromised-key SPKI blocklist, and weak-signature
24
+ hashes), with a `BASELINE` default. Keys and certificates gain
25
+ `acceptable_key?`/`acceptable?` and `key_policy_violations`/`policy_violations`.
26
+ - `CsrPeek.parse_certificates` for reading every certificate in a PEM bundle
27
+ (a chain / `fullchain.pem`) in order.
28
+ - A `csr_peek` command-line tool that prints a CSR or certificate as JSON.
29
+ - Value semantics on `Csr` and `Certificate`: `==`/`eql?`/`hash` keyed on the
30
+ canonical DER (two parses of the same bytes are equal and hash-compatible),
31
+ and a concise `#inspect`.
32
+
33
+ ### Changed
34
+
35
+ - **Requires Ruby 3.2+.** `Csr` and `Certificate` are now immutable `Data`
36
+ value objects. Every fact is resolved once, in a single guarded pass at parse
37
+ time, into frozen members; the objects are thread-safe and cannot be mutated.
38
+ Build them via `CsrPeek.parse`/`parse_certificate` or `.from_openssl`. The raw
39
+ OpenSSL object remains available as `#openssl`.
40
+ - Subject Alternative Names, `key_usage`, `extended_key_usage`, and
41
+ `basicConstraints` are decoded from the DER rather than OpenSSL's display
42
+ string. SAN values containing commas (a `directoryName` between RDNs) are no
43
+ longer split, IPv6 renders canonically, and usage names are the stable RFC
44
+ identifiers (`digitalSignature`, `serverAuth`) instead of localized labels.
45
+ - `weak_key?` now answers a purely cryptographic question. Ed25519/Ed448 are
46
+ no longer reported as weak, and DSA is judged by size; whether a key is
47
+ *permitted for issuance* moved to `acceptable_key?(policy)`.
48
+ - `all_names` is now SAN-driven: the DNS SANs when present, falling back to the
49
+ Common Name only when there are none.
50
+ - A public key that cannot be loaded now degrades to safe values (unknown type,
51
+ weak key, nil fingerprint) everywhere instead of letting an OpenSSL exception
52
+ escape from `weak_key?`/`to_h`.
53
+ - Subject and SAN views are frozen, so a caller cannot mutate a memoized result.
54
+ - `serial` is normalized to lower-case, even-length hex.
55
+
56
+ ### Internal
57
+
58
+ - Reading logic shared by both value objects lives in a prepended
59
+ `CsrPeek::Inspectable`; key facts are extracted once by `CsrPeek::KeyFacts`;
60
+ distinguished-name and extension decoding live in `CsrPeek::Names` and
61
+ `CsrPeek::Extensions`. The wrappers cannot drift, and no accessor re-touches
62
+ the raise-prone key-loading path.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suleyman Musayev
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 ADDED
@@ -0,0 +1,240 @@
1
+ # CsrPeek
2
+
3
+ A friendlier, safe way to read certificate signing requests and X.509
4
+ certificates in Ruby.
5
+
6
+ The standard library can parse a CSR, but the ergonomics are rough: subjects
7
+ come back as nested arrays, Subject Alternative Names are buried in a requested-
8
+ extensions attribute you have to walk by hand, and a single malformed upload
9
+ raises an `OpenSSL` exception that becomes a 500 if you forget to rescue it.
10
+ CsrPeek wraps all of that in a small, memoized, never-raises API.
11
+
12
+ ## Why it exists
13
+
14
+ - **Safe by default.** `CsrPeek.parse` returns `nil` for junk input - including
15
+ a certificate whose key cannot be loaded. A bad paste never becomes an
16
+ exception in your request path. Want the reason instead? Use `parse!`.
17
+ - **The parts you actually want.** Subject, common name, and categorized SANs
18
+ (DNS, IP, email, URI) without ASN.1 spelunking. SANs are decoded from the DER,
19
+ so a comma inside a value never splits it and IPv6 renders canonically.
20
+ - **Key and signature hygiene built in.** Key type, size, EC curve, a stable
21
+ SubjectPublicKey fingerprint, a `weak_key?` strength check, and a
22
+ `weak_signature?` check that flags MD5/SHA-1 certificates. Issuance rules live
23
+ in a pluggable `CsrPeek::Policy` (`acceptable_key?`), separate from raw
24
+ strength - so "strong but not permitted" (Ed25519 under the Baseline
25
+ Requirements) is expressible.
26
+ - **Immutable value objects.** `Csr` and `Certificate` are frozen `Data`
27
+ structs - every fact is resolved once, at parse time, into frozen members.
28
+ They are safe to share across threads, compare by value (two parses of the
29
+ same bytes are `==` and usable as hash keys), and cannot be mutated.
30
+ - **No dependencies.** Only `openssl` and `ipaddr`, both from the standard
31
+ library.
32
+
33
+ ## Installation
34
+
35
+ Requires Ruby 3.2 or newer.
36
+
37
+ ```ruby
38
+ gem "csr_peek"
39
+ ```
40
+
41
+ Then `bundle install`, or `gem install csr_peek`.
42
+
43
+ ## Usage
44
+
45
+ ### Certificate signing requests
46
+
47
+ ```ruby
48
+ require "csr_peek"
49
+
50
+ csr = CsrPeek.parse(pem_or_der_string) # => CsrPeek::Csr, or nil
51
+ # CsrPeek.parse!(bad_input) # raises CsrPeek::ParseError with a reason
52
+
53
+ csr.common_name # => "example.com"
54
+ csr.subject # => { "CN" => "example.com", "O" => "Example Inc" }
55
+ csr.dns_names # => ["example.com", "www.example.com"]
56
+ csr.ip_addresses # => ["192.0.2.10"]
57
+ csr.all_names # => ["example.com", "www.example.com"] (DNS SANs, else CN)
58
+
59
+ csr.signature_valid? # => true (self-signature verifies)
60
+ csr.key_type # => "RSA"
61
+ csr.key_bits # => 2048
62
+ csr.weak_key? # => false (RSA < 2048 or EC < 256 would be true)
63
+ csr.acceptable? # => true (satisfies the Baseline Requirements policy)
64
+
65
+ csr.spki_fingerprint(:sha256) # => stable identity of the public key
66
+ csr.fingerprint(:sha256) # => digest of the whole CSR
67
+
68
+ csr.to_h # => a flat summary hash
69
+ ```
70
+
71
+ ### Certificates
72
+
73
+ ```ruby
74
+ cert = CsrPeek.parse_certificate(pem_or_der_string) # => CsrPeek::Certificate, or nil
75
+
76
+ cert.common_name # => "example.com"
77
+ cert.issuer # => { "CN" => "Example Root CA" }
78
+ cert.serial # => "0a1b2c" (lower-case, even-length hex)
79
+ cert.version # => 3
80
+ cert.not_before # => Time
81
+ cert.not_after # => Time
82
+ cert.valid_at? # => true (now within [not_before, not_after])
83
+ cert.expired? # => false (now past not_after)
84
+ cert.not_yet_valid? # => false (now before not_before)
85
+ cert.expired?(Time.now + 86_400) # check against a specific moment
86
+ cert.self_signed? # => true/false
87
+
88
+ cert.ca? # => false (basicConstraints CA:TRUE)
89
+ cert.path_length # => nil (pathLenConstraint, when set)
90
+ cert.key_usage # => ["digitalSignature", "keyEncipherment"]
91
+ cert.extended_key_usage # => ["serverAuth", "clientAuth"]
92
+
93
+ cert.signature_algorithm # => "sha256WithRSAEncryption"
94
+ cert.weak_signature? # => false (true for MD5/SHA-1)
95
+
96
+ cert.dns_names # => ["example.com"]
97
+ cert.spki_fingerprint(:sha256)
98
+ ```
99
+
100
+ `key_usage`, `extended_key_usage`, and `basicConstraints` are decoded from the
101
+ DER, not from OpenSSL's display string, so their names are the stable RFC
102
+ identifiers (`digitalSignature`, `serverAuth`) rather than the localized labels.
103
+
104
+ `valid_at?`, `expired?`, and `not_yet_valid?` split what a single `expired?`
105
+ would conflate: `expired?` is strictly "past `not_after`", and a certificate
106
+ whose window has not begun is `not_yet_valid?`, not expired.
107
+
108
+ ### Certificate chains
109
+
110
+ `parse_certificate` reads the first block of its input. For a `fullchain.pem`
111
+ or any PEM bundle, use `parse_certificates` (plural), which returns one
112
+ `Certificate` per block, in order, skipping any that don't parse:
113
+
114
+ ```ruby
115
+ CsrPeek.parse_certificates(File.read("fullchain.pem")).map(&:common_name)
116
+ # => ["example.com", "Example Intermediate CA"]
117
+ ```
118
+
119
+ ## Weak keys vs. acceptable keys
120
+
121
+ Two different questions, kept deliberately separate:
122
+
123
+ **`weak_key?` - is the key cryptographically too small to be safe?** Strength
124
+ only, no policy:
125
+
126
+ | Key type | Weak when |
127
+ | --- | --- |
128
+ | RSA | modulus < 2048 bits |
129
+ | EC | curve degree < 256 bits |
130
+ | DSA | parameter size < 2048 bits |
131
+ | Ed25519 / Ed448 | never (these are strong) |
132
+ | unloadable | always (cannot verify => not trusted) |
133
+
134
+ **`acceptable_key?(policy)` - may I *issue* against this key?** That is a policy
135
+ decision, and it takes a `CsrPeek::Policy`. The default is the CA/Browser Forum
136
+ Baseline Requirements, under which an Ed25519 key is `weak_key? => false` but
137
+ `acceptable_key? => false` (strong, but not permitted for public TLS). Raise the
138
+ bar without monkey-patching:
139
+
140
+ ```ruby
141
+ strict = CsrPeek::Policy.new(
142
+ min_rsa_bits: 3072,
143
+ allowed_curves: %w[secp384r1],
144
+ blocked_spki_fingerprints: known_roca_fingerprints # matched by SPKI
145
+ )
146
+
147
+ csr.acceptable_key?(strict) # => false
148
+ csr.key_policy_violations(strict) # => [:rsa_too_small]
149
+ ```
150
+
151
+ For certificates, `acceptable?` also folds in `weak_signature?` (true for MD5/
152
+ SHA-1), and `policy_violations` returns the combined reasons.
153
+
154
+ The `spki_fingerprint` is the right value to check against known weak- or
155
+ compromised-key lists (Debian OpenSSL, ROCA) - pass them as
156
+ `blocked_spki_fingerprints` - because it identifies the key itself, not the
157
+ request or certificate that happens to carry it.
158
+
159
+ ## Command line
160
+
161
+ The gem ships a `csr_peek` executable that prints a CSR or certificate as JSON.
162
+ Kind is auto-detected from the PEM header; `--csr` / `--cert` force it.
163
+
164
+ ```bash
165
+ csr_peek request.csr # from a file
166
+ csr_peek --cert server.pem # force certificate parsing
167
+ cat request.csr | csr_peek # from stdin
168
+ ```
169
+
170
+ It exits 0 on a readable input and 1 on a parse failure.
171
+
172
+ ## Escape hatch
173
+
174
+ Every value object keeps the underlying OpenSSL object on `#openssl`, for the
175
+ occasional thing CsrPeek does not surface:
176
+
177
+ ```ruby
178
+ cert.openssl # => OpenSSL::X509::Certificate
179
+ csr.openssl # => OpenSSL::X509::Request
180
+ ```
181
+
182
+ ## Scope
183
+
184
+ CsrPeek inspects. It does not build trust chains, verify against a root store,
185
+ or check revocation. Pair it with a proper verification step when you need
186
+ those.
187
+
188
+ ## Handling untrusted input
189
+
190
+ CsrPeek is built to be handed attacker-controlled PEM/DER, but two things are
191
+ the caller's responsibility:
192
+
193
+ - **Escape the values you display or log.** Every name and SAN value
194
+ (`common_name`, `subject`, `issuer`, `dns_names`, SAN `email`/`uri`/`other`)
195
+ comes straight from the input and may contain newlines, control characters,
196
+ or markup. CsrPeek reports them faithfully; it does not sanitize. Escape
197
+ before rendering into HTML, logs, or a shell.
198
+ - **Size is bounded, not unbounded.** Input larger than `CsrPeek::MAX_INPUT_BYTES`
199
+ (1 MiB) is rejected up front (`parse`/`parse_certificate` return `nil`,
200
+ `parse!` raises), and `parse_certificates` returns at most
201
+ `CsrPeek::MAX_CHAIN_CERTIFICATES` (100) certificates from one bundle, so a
202
+ malicious paste cannot exhaust memory or CPU.
203
+
204
+ Signature and self-signature checks (`signature_valid?`, `self_signed?`) run
205
+ real crypto and are opt-in - they are not performed during parsing.
206
+
207
+ ## Compatibility
208
+
209
+ - Ruby >= 3.2.0 (the value objects are built on `Data`)
210
+ - Zero external dependencies (`openssl` and `ipaddr` ship with Ruby)
211
+ - Immutable, thread-safe value objects
212
+ - Linux, macOS, Windows
213
+
214
+ ## Development
215
+
216
+ The repo pins a Ruby version in `.tool-versions`.
217
+
218
+ ```bash
219
+ bundle install
220
+ bundle exec rake spec # run the tests
221
+ bundle exec standardrb # lint (Standard Ruby)
222
+ ```
223
+
224
+ Tests generate their own keys at runtime, so there is no checked-in key
225
+ material.
226
+
227
+ ## Contributing
228
+
229
+ 1. Fork it
230
+ 2. Create your feature branch (`git checkout -b feature/my-feature`)
231
+ 3. Commit your changes
232
+ 4. Push to the branch
233
+ 5. Create a Pull Request
234
+
235
+ CI runs the test suite (Ruby 3.2–3.4), Standard Ruby, and a gem build on every
236
+ pull request.
237
+
238
+ ## License
239
+
240
+ MIT. See [LICENSE](LICENSE).
data/exe/csr_peek ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "csr_peek"
5
+ require "json"
6
+ require "time"
7
+
8
+ # Peek at a CSR or certificate and print its summary as JSON.
9
+ #
10
+ # csr_peek path/to/request.csr
11
+ # csr_peek --cert path/to/cert.pem
12
+ # cat request.csr | csr_peek
13
+ #
14
+ # Kind is auto-detected from the PEM header; --csr / --cert force it. Exits 0 on
15
+ # a readable input, 1 on a parse failure or bad usage.
16
+ module CsrPeek
17
+ module CLI
18
+ module_function
19
+
20
+ USAGE = <<~TXT
21
+ Usage: csr_peek [--csr | --cert] [FILE]
22
+
23
+ Reads a certificate signing request or an X.509 certificate (PEM or DER)
24
+ from FILE, or from stdin when FILE is omitted, and prints its details as
25
+ JSON.
26
+
27
+ --csr force CSR parsing
28
+ --cert force certificate parsing
29
+ -h show this help
30
+ TXT
31
+
32
+ def run(argv)
33
+ force = nil
34
+ paths = []
35
+ argv.each do |arg|
36
+ case arg
37
+ when "--csr" then force = :csr
38
+ when "--cert" then force = :cert
39
+ when "-h", "--help" then warn USAGE
40
+ return 0
41
+ when /\A-/ then warn "unknown option: #{arg}\n\n#{USAGE}"
42
+ return 1
43
+ else paths << arg
44
+ end
45
+ end
46
+ return usage_error("at most one FILE is allowed") if paths.size > 1
47
+
48
+ input = read_input(paths.first)
49
+ object = parse(input, force)
50
+ return failure(paths.first) if object.nil?
51
+
52
+ puts JSON.pretty_generate(jsonify(object.to_h))
53
+ 0
54
+ rescue Errno::ENOENT
55
+ usage_error("no such file: #{paths.first}")
56
+ end
57
+
58
+ def read_input(path)
59
+ path ? File.read(path) : $stdin.read
60
+ end
61
+
62
+ # Force the kind when asked; otherwise sniff the PEM header, defaulting to
63
+ # CSR and falling back to certificate so a headerless DER still gets a try.
64
+ def parse(input, force)
65
+ case force
66
+ when :csr then CsrPeek.parse(input)
67
+ when :cert then CsrPeek.parse_certificate(input)
68
+ else
69
+ if input.include?("CERTIFICATE REQUEST")
70
+ CsrPeek.parse(input)
71
+ elsif input.include?("BEGIN CERTIFICATE")
72
+ CsrPeek.parse_certificate(input)
73
+ else
74
+ CsrPeek.parse(input) || CsrPeek.parse_certificate(input)
75
+ end
76
+ end
77
+ end
78
+
79
+ # Render Time values as ISO-8601 so the JSON is unambiguous.
80
+ def jsonify(hash)
81
+ hash.transform_values { |v| v.is_a?(Time) ? v.iso8601 : v }
82
+ end
83
+
84
+ def failure(path)
85
+ warn "could not parse #{path || "stdin"} as a CSR or certificate"
86
+ 1
87
+ end
88
+
89
+ def usage_error(message)
90
+ warn "#{message}\n\n#{USAGE}"
91
+ 1
92
+ end
93
+ end
94
+ end
95
+
96
+ exit CsrPeek::CLI.run(ARGV)
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require_relative "policy"
5
+ require_relative "names"
6
+ require_relative "extensions"
7
+ require_relative "key_facts"
8
+ require_relative "inspectable"
9
+
10
+ module CsrPeek
11
+ # A friendly, read-only view over an OpenSSL::X509::Certificate.
12
+ #
13
+ # An immutable value object: build one with CsrPeek.parse_certificate (which
14
+ # returns nil for junk input) or Certificate.from_openssl. Parsed facts are
15
+ # resolved once into frozen members. This class inspects; it does not build
16
+ # trust chains or perform revocation checks.
17
+ Certificate = Data.define(
18
+ :openssl, :public_key, :key_type, :key_bits, :ec_curve,
19
+ :subject, :subject_components, :subject_alt_names,
20
+ :issuer, :key_usage, :extended_key_usage, :basic_constraints
21
+ ) do
22
+ prepend Inspectable
23
+
24
+ def self.from_openssl(cert)
25
+ pkey = load_public_key(cert)
26
+ facts = KeyFacts.of(pkey)
27
+ new(
28
+ openssl: cert,
29
+ public_key: pkey,
30
+ key_type: facts[:type],
31
+ key_bits: facts[:bits],
32
+ ec_curve: facts[:curve],
33
+ subject: Names.subject_to_h(cert.subject).freeze,
34
+ subject_components: Names.components(cert.subject).freeze,
35
+ subject_alt_names: Extensions.subject_alt_names(find_ext(cert, "subjectAltName")),
36
+ issuer: Names.subject_to_h(cert.issuer).freeze,
37
+ key_usage: Extensions.key_usages(find_ext(cert, "keyUsage")).freeze,
38
+ extended_key_usage: Extensions.extended_key_usages(find_ext(cert, "extendedKeyUsage")).freeze,
39
+ basic_constraints: Extensions.basic_constraints(find_ext(cert, "basicConstraints")).freeze
40
+ )
41
+ end
42
+
43
+ def self.load_public_key(cert)
44
+ cert.public_key
45
+ rescue OpenSSL::PKey::PKeyError, OpenSSL::X509::CertificateError
46
+ nil
47
+ end
48
+ private_class_method :load_public_key
49
+
50
+ def self.find_ext(cert, oid)
51
+ cert.extensions.find { |e| e.oid == oid }
52
+ rescue OpenSSL::X509::CertificateError
53
+ nil
54
+ end
55
+ private_class_method :find_ext
56
+
57
+ # Serial as lower-case hex, zero-padded to an even number of digits.
58
+ def serial
59
+ hex = openssl.serial.to_s(16).downcase
60
+ hex.length.odd? ? "0#{hex}" : hex
61
+ end
62
+
63
+ # X.509 version as the human-facing number: 1, 2, or 3 (the DER field is
64
+ # zero-based, so v3 is stored as 2).
65
+ def version
66
+ openssl.version + 1
67
+ end
68
+
69
+ def not_before
70
+ openssl.not_before
71
+ end
72
+
73
+ def not_after
74
+ openssl.not_after
75
+ end
76
+
77
+ # True when now falls within [not_before, not_after].
78
+ def valid_at?(now = Time.now)
79
+ now.between?(not_before, not_after)
80
+ end
81
+
82
+ # True when now is after not_after. "expired" means the window has passed;
83
+ # a certificate whose window has not begun is #not_yet_valid?, not expired.
84
+ def expired?(now = Time.now)
85
+ now > not_after
86
+ end
87
+
88
+ def not_yet_valid?(now = Time.now)
89
+ now < not_before
90
+ end
91
+
92
+ def self_signed?
93
+ openssl.subject == openssl.issuer && openssl.verify(public_key)
94
+ rescue OpenSSL::X509::CertificateError, OpenSSL::PKey::PKeyError, TypeError
95
+ false
96
+ end
97
+
98
+ # The signature algorithm name, e.g. "sha256WithRSAEncryption".
99
+ def signature_algorithm
100
+ openssl.signature_algorithm
101
+ rescue OpenSSL::X509::CertificateError
102
+ nil
103
+ end
104
+
105
+ # True when signed with a hash the policy rejects (MD5/SHA-1 by default).
106
+ def weak_signature?(policy = Policy::BASELINE)
107
+ policy.weak_signature?(signature_algorithm)
108
+ end
109
+
110
+ # True when both the key and the signature satisfy the issuance policy.
111
+ def acceptable?(policy = Policy::BASELINE)
112
+ acceptable_key?(policy) && !weak_signature?(policy)
113
+ end
114
+
115
+ # All policy reasons this certificate is unacceptable, as symbols; empty
116
+ # when it passes. Combines key violations with :weak_signature.
117
+ def policy_violations(policy = Policy::BASELINE)
118
+ violations = key_policy_violations(policy)
119
+ violations << :weak_signature if weak_signature?(policy)
120
+ violations
121
+ end
122
+
123
+ # Whether this certificate may act as a CA (basicConstraints CA:TRUE).
124
+ def ca?
125
+ basic_constraints[:ca]
126
+ end
127
+
128
+ # The pathLenConstraint from basicConstraints, or nil when unset.
129
+ def path_length
130
+ basic_constraints[:path_length]
131
+ end
132
+
133
+ def to_h
134
+ {
135
+ subject: subject,
136
+ issuer: issuer,
137
+ common_name: common_name,
138
+ serial: serial,
139
+ version: version,
140
+ not_before: not_before,
141
+ not_after: not_after,
142
+ expired: expired?,
143
+ not_yet_valid: not_yet_valid?,
144
+ self_signed: self_signed?,
145
+ ca: ca?,
146
+ path_length: path_length,
147
+ key_usage: key_usage,
148
+ extended_key_usage: extended_key_usage,
149
+ dns_names: dns_names,
150
+ ip_addresses: ip_addresses,
151
+ all_names: all_names,
152
+ key_type: key_type,
153
+ key_bits: key_bits,
154
+ weak_key: weak_key?,
155
+ acceptable: acceptable?,
156
+ signature_algorithm: signature_algorithm,
157
+ weak_signature: weak_signature?,
158
+ spki_fingerprint_sha256: spki_fingerprint(:sha256),
159
+ fingerprint_sha256: fingerprint(:sha256)
160
+ }
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require_relative "policy"
5
+ require_relative "names"
6
+ require_relative "extensions"
7
+ require_relative "key_facts"
8
+ require_relative "inspectable"
9
+
10
+ module CsrPeek
11
+ # OIDs under which a CSR carries its requested X.509 extensions.
12
+ EXT_REQUEST_OIDS = %w[extReq msExtReq].freeze
13
+
14
+ # A friendly, read-only view over an OpenSSL::X509::Request.
15
+ #
16
+ # An immutable value object: build one with CsrPeek.parse (which returns nil
17
+ # for junk input) or Csr.from_openssl. Every fact is resolved once, in a
18
+ # single guarded pass, into frozen members - so no accessor can raise, and
19
+ # the object is safe to share across threads. The raw request stays available
20
+ # as #openssl for anything this wrapper does not surface.
21
+ Csr = Data.define(
22
+ :openssl, :public_key, :key_type, :key_bits, :ec_curve,
23
+ :subject, :subject_components, :subject_alt_names
24
+ ) do
25
+ prepend Inspectable
26
+
27
+ # Build a Csr from an OpenSSL::X509::Request, resolving every fact safely.
28
+ def self.from_openssl(request)
29
+ pkey = load_public_key(request)
30
+ facts = KeyFacts.of(pkey)
31
+ new(
32
+ openssl: request,
33
+ public_key: pkey,
34
+ key_type: facts[:type],
35
+ key_bits: facts[:bits],
36
+ ec_curve: facts[:curve],
37
+ subject: Names.subject_to_h(request.subject).freeze,
38
+ subject_components: Names.components(request.subject).freeze,
39
+ subject_alt_names: Extensions.subject_alt_names(san_extension(request))
40
+ )
41
+ end
42
+
43
+ def self.load_public_key(request)
44
+ request.public_key
45
+ rescue OpenSSL::PKey::PKeyError, OpenSSL::X509::RequestError
46
+ nil
47
+ end
48
+ private_class_method :load_public_key
49
+
50
+ # Dig the requested subjectAltName out of the requested-extensions
51
+ # attribute, or nil. Any structural surprise yields nil, not an exception.
52
+ def self.san_extension(request)
53
+ attr = request.attributes.find { |a| EXT_REQUEST_OIDS.include?(a.oid) }
54
+ return nil if attr.nil?
55
+
56
+ Array(attr.value.first&.value).each do |ext_asn1|
57
+ ext = OpenSSL::X509::Extension.new(ext_asn1)
58
+ return ext if ext.oid == "subjectAltName"
59
+ end
60
+ nil
61
+ rescue
62
+ nil
63
+ end
64
+ private_class_method :san_extension
65
+
66
+ # True when the CSR's self-signature checks out against its own key.
67
+ def signature_valid?
68
+ key = public_key
69
+ return false if key.nil?
70
+
71
+ openssl.verify(key)
72
+ rescue OpenSSL::X509::RequestError, OpenSSL::PKey::PKeyError
73
+ false
74
+ end
75
+ alias_method :valid?, :signature_valid?
76
+
77
+ # True when the requested key satisfies the issuance policy (Baseline
78
+ # Requirements by default). See CsrPeek::Policy.
79
+ def acceptable?(policy = Policy::BASELINE)
80
+ acceptable_key?(policy)
81
+ end
82
+
83
+ def to_h
84
+ {
85
+ subject: subject,
86
+ common_name: common_name,
87
+ dns_names: dns_names,
88
+ ip_addresses: ip_addresses,
89
+ all_names: all_names,
90
+ key_type: key_type,
91
+ key_bits: key_bits,
92
+ ec_curve: ec_curve,
93
+ weak_key: weak_key?,
94
+ acceptable: acceptable?,
95
+ signature_valid: signature_valid?,
96
+ spki_fingerprint_sha256: spki_fingerprint(:sha256),
97
+ fingerprint_sha256: fingerprint(:sha256)
98
+ }
99
+ end
100
+ end
101
+ end