CSR Generator — Ruby source
Generate a Certificate Signing Request and private key pair entirely in your browser. Download the CSR and key as PEM files.
This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# CSR Generator — ASN.1 DER encoding + PKCS#10 certificate-request assembly.
#
# Language: Ruby (3.x, standard library only — openssl, base64, ipaddr)
# Source: CosmoDev polyglot showcase port of the CSR Generator tool, ported
# from src/lib/csr-generator.ts (the canonical TypeScript
# implementation).
# License: display source — part of CosmoDev's polyglot tool pages.
#
# The DER encoder is written out by hand, exactly as the TS reference does on
# Web Crypto: length headers, INTEGER / OID / string primitives, SEQUENCE and
# SET constructors, then the CertificationRequestInfo, the signature, and the
# PEM wrapper. OpenSSL is used only for what it must be — key generation and the
# signature itself.
#
# One deliberate difference from the TS port: Web Crypto returns ECDSA
# signatures as raw r||s (IEEE P1363), so the TS code converts them to the
# ASN.1 ECDSA-Sig-Value that PKCS#10 requires. OpenSSL already emits that DER
# SEQUENCE, so no conversion step exists here.
#
# Nothing here talks to the network: the key pair is generated locally, the CSR
# is signed locally, and both PEM blocks are returned to the caller.
require 'openssl'
require 'base64'
require 'ipaddr'
module CsrGenerator
Options = Struct.new(
:common_name,
:organization,
:country,
:state,
:locality,
:email,
:key_algorithm, # 'RSA-2048' | 'RSA-4096' | 'ECDSA-P256'
:subject_alt_names, # Array of DNS names / IPs / emails / URIs
keyword_init: true
)
Result = Struct.new(:csr, :private_key, keyword_init: true)
KEY_ALGORITHMS = %w[RSA-2048 RSA-4096 ECDSA-P256].freeze
# --- object identifiers -----------------------------------------------------
OID_COUNTRY = '2.5.4.6'
OID_STATE = '2.5.4.8'
OID_LOCALITY = '2.5.4.7'
OID_ORGANIZATION = '2.5.4.10'
OID_COMMON_NAME = '2.5.4.3'
OID_EMAIL = '1.2.840.113549.1.9.1'
OID_EXT_REQUEST = '1.2.840.113549.1.9.14' # pkcs-9 at extensionRequest
OID_SUBJECT_ALT_NAME = '2.5.29.17'
OID_RSA_SHA256 = '1.2.840.113549.1.1.11' # sha256WithRSAEncryption
OID_ECDSA_SHA256 = '1.2.840.10045.4.3.2' # ecdsa-with-SHA256
PRINTABLE_STRING_RE = %r{\A[A-Za-z0-9 '()+,\-./:=?]*\z}
class << self
# --- DER primitives -------------------------------------------------------
# DER length: short form below 0x80, long form (0x80 | byte count) above.
def encode_length(len)
raise ArgumentError, 'Length must be a non-negative integer' unless len.is_a?(Integer) && len >= 0
return [len].pack('C') if len < 0x80
bytes = int_to_bytes(len)
([0x80 | bytes.bytesize] + bytes.bytes).pack('C*')
end
# DER INTEGER. Accepts a non-negative Integer, or raw big-endian bytes (a
# signature half): leading zeros are stripped and a 0x00 sign byte is
# prepended when the high bit is set, per DER minimal-encoding rules.
def encode_integer(value)
bytes =
if value.is_a?(Integer)
raise ArgumentError, 'encode_integer supports non-negative integers only' if value.negative?
value.zero? ? "\x00".b : int_to_bytes(value)
else
strip_leading_zeros(value.to_s.b)
end
bytes = "\x00".b + bytes if bytes.getbyte(0) > 0x7f
tlv(0x02, bytes)
end
# DER OBJECT IDENTIFIER from a dotted string, e.g. '1.2.840.113549.1.1.11'.
def encode_oid(oid)
parts = oid.to_s.split('.')
if parts.length < 2 || parts.any? { |p| !p.match?(/\A\d+\z/) }
raise ArgumentError, "Invalid OID: #{oid}"
end
nums = parts.map(&:to_i)
content = [(40 * nums[0]) + nums[1]]
nums.drop(2).each { |arc| content.concat(base128(arc)) }
tlv(0x06, content.pack('C*'))
end
def encode_utf8_string(str)
tlv(0x0c, str.to_s.encode(Encoding::UTF_8).b)
end
# PrintableString — the required type for countryName in a Name.
def encode_printable_string(str)
raise ArgumentError, "Not a PrintableString: #{str}" unless str.to_s.match?(PRINTABLE_STRING_RE)
tlv(0x13, str.to_s.b)
end
def encode_bit_string(data)
tlv(0x03, "\x00".b + data.to_s.b) # 0 unused bits in the last octet
end
def encode_octet_string(data)
tlv(0x04, data.to_s.b)
end
def encode_null
"\x05\x00".b
end
def encode_sequence(*parts)
tlv(0x30, parts.join.b)
end
def encode_set(*parts)
tlv(0x31, parts.join.b)
end
# --- validation -----------------------------------------------------------
# Raises on invalid input: CN required, country (when present) a 2-letter
# ISO 3166-1 code, key algorithm one of the supported values.
def validate_csr_options(options)
raise ArgumentError, 'Common Name (CN) is required' if blank?(options.common_name)
country = options.country.to_s.strip
unless country.empty? || country.match?(/\A[A-Za-z]{2}\z/)
raise ArgumentError, 'Country must be a 2-letter ISO 3166-1 code (e.g. US, DE)'
end
return if KEY_ALGORITHMS.include?(options.key_algorithm)
raise ArgumentError, "Unsupported key algorithm: #{options.key_algorithm}"
end
# --- subject alternative names --------------------------------------------
# Classify a SAN entry: IPv4/IPv6 -> :ip, http(s):// -> :uri, contains an
# '@' -> :email, everything else -> :dns.
def classify_san_type(entry)
value = entry.to_s.strip.downcase
return :ip if ip_to_bytes(value)
return :uri if value.match?(%r{\Ahttps?://})
return :email if value.include?('@')
:dns
end
# --- PEM ------------------------------------------------------------------
# PEM-wrap DER bytes: 64-character base64 lines between BEGIN/END markers.
def pem_encode(der, label)
lines = Base64.strict_encode64(der.to_s.b).scan(/.{1,64}/)
"-----BEGIN #{label}-----\n#{lines.join("\n")}\n-----END #{label}-----\n"
end
# --- key generation + CSR assembly ----------------------------------------
# Generate a key pair and build a signed PKCS#10 CSR (PEM) plus the matching
# PKCS#8 private key (PEM).
def generate_csr(options)
validate_csr_options(options)
key = generate_key_pair(options.key_algorithm)
cri = certification_request_info(options, key.public_to_der)
csr_der = encode_sequence(cri, signature_algorithm(options.key_algorithm),
encode_bit_string(key.sign(OpenSSL::Digest::SHA256.new, cri)))
Result.new(
csr: pem_encode(csr_der, 'CERTIFICATE REQUEST'),
private_key: pem_encode(key.private_to_der, 'PRIVATE KEY')
)
end
private
# --- DER helpers ----------------------------------------------------------
# Wrap content bytes in a tag + DER length header.
def tlv(tag, content)
bytes = content.to_s.b
[tag].pack('C') + encode_length(bytes.bytesize) + bytes
end
# Minimal big-endian byte string for a positive Integer.
def int_to_bytes(value)
bytes = []
n = value
while n.positive?
bytes.unshift(n & 0xff)
n >>= 8
end
bytes.pack('C*')
end
def strip_leading_zeros(bytes)
i = 0
i += 1 while i < bytes.bytesize - 1 && bytes.getbyte(i).zero?
bytes.byteslice(i..)
end
# One OID arc as base-128 septets, continuation bit set on all but the last.
def base128(arc)
stack = []
value = arc
loop do
stack.unshift(value & 0x7f)
value >>= 7
break if value.zero?
end
stack[0..-2].map { |b| b | 0x80 } + [stack[-1]]
end
def blank?(value)
value.to_s.strip.empty?
end
# --- subject / attributes -------------------------------------------------
# AttributeTypeAndValue (SEQUENCE of OID + value) wrapped in its RDN SET.
def rdn(oid, value)
encode_set(encode_sequence(encode_oid(oid), value))
end
# RDNSequence in the conventional C, ST, L, O, CN, email order.
def subject(options)
rdns = []
country = options.country.to_s.strip.upcase
rdns << rdn(OID_COUNTRY, encode_printable_string(country)) unless country.empty?
rdns << rdn(OID_STATE, encode_utf8_string(options.state.to_s.strip)) unless blank?(options.state)
rdns << rdn(OID_LOCALITY, encode_utf8_string(options.locality.to_s.strip)) unless blank?(options.locality)
unless blank?(options.organization)
rdns << rdn(OID_ORGANIZATION, encode_utf8_string(options.organization.to_s.strip))
end
rdns << rdn(OID_COMMON_NAME, encode_utf8_string(options.common_name.to_s.strip))
rdns << rdn(OID_EMAIL, encode_utf8_string(options.email.to_s.strip)) unless blank?(options.email)
encode_sequence(*rdns)
end
# [0] IMPLICIT SET OF Attribute — an extensionRequest carrying the
# subjectAltName extension. Empty (the field is omitted) when no SANs were
# requested, matching the TS reference.
def attributes(options)
sans = Array(options.subject_alt_names).map { |s| s.to_s.strip }.reject(&:empty?)
return ''.b if sans.empty?
extension = encode_sequence(
encode_oid(OID_SUBJECT_ALT_NAME),
encode_octet_string(encode_sequence(*sans.map { |san| encode_general_name(san) }))
)
tlv(0xa0, encode_sequence(encode_oid(OID_EXT_REQUEST), encode_set(encode_sequence(extension))))
end
# One GeneralName: context-specific implicit tags per RFC 5280.
def encode_general_name(entry)
value = entry.to_s.strip
case classify_san_type(value)
when :ip then tlv(0x87, ip_to_bytes(value.downcase))
when :uri then tlv(0x86, value.b)
when :email then tlv(0x81, value.b)
else tlv(0x82, value.b) # dNSName
end
end
# Packed 4- or 16-byte address, or nil when the entry is not an IP literal.
# A prefix length ('10.0.0.0/8') is not a SAN entry and is rejected.
def ip_to_bytes(value)
return nil if value.include?('/')
IPAddr.new(value).hton
rescue IPAddr::Error
nil
end
# --- keys + signature -----------------------------------------------------
def generate_key_pair(algorithm)
return OpenSSL::PKey::EC.generate('prime256v1') if algorithm == 'ECDSA-P256'
OpenSSL::PKey::RSA.generate(algorithm == 'RSA-4096' ? 4096 : 2048)
end
# CertificationRequestInfo: version 0, subject, SPKI, [0] attributes.
def certification_request_info(options, spki)
encode_sequence(encode_integer(0), subject(options), spki, attributes(options))
end
# RSA carries an explicit NULL parameter; ECDSA carries none.
def signature_algorithm(algorithm)
return encode_sequence(encode_oid(OID_ECDSA_SHA256)) if algorithm == 'ECDSA-P256'
encode_sequence(encode_oid(OID_RSA_SHA256), encode_null)
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 →