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.parse returns nil for 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? Use parse!.
  • 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 a weak_signature? check that flags MD5/SHA-1 certificates. Issuance rules live in a pluggable CsrPeek::Policy (acceptable_key?), separate from raw strength - so "strong but not permitted" (Ed25519 under the Baseline Requirements) is expressible.
  • Immutable value objects. Csr and Certificate are frozen Data structs - 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 openssl and ipaddr, 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, SAN email/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_certificate return nil, parse! raises), and parse_certificates returns at most CsrPeek::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 (openssl and ipaddr ship 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

  1. Fork it
  2. Create your feature branch (git checkout -b feature/my-feature)
  3. Commit your changes
  4. Push to the branch
  5. 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.