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 endend 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
OpenSSL::ASN1.decodeparses the whole DER string and raisesOpenSSL::ASN1::ASN1Errorif bytes are left over; the shape check rejects anything that is not exactly a SEQUENCE of two INTEGERs.integer.valueis anOpenSSL::BN;to_s(2)returns its big-endian bytes without a sign byte and without leading zeros, andrjust(size, "\0")restores the fixed width.OpenSSL::BN.new(bytes, 2)reads a raw half as an unsigned number, andOpenSSL::ASN1::Integer(...)encodes it as a minimal DER INTEGER — including the0x00when the top bit is set.- The JWS form is the raw bytes in base64url without padding:
pack("m0")gives strict base64,trswaps the alphabet anddeletedrops the=padding.
Gotchas
- Skipping
rjustproduces 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.encode64inserts a newline every 60 characters andBase64.urlsafe_encode64keeps the=padding by default; a JWS segment has neither. Usepack("m0")as above, orurlsafe_encode64(raw, padding: false).sizemust 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
verifyraises on raw input rather than returningfalse, so a missing conversion surfaces asOpenSSL::PKey::PKeyError: EVP_DigestVerify, not as an invalid signature.