Skip to content

Certificate Decoder — Ruby source

Paste a PEM X.509 certificate and see its subject, issuer, SAN, validity, key usage, fingerprints, and chain details in a human-readable format.

This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.

# Certificate Decoder — ASN.1 DER parser + X.509 certificate decoder.
#
# Language: Ruby (3.x, standard library only — base64 for the PEM envelope;
#           the DER parser is hand-rolled, exactly as in the TS reference)
# Source:   CosmoDev polyglot showcase port of the Certificate Decoder tool,
#           ported from src/lib/cert-decoder.ts (the canonical TypeScript
#           implementation).
# License:  display source — part of CosmoDev's polyglot tool pages.
#
# DER is deterministic and parsed sequentially, so the decoder needs no crypto
# library — OpenSSL::X509 would hide the very structure this tool exists to
# show. Every length is bounds-checked against the buffer before the slice: a
# pasted certificate is untrusted input, and a truncated or hostile length
# field must raise a clear error rather than read past the end.
#
# This decoder READS certificates. It performs no signature verification and no
# chain validation — a parsed certificate is not a trusted one.

require 'base64'

module CertDecoder
  # ── OID name map ──────────────────────────────────────────────────────────

  OID_NAMES = {
    '1.2.840.113549.1.1.1' => 'RSA',
    '1.2.840.113549.1.1.5' => 'SHA-1 with RSA',
    '1.2.840.113549.1.1.11' => 'SHA-256 with RSA',
    '1.2.840.113549.1.1.12' => 'SHA-384 with RSA',
    '1.2.840.113549.1.1.13' => 'SHA-512 with RSA',
    '1.2.840.113549.1.1.14' => 'SHA-224 with RSA',
    '1.2.840.10045.2.1' => 'ECDSA',
    '1.2.840.10045.4.3.2' => 'ECDSA with SHA-256',
    '1.2.840.10045.4.3.3' => 'ECDSA with SHA-384',
    '1.2.840.10045.4.3.4' => 'ECDSA with SHA-512',
    '1.3.14.3.2.29' => 'SHA-1 with RSA (OIW)',
    '2.5.4.3' => 'CN',
    '2.5.4.6' => 'C',
    '2.5.4.7' => 'L',
    '2.5.4.8' => 'ST',
    '2.5.4.10' => 'O',
    '2.5.4.11' => 'OU',
    '2.5.29.14' => 'Subject Key Identifier',
    '2.5.29.15' => 'Key Usage',
    '2.5.29.17' => 'Subject Alternative Name',
    '2.5.29.19' => 'Basic Constraints',
    '2.5.29.35' => 'Authority Key Identifier',
    '2.5.29.37' => 'Extended Key Usage',
    '1.3.6.1.5.5.7.1.1' => 'Authority Information Access',
    '1.3.6.1.5.5.7.3.1' => 'serverAuth',
    '1.3.6.1.5.5.7.3.2' => 'clientAuth',
    '1.3.6.1.5.5.7.3.3' => 'codeSigning',
    '1.3.6.1.5.5.7.3.4' => 'emailProtection',
    '1.3.6.1.5.5.7.3.8' => 'timeStamping',
    '1.2.840.113549.1.9.14' => 'Extension Request',
    '1.2.840.113549.1.9.1' => 'emailAddress',
    '1.3.6.1.4.1.11129.2.1.17' => 'CT Precertificate SCTs',
    '1.3.6.1.5.5.7.1.3' => 'CRL Distribution Points',
    '1.3.6.1.4.1.311.21.7' => 'Microsoft Certificate Template'
  }.freeze

  # EC named-curve OIDs
  EC_CURVE_NAMES = {
    '1.2.840.10045.3.1.7' => 'P-256',
    '1.3.132.0.34' => 'P-384',
    '1.3.132.0.35' => 'P-512',
    '1.3.132.0.10' => 'secp256k1'
  }.freeze

  # Key Usage bit names, in BIT STRING bit order.
  KEY_USAGE_BITS = %w[
    digitalSignature nonRepudiation keyEncipherment dataEncipherment
    keyAgreement keyCertSign cRLSign encipherOnly decipherOnly
  ].freeze

  # Tags whose content is text by definition: UTF8String(12),
  # PrintableString(19), IA5String(22), BMPString(30), and 36.
  TEXT_TAGS = [12, 19, 22, 30, 36].freeze

  RSA_OID = '1.2.840.113549.1.1.1'
  EC_OID = '1.2.840.10045.2.1'

  # ── ASN.1 DER types ───────────────────────────────────────────────────────

  # One TLV node. `raw_value` holds the decoded content bytes as a binary
  # String; `children` is nil for primitive nodes. `tag_class` is one of
  # :universal, :application, :context, :private.
  ASN1Node = Struct.new(:tag_class, :constructed, :tag_number, :raw_value,
                        :offset, :children, keyword_init: true)

  RDNAttribute = Struct.new(:type, :value, keyword_init: true)

  Extensions = Struct.new(:basic_constraints_ca, :key_usage, :ext_key_usage,
                          :san_dns, :san_ip, :san_email, keyword_init: true)

  CertificateInfo = Struct.new(
    :subject, :subject_dn, :issuer, :issuer_dn, :not_before, :not_after,
    :serial_number, :signature_algorithm, :key_algorithm, :key_size,
    :basic_constraints_ca, :key_usage, :ext_key_usage, :san_dns, :san_ip,
    :san_email, :version, :raw_der, keyword_init: true
  )

  # ── DER parsing ───────────────────────────────────────────────────────────

  class DERParser
    def initialize(buf)
      @buf = buf.dup.force_encoding(Encoding::BINARY)
      @pos = 0
    end

    def exhausted?
      @pos >= @buf.bytesize
    end

    # Read the next TLV node, recursing into constructed types.
    def read_node
      offset = @pos
      byte0 = read_byte

      tag_class = case byte0 & 0xc0
                  when 0x00 then :universal
                  when 0x40 then :application
                  when 0x80 then :context
                  else :private
                  end

      constructed = (byte0 & 0x20) != 0
      tag_number = byte0 & 0x1f

      # Long-form tag (tag number >= 31)
      if tag_number == 0x1f
        tag_number = 0
        loop do
          b = read_byte
          tag_number = (tag_number << 7) | (b & 0x7f)
          break if (b & 0x80).zero?
        end
      end

      length = read_length
      if @pos + length > @buf.bytesize
        raise ArgumentError,
              "Truncated DER: need #{length} bytes at offset #{@pos}, but only " \
              "#{@buf.bytesize - @pos} remain"
      end

      raw_value = @buf.byteslice(@pos, length)
      @pos += length

      node = ASN1Node.new(tag_class: tag_class, constructed: constructed,
                          tag_number: tag_number, raw_value: raw_value, offset: offset)

      # Parse children for constructed types.
      if constructed && !raw_value.empty?
        child_parser = DERParser.new(raw_value)
        children = []
        children << child_parser.read_node until child_parser.exhausted?
        node.children = children
      end

      node
    end

    private

    def read_byte
      raise ArgumentError, 'Unexpected end of DER data' if @pos >= @buf.bytesize

      byte = @buf.getbyte(@pos)
      @pos += 1
      byte
    end

    def read_length
      first = read_byte
      return first if first < 0x80

      num_bytes = first & 0x7f
      raise ArgumentError, 'Indefinite length is not supported in DER' if num_bytes.zero?
      raise ArgumentError, "Length too large: #{num_bytes} bytes" if num_bytes > 4

      len = 0
      num_bytes.times { len = (len << 8) | read_byte }
      len
    end
  end

  module_function

  # ── OID decoding ──────────────────────────────────────────────────────────

  def decode_oid(bytes)
    raise ArgumentError, 'Empty OID' if bytes.empty?

    first = bytes.getbyte(0)
    parts = [first / 40, first % 40]

    value = 0
    bytes.each_byte.drop(1).each do |b|
      value = (value << 7) | (b & 0x7f)
      if (b & 0x80).zero?
        parts << value
        value = 0
      end
    end
    parts.join('.')
  end

  def oid_name(oid)
    OID_NAMES.fetch(oid, oid)
  end

  # ── RDN (Relative Distinguished Name) helpers ─────────────────────────────

  def parse_rdn(node)
    attrs = []
    return attrs unless node.children

    node.children.each do |rdn_set|
      next unless rdn_set.children

      rdn_set.children.each do |attr_seq|
        next unless attr_seq.children && attr_seq.children.length >= 2

        oid = decode_oid(attr_seq.children[0].raw_value)
        attrs << RDNAttribute.new(type: oid_name(oid),
                                  value: read_string(attr_seq.children[1]))
      end
    end
    attrs
  end

  def format_dn(attrs)
    attrs.map { |a| "#{a.type}=#{a.value}" }.join(', ')
  end

  # Decode a node's content as text. Known string tags decode as UTF-8 with
  # invalid bytes replaced; anything else is decoded only when it is valid
  # UTF-8, and falls back to colon-separated hex so binary values are never
  # rendered as mojibake.
  def read_string(node)
    text = node.raw_value.dup.force_encoding(Encoding::UTF_8)
    return text.scrub if TEXT_TAGS.include?(node.tag_number)
    return text if text.valid_encoding?

    to_hex(node.raw_value)
  end

  def to_hex(bytes, separator = ':')
    bytes.each_byte.map { |b| format('%02x', b) }.join(separator)
  end

  # ── Time helpers ──────────────────────────────────────────────────────────

  def parse_time(node)
    str = read_string(node)
    # UTCTime (tag 23): YYMMDDHHMMSSZ
    if node.tag_number == 23
      m = /\A(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z\z/.match(str)
      raise ArgumentError, "Invalid UTCTime: #{str}" unless m

      year = m[1].to_i
      year += year >= 50 ? 1900 : 2000
      return Time.utc(year, m[2].to_i, m[3].to_i, m[4].to_i, m[5].to_i, m[6].to_i)
    end
    # GeneralizedTime (tag 24): YYYYMMDDHHMMSSZ
    if node.tag_number == 24
      m = /\A(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z\z/.match(str)
      raise ArgumentError, "Invalid GeneralizedTime: #{str}" unless m

      return Time.utc(m[1].to_i, m[2].to_i, m[3].to_i, m[4].to_i, m[5].to_i, m[6].to_i)
    end

    raise ArgumentError, "Unknown time tag: #{node.tag_number}"
  end

  # ── Extension parsing ─────────────────────────────────────────────────────

  def empty_extensions
    Extensions.new(basic_constraints_ca: nil, key_usage: nil, ext_key_usage: nil,
                   san_dns: [], san_ip: [], san_email: [])
  end

  def parse_extensions(nodes)
    ext = empty_extensions

    nodes.each do |ext_node|
      next unless ext_node.children && ext_node.children.length >= 2

      oid = decode_oid(ext_node.children[0].raw_value)

      # Skip the `critical` BOOLEAN when present.
      value_idx = 1
      if ext_node.children.length >= 3 &&
         ext_node.children[1].tag_number == 1 &&
         ext_node.children[1].tag_class == :universal
        value_idx = 2
      end

      # The value is wrapped in an OCTET STRING containing the actual DER.
      octet_content = ext_node.children[value_idx].raw_value
      next if octet_content.empty?

      inner = DERParser.new(octet_content)
      next if inner.exhausted?

      content = inner.read_node

      case oid
      when '2.5.29.19' then ext.basic_constraints_ca = basic_constraints_ca?(content)
      when '2.5.29.15' then ext.key_usage = parse_bit_string(content)
      when '2.5.29.37' then ext.ext_key_usage = parse_ext_key_usage(content)
      when '2.5.29.17' then parse_san(content, ext)
      end
    end

    ext
  end

  def basic_constraints_ca?(content)
    return false unless content.children && !content.children.empty?

    bool = content.children[0]
    bool.tag_number == 1 && bool.raw_value.bytesize == 1 && bool.raw_value.getbyte(0) == 0xff
  end

  def parse_ext_key_usage(content)
    return [] unless content.children

    content.children.map { |child| oid_name(decode_oid(child.raw_value)) }
  end

  # Subject Alternative Name: context tag 2 = dNSName, 7 = iPAddress,
  # 1 = rfc822Name.
  def parse_san(content, ext)
    return unless content.children

    content.children.each do |child|
      next unless child.tag_class == :context

      case child.tag_number
      when 2 then ext.san_dns << read_string(child)
      when 7 then (ip = format_ip(child.raw_value)) && ext.san_ip << ip
      when 1 then ext.san_email << read_string(child)
      end
    end
  end

  # 4 bytes -> dotted IPv4, 16 bytes -> colon-separated hex groups. Any other
  # length is not an address and is dropped.
  def format_ip(bytes)
    case bytes.bytesize
    when 4 then bytes.each_byte.to_a.join('.')
    when 16
      (0...16).step(2).map { |i| format('%x', (bytes.getbyte(i) << 8) | bytes.getbyte(i + 1)) }
             .join(':')
    end
  end

  def parse_bit_string(node)
    return [] if node.raw_value.bytesize < 2

    unused_bits = node.raw_value.getbyte(0)
    octets = node.raw_value.byteslice(1..)
    flags = []

    KEY_USAGE_BITS.each_with_index do |name, i|
      octet_idx = i / 8
      bit_idx = 7 - (i % 8)
      next if octet_idx >= octets.bytesize

      flags << name unless (octets.getbyte(octet_idx) & (1 << bit_idx)).zero?
    end

    # Mask out unused trailing bits.
    if unused_bits.positive? && !flags.empty?
      total_bits = (octets.bytesize * 8) - unused_bits
      flags.pop while flags.length > total_bits
    end

    flags
  end

  # ── PEM handling ──────────────────────────────────────────────────────────

  PEM_REGEX = /-----BEGIN\s+CERTIFICATE-----\s*\r?\n(.*?)\r?\n-----END\s+CERTIFICATE-----/m

  def pem_to_der(pem)
    Base64.strict_decode64(pem.gsub(/\s/, ''))
  rescue ArgumentError
    raise ArgumentError, 'Certificate body is not valid base64'
  end

  def pem_blocks(pem)
    pem.to_s.scan(PEM_REGEX).flatten
  end

  # ── Main decoder ──────────────────────────────────────────────────────────

  # Decode the first PEM certificate block into a CertificateInfo.
  def decode_certificate(pem)
    raise ArgumentError, 'Empty input — paste a PEM certificate' if pem.to_s.strip.empty?

    blocks = pem_blocks(pem)
    if blocks.empty?
      raise ArgumentError,
            'No PEM certificate block found — expected -----BEGIN CERTIFICATE-----'
    end

    der = pem_to_der(blocks[0])

    # Parse outer SEQUENCE: TBSCertificate, signatureAlgorithm, signatureValue.
    cert_seq = DERParser.new(der).read_node
    if cert_seq.children.nil? || cert_seq.children.length < 3
      raise ArgumentError,
            'Invalid certificate structure: expected TBSCertificate, ' \
            'signatureAlgorithm, signatureValue'
    end

    tbs = cert_seq.children[0]
    sig_alg_node = cert_seq.children[1]
    sig_alg_oid = sig_alg_node.children ? decode_oid(sig_alg_node.children[0].raw_value) : ''

    raise ArgumentError, 'Invalid TBSCertificate structure' if tbs.children.nil? ||
                                                               tbs.children.length < 7

    idx = 0

    # Version (explicit context [0]); absent means v1.
    version = 0
    if tbs.children[idx].tag_class == :context && tbs.children[idx].tag_number.zero?
      version_node = tbs.children[idx].children&.first
      version = version_node.raw_value.getbyte(0) if version_node&.raw_value&.bytesize == 1
      idx += 1
    end

    serial_node = tbs.children[idx]
    idx += 1
    idx += 1 # skip the inner signature algorithm

    issuer = parse_rdn(tbs.children[idx])
    idx += 1

    validity_node = tbs.children[idx]
    idx += 1
    not_before = Time.at(0).utc
    not_after = Time.at(0).utc
    if validity_node.children && validity_node.children.length >= 2
      not_before = parse_time(validity_node.children[0])
      not_after = parse_time(validity_node.children[1])
    end

    subject = parse_rdn(tbs.children[idx])
    idx += 1

    key_algorithm, key_size = parse_spki(tbs.children[idx])
    idx += 1

    # Extensions — context [3] after subjectPublicKeyInfo.
    extensions = empty_extensions
    while idx < tbs.children.length
      child = tbs.children[idx]
      if child.tag_class == :context && child.tag_number == 3 && child.children
        outer_seq = child.children[0]
        extensions = parse_extensions(outer_seq.children) if outer_seq&.children
      end
      idx += 1
    end

    CertificateInfo.new(
      subject: subject, subject_dn: format_dn(subject),
      issuer: issuer, issuer_dn: format_dn(issuer),
      not_before: not_before, not_after: not_after,
      serial_number: format_serial(serial_node.raw_value),
      signature_algorithm: oid_name(sig_alg_oid),
      key_algorithm: key_algorithm, key_size: key_size,
      basic_constraints_ca: extensions.basic_constraints_ca,
      key_usage: extensions.key_usage || [],
      ext_key_usage: extensions.ext_key_usage || [],
      san_dns: extensions.san_dns, san_ip: extensions.san_ip,
      san_email: extensions.san_email,
      version: version, raw_der: der
    )
  end

  # SubjectPublicKeyInfo -> [algorithm name, key size in bits].
  def parse_spki(spki_node)
    return ['unknown', 0] unless spki_node.children && spki_node.children.length >= 2

    alg_seq = spki_node.children[0]
    key_bits = spki_node.children[1].raw_value
    return ['unknown', 0] unless alg_seq.children && !alg_seq.children.empty?

    alg_oid = decode_oid(alg_seq.children[0].raw_value)
    key_algorithm = oid_name(alg_oid)

    case alg_oid
    when RSA_OID
      # The BIT STRING starts with an unused-bits byte (0x00), then a DER
      # SEQUENCE of { modulus INTEGER, exponent INTEGER }.
      [key_algorithm, rsa_key_size(key_bits)]
    when EC_OID
      if alg_seq.children.length >= 2
        curve_name = EC_CURVE_NAMES[decode_oid(alg_seq.children[1].raw_value)]
        key_algorithm = "#{key_algorithm} (#{curve_name})" if curve_name
      end
      # EC public key: 0x00 (unused bits) + uncompressed point (0x04 + X + Y).
      # For P-256 that is 65 bytes; subtract the 0x04 prefix for the key size.
      [key_algorithm, key_bits.bytesize > 1 ? (key_bits.bytesize - 2) * 8 : 0]
    else
      [key_algorithm, key_bits.bytesize > 1 ? (key_bits.bytesize - 1) * 8 : 0]
    end
  end

  def rsa_key_size(key_bits)
    return 0 unless key_bits.bytesize > 1

    rsa_inner = DERParser.new(key_bits.byteslice(1..)) # skip unused-bits byte
    return 0 if rsa_inner.exhausted?

    rsa_seq = rsa_inner.read_node
    return 0 unless rsa_seq.children && !rsa_seq.children.empty?

    mod_bytes = rsa_seq.children[0].raw_value
    # A leading 0x00 is the DER positive-sign pad, not modulus material.
    effective = mod_bytes.getbyte(0).zero? ? mod_bytes.bytesize - 1 : mod_bytes.bytesize
    effective * 8
  rescue ArgumentError
    0
  end

  # Serial number as uppercase colon-separated hex, sign pad removed.
  def format_serial(raw)
    return '' if raw.empty?

    bytes = raw.getbyte(0).zero? && raw.bytesize > 1 ? raw.byteslice(1..) : raw
    to_hex(bytes).upcase
  end

  def expired?(cert)
    cert.not_after < Time.now
  end

  def days_until_expiry(cert)
    ((cert.not_after - Time.now) / 86_400.0).ceil
  end

  # Extract all PEM certificate blocks and return info for each.
  def decode_certificate_chain(pem)
    raise ArgumentError, 'Empty input — paste PEM certificate(s)' if pem.to_s.strip.empty?

    blocks = pem_blocks(pem)
    if blocks.empty?
      raise ArgumentError,
            'No PEM certificate block found — expected -----BEGIN CERTIFICATE-----'
    end

    blocks.map do |block|
      decode_certificate("-----BEGIN CERTIFICATE-----\n#{block}\n-----END CERTIFICATE-----")
    end
  end
end

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →