Project

csr_peek

0.0
The project is in a healthy, maintained state
Parse a certificate signing request or an X.509 certificate and read the useful parts safely: subject, common name, Subject Alternative Names, key type and size, and public-key fingerprints. Malformed input returns nil instead of leaking raw OpenSSL exceptions. Includes CA/Browser Forum aware weak-key checks. Zero runtime dependencies beyond the standard library.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

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.