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 →