CsrPeek
A friendlier, safe way to read certificate signing requests and X.509 certificates in Ruby.
The standard library can parse a CSR, but the ergonomics are rough: subjects
come back as nested arrays, Subject Alternative Names are buried in a requested-
extensions attribute you have to walk by hand, and a single malformed upload
raises an OpenSSL exception that becomes a 500 if you forget to rescue it.
CsrPeek wraps all of that in a small, memoized, never-raises API.
Why it exists
- Safe by default.
CsrPeek.parsereturnsnilfor junk input - including a certificate whose key cannot be loaded. A bad paste never becomes an exception in your request path. Want the reason instead? Useparse!. - The parts you actually want. Subject, common name, and categorized SANs (DNS, IP, email, URI) without ASN.1 spelunking. SANs are decoded from the DER, so a comma inside a value never splits it and IPv6 renders canonically.
- Key and signature hygiene built in. Key type, size, EC curve, a stable
SubjectPublicKey fingerprint, a
weak_key?strength check, and aweak_signature?check that flags MD5/SHA-1 certificates. Issuance rules live in a pluggableCsrPeek::Policy(acceptable_key?), separate from raw strength - so "strong but not permitted" (Ed25519 under the Baseline Requirements) is expressible. - Immutable value objects.
CsrandCertificateare frozenDatastructs - every fact is resolved once, at parse time, into frozen members. They are safe to share across threads, compare by value (two parses of the same bytes are==and usable as hash keys), and cannot be mutated. - No dependencies. Only
opensslandipaddr, both from the standard library.
Installation
Requires Ruby 3.2 or newer.
gem "csr_peek"
Then bundle install, or gem install csr_peek.
Usage
Certificate signing requests
require "csr_peek"
csr = CsrPeek.parse(pem_or_der_string) # => CsrPeek::Csr, or nil
# CsrPeek.parse!(bad_input) # raises CsrPeek::ParseError with a reason
csr.common_name # => "example.com"
csr.subject # => { "CN" => "example.com", "O" => "Example Inc" }
csr.dns_names # => ["example.com", "www.example.com"]
csr.ip_addresses # => ["192.0.2.10"]
csr.all_names # => ["example.com", "www.example.com"] (DNS SANs, else CN)
csr.signature_valid? # => true (self-signature verifies)
csr.key_type # => "RSA"
csr.key_bits # => 2048
csr.weak_key? # => false (RSA < 2048 or EC < 256 would be true)
csr.acceptable? # => true (satisfies the Baseline Requirements policy)
csr.spki_fingerprint(:sha256) # => stable identity of the public key
csr.fingerprint(:sha256) # => digest of the whole CSR
csr.to_h # => a flat summary hash
Certificates
cert = CsrPeek.parse_certificate(pem_or_der_string) # => CsrPeek::Certificate, or nil
cert.common_name # => "example.com"
cert.issuer # => { "CN" => "Example Root CA" }
cert.serial # => "0a1b2c" (lower-case, even-length hex)
cert.version # => 3
cert.not_before # => Time
cert.not_after # => Time
cert.valid_at? # => true (now within [not_before, not_after])
cert.expired? # => false (now past not_after)
cert.not_yet_valid? # => false (now before not_before)
cert.expired?(Time.now + 86_400) # check against a specific moment
cert.self_signed? # => true/false
cert.ca? # => false (basicConstraints CA:TRUE)
cert.path_length # => nil (pathLenConstraint, when set)
cert.key_usage # => ["digitalSignature", "keyEncipherment"]
cert.extended_key_usage # => ["serverAuth", "clientAuth"]
cert.signature_algorithm # => "sha256WithRSAEncryption"
cert.weak_signature? # => false (true for MD5/SHA-1)
cert.dns_names # => ["example.com"]
cert.spki_fingerprint(:sha256)
key_usage, extended_key_usage, and basicConstraints are decoded from the
DER, not from OpenSSL's display string, so their names are the stable RFC
identifiers (digitalSignature, serverAuth) rather than the localized labels.
valid_at?, expired?, and not_yet_valid? split what a single expired?
would conflate: expired? is strictly "past not_after", and a certificate
whose window has not begun is not_yet_valid?, not expired.
Certificate chains
parse_certificate reads the first block of its input. For a fullchain.pem
or any PEM bundle, use parse_certificates (plural), which returns one
Certificate per block, in order, skipping any that don't parse:
CsrPeek.parse_certificates(File.read("fullchain.pem")).map(&:common_name)
# => ["example.com", "Example Intermediate CA"]
Weak keys vs. acceptable keys
Two different questions, kept deliberately separate:
weak_key? - is the key cryptographically too small to be safe? Strength
only, no policy:
| Key type | Weak when |
|---|---|
| RSA | modulus < 2048 bits |
| EC | curve degree < 256 bits |
| DSA | parameter size < 2048 bits |
| Ed25519 / Ed448 | never (these are strong) |
| unloadable | always (cannot verify => not trusted) |
acceptable_key?(policy) - may I issue against this key? That is a policy
decision, and it takes a CsrPeek::Policy. The default is the CA/Browser Forum
Baseline Requirements, under which an Ed25519 key is weak_key? => false but
acceptable_key? => false (strong, but not permitted for public TLS). Raise the
bar without monkey-patching:
strict = CsrPeek::Policy.new(
min_rsa_bits: 3072,
allowed_curves: %w[secp384r1],
blocked_spki_fingerprints: known_roca_fingerprints # matched by SPKI
)
csr.acceptable_key?(strict) # => false
csr.key_policy_violations(strict) # => [:rsa_too_small]
For certificates, acceptable? also folds in weak_signature? (true for MD5/
SHA-1), and policy_violations returns the combined reasons.
The spki_fingerprint is the right value to check against known weak- or
compromised-key lists (Debian OpenSSL, ROCA) - pass them as
blocked_spki_fingerprints - because it identifies the key itself, not the
request or certificate that happens to carry it.
Command line
The gem ships a csr_peek executable that prints a CSR or certificate as JSON.
Kind is auto-detected from the PEM header; --csr / --cert force it.
csr_peek request.csr # from a file
csr_peek --cert server.pem # force certificate parsing
cat request.csr | csr_peek # from stdin
It exits 0 on a readable input and 1 on a parse failure.
Escape hatch
Every value object keeps the underlying OpenSSL object on #openssl, for the
occasional thing CsrPeek does not surface:
cert.openssl # => OpenSSL::X509::Certificate
csr.openssl # => OpenSSL::X509::Request
Scope
CsrPeek inspects. It does not build trust chains, verify against a root store, or check revocation. Pair it with a proper verification step when you need those.
Handling untrusted input
CsrPeek is built to be handed attacker-controlled PEM/DER, but two things are the caller's responsibility:
- Escape the values you display or log. Every name and SAN value
(
common_name,subject,issuer,dns_names, SANemail/uri/other) comes straight from the input and may contain newlines, control characters, or markup. CsrPeek reports them faithfully; it does not sanitize. Escape before rendering into HTML, logs, or a shell. - Size is bounded, not unbounded. Input larger than
CsrPeek::MAX_INPUT_BYTES(1 MiB) is rejected up front (parse/parse_certificatereturnnil,parse!raises), andparse_certificatesreturns at mostCsrPeek::MAX_CHAIN_CERTIFICATES(100) certificates from one bundle, so a malicious paste cannot exhaust memory or CPU.
Signature and self-signature checks (signature_valid?, self_signed?) run
real crypto and are opt-in - they are not performed during parsing.
Compatibility
- Ruby >= 3.2.0 (the value objects are built on
Data) - Zero external dependencies (
opensslandipaddrship with Ruby) - Immutable, thread-safe value objects
- Linux, macOS, Windows
Development
The repo pins a Ruby version in .tool-versions.
bundle install
bundle exec rake spec # run the tests
bundle exec standardrb # lint (Standard Ruby)
Tests generate their own keys at runtime, so there is no checked-in key material.
Contributing
- Fork it
- Create your feature branch (
git checkout -b feature/my-feature) - Commit your changes
- Push to the branch
- Create a Pull Request
CI runs the test suite (Ruby 3.2–3.4), Standard Ruby, and a gem build on every pull request.
License
MIT. See LICENSE.