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
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
|
data/lib/csr_peek/csr.rb
ADDED
|
@@ -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
|