Skip to content
ecdsa.com

Convert DER ⇄ raw · Ruby

How to convert an ECDSA signature between DER and raw in Ruby

To convert an ECDSA signature between DER and raw in Ruby, use the OpenSSL::ASN1 module of its standard openssl library: decode the SEQUENCE to get r and s as OpenSSL::BN numbers, or build one from them. The library handles DER's sign bytes and lengths, so the only manual step is padding each integer to the curve size.

Tested with openssl (Ruby gem) 4.0.2 (Ruby 4.0.7, OpenSSL 3.5.7).

signature_formats.rb — der_to_raw and raw_to_der with OpenSSL::ASN1
require "openssl"
module SignatureFormats
# DER SEQUENCE { INTEGER r, INTEGER s } to fixed-width r‖s; size: 32 P-256, 48 P-384, 66 P-521
def self.der_to_raw(der, size)
sequence = OpenSSL::ASN1.decode(der) # raises OpenSSL::ASN1::ASN1Error on trailing bytes
integers = sequence.value
unless sequence.is_a?(OpenSSL::ASN1::Sequence) && integers.size == 2 && integers.all?(OpenSSL::ASN1::Integer)
raise ArgumentError, "not an ECDSA signature"
end
integers.map do |integer|
bytes = integer.value.to_s(2) # big-endian, no sign byte, no leading zeros
raise ArgumentError, "integer does not fit the curve" if bytes.bytesize > size
bytes.rjust(size, "\0")
end.join
end
# Fixed-width r‖s to DER; OpenSSL::ASN1::Integer adds the sign byte where needed
def self.raw_to_der(raw)
half = raw.bytesize / 2
r, s = [raw.byteslice(0, half), raw.byteslice(half, half)].map { |bytes| OpenSSL::BN.new(bytes, 2) }
OpenSSL::ASN1::Sequence([OpenSSL::ASN1::Integer(r), OpenSSL::ASN1::Integer(s)]).to_der
end
end
usage.rb — convert the shared example signature, check the round trip, print the JWS form
require_relative "signature_formats"
der = ["30450221009f4118b059a34a2f344c39a1d085d93bab560402938281dc1cb2f3dfb8eee58602200bd54b34a72042551c3316e10f461a1d69036ba4f0d72d95d42465340b56def8"].pack("H*")
raw = SignatureFormats.der_to_raw(der, 32)
puts "raw: #{raw.unpack1('H*')}"
puts "round trip: #{SignatureFormats.raw_to_der(raw) == der}"
puts "JWS form: #{[raw].pack('m0').tr('+/', '-_').delete('=')}" # base64url, no padding

How it works

  1. OpenSSL::ASN1.decode parses the whole DER string and raises OpenSSL::ASN1::ASN1Error if bytes are left over; the shape check rejects anything that is not exactly a SEQUENCE of two INTEGERs.
  2. integer.value is an OpenSSL::BN; to_s(2) returns its big-endian bytes without a sign byte and without leading zeros, and rjust(size, "\0") restores the fixed width.
  3. OpenSSL::BN.new(bytes, 2) reads a raw half as an unsigned number, and OpenSSL::ASN1::Integer(...) encodes it as a minimal DER INTEGER — including the 0x00 when the top bit is set.
  4. The JWS form is the raw bytes in base64url without padding: pack("m0") gives strict base64, tr swaps the alphabet and delete drops the = padding.

Gotchas

  • Skipping rjust produces a 63-byte (or shorter) raw signature whenever r or s happens to be small — about one signature in 128. The run converts two real 31-byte cases and 1,000 fresh signatures on P-256 and P-521.
  • Base64.encode64 inserts a newline every 60 characters and Base64.urlsafe_encode64 keeps the = padding by default; a JWS segment has neither. Use pack("m0") as above, or urlsafe_encode64(raw, padding: false).
  • size must match the curve (32, 48 or 66 bytes); a P-384 signature with 32 raises "integer does not fit the curve" instead of being truncated.
  • Ruby's verify raises on raw input rather than returning false, so a missing conversion surfaces as OpenSSL::PKey::PKeyError: EVP_DigestVerify, not as an invalid signature.

Related recipes