Skip to content
ecdsa.com

Decode a certificate · Ruby

How to decode an X.509 certificate in Ruby

To decode an X.509 certificate in Ruby, pass the file to OpenSSL::X509::Certificate.new — PEM and DER both work — and read subject, issuer, serial, validity, extensions and the public key from the object. The recipe prints the usual fields of an ECDSA certificate, its SHA-256 fingerprint, and then checks trust with an OpenSSL::X509::Store, because decoding alone proves nothing.

Tested with openssl (Ruby gem) 4.0.2 (Ruby 4.0.7, OpenSSL 3.5.7) and OpenSSL 3.5.7 (test CA, leaf and DER copy).

decode.rb — subject, validity, SANs, key and fingerprint
require "openssl"
cert = OpenSSL::X509::Certificate.new(File.binread(ARGV.fetch(0, "cert.pem"))) # PEM or DER
puts "subject: #{cert.subject}" # OpenSSL's one-line form
puts "common name: #{cert.subject.to_a.find { |name, _, _| name == 'CN' }&.at(1)}"
puts "issuer: #{cert.issuer.to_s(OpenSSL::X509::Name::RFC2253)}"
puts "serial: #{cert.serial.to_s(16)}"
puts "valid: #{cert.not_before.utc.iso8601} -> #{cert.not_after.utc.iso8601}"
puts "signed with: #{cert.signature_algorithm}" # the issuer's algorithm
# extensions come back as OpenSSL's text, e.g. "DNS:a, DNS:b"
san = cert.extensions.find { |extension| extension.oid == "subjectAltName" }
puts "DNS names: #{san.value.split(', ').filter_map { |name| name.delete_prefix('DNS:') if name.start_with?('DNS:') }.join(' ')}" if san
key = cert.public_key
puts "key: EC on #{key.group.curve_name}" if key.is_a?(OpenSSL::PKey::EC)
puts "SHA-256 fingerprint: #{OpenSSL::Digest::SHA256.hexdigest(cert.to_der).scan(/../).join(':').upcase}"
puts "days until expiry: #{((cert.not_after - Time.now) / 86_400).floor}"
trust.rb — validate against your own CA with OpenSSL::X509::Store
# Decoding proves nothing about trust; a store with your trust anchors does
store = OpenSSL::X509::Store.new
store.add_file("ca.pem") # or store.set_default_paths for the system roots
leaf = OpenSSL::X509::Certificate.new(File.read("leaf.pem"))
puts "leaf trusted: #{store.verify(leaf)}"
untrusted = OpenSSL::X509::Certificate.new(File.read("cert.pem"))
puts "example trusted: #{store.verify(untrusted)} (#{store.error_string})"

How it works

  1. OpenSSL::X509::Certificate.new detects PEM or DER from the bytes; File.binread keeps DER intact.
  2. subject prints OpenSSL's slash form, to_a gives [name, value, type] triples for picking attributes such as CN, and to_s(OpenSSL::X509::Name::RFC2253) the comma-separated RFC 2253 form.
  3. not_before and not_after are Time objects, so expiry is plain arithmetic; signature_algorithm is the issuer's algorithm, like ecdsa-with-SHA256.
  4. Each extension has oid, value and critical?; value is OpenSSL's text, so SAN names are split from DNS:a, DNS:b.
  5. OpenSSL::X509::Store#verify builds a chain to the anchors you added; error_string explains a failure, such as self-signed certificate for the example.

Gotchas

  • RFC 2253 output is in reverse order — C=US,O=…,CN=demo.ecdsa.com here, while the default to_s and OpenSSL's CLI start with CN. Compare OpenSSL::X509::Name objects (or to_a) rather than formatted strings.
  • Extension values are display text whose exact wording comes from OpenSSL and can change between versions. For key usage or basic constraints in security decisions, rely on Store#verify and its purpose settings instead of parsing the text.
  • OpenSSL::Digest::SHA256.hexdigest(cert.to_der) is the certificate fingerprint; the fingerprint of its key is a different value — see the fingerprint recipe.
  • store.set_default_paths loads the system roots; in slim containers they exist only when the ca-certificates package is installed, which is the usual cause of unable to get local issuer certificate in Docker.

Related recipes